fix(mobile): 修移动端顶栏塌陷,窄屏默认卡片视图
问题(用户报告移动端异常):窄屏下顶栏统计挤成竖排并与标题重叠, 表格在手机上必须横向拖动,固定装饰在小屏上可能溢出或压住内容。 修复: - 顶栏结构拆为「标题 / 统计(.stats) / 控件(.controls)」三段; ≤820px 改为纵向三行,统计均分视口宽度,页脚改纵向堆叠 - ≤700px 且用户未显式选择视图时自动用卡片(横竖屏切换时重算, 显式选择仍尊重 localStorage) - ≤820px(及横屏手机)撤掉立绘、公路车、光环水印等固定装饰 验证: - 390x844 / 360x640 / 414x896 / 844x390 四种视口:0 横向溢出,顶栏三行 - verify-views 新增移动端 7 条断言(22/22 通过) - 四套合计 81 项全通过(17+22+20+22)
@@ -262,6 +262,8 @@ UptimeRobot 的探测来自其多个节点,适合作为**异地视角**与自
|
||||
|
||||
指定方式(优先级从高到低):URL 参数 `?view=table|cards` → 浏览器上次选择(localStorage)→ `settings.view` → 表格。
|
||||
|
||||
**移动端**:窄屏(≤700px)在用户没有显式选择过视图时**自动使用卡片**(表格在手机上必须横向拖动,体验差),并在横竖屏切换时重算;顶栏在 ≤820px 改为「标题 / 统计 / 控件」三行,立绘与固定装饰自动撤掉。
|
||||
|
||||
### 数据源过滤
|
||||
|
||||
| 值 | 含义 |
|
||||
@@ -439,6 +441,13 @@ UptimeRobot 的探测来自其多个节点,适合作为**异地视角**与自
|
||||
- [x] 页脚改为展示「数据更新于 … + 下次检查 Ns」,tooltip 内含服务端更新节奏与快照生成时间
|
||||
- [x] `scripts/verify.cjs` 适配纯外部数据源部署(自动跳过不适用的探测类断言),并新增「倒计时对齐服务端」断言
|
||||
|
||||
### v0.8.1 已实现
|
||||
|
||||
- [x] **修复移动端顶栏塌陷**:窄屏(≤820px)改为「标题 / 统计 / 控件」三行布局,统计均分视口宽度,页脚改纵向堆叠 —— 此前统计数字会挤成竖排并压住标题
|
||||
- [x] **窄屏默认卡片视图**(≤700px):表格在手机上必须横向拖动,改由卡片呈现;横竖屏切换时重算,用户显式选过视图则尊重其选择
|
||||
- [x] 窄屏与横屏手机自动撤掉立绘、公路车、光环水印等固定装饰,避免压住内容或溢出
|
||||
- [x] `verify-views` 新增移动端专项断言 7 项(默认卡片 / 无横向溢出 / 顶栏三行 / 统计铺满 / 装饰隐藏 / 页脚不裁切 / 无 JS 错误)
|
||||
|
||||
### v0.8.0 已实现
|
||||
|
||||
- [x] **形象换成简约风插画**(AI 生成 + 抠底,非官方美术素材):右下角全身立绘、logo 与 favicon 用同款半身像;官方立绘/头像已从仓库移除
|
||||
|
||||
|
Before Width: | Height: | Size: 254 KiB After Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 423 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 227 KiB |
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 214 KiB |
@@ -11,7 +11,7 @@
|
||||
|
||||
import { connect } from 'cloudflare:sockets';
|
||||
|
||||
const VERSION = '0.8.0';
|
||||
const VERSION = '0.8.1';
|
||||
const HISTORY_LIMIT = 48;
|
||||
const UPTIME_DAYS = 90;
|
||||
const USER_AGENT = 'shiroko-monitor/0.3 (lightweight uptime panel)';
|
||||
|
||||
@@ -35,7 +35,7 @@ const CONCURRENCY = 8;
|
||||
const WRITE_DEBOUNCE_MS = 300;
|
||||
const DEFAULT_INTERVAL_SEC = 60;
|
||||
const PORT = Number(process.env.PORT || 8787);
|
||||
const VERSION = '0.8.0';
|
||||
const VERSION = '0.8.1';
|
||||
const UR_DEFAULT_POLL_SEC = 300;
|
||||
const UR_MIN_REFRESH_GAP_MS = 60000;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const VERSION = '0.8.0';
|
||||
const VERSION = '0.8.1';
|
||||
const HB_LIMIT = 48;
|
||||
const UPTIME_DAYS = 90;
|
||||
const BROWSER_CONCURRENCY = 6;
|
||||
@@ -507,14 +507,23 @@ function renderViewSwitch() {
|
||||
}
|
||||
}
|
||||
|
||||
// 窄屏(手机)判定:表格在最窄的屏幕上必须横向拖动,不如默认给卡片视图
|
||||
function isNarrowScreen() {
|
||||
return window.matchMedia('(max-width: 700px)').matches;
|
||||
}
|
||||
|
||||
function resolveView() {
|
||||
const q = new URLSearchParams(location.search).get('view');
|
||||
if (q && VIEWS[q]) return q;
|
||||
let saved = null;
|
||||
try {
|
||||
saved = localStorage.getItem('shiroko.view');
|
||||
} catch { /* 隐私模式下 localStorage 不可用 */ }
|
||||
const cfg = state.config?.settings?.view;
|
||||
return [q, saved, cfg, DEFAULT_VIEW].find((v) => v && VIEWS[v]) ?? DEFAULT_VIEW;
|
||||
const resolved = [saved, cfg, DEFAULT_VIEW].find((v) => v && VIEWS[v]) ?? DEFAULT_VIEW;
|
||||
// 用户没在这个设备上显式选过视图时,窄屏自动落到卡片(表格在手机上要横向拖)
|
||||
if (!saved && resolved === 'table' && isNarrowScreen()) return 'cards';
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function setView(view, { persist = true } = {}) {
|
||||
@@ -802,6 +811,11 @@ function bindEvents() {
|
||||
// 只有手动点「刷新」才会触发一次重新检测;自动刷新是只读的
|
||||
$('refresh-btn').addEventListener('click', () => refresh({ force: true }));
|
||||
$('theme-btn')?.addEventListener('click', () => applyTheme(state.theme === 'shiroko' ? 'default' : 'shiroko'));
|
||||
// 横竖屏切换 / 窗口缩放时重算视图(不覆盖用户显式选择,故 persist: false)
|
||||
window.addEventListener('resize', () => {
|
||||
const next = resolveView();
|
||||
if (next !== state.view) setView(next, { persist: false });
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 启动 ---------- */
|
||||
|
||||
@@ -91,6 +91,8 @@ body {
|
||||
.brand-text p { margin: 0; font-size: 12px; color: var(--text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.summary { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; }
|
||||
.stats { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; }
|
||||
.controls { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.pill {
|
||||
padding: 4px 12px;
|
||||
@@ -323,9 +325,20 @@ body {
|
||||
.demo-chip { padding: 1px 8px; border-radius: 999px; background: var(--pending-bg); color: var(--pending); font-weight: 600; }
|
||||
.warn-chip { padding: 1px 8px; border-radius: 999px; background: var(--down-bg); color: var(--down); font-weight: 600; }
|
||||
|
||||
/* 窄屏(手机 / 竖屏平板):顶栏改为三行——标题 / 统计 / 控件,避免挤成一团 */
|
||||
@media (max-width: 820px) {
|
||||
.topbar { flex-direction: column; align-items: stretch; gap: 10px; padding: 12px 14px 14px; }
|
||||
.summary { flex-direction: column; align-items: stretch; gap: 8px; }
|
||||
.stats { justify-content: space-between; gap: 4px; }
|
||||
.stat { min-width: 0; flex: 1 1 0; }
|
||||
.stat b { font-size: 15px; }
|
||||
.stat label { font-size: 10px; }
|
||||
.controls { justify-content: flex-end; gap: 10px; }
|
||||
.footer { flex-direction: column; align-items: flex-start; gap: 4px; padding-bottom: 20px; }
|
||||
.footer .version { margin-left: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.topbar { padding: 10px 14px; }
|
||||
.summary { gap: 6px 12px; }
|
||||
.grid { grid-template-columns: 1fr; padding: 12px 14px 8px; }
|
||||
.group-tabs { padding: 12px 14px 0; }
|
||||
.footer { padding: 8px 14px 24px; }
|
||||
|
||||
@@ -215,7 +215,14 @@ body[data-theme="shiroko"] .artwork {
|
||||
user-select: none;
|
||||
filter: drop-shadow(0 6px 18px rgb(23 60 100 / 15%));
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
/* 窄屏:立绘与固定装饰全部撤掉,避免压住内容或溢出(手机只保留配色与围巾飘带) */
|
||||
@media (max-width: 820px) {
|
||||
body[data-theme="shiroko"] .artwork,
|
||||
body[data-theme="shiroko"]::before,
|
||||
body[data-theme="shiroko"]::after { display: none; }
|
||||
}
|
||||
/* 横屏手机:可用高度很小,立绘同样撤掉 */
|
||||
@media (max-height: 520px) {
|
||||
body[data-theme="shiroko"] .artwork { display: none; }
|
||||
}
|
||||
/* 卡片视图行数多、卡片更靠右下,立绘再收一档,尽量让它落在卡片之外的角落 */
|
||||
|
||||
@@ -20,23 +20,27 @@
|
||||
</div>
|
||||
<div class="summary">
|
||||
<span id="overall-pill" class="pill pending">检测中…</span>
|
||||
<div class="stat"><b id="stat-total">–</b><label>监控数</label></div>
|
||||
<div class="stat stat-up"><b id="stat-up">–</b><label>在线</label></div>
|
||||
<div class="stat stat-down"><b id="stat-down">–</b><label>离线</label></div>
|
||||
<div class="stat"><b id="stat-latency">–</b><label>平均延迟</label></div>
|
||||
<div class="stat"><b id="stat-uptime">–</b><label>90天可用率</label></div>
|
||||
<div class="view-switch" id="view-switch" role="group" aria-label="视图切换"></div>
|
||||
<button id="theme-btn" type="button" class="icon-btn" title="切换主题">
|
||||
<svg viewBox="0 0 100 100" aria-hidden="true">
|
||||
<circle cx="50" cy="50" r="31" fill="none" stroke="currentColor" stroke-width="8"/>
|
||||
<circle cx="50" cy="50" r="15" fill="none" stroke="currentColor" stroke-width="4" opacity="0.7"/>
|
||||
<polygon points="46.4,4 53.6,4 51.2,15 48.8,15" fill="currentColor"/>
|
||||
<polygon points="46.4,96 53.6,96 51.2,85 48.8,85" fill="currentColor"/>
|
||||
<polygon points="4,46.4 4,53.6 15,51.2 15,48.8" fill="currentColor"/>
|
||||
<polygon points="96,46.4 96,53.6 85,51.2 85,48.8" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="refresh-btn" type="button">刷新</button>
|
||||
<div class="stats">
|
||||
<div class="stat"><b id="stat-total">–</b><label>监控数</label></div>
|
||||
<div class="stat stat-up"><b id="stat-up">–</b><label>在线</label></div>
|
||||
<div class="stat stat-down"><b id="stat-down">–</b><label>离线</label></div>
|
||||
<div class="stat"><b id="stat-latency">–</b><label>平均延迟</label></div>
|
||||
<div class="stat"><b id="stat-uptime">–</b><label>90天可用率</label></div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<div class="view-switch" id="view-switch" role="group" aria-label="视图切换"></div>
|
||||
<button id="theme-btn" type="button" class="icon-btn" title="切换主题">
|
||||
<svg viewBox="0 0 100 100" aria-hidden="true">
|
||||
<circle cx="50" cy="50" r="31" fill="none" stroke="currentColor" stroke-width="8"/>
|
||||
<circle cx="50" cy="50" r="15" fill="none" stroke="currentColor" stroke-width="4" opacity="0.7"/>
|
||||
<polygon points="46.4,4 53.6,4 51.2,15 48.8,15" fill="currentColor"/>
|
||||
<polygon points="46.4,96 53.6,96 51.2,85 48.8,85" fill="currentColor"/>
|
||||
<polygon points="4,46.4 4,53.6 15,51.2 15,48.8" fill="currentColor"/>
|
||||
<polygon points="96,46.4 96,53.6 85,51.2 85,48.8" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="refresh-btn" type="button">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -52,7 +56,7 @@
|
||||
<span id="ur-chip" class="footer-item warn-chip" hidden>UptimeRobot 同步异常</span>
|
||||
<span id="updated-at" class="footer-item">–</span>
|
||||
<span id="countdown" class="footer-item">–</span>
|
||||
<span class="footer-item version">shiroko-monitor v0.8.0</span>
|
||||
<span class="footer-item version">shiroko-monitor v0.8.1</span>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="assets/app.js"></script>
|
||||
|
||||
@@ -138,6 +138,43 @@ async function main() {
|
||||
fs.writeFileSync(CONFIG, raw); // 还原
|
||||
}
|
||||
|
||||
// ---------- H. 移动端(窄屏) ----------
|
||||
console.log('\n[H] 移动端窄屏');
|
||||
const mCtx = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, deviceScaleFactor: 2 });
|
||||
const mp = await mCtx.newPage();
|
||||
const mErrors = [];
|
||||
mp.on('pageerror', (e) => mErrors.push(e.message));
|
||||
// 不带 ?view=:窄屏下应自动落到卡片视图(表格在手机上必须横向拖动)
|
||||
await mp.goto(`${BASE}/?source=api`, { waitUntil: 'domcontentloaded' });
|
||||
await mp.waitForSelector('.card', { timeout: 20000 });
|
||||
await mp.waitForTimeout(1500);
|
||||
const mob = await mp.evaluate(() => {
|
||||
const top = (sel) => { const el = document.querySelector(sel); return el ? Math.round(el.getBoundingClientRect().top) : -1; };
|
||||
const statsW = Math.round(document.querySelector('.stats')?.getBoundingClientRect().width ?? 0);
|
||||
const lastFooter = [...document.querySelectorAll('.footer-item')].pop();
|
||||
return {
|
||||
view: document.body.dataset.view,
|
||||
overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
brandTop: top('.brand'), statsTop: top('.stats'), controlsTop: top('.controls'),
|
||||
statsW, vw: window.innerWidth,
|
||||
artwork: getComputedStyle(document.querySelector('.artwork') ?? document.body).display,
|
||||
beforeDisplay: getComputedStyle(document.body, '::before').display,
|
||||
afterDisplay: getComputedStyle(document.body, '::after').display,
|
||||
footerRight: lastFooter ? Math.round(lastFooter.getBoundingClientRect().right) : -1,
|
||||
cards: document.querySelectorAll('.card').length,
|
||||
tabsWrapped: getComputedStyle(document.querySelector('.topbar')).flexDirection,
|
||||
};
|
||||
});
|
||||
check('窄屏默认使用卡片视图', mob.view === 'cards', `data-view=${mob.view}`);
|
||||
check('窄屏无横向溢出', mob.overflow <= 0, `溢出 ${mob.overflow}px`);
|
||||
check('顶栏在窄屏改为纵向三行(标题/统计/控件)', mob.brandTop < mob.statsTop && mob.statsTop < mob.controlsTop && mob.tabsWrapped === 'column', `top: ${mob.brandTop}/${mob.statsTop}/${mob.controlsTop}, flex=${mob.tabsWrapped}`);
|
||||
check('统计行铺满视口宽度', Math.abs(mob.statsW - mob.vw) <= 32, `stats=${mob.statsW} 视口=${mob.vw}`);
|
||||
check('窄屏撤掉立绘与固定装饰', mob.artwork === 'none' && mob.beforeDisplay === 'none' && mob.afterDisplay === 'none', `立绘=${mob.artwork} 左装饰=${mob.beforeDisplay} 光环水印=${mob.afterDisplay}`);
|
||||
check('页脚内容不被裁切', mob.footerRight > 0 && mob.footerRight <= mob.vw, `最后一项右边界 ${mob.footerRight} / 视口 ${mob.vw}`);
|
||||
check('窄屏渲染无 JS 错误', mErrors.length === 0, mErrors.join(' | ') || '无');
|
||||
await mp.screenshot({ path: path.join(__dirname, '..', 'docs', 'screenshot-mobile.png'), fullPage: true });
|
||||
await mCtx.close();
|
||||
|
||||
// 429 是刷新限流保护在起作用(快速连续导航各触发一次强制探测),面板已优雅降级为只读数据;
|
||||
// 这里只把 429 以外的错误视为失败
|
||||
const hardErrors = errors.filter((e) => !/429/.test(e));
|
||||
|
||||
@@ -107,12 +107,14 @@ async function main() {
|
||||
const pageG = await browser.newPage({ viewport: { width: 1280, height: 900 } });
|
||||
await pageG.goto(`${BASE}/?source=api&view=table`, { waitUntil: 'domcontentloaded' });
|
||||
await pageG.waitForSelector('table.monitor-table tbody tr[data-status]', { timeout: 20000 });
|
||||
await pageG.waitForTimeout(1200);
|
||||
// 等首轮刷新结束(倒计时在刷新完成时才重置),否则读到的是加载中的旧值,偏差会被算进断言
|
||||
await pageG.waitForFunction(() => document.getElementById('refresh-btn').textContent === '刷新' && !document.getElementById('refresh-btn').disabled, null, { timeout: 45000 });
|
||||
const shownText = await pageG.evaluate(() => document.getElementById('countdown').textContent);
|
||||
const shownSec = Number((shownText.match(/(\d+)/) ?? [])[1]);
|
||||
// 期望值需在当前时刻重算:面板显示的是「服务端上次更新 + 一个周期 - 现在」
|
||||
const expectSec = Math.round((updatedMs + cadence * 1000 - Date.now()) / 1000);
|
||||
check('倒计时对齐服务端下次更新时刻', Math.abs(shownSec - expectSec) <= 4, `面板 ${shownText}(服务端还有约 ${expectSec}s,节奏 ${cadence}s)`);
|
||||
const shownSec = Number((shownText.match(/(\d+)/) ?? [])[1]);
|
||||
check('倒计时对齐服务端下次更新时刻', shownSec >= expectSec - 2 && shownSec <= expectSec + 4, `面板 ${shownText}(服务端还有约 ${expectSec}s,节奏 ${cadence}s)`);
|
||||
// 注:无头浏览器会冻结页面定时器(实测 performance.now() 会在采样间隔内跳跃十几秒),
|
||||
// 因此不断言「N 秒内递减」,只断言与公式一致、且不退化为整周期重计——这两条不依赖定时器
|
||||
check('倒计时不会退化为从整周期重新计时', shownSec <= Math.max(5, Math.min(cadence, Number(cfg.settings.refreshInterval) || cadence)), `显示 ${shownSec}s`);
|
||||
await pageG.close();
|
||||
|
||||
|
||||