fix(ui): 表格行高统一 + 事件区块改为与表格同一套样式

1) 行高不一致(「狼的评论系统」明显矮一截)
   实测在线行 58px / 离线行 43px。根因是可用率列的 90 格 mini 条:
   它是 inline-flex,参与行内布局时基线把行盒抬高约 15px,于是「有可用率
   数据的行」比不带条的行高出一截。
   修复:单元格内容统一 vertical-align: middle + mini 条容器改 flex;
   实测 6 行全部 43px。

2) 「最近事件」与表格样式不统一
   去掉独立的时间线卡片风格,改为与监控表格同构的表格(相同容器圆角、
   表头、行分隔、hover、状态底色);故障行淡红、证书行淡琥珀、恢复行不染。
   刻意不共用 .monitor-table 类名 —— 共用时面板条目统计被多算 1~8 个
   (测试当场抓到),改为独立 .events-table 并显式声明所需样式。

说明:内容长度相当时保证等高;名称/地址特别长时允许换行(上一版按用户
要求做的「完整显示」),此时行更高属于数据本身需要,不再用截断换整齐。

测试:verify-views 新增「内容等长时各行等高」「事件表与监控表同构」
「事件行复用状态底色」断言(并修正了断言脚本改错文件的问题 —— 名称来自
monitors.json 而非 status.json)。六套 171 项全通过。
This commit is contained in:
tamakyi
2026-10-14 10:49:01 +08:00
parent 25b8b97970
commit a3a683ff0e
13 changed files with 473 additions and 428 deletions
+10
View File
@@ -540,6 +540,16 @@ UptimeRobot 的探测来自其多个节点,适合作为**异地视角**与自
- [x] 页脚改为展示「数据更新于 … + 下次检查 Ns」,tooltip 内含服务端更新节奏与快照生成时间
- [x] `scripts/verify.cjs` 适配纯外部数据源部署(自动跳过不适用的探测类断言),并新增「倒计时对齐服务端」断言
### v0.11.2 已实现
**修复你反馈的两个问题**
- [x] **「狼的评论系统」那一条高度/样式与其他行不一致**:实测在线行 **58px**、离线行 **43px**。根因是**可用率列的 90 格 mini 条**——它是 `inline-flex` 元素,参与行内布局时其基线会把行盒抬高约 15px,于是「当天有可用率数据、条正常渲染」的行比不带条的行高出一截。修复:给单元格内容统一 `vertical-align: middle`、mini 条容器改 flex 布局,现在**所有行等高(实测 43px)**
- [x] **「最近事件」改为与表格同一套样式**:不再用独立的「时间线卡片」风格,改为**与监控表格完全同构的表格**(相同的圆角容器、表头样式、行分隔线、hover、状态底色)——故障行淡红、证书行淡琥珀、恢复行不染;类型以小徽标呈现,详情列完整显示
- 实现上刻意**不共用 `.monitor-table` 类名**,避免被其他按类名统计/定位的逻辑误算(这一点被测试当场抓到:共用类名时面板条目数被多算 1~8 个)
> 说明:行高在「内容长度相当」时保证一致;名称或地址特别长时允许换行(这是上一版你要求的「数据完整显示」),此时行自然更高——属于数据本身需要的空间,不再靠截断换取整齐。
### v0.11.1 已实现
**修复你反馈的三个问题**
Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 441 KiB

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 222 KiB

