diff --git a/docs/review/layout-20261104/README.md b/docs/review/layout-20261104/README.md new file mode 100644 index 0000000..2b1df08 --- /dev/null +++ b/docs/review/layout-20261104/README.md @@ -0,0 +1,43 @@ +# 界面对齐修复 + +基于 `main` 的 `fd3fd0d4cf6f082d25fbab44bfe674dee2621609`,保留现有经典/玻璃外观及功能。 + +| 问题 | 修复 | +| --- | --- | +| 总览外层 page 和内部 home 重复添加边距,顶栏标题另用固定边距 | 由 page 统一提供内容宽度和边距,顶栏使用同一边界 | +| 首页各行列宽分别为 4 等分、1.6:1、1.3:1:1 | 统一为四列网格对应的整列、半宽与四分之一宽;根据实际内容宽度切换双列/单列 | +| 卡片 height:100%、自身底部 margin、行间距叠加 | 由网格负责等高和行间距;内容保持自然高度 | +| 手机侧栏被后置 position:relative 覆盖,正文出现在导航下方 | 恢复手机 fixed 抽屉定位;桌面相对定位放回基础规则 | +| 手机表单标签固定 150px,输入框只剩约 70px | 窄屏/窄内容区标签与字段上下排列;320px 下普通设置输入框为 256px | +| AI 补丁使用不存在的 .ai-panel/.ai-drawer,1100px 下正文被压到412px | 使用实际 .ai-host.panel 与共享宽度变量;1400px 起并排,其余覆盖显示 | +| 操作单元格右对齐被通用 table td 规则覆盖 | 提高操作列规则优先级,表头采用统一 ops 标记;CDN 桌面保持 table-cell | +| 320px 下统计页分段切换和待处理操作条溢出 | 分段控件内部滚动,操作条换行 | +| 紧凑模式的间距与列宽计算不一致 | 通过共享 layout-gap 同步计算;紧凑表格 padding 只应用于桌面 | +| Baize 双列内容面板在手机上被基础规则覆盖 | 窄容器内明确改为单列 | +| 按钮与筛选输入框/下拉框高度不同 | 公共控件使用 32px 高度;登录等专用大输入框保留原规则 | + +## 验证 + +运行:安装 Playwright 后执行 `node scripts/layout_smoke.mjs /path/to/output`。 +可用 `PLAYWRIGHT_MODULE` 指向已安装的 Playwright `index.mjs`。 + +- Chromium:1920、1440、1280、1100、1024、768、720、390、320px。 +- 经典浅色,以及玻璃+紧凑+深色,共 306 个页面状态。 +- 16 个主页面:总览、服务器、网站、访问统计、监控、待处理、证书、云服务器、CDN、解析、存储、终端、文件、记录、设置、AI 助手;另检查总览打开 AI 侧栏。 +- 断言页面和正文不横向溢出、桌面标题与正文边距一致、同排首页卡片等高、首页共享列线、服务器表头与数据列一致、设置输入框宽度、手机菜单打开/关闭、AI 侧栏关闭。 +- JavaScript 语法检查、`git diff --check`、布局检测器通过。 +- 全部为本地模拟数据:首页包含长名称、长待办和 5 条变更;其余页面主要覆盖未配置/空数据状态;文件页覆盖明确的离线错误状态。没有连接生产云账号或服务器,没有验证真实服务端操作和所有弹窗。Go 后端未修改,未在本机运行完整 Go CI。 + +## 截图 + +桌面修复前: + +![修复前](before-desktop.png) + +桌面修复后: + +![修复后](after-desktop.png) + +手机修复后: + +![手机](after-mobile.png) diff --git a/docs/review/layout-20261104/after-desktop.png b/docs/review/layout-20261104/after-desktop.png new file mode 100644 index 0000000..e45904b Binary files /dev/null and b/docs/review/layout-20261104/after-desktop.png differ diff --git a/docs/review/layout-20261104/after-mobile.png b/docs/review/layout-20261104/after-mobile.png new file mode 100644 index 0000000..0fadaba Binary files /dev/null and b/docs/review/layout-20261104/after-mobile.png differ diff --git a/docs/review/layout-20261104/before-desktop.png b/docs/review/layout-20261104/before-desktop.png new file mode 100644 index 0000000..261d2cd Binary files /dev/null and b/docs/review/layout-20261104/before-desktop.png differ diff --git a/internal/webui/static/app.js b/internal/webui/static/app.js index d2eefbe..ef44ee6 100644 --- a/internal/webui/static/app.js +++ b/internal/webui/static/app.js @@ -2324,7 +2324,7 @@ const DnsPage = {
- + @@ -2679,7 +2679,7 @@ const HomePage = {

服务器

名称CPU内存磁盘
主机记录类型线路记录值TTL状态操作
主机记录类型线路记录值TTL状态操作
{{ r.name }}
{{ r.full }}
- + @@ -3408,7 +3408,7 @@ const CloudPage = {
没有入站规则
容器镜像状态内存CPU操作
容器镜像状态内存CPU操作
{{ c.name }}
{{ c.ports }}
{{ c.image }} {{ c.status }} {{ c.mem ? c.mem.split(' / ')[0] : '—' }}{{ c.cpu || '—' }}
- + @@ -3579,7 +3579,7 @@ const CdnPage = {
端口协议来源策略备注操作
端口协议来源策略备注操作
{{ r.port }}{{ r.protocol }} {{ r.everyone ? '所有人' : r.source }}{{ r.source }}
- + @@ -3812,7 +3812,7 @@ const MonitorPage = {
{{ v.listed ? '没有找到网站。在「设置」里手动添加要监控的地址,或者在服务器上用 1Panel 建站。' : '正在整理要监控的网站,一分钟内出结果……' }}
加速域名状态CNAME源站HTTPS操作
加速域名状态CNAME源站HTTPS操作
{{ d.domain }}
{{ CDN_TYPE[d.type] || d.type }}
- + @@ -4460,7 +4460,7 @@ const SitePage = {
网站现在响应24 小时可用率最近一小时操作
网站现在响应24 小时可用率最近一小时操作
{{ s.name }}
{{ s.source }}
- + @@ -5357,7 +5357,7 @@ const StoragePage = {
网站类型状态HTTPS操作
网站类型状态HTTPS操作
{{ x.domain }}
{{ [x.remark, x.app, x.runtime].filter(Boolean).join(' · ') }}
- + diff --git a/internal/webui/static/style.css b/internal/webui/static/style.css index d8e30e1..d7db324 100644 --- a/internal/webui/static/style.css +++ b/internal/webui/static/style.css @@ -33,6 +33,10 @@ --shadow: 0 0 0 .5px rgba(0, 0, 0, .06), 0 1px 3px rgba(0, 0, 0, .05), 0 8px 24px rgba(0, 0, 0, .04); --shadow-lg: 0 20px 60px rgba(0, 0, 0, .18), 0 0 0 .5px rgba(0, 0, 0, .08); --radius: 14px; + --page-gutter: 28px; + --layout-gap: 16px; + --control-height: 32px; + --ai-panel-width: 380px; color-scheme: light; } @media (prefers-color-scheme: dark) { @@ -109,7 +113,7 @@ a { color: var(--accent); text-decoration: none; cursor: pointer; } /* Controls */ button { font: inherit; font-size: 13px; display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; - height: 30px; padding: 0 12px; border-radius: 7px; border: none; cursor: pointer; + height: var(--control-height); padding: 0 12px; border-radius: 7px; border: none; cursor: pointer; background: var(--surface); color: var(--text); box-shadow: var(--shadow); } button:hover:not(:disabled) { background: var(--surface-2); } @@ -126,6 +130,7 @@ input, select, textarea { border: none; border-radius: 7px; padding: 6px 10px; outline: none; width: 100%; box-shadow: inset 0 0 0 1px var(--sep); } +:where(input:not([type=checkbox]):not([type=radio])), select { height: var(--control-height); min-width: 0; } input:focus, select:focus, textarea:focus { box-shadow: inset 0 0 0 1px var(--accent), 0 0 0 3px var(--accent-track); } /* Drop-downs look like the fields around them, with a thin chevron, not the system's own box. */ @@ -149,7 +154,7 @@ input[type=checkbox]:focus-visible, input[type=radio]:focus-visible { outline: 2 /* App layout */ .app { display: grid; grid-template-columns: 248px 1fr; height: 100vh; } .sidebar { - background: var(--sidebar); border-right: 1px solid var(--sep); + position: relative; background: var(--sidebar); border-right: 1px solid var(--sep); display: flex; flex-direction: column; padding: 16px 10px 12px; overflow-y: auto; } .app-title { display: flex; align-items: center; gap: 10px; padding: 2px 10px 18px; font-weight: 600; font-size: 15px; letter-spacing: -.01em; } @@ -182,17 +187,18 @@ input[type=checkbox]:focus-visible, input[type=radio]:focus-visible { outline: 2 .sidebar-foot { margin-top: auto; } .spend { padding: 8px 10px 0; font-size: 12px; color: var(--text-3); } -.main { overflow-y: auto; display: flex; flex-direction: column; min-width: 0; } +.main { --page-max: 1040px; overflow-y: auto; scrollbar-gutter: stable; display: flex; flex-direction: column; min-width: 0; } +.main:has(> .page.wide), .tab-pane:has(> .page.wide) { --page-max: 1400px; } .toolbar { position: sticky; top: 0; z-index: 5; display: flex; align-items: center; gap: 12px; - min-height: 52px; padding: 10px 28px; background: color-mix(in srgb, var(--bg) 82%, transparent); + min-height: 52px; flex-shrink: 0; padding: 10px max(var(--page-gutter), calc((100% - var(--page-max)) / 2 + var(--page-gutter))); background: color-mix(in srgb, var(--bg) 82%, transparent); backdrop-filter: saturate(180%) blur(20px); -webkit-backdrop-filter: saturate(180%) blur(20px); border-bottom: 1px solid var(--sep); } -.toolbar h1 { font-size: 15px; } +.toolbar h1 { font-size: 15px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .toolbar .spacer { flex: 1; } -.toolbar .actions { display: flex; gap: 8px; } -.page { width: 100%; max-width: 1040px; margin: 0 auto; padding: 28px 28px 48px; } +.toolbar .actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; } +.page { width: 100%; max-width: var(--page-max); flex-shrink: 0; margin: 0 auto; padding: 24px var(--page-gutter) 48px; container: page / inline-size; } .page-head { margin-bottom: 22px; } .page-head h2 { font-size: 26px; letter-spacing: -.02em; } @@ -226,7 +232,7 @@ input[type=checkbox]:focus-visible, input[type=radio]:focus-visible { outline: 2 .row .grow { flex: 1; min-width: 0; } .row .v.ellipsis { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; overflow-wrap: normal; } .mono { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; } -.row.form .k { min-width: 150px; } +.row.form .k { flex: 0 0 150px; min-width: 0; overflow-wrap: anywhere; } .row.form .v { flex: 1; max-width: 460px; text-align: left; } .row.stack { display: block; } .security-block > b, .security-block > .secondary { display: block; } @@ -439,12 +445,16 @@ details.guide code { font: 12px ui-monospace, "SF Mono", Menlo, Consolas, monosp .phone-menu, .nav-scrim { display: none; } .sidebar:focus { outline: none; } @media (max-width: 720px) { + :root { --page-gutter: 16px; } + .main { scrollbar-gutter: auto; } .tiles.two { grid-template-columns: minmax(0, 1fr); } .app { grid-template-columns: 1fr; height: auto; } .main { overflow: visible; overflow-x: clip; } .toolbar { padding: 9px 16px 9px 58px; flex-wrap: wrap; row-gap: 8px; } .toolbar h1 { white-space: nowrap; line-height: 34px; min-width: 0; overflow: hidden; text-overflow: ellipsis; } /* level with the menu button */ /* A row of page buttons goes under the title and scrolls sideways. */ + .toolbar > .segmented { flex: 1 0 100%; margin-left: -42px; min-width: 0; max-width: calc(100% + 42px); overflow-x: auto; } + .toolbar > .segmented button { flex: none; } .toolbar .actions { flex: 1 0 100%; margin-left: -42px; padding: 2px 0; overflow-x: auto; scrollbar-width: none; } .toolbar .actions::-webkit-scrollbar { display: none; } .toolbar .actions > * { flex: none; } @@ -468,7 +478,9 @@ details.guide code { font: 12px ui-monospace, "SF Mono", Menlo, Consolas, monosp } .chat-layout { height: auto; } .chat { height: auto; min-height: 70vh; } - .row.form { flex-wrap: wrap; } + .row.form { flex-direction: column; align-items: stretch; gap: 6px; } + .row.form .k { flex: none; } + .row.form .v { flex: none; width: 100%; max-width: none; margin-left: 0; } .log-bar { flex-wrap: wrap; } .log-bar .grow { order: 3; flex-basis: 100%; } .exec-detail { padding-left: 16px; } @@ -585,10 +597,10 @@ button.th-sort:focus-visible { outline: 2px solid var(--accent); outline-offset: @media (max-width: 720px) { .term-page { height: 80vh; } } /* 网站统计 → 访问分析: filter bar, sections, cards */ -.page.wide { max-width: 1400px; } +.page.wide { --page-max: 1400px; } .stat-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 10px 14px; background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); padding: 10px 14px; } .stat-bar .field { display: inline-flex; flex-direction: row; align-items: center; gap: 8px; font-size: 13px; color: var(--text-2); white-space: nowrap; } -.stat-bar .field input { height: 30px; font-size: 13px; } +.stat-bar .field input { height: var(--control-height); font-size: 13px; } .stat-bar .field > span { flex: none; } .stat-bar select { min-width: 150px; max-width: 260px; } .stat-bar .grow { flex: 1; } @@ -620,7 +632,7 @@ button.th-sort:focus-visible { outline: 2px solid var(--accent); outline-offset: .card { background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); margin-bottom: 18px; overflow: hidden; min-width: 0; } .card-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 12px 16px; border-bottom: 1px solid var(--sep); } -.card-head h3 { font-size: 14px; font-weight: 600; margin: 0; flex: 1; white-space: nowrap; } +.card-head h3 { font-size: 14px; font-weight: 600; margin: 0; flex: 1; min-width: 0; overflow-wrap: anywhere; } .card-head .grow { flex: 1; } .card-head h3 + .small { flex: none; } .card .lchart { padding: 8px 8px 0; } @@ -727,7 +739,7 @@ button.th-sort:focus-visible { outline: 2px solid var(--accent); outline-offset: .fm-bar > select { width: auto; max-width: 200px; } .fm-server { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; font-size: 13px; padding-right: 2px; white-space: nowrap; } .fm-server .icon { color: var(--accent); } -.fm-path { flex: 1; min-width: 220px; display: flex; align-items: center; gap: 0; height: 30px; padding: 0 4px; border-radius: 7px; +.fm-path { flex: 1; min-width: 220px; display: flex; align-items: center; gap: 0; height: var(--control-height); padding: 0 4px; border-radius: 7px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--sep); overflow-x: auto; overflow-y: hidden; scrollbar-width: none; cursor: text; } .fm-path::-webkit-scrollbar { display: none; } .fm-path.editing { box-shadow: inset 0 0 0 1px var(--accent), 0 0 0 3px var(--accent-track); } @@ -1096,11 +1108,11 @@ a.btn:hover { background: var(--hover); } } /* 总览 */ -.home-head { display: flex; align-items: center; gap: 10px; margin-bottom: 18px; } +.home-head { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: var(--layout-gap); } .home-head h2 { font-size: 26px; letter-spacing: -.02em; } .home-head .grow { flex: 1; min-width: 0; } .home-start { max-width: 640px; } -.home-cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; } +.home-cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--layout-gap); margin-bottom: var(--layout-gap); } button.home-card { height: auto; display: flex; flex-direction: column; align-items: stretch; gap: 6px; padding: 14px 16px; border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); text-align: left; white-space: normal; min-width: 0; } button.home-card:hover:not(:disabled) { background: var(--surface); box-shadow: var(--shadow), 0 0 0 2px var(--accent-track); } @@ -1112,15 +1124,18 @@ button.home-card:hover:not(:disabled) { background: var(--surface); box-shadow: .home-card-value { font-size: 30px; font-weight: 700; letter-spacing: -.02em; font-variant-numeric: tabular-nums; line-height: 1.2; } .home-card-value small { font-size: 14px; font-weight: 500; color: var(--text-2); margin-left: 4px; letter-spacing: 0; } .home-card-sub { font-size: 12.5px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.home-row { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); gap: 14px; align-items: start; } -.home-row.three { grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr) minmax(0, 1fr); } -.home-row .card { margin-bottom: 14px; } +.home-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--layout-gap); align-items: stretch; margin-bottom: var(--layout-gap); } +/* Half + quarter + quarter share the same column lines as the four tiles. */ +.home-row.three { grid-template-columns: minmax(0, calc((100% - var(--layout-gap)) / 2)) repeat(2, minmax(0, 1fr)); } +.home-row > .card { display: flex; flex-direction: column; margin-bottom: 0; } +.home-row > .card > * { flex-shrink: 0; } .home-row .card-head h3 { flex: none; } .home-empty { padding: 16px; color: var(--text-2); font-size: 13px; display: flex; align-items: center; gap: 4px; } -.todo-row { display: flex; align-items: center; gap: 12px; padding: 10px 16px; } +.todo-row { display: grid; grid-template-columns: 32px minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 12px 16px; } +.todo-row > button { min-width: 84px; justify-content: center; } .todo-row + .todo-row { border-top: 1px solid var(--sep); } .todo-row .grow { flex: 1; min-width: 0; } -.todo-title { font-weight: 500; } +.todo-title { font-weight: 500; overflow-wrap: anywhere; } .todo-icon { width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; background: var(--fill); flex: none; } .todo-icon .icon { width: 16px; height: 16px; stroke-width: 2; } .todo-icon.lv-crit .icon { color: var(--crit); } @@ -1131,11 +1146,11 @@ button.home-card:hover:not(:disabled) { background: var(--surface); box-shadow: .home-ask .card-head { border-bottom: 0; padding-bottom: 0; } .home-ask-icon { color: var(--accent); width: 18px; height: 18px; } .home-ask > p, .home-ask > textarea, .home-asks, .home-ask-foot { margin-left: 16px; margin-right: 16px; width: auto; } -.home-ask > textarea { display: block; resize: none; width: calc(100% - 32px); } +.home-ask > textarea { display: block; resize: vertical; min-height: 88px; flex: 1; width: calc(100% - 32px); } .home-asks { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; } button.chip { height: 28px; padding: 0 12px; border-radius: 14px; background: var(--fill); box-shadow: none; color: var(--text); font-size: 12.5px; } button.chip:hover:not(:disabled) { background: var(--selected); } -.home-ask-foot { display: flex; align-items: center; margin-top: 10px; } +.home-ask-foot { display: flex; align-items: center; padding-top: 12px; margin-top: auto; } .home-ask-foot .grow { flex: 1; } button.round { border-radius: 50%; width: 32px; height: 32px; } .srv-grid { display: grid; grid-template-columns: minmax(0, 1.4fr) repeat(3, minmax(0, 1fr)); gap: 10px; align-items: center; padding: 0 16px; } @@ -1143,7 +1158,9 @@ button.round { border-radius: 50%; width: 32px; height: 32px; } button.srv-row { width: 100%; height: auto; min-height: 50px; border-radius: 0; background: none; box-shadow: none; text-align: left; border-top: 1px solid var(--sep); white-space: normal; } button.srv-row:hover:not(:disabled) { background: var(--hover); } .srv-name { display: flex; align-items: flex-start; gap: 6px; min-width: 0; } -.srv-name .sdot { margin-top: 7px; } +.srv-name .sdot { margin-top: 7px; flex: none; } +.srv-name > span:last-child { min-width: 0; overflow-wrap: anywhere; } +.srv-name b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .srv-metric { display: flex; flex-direction: column; gap: 3px; font-size: 12.5px; font-variant-numeric: tabular-nums; } .mini-bar { height: 4px; border-radius: 2px; background: var(--fill); overflow: hidden; } .mini-bar > span { display: block; height: 100%; border-radius: 2px; background: var(--accent); } @@ -1163,9 +1180,22 @@ button.change-row:hover:not(:disabled) { background: var(--hover); } .change-when { width: 70px; flex: none; padding-top: 1px; } .change-row .grow { flex: 1; min-width: 0; } /* By the page's own width, which the AI side panel also takes from. */ -.home { container-type: inline-size; } -@container (max-width: 860px) { .home-cards, .home-row.three { grid-template-columns: repeat(2, minmax(0, 1fr)); } } -@container (max-width: 640px) { .home-row, .home-row.three { grid-template-columns: minmax(0, 1fr); } } +.home { container: home / inline-size; } +@container home (max-width: 1000px) { + .home-cards, .home-row.three { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .home-row.three > .card:first-child { grid-column: 1 / -1; } +} +@container home (max-width: 680px) { + .home-row, .home-row.three { grid-template-columns: minmax(0, 1fr); } +} +@container home (max-width: 420px) { + .home-head > .grow { flex-basis: 100%; } + .home-card-top .pill { display: none; } + .home-card-value { font-size: 26px; } + .home-card-value small { font-size: 12px; } + .todo-row { grid-template-columns: 32px minmax(0, 1fr); } + .todo-row > button { grid-column: 2; justify-self: end; } +} .home-card-sub .phone-only { display: none; } @media (max-width: 560px) { .home-card-top .pill { display: none; } @@ -1177,17 +1207,17 @@ button.change-row:hover:not(:disabled) { background: var(--hover); } } /* 待处理 */ -.inbox-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; } +.inbox-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 14px; } .inbox-bar .side-count { margin-left: 2px; } .inbox-lead { margin: 0 4px 14px; } /* AI side panel */ .ai-host { display: contents; } -.ai-host.panel { display: flex; flex-direction: column; position: fixed; top: 0; right: 0; bottom: 0; width: min(440px, 100vw); z-index: 40; +.ai-host.panel { display: flex; flex-direction: column; position: fixed; top: 0; right: 0; bottom: 0; width: min(var(--ai-panel-width), 100vw); z-index: 40; background: var(--bg); box-shadow: -1px 0 0 var(--sep), -12px 0 40px rgba(0, 0, 0, .12); } /* On a wide window the page makes room for the side panel instead of hiding under it. */ -@media (min-width: 1100px) { .main.beside-ai { padding-right: 440px; } } +@media (min-width: 1400px) { .main.beside-ai { padding-right: var(--ai-panel-width); } } .ai-host.panel .chat-layout { flex: 1; min-height: 0; display: flex; height: auto; } .ai-host.panel .conv-list { display: none; } .ai-host.panel .chat { flex: 1; min-width: 0; } @@ -1379,18 +1409,8 @@ button.wb-site:hover:not(:disabled) { background: var(--hover); } .eo-rl-threshold select { width: auto; } .eo-rl-threshold input { width: 90px; } -/* ---- 布局修正(NAS 部署后追加)---- */ -/* 1) 粘性工具栏改为不透明,滚动时卡片不再“穿透”露出半截 */ -.toolbar { background: var(--bg); } -/* 2) 首页底行卡片等高,避免高低不齐 */ -.home-row { align-items: stretch; } -.home-row > .card { height: 100%; margin-bottom: 0; } -/* 3) 首页卡片行底部留白一致 */ -.home-row { margin-bottom: 14px; } -/* ================= 对标优化(参考 Beszel / 1Panel / Dashy) ================= */ -/* 1) 内容限宽 1500px 居中:宽屏下卡片不再被拉成超宽(Beszel --container: 1500px 同值) */ -.home { max-width: 1500px; margin: 0 auto; padding: 24px 28px 44px; width: 100%; } -@media (max-width: 700px) { .home { padding: 16px 14px 32px; } } +/* The page owns the outer gutters; Home must not add a second inset. */ +.home { width: 100%; } /* 2) 卡片层次:1px 边框 + 极轻阴影(1Panel: 1px solid #f2f2f2 / Beszel: border var(--border)) */ .card, button.home-card { border: 1px solid var(--sep); box-shadow: 0 1px 2px rgba(0, 0, 0, .03); } @@ -1407,8 +1427,6 @@ button.home-card:hover:not(:disabled) { border-color: var(--accent-track); box-s /* 4) 数字统一等宽,避免跳动 */ .home-card-value, .home-pv b, .delta, .srv-grid, .metric, .num { font-variant-numeric: tabular-nums; } -/* 5) 首页底行卡片等高后,内部内容顶部对齐 */ -.home-row > .card { display: flex; flex-direction: column; } /* ================= 修复 2026-10-24(溢出 / 对齐 / 截断) ================= */ /* flex 子项默认 min-width:auto 会把容器撑破 */ .grow { min-width: 0; } @@ -1423,15 +1441,12 @@ button.home-card:hover:not(:disabled) { border-color: var(--accent-track); box-s .conv-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } /* 服务器页:指标卡少于 4 个时自动铺满一行,不再左右缺一块 */ -.wb-rings { grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); } +.wb-rings { grid-template-columns: repeat(auto-fit, minmax(min(210px, 100%), 1fr)); } /* 滚动条占位,保证消息区与输入框左右对齐 */ .messages { scrollbar-gutter: stable; } /* ================= 响应式补丁 + 清单按钮排版 ================= */ -@media (max-width: 1280px) { - .home-row.three { grid-template-columns: repeat(2, minmax(0, 1fr)); } -} @media (max-width: 980px) { - .home-row, .home-row.three, .wb-row { grid-template-columns: minmax(0, 1fr); } + .wb-row { grid-template-columns: minmax(0, 1fr); } } @media (prefers-color-scheme: dark) { :root { --sep: rgba(120, 120, 128, .62); } @@ -1440,7 +1455,7 @@ button.home-card:hover:not(:disabled) { border-color: var(--accent-track); box-s .plan-foot button { flex: none; } /* ================= PWA / 骨架屏 / 紧凑模式 / 可访问性 ================= */ /* 骨架屏 */ -.home-skeleton { max-width: 1400px; margin: 0 auto; padding: 8px 28px; } +.home-skeleton { width: 100%; } .home-skeleton .sk-cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; } .sk-card, .sk-block { position: relative; overflow: hidden; background: var(--surface); border: 1px solid var(--sep); border-radius: 12px; } .sk-card { height: 108px; } @@ -1457,17 +1472,19 @@ button.home-card:hover:not(:disabled) { border-color: var(--accent-track); box-s @keyframes sk-shine { 100% { transform: translateX(100%); } } /* 紧凑模式:更小的行高与内边距 */ -html.compact .table th, html.compact .table td { padding: 6px 12px; } +html.compact { --layout-gap: 10px; } +@media (min-width: 721px) { + html.compact .table th, html.compact .table td { padding: 6px 12px; } +} html.compact .card-head { padding: 9px 14px; } html.compact .row { padding: 8px 14px; } html.compact .todo-row, html.compact .wb-check, html.compact .wb-site { padding: 7px 14px; } html.compact .home-cards { gap: 10px; margin-bottom: 12px; } -html.compact .home-row .card { margin-bottom: 10px; } +html.compact .home-row { gap: 10px; margin-bottom: 10px; } html.compact .plan .row.step { padding: 8px 14px; } html.compact .page { padding-top: 18px; } /* 侧边栏分组标题在滚动时保持可见 */ -.sidebar { position: relative; } .side-label { position: sticky; top: 0; z-index: 2; background: var(--sidebar); } /* 键盘焦点可见(可访问性) */ @@ -1651,16 +1668,6 @@ details[open] > summary .icon { transition: transform var(--dur-2) var(--ease-ou } .home-empty .spinner.inline { margin-right: 2px; } -/* 右侧 AI 面板在中等宽度下更窄一点,避免挤压主内容 */ -@media (max-width: 1400px) { - .main.beside-ai .ai-panel, .main.beside-ai .ai-drawer { width: min(380px, 34vw); } -} -@media (max-width: 1180px) { - .main.beside-ai .ai-panel, .main.beside-ai .ai-drawer { - position: fixed; right: 14px; bottom: 14px; top: auto; height: min(72vh, 640px); width: min(420px, 92vw); - box-shadow: var(--shadow-lg); z-index: 30; - } -} /* 侧栏底部(账号/退出/花费/外观开关)吸底:侧栏内容比视口高时也始终可见 */ .sidebar { display: flex; flex-direction: column; } .sidebar > .side-section, .sidebar > .side-search, .sidebar > .app-title { flex: none; } @@ -1674,4 +1681,28 @@ html.glass .sidebar > .sidebar-foot { background: color-mix(in srgb, var(--sidebar) 88%, transparent); backdrop-filter: blur(18px) saturate(160%); -webkit-backdrop-filter: blur(18px) saturate(160%); -} \ No newline at end of file +} +/* Shared alignment and reflow for pages, including an open AI panel. */ +.table th.ops, .table td.site-ops, .table td.dns-ops, .table td.cos-ops { text-align: right; vertical-align: middle; } +.cdn-table td.site-ops { display: table-cell; } +.cdn-table .site-ops button + button { margin-left: 12px; } +@container page (max-width: 600px) { + .tiles.two { grid-template-columns: minmax(0, 1fr); } + .row.form { flex-direction: column; align-items: stretch; gap: 6px; } + .row.form .k { flex: none; } + .row.form .v { flex: none; width: 100%; max-width: none; margin-left: 0; } + .wb-tabs { display: flex; overflow-x: auto; max-width: 100%; } + .wb-tabs button { flex: none; } + .inbox-bar > .segmented { max-width: 100%; overflow-x: auto; } + .inbox-bar > .segmented button { flex: none; } + .stat-bar .field { min-width: 0; max-width: 100%; } + .stat-bar select { min-width: 0; } +} +@container home (max-width: 1000px) { .home-skeleton .sk-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@container home (max-width: 680px) { .home-skeleton .sk-row { grid-template-columns: minmax(0, 1fr); } } +@media (max-width: 720px) { + .table td.site-ops, .table td.dns-ops, .table td.cos-ops { text-align: left; } + .cdn-table td.site-ops { display: flex; } +} +/* Data rows keep their column positions on hover. */ +button.srv-row:hover, button.change-row:hover, button.wb-site:hover { transform: none; } diff --git a/scripts/layout_smoke.mjs b/scripts/layout_smoke.mjs new file mode 100644 index 0000000..20e2413 --- /dev/null +++ b/scripts/layout_smoke.mjs @@ -0,0 +1,123 @@ +// Local UI geometry regression checks. No credentials or live services are used. +// npm install --no-save playwright; node scripts/layout_smoke.mjs [output-directory] +import { createServer } from 'node:http'; +import { readFile, mkdir, writeFile } from 'node:fs/promises'; +import { resolve, extname } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import assert from 'node:assert/strict'; +const { chromium } = await import(process.env.PLAYWRIGHT_MODULE ? pathToFileURL(process.env.PLAYWRIGHT_MODULE).href : 'playwright'); +const root = resolve('internal/webui/static'); +const output = resolve(process.argv[2] || 'layout-review'); +await mkdir(output, { recursive: true }); +const serverInfo = { id: 'layout-server', name: '生产环境-web-01.example.com', host: '192.0.2.1', adapter: 'linux', authKind: 'key', port: 22, username: 'admin' }; +const overview = { + servers: [{ ...serverInfo, level: 'warn', note: 'SSH 允许密码登录', cpuPct: 27, memPct: 53, diskPct: 42 }], + visits: { pv: 1306, pvBefore: 6000, sites: 16, title: '测试日志', hours: [30, 15, 20, 10, 12, 14, 10, 12, 20, 40, 150, 145, 55, 25, 18, 20, 25, 22, 20, 30, 20, 0, 0, 0] }, + certs: { total: 2, attention: 0 }, security: { known: true, high: 0, unblocked: 0 }, unread: 1, + todo: Array.from({ length: 5 }, (_, i) => ({ id: String(i), kind: 'plan', level: 'plan', title: i === 2 ? '清单等你确认:给 Halo 和 MySQL 设内存上限(需先固定 JVM 堆,否则 Halo 会出现问题)' : '清单等你确认:只读查看服务器的配置与运行状态', meta: '11-04 12:00', action: '查看清单' })), + changes: Array.from({ length: 5 }, (_, i) => ({ id: String(i), title: '添加 very-long-subdomain.example.com 的 TXT 解析记录', serverName: '腾讯云', status: 'done', startedAt: '2026-11-04T12:00:00Z' })) +}; +const fixtures = { + '/api/auth/state': { mode: 'desktop' }, '/api/info': {}, '/api/ai/presets': [], + '/api/servers': [serverInfo], '/api/settings/ai': { hasKey: true }, '/api/usage': {}, + '/api/conversations': [], '/api/settings/tencent': {}, '/api/settings/aliyun': {}, + '/api/settings/free-command': {}, '/api/notices/unread': { unread: 1 }, '/api/overview': overview, + '/api/servers/layout-server/cloud': {}, '/api/servers/layout-server/profile': { server: serverInfo, profile: null }, + '/api/plans': [], '/api/exec': [], '/api/audit': [], '/api/terminals': [], + '/api/websites': { servers: [], errors: [] }, '/api/certificates': { certificates: [], errors: [] }, + '/api/visits/sources': [], '/api/visits/blocked': [], '/api/baize/settings': {}, + '/api/monitor': { sites: [], servers: [], settings: {} }, '/api/statuspage': {}, + '/api/cloud/alarms': { alarms: [] }, '/api/notices': { notices: [], settings: {} }, + '/api/update': {}, '/api/preload': {}, '/api/eo/sites': { sites: [] }, +}; +const missing = new Set(); +const server = createServer(async (req, res) => { + const pathname = new URL(req.url, 'http://localhost').pathname; + if (pathname.startsWith('/api/')) { + res.setHeader('Content-Type', 'application/json'); + if (req.method !== 'GET') { res.writeHead(405); res.end('{}'); return; } + if (!(pathname in fixtures)) { missing.add(pathname); res.writeHead(503); res.end('{"error":"本地布局测试未连接服务"}'); return; } + res.end(JSON.stringify(fixtures[pathname])); return; + } + try { + const file = resolve(root, '.' + (pathname === '/' ? '/index.html' : pathname)); + if (!file.startsWith(root)) throw new Error('path'); + res.setHeader('Content-Type', ({ '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml' })[extname(file)] || 'application/octet-stream'); + res.end(await readFile(file)); + } catch { res.writeHead(404); res.end(); } +}); +await new Promise(r => server.listen(0, '127.0.0.1', r)); +const browser = await chromium.launch({ headless: true }); +const results = [], errors = []; +const inspect = page => page.evaluate(() => { + const rect = el => { const b = el.getBoundingClientRect(); return { x: b.x, y: b.y, w: b.width, h: b.height, right: b.right, bottom: b.bottom }; }; + const main = document.querySelector('.main'); + const visible = el => el.getClientRects().length && getComputedStyle(el).visibility !== 'hidden'; + const page = [...document.querySelectorAll('.page')].find(visible); + const heading = [...document.querySelectorAll('.toolbar h1')].find(visible); + return { + documentOverflow: document.documentElement.scrollWidth > innerWidth + 1, + mainOverflow: main.scrollWidth > main.clientWidth + 1, + page: page && rect(page), + contentLeft: page && page.getBoundingClientRect().x + parseFloat(getComputedStyle(page).paddingLeft), + heading: heading && rect(heading), + formWidths: [...document.querySelectorAll('.row.form .v input')].filter(visible).map(el => el.getBoundingClientRect().width), + home: document.querySelector('.home') && rect(document.querySelector('.home')), + tiles: [...document.querySelectorAll('.home-card')].map(rect), + rows: [...document.querySelectorAll('.home-row')].map(el => [...el.children].map(rect)), + serverColumns: [...document.querySelectorAll('.srv-grid')].map(el => [...el.children].map(rect)), + overflowing: [...document.querySelectorAll('.main *')].filter(el => visible(el) && !el.closest('.table-wrap, .fm-list, .subtabs, .segmented, pre') && el.getBoundingClientRect().right > main.getBoundingClientRect().right + 1).slice(0, 8).map(el => el.className), + }; +}); +try { + for (const variant of ['classic', 'glass-compact-dark']) for (const width of [1920, 1440, 1280, 1100, 1024, 768, 720, 390, 320]) { + const page = await browser.newPage({ viewport: { width, height: 1080 }, reducedMotion: 'reduce', colorScheme: variant === 'classic' ? 'light' : 'dark' }); + page.on('pageerror', e => { errors.push({ width, error: e.message }); console.error(e.stack); }); + page.on('console', msg => { if (msg.type() === 'error' && !msg.text().includes('Failed to load resource')) errors.push({ width, variant, error: msg.text() }); }); + await page.addInitScript(variant => { localStorage.setItem('miao.skin', variant === 'classic' ? 'classic' : 'glass'); if (variant !== 'classic') localStorage.setItem('miao.density', 'compact'); }, variant); + await page.goto(`http://127.0.0.1:${server.address().port}`); + await page.waitForSelector('.home-card'); + // Delayed tile entrances still have a delay under reduced motion. + await page.waitForTimeout(250); + const home = await inspect(page); + results.push({ width, variant, tab: 'home', ...home }); + if ([1920, 1024, 390].includes(width)) await page.screenshot({ path: resolve(output, `home-${variant}-${width}.png`), fullPage: true }); + if (width <= 720) { + await page.getByRole('button', { name: /菜单/ }).click(); + assert.ok(await page.locator('.sidebar.open').isVisible()); + await page.locator('.nav-scrim').click({ position: { x: width - 5, y: 200 } }); + assert.equal(await page.locator('.sidebar.open').count(), 0); + } + for (const tab of ['servers', 'sites', 'stats', 'monitor', 'inbox', 'certs', 'cloud', 'cdn', 'dns', 'storage', 'terminal', 'files', 'logs', 'settings', 'chat']) { + await page.evaluate(tab => app._container._vnode.component.proxy.go(tab), tab); + await page.waitForTimeout(80); + const geometry = await inspect(page); + assert.ok(geometry.heading, `Missing page: ${tab}`); + results.push({ width, variant, tab, ...geometry }); + if (tab === 'settings' && [1920, 390].includes(width)) await page.screenshot({ path: resolve(output, `settings-${variant}-${width}.png`), fullPage: true }); + } + await page.evaluate(() => { const vm = app._container._vnode.component.proxy; vm.go('home'); vm.toggleAI(true); }); + await page.waitForTimeout(100); + results.push({ width, variant, tab: 'home-ai', ...await inspect(page) }); + await page.getByRole('button', { name: '关闭 AI 侧栏', exact: true }).click(); + assert.equal(await page.locator('.ai-host.panel').count(), 0); + await page.close(); + } + await writeFile(resolve(output, 'results.json'), JSON.stringify({ results, errors, missing: [...missing] }, null, 2)); + console.log(JSON.stringify({ cases: results.length, errors, overflow: results.filter(r => r.documentOverflow || r.mainOverflow).map(r => ({ width: r.width, tab: r.tab })), missing: [...missing] })); + if (!process.env.LAYOUT_BASELINE) { + assert.equal(errors.length, 0, 'Browser runtime errors'); + assert.equal(results.filter(r => r.documentOverflow || r.mainOverflow).length, 0, 'Page overflow'); + for (const r of results.filter(r => r.tab === 'home')) { + assert.ok(r.page.y < 100, `Sidebar displaced page at ${r.width}`); + if (r.width >= 1400) { + assert.ok(Math.abs(r.rows[0][1].x - r.tiles[2].x) < 1, 'Top cards share middle grid line'); + assert.ok(Math.abs(r.rows[1][1].x - r.tiles[2].x) < 1, 'Bottom cards share middle grid line'); + } + for (const row of r.rows) if (row.every(b => Math.abs(b.y - row[0].y) < 1)) assert.ok(row.every(b => Math.abs(b.bottom - row[0].bottom) < 1), `Unequal cards at ${r.width}`); + if (r.serverColumns.length > 1) for (let i = 0; i < 4; i++) assert.ok(Math.abs(r.serverColumns[0][i].x - r.serverColumns[1][i].x) < 1, `Server column ${i} at ${r.width}`); + } + for (const r of results.filter(r => r.page && r.width > 720 && r.tab !== 'home-ai')) assert.ok(Math.abs(r.heading.x - r.contentLeft) < 1, `Header gutter: ${r.width}/${r.tab}`); + for (const r of results.filter(r => r.tab === 'settings')) assert.ok(r.formWidths.every(w => w >= 180), `Narrow settings fields: ${r.width}`); + } +} finally { await browser.close(); server.close(); }
名称大小修改时间存储类型操作
名称大小修改时间存储类型操作