主题换肤与图表定制:6 套皮肤动态切换、渠道自定义图表颜色、历史账单滚动折叠
This commit is contained in:
+106
-149
@@ -5,138 +5,11 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Token Meter · AI 用量控制台</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
|
||||
:root{
|
||||
--bg:#1a1a2e;--panel:#16213e;--text:#e0e0e0;--accent:#0f3460;
|
||||
--border:#0f3460;--hover:#1a3a6e;--danger:#e74c3c;--sidebar-w:260px;
|
||||
}
|
||||
html,body{height:100%;font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text)}
|
||||
/* ── layout ── */
|
||||
.app{display:flex;height:100vh;overflow:hidden;position:relative}
|
||||
.sidebar{position:absolute;left:0;top:0;bottom:0;width:var(--sidebar-w);min-width:var(--sidebar-w);background:var(--panel);display:flex;flex-direction:column;border-right:1px solid var(--border);overflow-y:auto;transition:transform .3s}
|
||||
.main{flex:1;display:flex;flex-direction:column;overflow:hidden;margin-left:var(--sidebar-w);transition:margin-left .3s}
|
||||
.side-toggle{position:absolute;top:14px;left:calc(var(--sidebar-w) - 32px);z-index:60;width:28px;height:28px;display:flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:6px;background:var(--panel);color:var(--text);cursor:pointer;font-size:1.05rem;font-weight:700;transition:left .3s,transform .3s,background .2s}
|
||||
.side-toggle:hover{background:var(--hover)}
|
||||
/* ── sidebar drag resizer:拖拽热区在右缘,竖条悬停高亮;拖拽期间禁用过渡与文本选中 ── */
|
||||
.side-resizer{position:absolute;top:0;right:-6px;width:12px;height:100%;cursor:col-resize;z-index:55}
|
||||
.side-resizer::after{content:'';position:absolute;top:0;right:5px;width:3px;height:100%;transition:background .2s}
|
||||
.side-resizer:hover::after,.side-resizer.resizing::after{background:#1a5276}
|
||||
body.dragging-side,body.dragging-side *{user-select:none;cursor:col-resize}
|
||||
body.dragging-side .sidebar,body.dragging-side .main{transition:none}
|
||||
/* ── topbar ── */
|
||||
.topbar{display:flex;align-items:center;gap:12px;padding:14px 24px;background:var(--panel);border-bottom:1px solid var(--border);flex-wrap:wrap}
|
||||
.topbar h1{font-size:1.15rem;font-weight:700;white-space:nowrap;margin-right:auto}
|
||||
.btn-group{display:flex;gap:0}
|
||||
.btn-group .btn{border-radius:0}.btn-group .btn:first-child{border-radius:6px 0 0 6px}.btn-group .btn:last-child{border-radius:0 6px 6px 0}
|
||||
.btn{padding:6px 14px;font-size:.82rem;border:1px solid var(--border);background:transparent;color:var(--text);cursor:pointer;transition:background .2s}
|
||||
.btn:hover{background:var(--hover)}
|
||||
.btn.active{background:var(--accent);border-color:#1a5276}
|
||||
.date-inputs{display:flex;align-items:center;gap:6px}
|
||||
.date-inputs input[type=date]{background:var(--bg);border:1px solid var(--border);color:var(--text);padding:5px 8px;border-radius:6px;font-size:.82rem}
|
||||
.date-inputs span{font-size:.8rem;opacity:.6}
|
||||
.icon-btn{width:34px;height:34px;display:flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:6px;background:transparent;color:var(--text);cursor:pointer;font-size:1rem;transition:background .2s}
|
||||
.icon-btn:hover{background:var(--hover)}
|
||||
/* ── chart series toggles ── */
|
||||
.chart-toggles{position:absolute;top:14px;right:14px;z-index:5;display:flex;gap:10px}
|
||||
.toggle-btn{display:flex;align-items:center;gap:7px;padding:5px 12px;border:1px solid var(--border);border-radius:16px;background:transparent;color:var(--text);cursor:pointer;font-size:.78rem;opacity:.55;transition:opacity .2s,background .2s}
|
||||
.toggle-btn:hover{background:var(--hover)}
|
||||
.toggle-btn.active{opacity:1;background:var(--accent);border-color:#1a5276}
|
||||
.sw{width:26px;height:14px;border-radius:7px;background:#3a3a55;position:relative;flex-shrink:0;transition:background .2s}
|
||||
.sw::after{content:'';position:absolute;top:2px;left:2px;width:10px;height:10px;border-radius:50%;background:#999;transition:left .2s,background .2s}
|
||||
.toggle-btn.active .sw{background:#1a5276}
|
||||
.toggle-btn.active .sw::after{left:14px;background:#fff}
|
||||
/* ── chart area ── */
|
||||
.chart-wrap{flex:1;padding:24px;position:relative;min-height:0}
|
||||
.chart-box{background:var(--panel);border:1px solid var(--border);border-radius:12px;height:100%;padding:20px;position:relative}
|
||||
.chart-box canvas{width:100%!important;height:100%!important}
|
||||
.empty-state{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;opacity:.5}
|
||||
.empty-state svg{width:48px;height:48px}
|
||||
/* ── sidebar sections ── */
|
||||
.sidebar-section{padding:16px 14px 18px}
|
||||
.sidebar-section+.sidebar-section{border-top:1px solid var(--border)}
|
||||
.sidebar-title{font-size:.78rem;text-transform:uppercase;letter-spacing:.08em;opacity:.5;margin-bottom:12px;font-weight:600}
|
||||
.ch-item{display:flex;flex-wrap:wrap;align-items:center;padding:7px 0;cursor:pointer;user-select:none;font-size:.9rem;position:relative;padding-right:32px}
|
||||
.ch-item:hover{background:rgba(26,58,110,.35);border-radius:8px}
|
||||
.ch-item+.ch-item{border-top:1px solid rgba(15,52,96,.35)}
|
||||
.ch-main{display:flex;align-items:center;gap:8px;min-width:0}
|
||||
.ch-status{width:100%;font-size:.72rem;opacity:.85;margin-top:3px;padding-left:23px}
|
||||
.ch-source{width:100%;font-size:.68rem;opacity:.55;margin-top:2px;padding-left:23px;word-break:break-all}
|
||||
.ch-bills{width:100%;margin-top:4px;padding-left:23px;font-size:.7rem}
|
||||
.ch-bills-head{display:flex;align-items:center;justify-content:space-between;gap:6px;margin-bottom:2px}
|
||||
.bill-title{opacity:.6}
|
||||
.bill-sync-btn{font-size:.66rem;padding:1px 8px;border:1px solid var(--border);background:transparent;color:var(--text);border-radius:6px;cursor:pointer;opacity:.85}
|
||||
.bill-sync-btn:hover{background:var(--hover)}
|
||||
.bill-sync-btn:disabled{opacity:.45;cursor:wait}
|
||||
.bill-row{display:flex;justify-content:space-between;align-items:center;padding:1px 0;opacity:.75;font-family:Consolas,monospace}
|
||||
.bill-total{border-top:1px dashed rgba(255,255,255,.14);margin-top:2px;padding-top:2px;opacity:.95;font-weight:600}
|
||||
.bill-empty{opacity:.5;padding:2px 0}
|
||||
.status-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;background:#95a5a6}
|
||||
.status-dot.ok{background:#2ecc71;box-shadow:0 0 6px #2ecc71}
|
||||
.status-dot.warn{background:#f39c12}
|
||||
.status-dot.err{background:#e74c3c}
|
||||
.status-dot.off{background:#7f8c8d}
|
||||
.sys-row{display:flex;align-items:center;gap:8px;padding:5px 0;font-size:.82rem}
|
||||
.sys-row span:last-of-type{word-break:break-all}
|
||||
.sys-hint{font-size:.7rem;opacity:.55;margin-top:6px;line-height:1.5}
|
||||
.ch-item input[type=checkbox]{accent-color:var(--accent);width:15px;height:15px;cursor:pointer}
|
||||
.ch-tag{font-size:.65rem;padding:1px 6px;border-radius:4px;background:var(--accent);opacity:.7;margin-left:auto}
|
||||
.ch-tag.mode-tag{margin-left:4px;background:transparent;border:1px solid;opacity:1}
|
||||
.ch-tag.mode-tag.tag-balance{border-color:#f39c12;color:#f39c12}
|
||||
.ch-tag.mode-tag.tag-used{border-color:#2ecc71;color:#2ecc71}
|
||||
/* ── channel more menu ── */
|
||||
.ch-more{position:absolute;top:6px;right:4px;width:24px;height:24px;display:flex;align-items:center;justify-content:center;border:none;background:transparent;color:var(--text);opacity:.55;cursor:pointer;border-radius:6px;font-size:1rem;line-height:1}
|
||||
.ch-more:hover{opacity:1;background:var(--hover)}
|
||||
.ch-menu{position:absolute;top:34px;right:4px;background:var(--panel);border:1px solid var(--border);border-radius:8px;box-shadow:0 6px 18px rgba(0,0,0,.5);min-width:120px;z-index:30;display:none;overflow:hidden}
|
||||
.ch-menu button{display:block;width:100%;padding:8px 14px;background:transparent;border:none;color:var(--text);text-align:left;font-size:.82rem;cursor:pointer}
|
||||
.ch-menu button:hover{background:var(--hover)}
|
||||
.ch-menu .danger{color:var(--danger)}
|
||||
.ch-menu .danger:hover{background:rgba(231,76,60,.14)}
|
||||
.subtotal-row{display:flex;justify-content:space-between;align-items:center;padding:6px 0;font-size:.85rem}
|
||||
.subtotal-row .cost{font-variant-numeric:tabular-nums}
|
||||
.subtotal-row .remaining{font-size:.75rem;opacity:.6;font-variant-numeric:tabular-nums;margin-left:6px}
|
||||
.add-btn{width:100%;padding:10px;border:1px dashed var(--border);border-radius:8px;background:transparent;color:var(--text);cursor:pointer;font-size:.88rem;transition:background .2s,border-color .2s;margin-top:6px}
|
||||
.add-btn:hover{background:var(--hover);border-color:#1a5276}
|
||||
/* ── modal ── */
|
||||
.overlay{position:fixed;inset:0;background:rgba(0,0,0,.55);display:none;align-items:center;justify-content:center;z-index:100}
|
||||
.overlay.open{display:flex}
|
||||
.modal{background:var(--panel);border:1px solid var(--border);border-radius:14px;width:420px;max-width:92vw;padding:28px;position:relative}
|
||||
.modal h2{font-size:1.05rem;margin-bottom:18px}
|
||||
.form-group{margin-bottom:14px}
|
||||
.form-group label{display:block;font-size:.8rem;margin-bottom:5px;opacity:.7}
|
||||
.form-group input,.form-group select{width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:.88rem}
|
||||
.form-group input:focus,.form-group select:focus{outline:none;border-color:#1a5276}
|
||||
.modal-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:20px}
|
||||
.modal-actions .btn{padding:8px 20px;border-radius:6px}
|
||||
.btn-primary{background:var(--accent);border-color:#1a5276}
|
||||
.btn-primary:hover{background:#1a5276}
|
||||
/* ── toast ── */
|
||||
.toast{position:fixed;top:20px;right:20px;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:12px 20px;font-size:.88rem;z-index:200;opacity:0;transform:translateY(-10px);transition:all .3s;pointer-events:none}
|
||||
.toast.show{opacity:1;transform:translateY(0)}
|
||||
.toast.error{border-color:var(--danger)}
|
||||
/* ── hamburger ── */
|
||||
.hamburger{display:none;width:34px;height:34px;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:6px;background:transparent;color:var(--text);cursor:pointer;font-size:1.2rem}
|
||||
/* ── loading ── */
|
||||
.spinner{display:inline-block;width:18px;height:18px;border:2px solid var(--border);border-top-color:var(--text);border-radius:50%;animation:spin .6s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.loading-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(22,33,62,.7);border-radius:12px;z-index:10;gap:10px;font-size:.9rem}
|
||||
/* ── responsive ── */
|
||||
@media(max-width:768px){
|
||||
.sidebar{position:fixed;left:0;top:0;bottom:0;z-index:50;margin-left:calc(var(--sidebar-w)*-1)}
|
||||
.sidebar.open{margin-left:0}
|
||||
.main{margin-left:0}
|
||||
.hamburger{display:flex}
|
||||
.side-toggle{display:none}
|
||||
.sidebar-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:40;display:none}
|
||||
.sidebar-backdrop.open{display:block}
|
||||
}
|
||||
@media(min-width:769px){
|
||||
/* 收起/展开只平移坐标(transform),不修改面板宽度,内容全程不被挤压 */
|
||||
.app.collapsed .sidebar{transform:translateX(-100%)}
|
||||
.app.collapsed .main{margin-left:0}
|
||||
.app.collapsed .side-toggle{left:14px;transform:rotate(180deg)}
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="token-meter.css">
|
||||
<link rel="stylesheet" href="theme-dark.css" id="themeCss">
|
||||
<script>/* 首帧前恢复上次选中的主题,避免刷新时闪回默认配色 */
|
||||
try{var t=localStorage.getItem('tmTheme');if({'light':1,'dark':1,'azure':1,'fire':1,'orange':1,'emerald':1}[t]){document.getElementById('themeCss').href='theme-'+t+'.css';}}catch(e){}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app" id="app">
|
||||
@@ -184,6 +57,7 @@ body.dragging-side .sidebar,body.dragging-side .main{transition:none}
|
||||
<button class="icon-btn" id="autoRefreshBtn" title="自动刷新:开(数据变化时自动重拉图表)" onclick="toggleAutoRefresh()">⟳</button>
|
||||
<button class="icon-btn" title="刷新" onclick="refresh()">↻</button>
|
||||
<button class="icon-btn" title="导出 CSV" onclick="exportCSV()">⤓</button>
|
||||
<span class="theme-wrap"><button class="icon-btn" id="themeBtn" title="切换界面主题" onclick="toggleThemePop()">🎨</button><div class="theme-pop" id="themePop"></div></span>
|
||||
</div>
|
||||
<div class="chart-wrap">
|
||||
<div class="chart-box" id="chartBox">
|
||||
@@ -227,7 +101,7 @@ body.dragging-side .sidebar,body.dragging-side .main{transition:none}
|
||||
<div class="form-group">
|
||||
<label id="fKeyLabel">API Key(可选)</label>
|
||||
<input type="password" id="fKey" placeholder="sk-...(阿里云百炼填写 AccessKeyId:AccessKeySecret)">
|
||||
<small id="fKeyHint" style="display:none;color:#888">内网平台:粘贴浏览器会话 Cookie(F12 → Network → 任意请求 → Request Headers → Cookie 全值),服务端即可定时直连采集,无需保持页面打开</small>
|
||||
<small id="fKeyHint" style="display:none;color:var(--text);opacity:.7">内网平台:粘贴浏览器会话 Cookie(F12 → Network → 任意请求 → Request Headers → Cookie 全值),服务端即可定时直连采集,无需保持页面打开</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>计费模式</label>
|
||||
@@ -258,6 +132,12 @@ body.dragging-side .sidebar,body.dragging-side .main{transition:none}
|
||||
/* ── config ── */
|
||||
const API_BASE = window.location.origin;
|
||||
const COLORS = ['#e74c3c','#3498db','#2ecc71','#f39c12','#9b59b6','#1abc9c','#e67e22','#3498db'];
|
||||
// 界面主题:名称 -> 中文名(theme-*.css 变量文件由 <link id="themeCss"> 持有,切换即换样式表)
|
||||
const THEMES={dark:'黑暗',light:'明亮',azure:'蔚蓝',fire:'火红',orange:'橙黄',emerald:'翠绿'};
|
||||
// 主题弹层里的色块预览:用各主题强调色
|
||||
const THEME_SWATCHES={dark:'#0f3460',light:'#2563eb',azure:'#3b82f6',fire:'#ef4444',orange:'#f59e0b',emerald:'#10b981'};
|
||||
let currentTheme=(()=>{try{const t=localStorage.getItem('tmTheme');return THEMES[t]?t:'dark';}catch(e){return 'dark';}})();
|
||||
const billCollapsed=new Set(); // 历史账单折叠集(渠道 id),不随侧边栏重建丢失
|
||||
const BASE_URLS = {
|
||||
'DeepSeek':'https://api.deepseek.com',
|
||||
'阿里云百炼':'https://dashscope.aliyuncs.com',
|
||||
@@ -284,6 +164,7 @@ const staleAlerted = {}; // 已提示过「停止更新」的渠道 id
|
||||
|
||||
/* ── init ── */
|
||||
async function init(){
|
||||
renderThemePop();
|
||||
showLoading(true);
|
||||
try{
|
||||
await loadChannels();
|
||||
@@ -402,7 +283,7 @@ const crosshairPlugin={
|
||||
if(!Number.isFinite(x)||x<area.left||x>area.right) return;
|
||||
const ctx=chart.ctx;
|
||||
ctx.save();
|
||||
ctx.strokeStyle='rgba(255,255,255,.45)';
|
||||
ctx.strokeStyle=getComputedStyle(document.documentElement).getPropertyValue('--crosshair').trim()||'rgba(255,255,255,.45)';
|
||||
ctx.lineWidth=1;
|
||||
ctx.setLineDash([4,4]);
|
||||
ctx.beginPath();
|
||||
@@ -418,6 +299,11 @@ function renderChart(){
|
||||
const allLabels=new Set();
|
||||
const series=[];
|
||||
let hasData=false;
|
||||
// 图表配色随主题:从当前主题 CSS 变量读取(canvas 无法解析 var(),渲染前已读为实际色值)
|
||||
const cssVar=(n)=>getComputedStyle(document.documentElement).getPropertyValue(n).trim()||'';
|
||||
const chartText=cssVar('--chart-text')||'#e0e0e0';
|
||||
const chartGrid=cssVar('--chart-grid')||'rgba(255,255,255,.06)';
|
||||
const tooltipBg=cssVar('--tooltip-bg')||cssVar('--panel')||'#16213e';
|
||||
// 第一遍:收集各渠道原始采样点 + 计算差值小时均摊(相邻两次采样的差值平均分配到中间每个整点小时)
|
||||
channels.forEach((ch,i)=>{
|
||||
if(!selectedChannels.has(ch.id)) return;
|
||||
@@ -445,7 +331,7 @@ function renderChart(){
|
||||
}
|
||||
}
|
||||
diffBuckets.forEach((v,k)=>allLabels.add(k));
|
||||
series.push({ch,rawByDate,diffBuckets,color:COLORS[i%COLORS.length]});
|
||||
series.push({ch,rawByDate,diffBuckets,color:ch.color||COLORS[i%COLORS.length]});
|
||||
});
|
||||
|
||||
const labels=[...allLabels].sort();
|
||||
@@ -545,21 +431,21 @@ function renderChart(){
|
||||
chart.update('none');
|
||||
},
|
||||
scales:{
|
||||
x:{type:'category',ticks:{color:'#e0e0e0',maxRotation:45,maxTicksLimit:14,callback:v=>String(v).slice(5).replace(':00','时')},grid:{color:'rgba(255,255,255,.06)'}},
|
||||
x:{type:'category',ticks:{color:chartText,maxRotation:45,maxTicksLimit:14,callback:v=>String(v).slice(5).replace(':00','时')},grid:{color:chartGrid}},
|
||||
y:{
|
||||
display:showUsage,
|
||||
ticks:{color:'#e0e0e0',callback:v=>'¥'+v},
|
||||
grid:{color:'rgba(255,255,255,.06)'},
|
||||
ticks:{color:chartText,callback:v=>'¥'+v},
|
||||
grid:{color:chartGrid},
|
||||
beginAtZero:true,
|
||||
title:{display:showUsage,text:'用量 ¥',color:'#e0e0e0'}
|
||||
title:{display:showUsage,text:'用量 ¥',color:chartText}
|
||||
},
|
||||
y2:{
|
||||
display:showDiff,
|
||||
position:'right',
|
||||
ticks:{color:'#e0e0e0',callback:v=>'¥'+v},
|
||||
ticks:{color:chartText,callback:v=>'¥'+v},
|
||||
grid:{drawOnChartArea:false},
|
||||
beginAtZero:true,
|
||||
title:{display:showDiff,text:'差值 ¥',color:'#e0e0e0'}
|
||||
title:{display:showDiff,text:'差值 ¥',color:chartText}
|
||||
}
|
||||
},
|
||||
plugins:{
|
||||
@@ -587,6 +473,11 @@ function renderChart(){
|
||||
tooltip:{
|
||||
// 跟随鼠标:tooltip 钉在光标位置,数据为光标垂直对应的各点位(index 模式聚合全部渠道的用量与差值)
|
||||
position:'follow',
|
||||
backgroundColor:tooltipBg,
|
||||
borderColor:cssVar('--border')||'#0f3460',
|
||||
borderWidth:1,
|
||||
titleColor:chartText,
|
||||
bodyColor:chartText,
|
||||
callbacks:{
|
||||
// 吸附兜底命中时标题显示最近数据点时刻,并加「≈ 」区分于精确命中
|
||||
title:(items)=>{
|
||||
@@ -645,17 +536,24 @@ function manualUpdateChannel(id){
|
||||
});
|
||||
}
|
||||
/* ── 历史账单:手动从平台同步(按月),仅账单调口渠道(阿里云百炼)展示 ── */
|
||||
const BILLS_MAX_SHOWN=12; // 侧边栏最多直接列出最近 12 个月,更早的折叠为一行提示
|
||||
function billBlockHtml(ch){
|
||||
const bills=ch.bills||[];
|
||||
if(!(ch.platform==='阿里云百炼'&&ch.billing_model==='balance')) return '';
|
||||
const rows=bills.map(b=>`<div class="bill-row"><span>${b.month}</span><span>¥${fmtMoney(b.total)}</span></div>`).join('');
|
||||
const shown=bills.slice(-BILLS_MAX_SHOWN); // bills 按月份升序,取最近 N 个月
|
||||
const rows=shown.map(b=>`<div class="bill-row"><span>${b.month}</span><span>¥${fmtMoney(b.total)}</span></div>`).join('');
|
||||
const hidden=bills.length-shown.length;
|
||||
const more=hidden>0?`<div class="bill-row bill-more"><span>… 更早 ${hidden} 个月(自 ${bills[0].month} 起)</span></div>`:'';
|
||||
const total=bills.reduce((s,b)=>s+Number(b.total),0);
|
||||
const list=bills.length?rows+`<div class="bill-row bill-total"><span>合计</span><span>¥${fmtMoney(total)}</span></div>`:'<div class="bill-empty">未同步 · 点上方按钮拉取平台账单</div>';
|
||||
return `<div class="ch-bills"><div class="ch-bills-head"><span class="bill-title">历史账单</span><button class="bill-sync-btn" id="billSyncBtn${ch.id}" onclick="syncBills(${ch.id})">⟳ 同步历史账单</button></div>${list}</div>`;
|
||||
const list=bills.length?rows+more+`<div class="bill-row bill-total"><span>合计</span><span>¥${fmtMoney(total)}</span></div>`:'<div class="bill-empty">未同步 · 点 ⋮ 更多 → 同步历史账单</div>';
|
||||
// 头部可点击折叠/收起整个账单区块;列表体为滚动框
|
||||
const collapsed=billCollapsed.has(ch.id);
|
||||
const count=bills.length?`(${bills.length} 个月)`:'';
|
||||
return `<div class="ch-bills ${collapsed?'collapsed':''}"><div class="ch-bills-head" onclick="toggleBills(${ch.id})" title="点击折叠 / 展开"><span class="bill-title"><span class="bill-caret">${collapsed?'▸':'▾'}</span>历史账单${count}</span></div><div class="bill-body">${list}</div></div>`;
|
||||
}
|
||||
async function syncBills(id){
|
||||
const btn=document.getElementById('billSyncBtn'+id);
|
||||
if(btn){btn.disabled=true;btn.textContent='同步中…';}
|
||||
async function syncBills(id,ev){
|
||||
if(ev){ev.stopPropagation();closeMenus();}
|
||||
showToast('开始同步历史账单…');
|
||||
try{
|
||||
const res=await fetch(`${API_BASE}/api/channels/${id}/sync-bills`,{method:'POST'});
|
||||
const data=await res.json().catch(()=>({}));
|
||||
@@ -669,7 +567,6 @@ async function syncBills(id){
|
||||
showToast(`历史账单同步完成:${data.synced} 个月,合计 ¥${fmtMoney(data.total)}${curveTip}`);
|
||||
}catch(e){
|
||||
showToast(e.message,true);
|
||||
if(btn){btn.disabled=false;btn.textContent='⟳ 同步历史账单';}
|
||||
}
|
||||
}
|
||||
function renderSidebar(){
|
||||
@@ -680,7 +577,10 @@ function renderSidebar(){
|
||||
div.className='ch-item';
|
||||
div.title=ch.billing_model==='balance'?'点击此渠道立即手动采集一次(10 秒冷却)':'点击测试连接(订阅制数据由采集脚本推送)';
|
||||
const checked=selectedChannels.has(ch.id)?'checked':'';
|
||||
div.innerHTML=`<div class="ch-main"><input type="checkbox" ${checked} onchange="toggleChannel(${ch.id})"><span>${esc(ch.name)}</span><span class="ch-tag">${esc(ch.platform||'')}</span>${ch.billing_model==='balance'?'<span class="ch-tag mode-tag tag-balance">按量</span>':'<span class="ch-tag mode-tag tag-used">订阅</span>'}<span class="status-dot" id="chDot${ch.id}"></span></div><div class="ch-status" id="chStat${ch.id}"></div><div class="ch-source" id="chSrc${ch.id}"></div>${billBlockHtml(ch)}<button class="ch-more" onclick="toggleChannelMenu(${ch.id},event)" title="更多操作">⋮</button><div class="ch-menu" id="chMenu${ch.id}"><button onclick="openEditChannel(${ch.id},event)">✎ 修改</button><button class="danger" onclick="removeChannel(${ch.id},event)">🗑 删除</button></div>`;
|
||||
// ⋮ 更多菜单:同步历史账单入口仅按量(余额)计费的百炼渠道提供;图表颜色所有渠道可改
|
||||
const billMenu=(ch.platform==='阿里云百炼'&&ch.billing_model==='balance')?`<button onclick="syncBills(${ch.id},event)">⟳ 同步历史账单</button>`:'';
|
||||
const colorMenu=`<button onclick="changeChannelColor(${ch.id},event)">🎨 图表颜色</button>`;
|
||||
div.innerHTML=`<div class="ch-main"><input type="checkbox" ${checked} onchange="toggleChannel(${ch.id})"><span>${esc(ch.name)}</span><span class="ch-tag">${esc(ch.platform||'')}</span>${ch.billing_model==='balance'?'<span class="ch-tag mode-tag tag-balance">按量</span>':'<span class="ch-tag mode-tag tag-used">订阅</span>'}<span class="status-dot" id="chDot${ch.id}"></span></div><div class="ch-status" id="chStat${ch.id}"></div><div class="ch-source" id="chSrc${ch.id}"></div>${billBlockHtml(ch)}<button class="ch-more" onclick="toggleChannelMenu(${ch.id},event)" title="更多操作">⋮</button><div class="ch-menu" id="chMenu${ch.id}">${billMenu}${colorMenu}<button onclick="openEditChannel(${ch.id},event)">✎ 修改</button><button class="danger" onclick="removeChannel(${ch.id},event)">🗑 删除</button></div>`;
|
||||
// 点击条目主体(排除 checkbox、⋮ 按钮与弹出菜单)触发手动采集
|
||||
div.onclick=(ev)=>{
|
||||
if(ev.target.closest('input,button,.ch-menu')) return;
|
||||
@@ -994,6 +894,63 @@ function openEditChannel(id,ev){
|
||||
if(ch) openModal(ch);
|
||||
}
|
||||
|
||||
/* ── theme switcher ── */
|
||||
function renderThemePop(){
|
||||
const pop=document.getElementById('themePop');
|
||||
if(!pop) return;
|
||||
pop.innerHTML=Object.entries(THEMES).map(([k,v])=>`<button class="theme-opt ${currentTheme===k?'active':''}" data-theme="${k}" onclick="setTheme('${k}')"><span class="theme-swatch" style="background:${THEME_SWATCHES[k]}"></span>${v}</button>`).join('');
|
||||
}
|
||||
function setTheme(name){
|
||||
if(!THEMES[name]) return;
|
||||
currentTheme=name;
|
||||
const link=document.getElementById('themeCss');
|
||||
if(link) link.href='theme-'+name+'.css'; // 直接切换样式表:变量文件覆盖为整套主题配色
|
||||
try{localStorage.setItem('tmTheme',name);}catch(e){}
|
||||
document.querySelectorAll('.theme-opt').forEach(o=>o.classList.toggle('active',o.dataset.theme===name));
|
||||
toggleThemePop(false);
|
||||
renderChart(); // 图表配色从 CSS 变量重读(数据在 usageData 缓存中,无需重新请求)
|
||||
}
|
||||
function toggleThemePop(force){
|
||||
const pop=document.getElementById('themePop');
|
||||
if(!pop) return;
|
||||
const open=force!==undefined?force:!pop.classList.contains('open');
|
||||
pop.classList.toggle('open',open);
|
||||
}
|
||||
|
||||
/* 历史账单折叠/展开(状态存内存,不随侧边栏重建丢失) */
|
||||
function toggleBills(id){
|
||||
if(billCollapsed.has(id)) billCollapsed.delete(id); else billCollapsed.add(id);
|
||||
renderSidebar();
|
||||
}
|
||||
|
||||
/* 渠道图表颜色:弹出系统取色器(showPicker 需要用户手势,故在菜单点击中同步触发),选择后保存到后端并重绘 */
|
||||
function changeChannelColor(id,ev){
|
||||
if(ev) ev.stopPropagation();
|
||||
closeMenus();
|
||||
const ch=channels.find(c=>c.id===id);
|
||||
if(!ch) return;
|
||||
const input=document.createElement('input');
|
||||
input.type='color';
|
||||
input.value=/^#[0-9a-f]{6}$/i.test(ch.color||'')?ch.color:'#3498db';
|
||||
input.style.cssText='position:fixed;left:0;top:0;opacity:0;pointer-events:none';
|
||||
input.onchange=async()=>{
|
||||
input.remove();
|
||||
try{
|
||||
const res=await fetch(`${API_BASE}/api/channels/${id}`,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:ch.name,platform:ch.platform,api_base_url:ch.api_base_url||'',api_key:ch.api_key||'',billing_model:ch.billing_model,alert_threshold:ch.alert_threshold==null?'':ch.alert_threshold,spend_alert_threshold:ch.spend_alert_threshold==null?'':ch.spend_alert_threshold,color:input.value})});
|
||||
const data=await res.json().catch(()=>({}));
|
||||
if(!res.ok) throw new Error(data.message||'保存失败');
|
||||
await loadChannels();
|
||||
renderSidebar();
|
||||
renderChart();
|
||||
showToast(`已更新「${ch.name}」图表颜色`);
|
||||
}catch(e){showToast(e.message,true)}
|
||||
};
|
||||
input.oncancel=()=>input.remove();
|
||||
input.onkeydown=()=>input.remove();
|
||||
document.body.appendChild(input);
|
||||
try{input.showPicker();}catch(e){input.remove();} // 浏览器不支持 showPicker 时直接放弃弹出
|
||||
}
|
||||
|
||||
async function removeChannel(id,ev){
|
||||
if(ev) ev.stopPropagation();
|
||||
closeMenus();
|
||||
|
||||
+39
-11
@@ -308,6 +308,15 @@ async function ensureSchema() {
|
||||
await pool.query('ALTER TABLE `ai_usage_points` ADD COLUMN `is_bill_history` TINYINT(1) NOT NULL DEFAULT 0');
|
||||
}
|
||||
|
||||
// ── 确保 channels 有 color 图表主题色列(渠道在图中的自定义颜色,为空时前端用默认调色板轮询)──
|
||||
const [channelColorCols] = await pool.query(
|
||||
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'channels' AND COLUMN_NAME = 'color'",
|
||||
[dbConfig.database]
|
||||
);
|
||||
if (!channelColorCols.length) {
|
||||
await pool.query('ALTER TABLE `channels` ADD COLUMN `color` VARCHAR(9) NULL');
|
||||
}
|
||||
|
||||
// ── 重建主键以包含 channel_id ──
|
||||
const [pkCheck] = await pool.query(`
|
||||
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
|
||||
@@ -564,12 +573,19 @@ async function handleSync(body) {
|
||||
return { inserted, assignments: assignments.length };
|
||||
}
|
||||
|
||||
// ── 静态文件服务 ──
|
||||
function serveHtml(response, filePath) {
|
||||
// ── 静态文件服务(根据扩展名推断 Content-Type,支持 html/css,白名单外一律 404)──
|
||||
function serveStatic(response, filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const typeMap = { '.html': 'text/html', '.css': 'text/css' };
|
||||
const contentType = typeMap[ext];
|
||||
if (!contentType) {
|
||||
response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8', ...corsHeaders() });
|
||||
return response.end('文件类型不支持');
|
||||
}
|
||||
const fullPath = path.join(__dirname, filePath);
|
||||
try {
|
||||
const content = fs.readFileSync(fullPath, 'utf8');
|
||||
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', ...corsHeaders() });
|
||||
response.writeHead(200, { 'Content-Type': `${contentType}; charset=utf-8`, 'Cache-Control': 'no-store', ...corsHeaders() });
|
||||
response.end(content);
|
||||
} catch (_) {
|
||||
response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8', ...corsHeaders() });
|
||||
@@ -579,7 +595,7 @@ function serveHtml(response, filePath) {
|
||||
|
||||
async function handleGetChannels(response) {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT `id`, `name`, `platform`, `api_base_url`, `api_key`, `billing_model`, `baseline_balance`, `alert_threshold`, `spend_alert_threshold`, `enabled`, `created_at` FROM `channels` ORDER BY `id` ASC'
|
||||
'SELECT `id`, `name`, `platform`, `api_base_url`, `api_key`, `billing_model`, `baseline_balance`, `alert_threshold`, `spend_alert_threshold`, `color`, `enabled`, `created_at` FROM `channels` ORDER BY `id` ASC'
|
||||
);
|
||||
// ── 批量查询每个渠道的 total_usage ──
|
||||
const [usageRows] = await pool.query(
|
||||
@@ -609,6 +625,7 @@ async function handleGetChannels(response) {
|
||||
baseline_balance: r.baseline_balance == null ? null : Number(r.baseline_balance),
|
||||
alert_threshold: r.alert_threshold == null ? null : Number(r.alert_threshold),
|
||||
spend_alert_threshold: r.spend_alert_threshold == null ? null : Number(r.spend_alert_threshold),
|
||||
color: r.color || null,
|
||||
enabled: !!r.enabled,
|
||||
created_at: r.created_at,
|
||||
total_usage: usageMap[r.id] || 0,
|
||||
@@ -1524,17 +1541,25 @@ async function handleUpdateChannel(request, response, idStr) {
|
||||
if (!name || !platform) throw new ApiError(400, '缺少必填字段 name 或 platform。');
|
||||
const billing_model = body.billing_model === 'balance' ? 'balance' : 'used';
|
||||
// 计费模式变更时重置余额基线,下次采集以新模式重新定基
|
||||
const [prevRows] = await pool.query('SELECT `billing_model` FROM `channels` WHERE `id` = ?', [id]);
|
||||
const [prevRows] = await pool.query('SELECT `billing_model`, `color` FROM `channels` WHERE `id` = ?', [id]);
|
||||
if (!prevRows.length) throw new ApiError(404, '渠道未找到。');
|
||||
const modeChanged = prevRows[0].billing_model !== billing_model;
|
||||
// 图表主题色:未提交时保持原值;提交时校验 #rrggbb 格式(空串视为清除回默认调色板)
|
||||
const prevColor = prevRows[0].color;
|
||||
let color = prevColor;
|
||||
if (body.color !== undefined) {
|
||||
if (body.color === '' || body.color === null) color = null;
|
||||
else if (/^#[0-9a-fA-F]{6}$/.test(body.color)) color = body.color;
|
||||
else throw new ApiError(400, '颜色格式须为 #rrggbb。');
|
||||
}
|
||||
const [result] = await pool.query(
|
||||
'UPDATE `channels` SET `name` = ?, `platform` = ?, `api_base_url` = ?, `api_key` = ?, `billing_model` = ?, `alert_threshold` = ?, `spend_alert_threshold` = ? WHERE `id` = ?',
|
||||
[name, platform, api_base_url || null, api_key || null, billing_model, thresholdOrNull(body.alert_threshold), thresholdOrNull(body.spend_alert_threshold), id]
|
||||
'UPDATE `channels` SET `name` = ?, `platform` = ?, `api_base_url` = ?, `api_key` = ?, `billing_model` = ?, `alert_threshold` = ?, `spend_alert_threshold` = ?, `color` = ? WHERE `id` = ?',
|
||||
[name, platform, api_base_url || null, api_key || null, billing_model, thresholdOrNull(body.alert_threshold), thresholdOrNull(body.spend_alert_threshold), color, id]
|
||||
);
|
||||
if (!result.affectedRows) throw new ApiError(404, '渠道未找到。');
|
||||
if (modeChanged) await pool.query('UPDATE `channels` SET `baseline_balance` = NULL WHERE `id` = ?', [id]);
|
||||
const [rows] = await pool.query(
|
||||
'SELECT `id`, `name`, `platform`, `api_base_url`, `api_key`, `billing_model`, `baseline_balance`, `alert_threshold`, `spend_alert_threshold`, `enabled`, `created_at` FROM `channels` WHERE `id` = ?',
|
||||
'SELECT `id`, `name`, `platform`, `api_base_url`, `api_key`, `billing_model`, `baseline_balance`, `alert_threshold`, `spend_alert_threshold`, `color`, `enabled`, `created_at` FROM `channels` WHERE `id` = ?',
|
||||
[id]
|
||||
);
|
||||
return sendJson(response, 200, { channel: rows[0] });
|
||||
@@ -1657,9 +1682,12 @@ async function handleRequest(request, response) {
|
||||
const url = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`);
|
||||
if (request.method === 'OPTIONS') return sendJson(response, 204, { ok: true });
|
||||
|
||||
// ── 静态文件服务(不需要数据库) ──
|
||||
if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '/dashboard.html')) {
|
||||
return serveHtml(response, 'dashboard.html');
|
||||
// ── 静态文件服务(不需要数据库;白名单:首页、仪表盘页、主题样式表)──
|
||||
if (request.method === 'GET') {
|
||||
const isPage = url.pathname === '/' || url.pathname === '/dashboard.html';
|
||||
const isThemeCss = /^\/theme-[a-z]+\.css$/.test(url.pathname);
|
||||
if (isPage) return serveStatic(response, 'dashboard.html');
|
||||
if (url.pathname === '/token-meter.css' || isThemeCss) return serveStatic(response, url.pathname.slice(1));
|
||||
}
|
||||
|
||||
if (!dbConnected || !pool) return sendJson(response, 503, { ok: false, error: dbError || 'MySQL 尚未连接。' });
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/* 主题:蔚蓝 */
|
||||
:root{
|
||||
--bg:#0a1e33;--panel:#0f2a47;--text:#dbeafe;--accent:#1e4e8c;
|
||||
--border:#1e4e8c;--hover:#1b3f66;--danger:#f87171;
|
||||
--accent-strong:#3b82f6;
|
||||
--hover-soft:rgba(59,130,246,.16);
|
||||
--item-border:rgba(59,130,246,.22);
|
||||
--switch-bg:#1e3a5f;--switch-knob:#93c5fd;
|
||||
--danger-soft:rgba(248,113,113,.14);
|
||||
--overlay-soft:rgba(10,30,51,.75);
|
||||
--divider-soft:rgba(255,255,255,.14);
|
||||
--ok:#34d399;--warn:#fbbf24;--err:#f87171;
|
||||
--mask:rgba(0,0,0,.5);
|
||||
--chart-text:#dbeafe;--chart-grid:rgba(147,197,253,.14);
|
||||
--crosshair:rgba(147,197,253,.5);
|
||||
--tooltip-bg:#0f2a47;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/* 主题:黑暗(默认) */
|
||||
:root{
|
||||
--bg:#1a1a2e;--panel:#16213e;--text:#e0e0e0;--accent:#0f3460;
|
||||
--border:#0f3460;--hover:#1a3a6e;--danger:#e74c3c;
|
||||
--accent-strong:#1a5276;
|
||||
--hover-soft:rgba(26,58,110,.35);
|
||||
--item-border:rgba(15,52,96,.35);
|
||||
--switch-bg:#3a3a55;--switch-knob:#999;
|
||||
--danger-soft:rgba(231,76,60,.14);
|
||||
--overlay-soft:rgba(22,33,62,.7);
|
||||
--divider-soft:rgba(255,255,255,.14);
|
||||
--ok:#2ecc71;--warn:#f39c12;--err:#e74c3c;
|
||||
--mask:rgba(0,0,0,.55);
|
||||
--chart-text:#e0e0e0;--chart-grid:rgba(255,255,255,.06);
|
||||
--crosshair:rgba(255,255,255,.45);
|
||||
--tooltip-bg:#16213e;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/* 主题:翠绿 */
|
||||
:root{
|
||||
--bg:#0b2318;--panel:#122f22;--text:#d1fae5;--accent:#14532d;
|
||||
--border:#14532d;--hover:#17452c;--danger:#f87171;
|
||||
--accent-strong:#10b981;
|
||||
--hover-soft:rgba(16,185,129,.16);
|
||||
--item-border:rgba(16,185,129,.22);
|
||||
--switch-bg:#143f2c;--switch-knob:#6ee7b7;
|
||||
--danger-soft:rgba(248,113,113,.14);
|
||||
--overlay-soft:rgba(11,35,24,.75);
|
||||
--divider-soft:rgba(255,255,255,.14);
|
||||
--ok:#34d399;--warn:#fbbf24;--err:#f87171;
|
||||
--mask:rgba(0,0,0,.5);
|
||||
--chart-text:#d1fae5;--chart-grid:rgba(110,231,183,.14);
|
||||
--crosshair:rgba(110,231,183,.5);
|
||||
--tooltip-bg:#122f22;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/* 主题:火红 */
|
||||
:root{
|
||||
--bg:#2a0d0d;--panel:#3c1616;--text:#fde8e8;--accent:#7f1d1d;
|
||||
--border:#7f1d1d;--hover:#551c1c;--danger:#f87171;
|
||||
--accent-strong:#ef4444;
|
||||
--hover-soft:rgba(239,68,68,.16);
|
||||
--item-border:rgba(239,68,68,.22);
|
||||
--switch-bg:#4a1d1d;--switch-knob:#fca5a5;
|
||||
--danger-soft:rgba(248,113,113,.16);
|
||||
--overlay-soft:rgba(42,13,13,.75);
|
||||
--divider-soft:rgba(255,255,255,.14);
|
||||
--ok:#34d399;--warn:#fbbf24;--err:#f87171;
|
||||
--mask:rgba(0,0,0,.5);
|
||||
--chart-text:#fde8e8;--chart-grid:rgba(252,165,165,.14);
|
||||
--crosshair:rgba(252,165,165,.5);
|
||||
--tooltip-bg:#3c1616;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/* 主题:明亮 */
|
||||
:root{
|
||||
--bg:#f2f4f8;--panel:#ffffff;--text:#28303e;--accent:#dbe7ff;
|
||||
--border:#ccd6e4;--hover:#e9f0fa;--danger:#dc2626;
|
||||
--accent-strong:#2563eb;
|
||||
--hover-soft:rgba(37,99,235,.08);
|
||||
--item-border:rgba(37,99,235,.14);
|
||||
--switch-bg:#cbd5e1;--switch-knob:#ffffff;
|
||||
--danger-soft:rgba(220,38,38,.1);
|
||||
--overlay-soft:rgba(255,255,255,.8);
|
||||
--divider-soft:rgba(0,0,0,.12);
|
||||
--ok:#16a34a;--warn:#d97706;--err:#dc2626;
|
||||
--mask:rgba(31,42,62,.45);
|
||||
--chart-text:#4b5563;--chart-grid:rgba(0,0,0,.08);
|
||||
--crosshair:rgba(0,0,0,.35);
|
||||
--tooltip-bg:#ffffff;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/* 主题:橙黄 */
|
||||
:root{
|
||||
--bg:#2b1a0c;--panel:#3d2812;--text:#ffedd5;--accent:#8a4f12;
|
||||
--border:#8a4f12;--hover:#543318;--danger:#f87171;
|
||||
--accent-strong:#f59e0b;
|
||||
--hover-soft:rgba(245,158,11,.16);
|
||||
--item-border:rgba(245,158,11,.22);
|
||||
--switch-bg:#4a2f14;--switch-knob:#fcd34d;
|
||||
--danger-soft:rgba(248,113,113,.14);
|
||||
--overlay-soft:rgba(43,26,12,.75);
|
||||
--divider-soft:rgba(255,255,255,.14);
|
||||
--ok:#34d399;--warn:#fbbf24;--err:#f87171;
|
||||
--mask:rgba(0,0,0,.5);
|
||||
--chart-text:#ffedd5;--chart-grid:rgba(252,211,77,.14);
|
||||
--crosshair:rgba(252,211,77,.5);
|
||||
--tooltip-bg:#3d2812;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/* Token Meter 基础样式:布局与组件全部引用主题变量(变量定义见 theme-*.css,由 <link id="themeCss"> 切换) */
|
||||
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
|
||||
:root{--sidebar-w:260px}
|
||||
html,body{height:100%;font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text)}
|
||||
/* ── layout ── */
|
||||
.app{display:flex;height:100vh;overflow:hidden;position:relative}
|
||||
.sidebar{position:absolute;left:0;top:0;bottom:0;width:var(--sidebar-w);min-width:var(--sidebar-w);background:var(--panel);display:flex;flex-direction:column;border-right:1px solid var(--border);overflow-y:auto;transition:transform .3s}
|
||||
.main{flex:1;display:flex;flex-direction:column;overflow:hidden;margin-left:var(--sidebar-w);transition:margin-left .3s}
|
||||
.side-toggle{position:absolute;top:14px;left:calc(var(--sidebar-w) - 32px);z-index:60;width:28px;height:28px;display:flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:6px;background:var(--panel);color:var(--text);cursor:pointer;font-size:1.05rem;font-weight:700;transition:left .3s,transform .3s,background .2s}
|
||||
.side-toggle:hover{background:var(--hover)}
|
||||
/* ── sidebar drag resizer:拖拽热区在右缘,竖条悬停高亮;拖拽期间禁用过渡与文本选中 ── */
|
||||
.side-resizer{position:absolute;top:0;right:-6px;width:12px;height:100%;cursor:col-resize;z-index:55}
|
||||
.side-resizer::after{content:'';position:absolute;top:0;right:5px;width:3px;height:100%;transition:background .2s}
|
||||
.side-resizer:hover::after,.side-resizer.resizing::after{background:var(--accent-strong)}
|
||||
body.dragging-side,body.dragging-side *{user-select:none;cursor:col-resize}
|
||||
body.dragging-side .sidebar,body.dragging-side .main{transition:none}
|
||||
/* ── topbar ── */
|
||||
.topbar{display:flex;align-items:center;gap:12px;padding:14px 24px;background:var(--panel);border-bottom:1px solid var(--border);flex-wrap:wrap}
|
||||
.topbar h1{font-size:1.15rem;font-weight:700;white-space:nowrap;margin-right:auto}
|
||||
.btn-group{display:flex;gap:0}
|
||||
.btn-group .btn{border-radius:0}.btn-group .btn:first-child{border-radius:6px 0 0 6px}.btn-group .btn:last-child{border-radius:0 6px 6px 0}
|
||||
.btn{padding:6px 14px;font-size:.82rem;border:1px solid var(--border);background:transparent;color:var(--text);cursor:pointer;transition:background .2s}
|
||||
.btn:hover{background:var(--hover)}
|
||||
.btn.active{background:var(--accent);border-color:var(--accent-strong)}
|
||||
.date-inputs{display:flex;align-items:center;gap:6px}
|
||||
.date-inputs input[type=date]{background:var(--bg);border:1px solid var(--border);color:var(--text);padding:5px 8px;border-radius:6px;font-size:.82rem}
|
||||
.date-inputs span{font-size:.8rem;opacity:.6}
|
||||
.icon-btn{width:34px;height:34px;display:flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:6px;background:transparent;color:var(--text);cursor:pointer;font-size:1rem;transition:background .2s}
|
||||
.icon-btn:hover{background:var(--hover)}
|
||||
/* ── theme switcher ── */
|
||||
.theme-wrap{position:relative}
|
||||
.theme-pop{position:absolute;top:40px;right:0;background:var(--panel);border:1px solid var(--border);border-radius:10px;box-shadow:0 6px 18px rgba(0,0,0,.4);padding:8px;display:none;z-index:40}
|
||||
.theme-pop.open{display:grid;grid-template-columns:repeat(3,1fr);gap:6px}
|
||||
.theme-opt{display:flex;flex-direction:column;align-items:center;gap:3px;padding:6px 4px;border-radius:8px;background:transparent;border:1px solid transparent;color:var(--text);cursor:pointer;font-size:.66rem;white-space:nowrap}
|
||||
.theme-opt:hover{background:var(--hover)}
|
||||
.theme-opt.active{border-color:var(--accent-strong)}
|
||||
.theme-swatch{width:22px;height:22px;border-radius:50%;border:1px solid rgba(0,0,0,.25)}
|
||||
/* ── chart series toggles ── */
|
||||
.chart-toggles{position:absolute;top:14px;right:14px;z-index:5;display:flex;gap:10px}
|
||||
.toggle-btn{display:flex;align-items:center;gap:7px;padding:5px 12px;border:1px solid var(--border);border-radius:16px;background:transparent;color:var(--text);cursor:pointer;font-size:.78rem;opacity:.55;transition:opacity .2s,background .2s}
|
||||
.toggle-btn:hover{background:var(--hover)}
|
||||
.toggle-btn.active{opacity:1;background:var(--accent);border-color:var(--accent-strong)}
|
||||
.sw{width:26px;height:14px;border-radius:7px;background:var(--switch-bg);position:relative;flex-shrink:0;transition:background .2s}
|
||||
.sw::after{content:'';position:absolute;top:2px;left:2px;width:10px;height:10px;border-radius:50%;background:var(--switch-knob);transition:left .2s,background .2s}
|
||||
.toggle-btn.active .sw{background:var(--accent-strong)}
|
||||
.toggle-btn.active .sw::after{left:14px;background:#fff}
|
||||
/* ── chart area ── */
|
||||
.chart-wrap{flex:1;padding:24px;position:relative;min-height:0}
|
||||
.chart-box{background:var(--panel);border:1px solid var(--border);border-radius:12px;height:100%;padding:20px;position:relative}
|
||||
.chart-box canvas{width:100%!important;height:100%!important}
|
||||
.empty-state{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;opacity:.5}
|
||||
.empty-state svg{width:48px;height:48px}
|
||||
/* ── sidebar sections ── */
|
||||
.sidebar-section{padding:16px 14px 18px}
|
||||
.sidebar-section+.sidebar-section{border-top:1px solid var(--border)}
|
||||
.sidebar-title{font-size:.78rem;text-transform:uppercase;letter-spacing:.08em;opacity:.5;margin-bottom:12px;font-weight:600}
|
||||
.ch-item{display:flex;flex-wrap:wrap;align-items:center;padding:7px 0;cursor:pointer;user-select:none;font-size:.9rem;position:relative;padding-right:32px}
|
||||
.ch-item:hover{background:var(--hover-soft);border-radius:8px}
|
||||
.ch-item+.ch-item{border-top:1px solid var(--item-border)}
|
||||
.ch-main{display:flex;align-items:center;gap:8px;min-width:0}
|
||||
.ch-status{width:100%;font-size:.72rem;opacity:.85;margin-top:3px;padding-left:23px}
|
||||
.ch-source{width:100%;font-size:.68rem;opacity:.55;margin-top:2px;padding-left:23px;word-break:break-all}
|
||||
/* ── 历史账单:头部可点击折叠/收起,列表区域为滚动框(最多同时可见约 6 行) ── */
|
||||
.ch-bills{width:100%;margin-top:4px;padding-left:23px;font-size:.7rem}
|
||||
.ch-bills-head{display:flex;align-items:center;gap:6px;margin-bottom:2px;cursor:pointer;user-select:none}
|
||||
.ch-bills-head:hover{opacity:1}
|
||||
.bill-title{opacity:.6}
|
||||
.bill-title:hover{opacity:.9}
|
||||
.bill-caret{display:inline-block;width:12px;flex-shrink:0}
|
||||
.bill-body{max-height:132px;overflow-y:auto}
|
||||
.ch-bills.collapsed .bill-body{display:none}
|
||||
.bill-row{display:flex;justify-content:space-between;align-items:center;padding:1px 0;opacity:.75;font-family:Consolas,monospace}
|
||||
.bill-total{border-top:1px dashed var(--divider-soft);margin-top:2px;padding-top:2px;opacity:.95;font-weight:600}
|
||||
.bill-empty{opacity:.5;padding:2px 0}
|
||||
.bill-more{opacity:.45;font-size:.66rem}
|
||||
.status-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;background:#95a5a6}
|
||||
.status-dot.ok{background:var(--ok);box-shadow:0 0 6px var(--ok)}
|
||||
.status-dot.warn{background:var(--warn)}
|
||||
.status-dot.err{background:var(--err)}
|
||||
.status-dot.off{background:#7f8c8d}
|
||||
.sys-row{display:flex;align-items:center;gap:8px;padding:5px 0;font-size:.82rem}
|
||||
.sys-row span:last-of-type{word-break:break-all}
|
||||
.sys-hint{font-size:.7rem;opacity:.55;margin-top:6px;line-height:1.5}
|
||||
.ch-item input[type=checkbox]{accent-color:var(--accent);width:15px;height:15px;cursor:pointer}
|
||||
.ch-tag{font-size:.65rem;padding:1px 6px;border-radius:4px;background:var(--accent);opacity:.7;margin-left:auto}
|
||||
.ch-tag.mode-tag{margin-left:4px;background:transparent;border:1px solid;opacity:1}
|
||||
.ch-tag.mode-tag.tag-balance{border-color:var(--warn);color:var(--warn)}
|
||||
.ch-tag.mode-tag.tag-used{border-color:var(--ok);color:var(--ok)}
|
||||
/* ── channel more menu ── */
|
||||
.ch-more{position:absolute;top:6px;right:4px;width:24px;height:24px;display:flex;align-items:center;justify-content:center;border:none;background:transparent;color:var(--text);opacity:.55;cursor:pointer;border-radius:6px;font-size:1rem;line-height:1}
|
||||
.ch-more:hover{opacity:1;background:var(--hover)}
|
||||
.ch-menu{position:absolute;top:34px;right:4px;background:var(--panel);border:1px solid var(--border);border-radius:8px;box-shadow:0 6px 18px rgba(0,0,0,.5);min-width:120px;z-index:30;display:none;overflow:hidden}
|
||||
.ch-menu button{display:block;width:100%;padding:8px 14px;background:transparent;border:none;color:var(--text);text-align:left;font-size:.82rem;cursor:pointer}
|
||||
.ch-menu button:hover{background:var(--hover)}
|
||||
.ch-menu .danger{color:var(--danger)}
|
||||
.ch-menu .danger:hover{background:var(--danger-soft)}
|
||||
.subtotal-row{display:flex;justify-content:space-between;align-items:center;padding:6px 0;font-size:.85rem}
|
||||
.subtotal-row .cost{font-variant-numeric:tabular-nums}
|
||||
.subtotal-row .remaining{font-size:.75rem;opacity:.6;font-variant-numeric:tabular-nums;margin-left:6px}
|
||||
.add-btn{width:100%;padding:10px;border:1px dashed var(--border);border-radius:8px;background:transparent;color:var(--text);cursor:pointer;font-size:.88rem;transition:background .2s,border-color .2s;margin-top:6px}
|
||||
.add-btn:hover{background:var(--hover);border-color:var(--accent-strong)}
|
||||
/* ── modal ── */
|
||||
.overlay{position:fixed;inset:0;background:var(--mask);display:none;align-items:center;justify-content:center;z-index:100}
|
||||
.overlay.open{display:flex}
|
||||
.modal{background:var(--panel);border:1px solid var(--border);border-radius:14px;width:420px;max-width:92vw;padding:28px;position:relative}
|
||||
.modal h2{font-size:1.05rem;margin-bottom:18px}
|
||||
.form-group{margin-bottom:14px}
|
||||
.form-group label{display:block;font-size:.8rem;margin-bottom:5px;opacity:.7}
|
||||
.form-group input,.form-group select{width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:.88rem}
|
||||
.form-group input:focus,.form-group select:focus{outline:none;border-color:var(--accent-strong)}
|
||||
.modal-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:20px}
|
||||
.modal-actions .btn{padding:8px 20px;border-radius:6px}
|
||||
.btn-primary{background:var(--accent);border-color:var(--accent-strong)}
|
||||
.btn-primary:hover{background:var(--accent-strong)}
|
||||
/* ── toast ── */
|
||||
.toast{position:fixed;top:20px;right:20px;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:12px 20px;font-size:.88rem;z-index:200;opacity:0;transform:translateY(-10px);transition:all .3s;pointer-events:none}
|
||||
.toast.show{opacity:1;transform:translateY(0)}
|
||||
.toast.error{border-color:var(--danger)}
|
||||
/* ── hamburger ── */
|
||||
.hamburger{display:none;width:34px;height:34px;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:6px;background:transparent;color:var(--text);cursor:pointer;font-size:1.2rem}
|
||||
/* ── loading ── */
|
||||
.spinner{display:inline-block;width:18px;height:18px;border:2px solid var(--border);border-top-color:var(--text);border-radius:50%;animation:spin .6s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.loading-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:var(--overlay-soft);border-radius:12px;z-index:10;gap:10px;font-size:.9rem}
|
||||
/* ── responsive ── */
|
||||
@media(max-width:768px){
|
||||
.sidebar{position:fixed;left:0;top:0;bottom:0;z-index:50;margin-left:calc(var(--sidebar-w)*-1)}
|
||||
.sidebar.open{margin-left:0}
|
||||
.main{margin-left:0}
|
||||
.hamburger{display:flex}
|
||||
.side-toggle{display:none}
|
||||
.sidebar-backdrop{position:fixed;inset:0;background:var(--mask);z-index:40;display:none}
|
||||
.sidebar-backdrop.open{display:block}
|
||||
}
|
||||
@media(min-width:769px){
|
||||
/* 收起/展开只平移坐标(transform),不修改面板宽度,内容全程不被挤压 */
|
||||
.app.collapsed .sidebar{transform:translateX(-100%)}
|
||||
.app.collapsed .main{margin-left:0}
|
||||
.app.collapsed .side-toggle{left:14px;transform:rotate(180deg)}
|
||||
}
|
||||
Reference in New Issue
Block a user