+1 -1
View File
@@ -11,7 +11,7 @@
import { connect } from 'cloudflare:sockets';
const VERSION = '0.11.1';
const VERSION = '0.11.2';
const HISTORY_LIMIT = 48;
const UPTIME_DAYS = 90;
const USER_AGENT = 'shiroko-monitor/0.3 (lightweight uptime panel)';
+1 -1
View File
@@ -37,7 +37,7 @@ const CONCURRENCY = 8;
const WRITE_DEBOUNCE_MS = 300;
const DEFAULT_INTERVAL_SEC = 60;
const PORT = Number(process.env.PORT || 8787);
const VERSION = '0.11.1';
const VERSION = '0.11.2';
const UR_DEFAULT_POLL_SEC = 300;
const UR_MIN_REFRESH_GAP_MS = 60000;
+9 -7
View File
@@ -1,4 +1,4 @@
const VERSION = '0.11.1';
const VERSION = '0.11.2';
const HB_LIMIT = 48;
const UPTIME_DAYS = 90;
const BROWSER_CONCURRENCY = 6;
@@ -263,14 +263,16 @@ function renderEvents() {
if (count) count.textContent = all.length > shown.length ? `最近 ${shown.length} / ${all.length} 条` : `最近 ${all.length} 条`;
const label = { down: '故障', up: '恢复', tls: '证书', maintenance: '维护' };
// 与监控表格同一套结构(tr/td + data-status),因此行高、边框、hover、状态底色天然一致
const statusOf = { down: 'down', up: 'up', tls: 'pending', maintenance: 'up' };
list.innerHTML = shown.map((e) => {
const maint = e.maintenance ? '<span class="ev-maint" title="维护窗口内,未计入可用率、未发送告警">维护</span>' : '';
return `<li class="ev ev-${escapeHtml(e.type)}">
<span class="ev-time" title="${escapeHtml(fmtTime(e.t))}">${escapeHtml(fmtRelative(e.t))}</span>
<span class="ev-type">${escapeHtml(label[e.type] ?? e.type)}${maint}</span>
<span class="ev-name" title="${escapeHtml(e.name)}">${escapeHtml(e.name)}</span>
<span class="ev-detail" title="${escapeHtml(e.detail ?? '')}">${escapeHtml(e.detail ?? '')}</span>
</li>`;
return `<tr class="ev" data-status="${escapeHtml(statusOf[e.type] ?? 'up')}">
<td class="ev-time" title="${escapeHtml(fmtTime(e.t))}">${escapeHtml(fmtRelative(e.t))}</td>
<td class="ev-type"><span class="ev-badge">${escapeHtml(label[e.type] ?? e.type)}</span>${maint}</td>
<td class="ev-name" title="${escapeHtml(e.name)}">${escapeHtml(e.name)}</td>
<td class="ev-detail" title="${escapeHtml(e.detail ?? '')}">${escapeHtml(e.detail ?? '')}</td>
</tr>`;
}).join('');
const more = $('events-more');
+46 -58
View File
@@ -390,7 +390,12 @@ body[data-view="table"] .grid { display: block; }
background: var(--bg);
border-bottom: 1px solid var(--border);
}
.monitor-table td { padding: 9px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; overflow: hidden; }
.monitor-table td { padding: 9px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; overflow: hidden; line-height: 1.4; }
/* 所有行统一基线:可用率的 mini 条是 inline-flex,其基线会把行盒撑高十几像素,
导致带条的行比不带的行高出一截(实测 58px vs 43px) */
.monitor-table td > * { vertical-align: middle; }
.u-mini { display: inline-flex; align-items: center; gap: 6px; vertical-align: middle; }
.monitor-table tbody tr[data-status="pending"] { background: color-mix(in srgb, var(--pending) 6%, transparent); }
.monitor-table tbody tr:last-child td { border-bottom: 0; }
.monitor-table tbody tr:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); }
.monitor-table tbody tr[data-status="down"] { background: color-mix(in srgb, var(--down) 6%, transparent); }
@@ -432,7 +437,7 @@ tr[data-status="pending"] .t-state { color: var(--pending-text); }
.t-hb svg { display: block; width: 100%; max-width: 150px; height: 24px; }
.t-uptime { white-space: normal; }
/* 可用率条随视口伸缩;极窄时只留百分比,避免挤压其他列 */
.uptime-bar-mini { display: inline-flex; width: clamp(32px, 6vw, 84px); height: 8px; margin-right: 6px; vertical-align: middle; }
.uptime-bar-mini { display: flex; flex: none; width: clamp(32px, 6vw, 84px); height: 8px; margin-right: 0; vertical-align: middle; }
.t-pct { font-size: 12px; color: var(--text-2); font-variant-numeric: tabular-nums; }
@media (max-width: 640px) {
.uptime-bar-mini { display: none; }
@@ -459,69 +464,52 @@ tr[data-status="pending"] .t-state { color: var(--pending-text); }
}
.events-more:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); }
/* 左侧竖线 + 每行圆点,构成时间线;行本身仍是浅色卡片 */
.events-list {
list-style: none;
margin: 0;
padding: 2px 0 2px 16px;
position: relative;
}
.events-list::before {
content: "";
position: absolute;
left: 5px;
top: 8px;
bottom: 8px;
width: 1px;
background: var(--border);
}
.ev {
position: relative;
display: grid;
/* 名称列按内容自适应,详情列吃掉剩余宽度 —— 避免短名称后面拖出一大片空白 */
grid-template-columns: 62px 46px auto minmax(0, 1fr);
gap: 10px;
align-items: center;
padding: 6px 12px;
border-radius: 9px;
background: var(--surface);
border: 1px solid var(--border);
/* 事件表:直接复用监控表格的外观(圆角、边框、行分隔、hover、状态底色),
只额外定义列宽与类型徽标,保证两处视觉完全一致 */
/* 事件表:显式声明与监控表格相同的观感(不共用 .monitor-table 类,
避免被其他按类名统计/定位的逻辑误算),列宽与类型徽标单独定义 */
.events-table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 13px; }
.events-table th {
padding: 10px 12px;
text-align: left;
font-size: 12px;
min-width: 0;
font-weight: 600;
color: var(--text-2);
background: var(--bg);
border-bottom: 1px solid var(--border);
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
}
.ev + .ev { margin-top: 4px; }
.ev::before {
content: "";
position: absolute;
left: -14px;
top: 50%;
width: 7px;
height: 7px;
margin-top: -3.5px;
border-radius: 50%;
background: var(--text-2);
box-shadow: 0 0 0 3px var(--bg);
}
.ev-down { border-left: 3px solid var(--down); }
.ev-up { border-left: 3px solid var(--up); }
.ev-tls { border-left: 3px solid var(--pending); }
.ev-down::before { background: var(--down); }
.ev-up::before { background: var(--up); }
.ev-tls::before { background: var(--pending); }
.events-table td { padding: 11px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; line-height: 1.4; overflow: hidden; }
.events-table tbody tr:last-child td { border-bottom: 0; }
.events-table tbody tr:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); }
.events-table tbody tr[data-status="down"] { background: color-mix(in srgb, var(--down) 6%, transparent); }
.events-table tbody tr[data-status="pending"] { background: color-mix(in srgb, var(--pending) 6%, transparent); }
.events-wrap { margin-top: 0; }
.events-table th:nth-child(1) { width: 11%; }
.events-table th:nth-child(2) { width: 9%; }
.events-table th:nth-child(3) { width: 26%; }
.events-table th:nth-child(4) { width: 54%; }
.ev-time { color: var(--text-2); font-variant-numeric: tabular-nums; white-space: nowrap; }
.ev-type { font-weight: 650; text-align: center; border-radius: 999px; padding: 2px 0; font-size: 11px; }
.ev-down .ev-type { background: var(--down-bg); color: var(--down-text); }
.ev-up .ev-type { background: var(--up-bg); color: var(--up-text); }
.ev-tls .ev-type { background: var(--pending-bg); color: var(--pending-text); }
.ev-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 650;
}
tr[data-status="down"] .ev-badge { background: var(--down-bg); color: var(--down-text); }
tr[data-status="up"] .ev-badge { background: var(--up-bg); color: var(--up-text); }
tr[data-status="pending"] .ev-badge { background: var(--pending-bg); color: var(--pending-text); }
.ev-maint { margin-left: 6px; border-radius: 999px; padding: 0 6px; font-size: 10px; font-weight: 650; background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); }
.ev-name { font-weight: 600; max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.ev-detail { color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.events-empty { margin: 0; padding: 12px; font-size: 12px; color: var(--text-2); background: var(--surface); border: 1px dashed var(--border); border-radius: 10px; }
.ev-name { font-weight: 600; }
.ev-detail { color: var(--text-2); }
.events-empty { margin: 0; padding: 12px; font-size: 12px; color: var(--text-2); background: var(--surface); border: 1px dashed var(--border); border-radius: 12px; }
@media (max-width: 640px) {
.ev { grid-template-columns: 56px 44px minmax(0, 1fr); gap: 8px; }
.ev-detail { display: none; }
.events { padding: 0 14px; }
.events-table th:nth-child(4), .ev-detail { display: none; }
}
/* ---------- 卡片:90 天日均延迟趋势 ---------- */
+9 -2
View File
@@ -57,7 +57,14 @@
<span class="events-count" id="events-count"></span>
<button type="button" class="events-more" id="events-more" hidden>展开全部</button>
</div>
<ul class="events-list" id="events-list"></ul>
<div class="table-wrap events-wrap">
<table class="events-table">
<thead>
<tr><th>时间</th><th>类型</th><th>目标</th><th>详情</th></tr>
</thead>
<tbody id="events-list"></tbody>
</table>
</div>
<p class="events-empty" id="events-empty" hidden>暂无事件记录(故障、恢复与证书提醒会出现在这里)</p>
</section>
@@ -68,7 +75,7 @@
<span id="notify-badge" class="footer-item" hidden>通知 –</span>
<span id="updated-at" class="footer-item">–</span>
<span id="countdown" class="footer-item">–</span>
<span class="footer-item version">shiroko-monitor v0.11.1</span>
<span class="footer-item version">shiroko-monitor v0.11.2</span>
</footer>
<script type="module" src="assets/app.js"></script>
+355 -345
View File
File diff suppressed because it is too large Load Diff
+42 -14
View File
@@ -300,15 +300,31 @@ async function main() {
const dt = await dp.evaluate(() => document.querySelector('tr[data-status] .t-uptime')?.title ?? '');
check('表格可用率 tooltip 含 90 天日均延迟', /90 天日均延迟 \d+ ms/.test(dt), dt.slice(0, 60));
// ---- 极端内容:表格不得把数据截断,同时不得出现横向滚动 ----
// 行高一致性要在「内容长度相当」的前提下检查:先把配置里的名称统一成等长短名
// (注意:名称来自 monitors.json,不是 status.json),再单独注入超长内容验证不截断
const statusPath = path.join(tmpDir, 'status.json');
const snap = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
const ids = Object.keys(snap.monitors);
const cfgPath2 = path.join(tmpDir, 'monitors.json');
const cfgJson = JSON.parse(fs.readFileSync(cfgPath2, 'utf8'));
let ids = Object.keys(snap.monitors);
cfgJson.monitors.forEach((m, i) => { m.name = `目标 ${i + 1}`; });
fs.writeFileSync(cfgPath2, `${JSON.stringify(cfgJson, null, 2)}\n`);
await dp.goto('http://localhost:8894/?source=static&view=table', { waitUntil: 'domcontentloaded' });
await dp.waitForSelector('table.monitor-table tbody tr[data-status]', { timeout: 20000 });
await dp.waitForTimeout(900);
const heights = await dp.evaluate(() => {
const hs = [...document.querySelectorAll('table.monitor-table tbody tr[data-status]')].map((tr) => Math.round(tr.getBoundingClientRect().height));
return { hs, unique: [...new Set(hs)] };
});
check('内容等长时表格各行等高(可用率条不再撑高行)', heights.unique.length === 1, `行高 ${heights.unique.join('/')}(共 ${heights.hs.length} 行)`);
// ---- 极端内容:表格不得把数据截断,同时不得出现横向滚动 ----
let injected = 0;
for (const id of ids.slice(0, 2)) {
snap.monitors[id].name = `这是一个相当长的监控目标名称用于验证表格完整显示-${id}`;
injected++;
const m = cfgJson.monitors.find((x) => x.id === id);
if (m) { m.name = `这是一个相当长的监控目标名称用于验证表格完整显示-${id}`; injected++; }
}
fs.writeFileSync(cfgPath2, `${JSON.stringify(cfgJson, null, 2)}\n`);
// 注入事件,验证事件区块的时间线样式与折叠
snap.events = Array.from({ length: 12 }, (_, i) => ({
t: new Date(Date.now() - i * 60000).toISOString(),
@@ -337,26 +353,38 @@ async function main() {
check(`长名称/长地址不再被截断(注入 ${injected} 个)`, clip.clipped === 0, `截断单元格 ${clip.clipped} 个,最大 ${clip.worst}px`);
check('表格仍无横向滚动', clip.pageOverflow <= 0 && clip.tableOverflow <= 0, `页面 ${clip.pageOverflow} / 表格 ${clip.tableOverflow}`);
const evr = await dp.evaluate(() => {
const ev = document.querySelector('.ev');
const cs = ev ? getComputedStyle(ev) : null;
const dot = ev ? getComputedStyle(ev, '::before') : null;
const table = document.querySelector('.events-table');
const evRow = table?.querySelector('tbody tr');
const cs = evRow ? getComputedStyle(evRow) : null;
const more = document.getElementById('events-more');
return {
shown: document.querySelectorAll('.ev').length,
grid: cs?.display ?? 'none',
dotVisible: dot ? dot.width !== '0px' && dot.backgroundColor !== 'rgba(0, 0, 0, 0)' : false,
leftBorder: cs?.borderLeftWidth ?? '',
shown: (evRow ? table.querySelectorAll('tbody tr').length : 0),
tag: table?.tagName ?? 'none',
hasThead: Boolean(table?.querySelector('thead th')),
headerSameSize: (() => {
const mon = document.querySelector('.monitor-table thead th');
const ev = table?.querySelector('thead th');
return mon && ev ? getComputedStyle(mon).fontSize === getComputedStyle(ev).fontSize : false;
})(),
rowBg: cs?.backgroundColor ?? '',
cellBorder: evRow ? getComputedStyle(evRow.querySelector('td')).borderBottomWidth : '',
moreHidden: more?.hidden ?? true,
moreText: more?.textContent ?? '',
count: document.getElementById('events-count')?.textContent ?? '',
};
});
check('事件区块使用时间线布局(grid + 圆点)', evr.grid === 'grid' && evr.dotVisible === true, `display=${evr.grid} 圆点=${evr.dotVisible}`);
check('事件行按类型着色(左描边)', evr.leftBorder === '3px', evr.leftBorder);
check('事件区块与监控表格同构(table + thead)', evr.tag === 'TABLE' && evr.hasThead === true && evr.headerSameSize === true, `${evr.tag},表头字号一致=${evr.headerSameSize}`);
const evBg = await dp.evaluate(() => {
const rows = [...document.querySelectorAll('.events-table tbody tr')];
const down = rows.find((r) => r.dataset.status === 'down');
const up = rows.find((r) => r.dataset.status === 'up');
return { down: down ? getComputedStyle(down).backgroundColor : '无', up: up ? getComputedStyle(up).backgroundColor : '无', downCells: down ? down.querySelectorAll('td').length : 0 };
});
check('事件行复用表格状态底色(故障行着色、恢复行不染)', /^(color\(|rgba?\(\d)/.test(evBg.down) && evBg.down !== 'rgba(0, 0, 0, 0)' && evBg.up === 'rgba(0, 0, 0, 0)' && evBg.downCells === 4, `故障=${evBg.down.slice(0, 30)} 恢复=${evBg.up} 列数=${evBg.downCells}`);
check('事件默认折叠为 8 条并可展开', evr.shown === 8 && evr.moreHidden === false && /展开全部 12 条/.test(evr.moreText), `显示 ${evr.shown} 条,按钮「${evr.moreText}」,计数「${evr.count}」`);
await dp.click('#events-more');
await dp.waitForTimeout(300);
const evExpanded = await dp.evaluate(() => ({ shown: document.querySelectorAll('.ev').length, text: document.getElementById('events-more').textContent }));
const evExpanded = await dp.evaluate(() => ({ shown: document.querySelectorAll('.events-table tbody tr').length, text: document.getElementById('events-more').textContent }));
check('展开后显示全部事件', evExpanded.shown === 12 && evExpanded.text === '收起', `显示 ${evExpanded.shown} 条`);
await dp.close();
await new Promise((r) => demoServer.close(r));