多栏布局(主内容 + 元信息侧栏)
详情页的地基形状:左是主内容(描述、正文、讨论),右是窄栏元信息(状态、参与者、相关链接)。宽屏两栏并列,窄屏塌缩为单栏。用现成小组件(卡片 / 描述列表 / 状态芯片)拼装,不搞硬编码大块。
试这几下:
- 点右上切窄屏预览 —— 布局立刻塌成单栏,侧栏三张卡片变手风琴可折叠
- 直接把浏览器/iframe 拉窄到 720px 以下 —— 效果一样,走的是 CSS 断点(此时按钮变灰,因为已经在自然塌缩下了)
- 侧栏「状态」段控切换 / 发评论 / 删评论 —— 变更 toast、破坏性删除先弹确认
规矩
- 主内容独立可读:侧栏塞的是补充信息(谁、什么时候、去哪里看更多),移除后主内容仍讲得通;这是「可以塌缩到下面」的前提
- 必须响应式:宽屏
1fr / 320px两栏,窄屏单栏堆叠。demo 用 CSS Grid +.narrow类内联切换 + 媒体查询做自然塌缩;真实版做成布局组件,断点靠 Tailwindlg:grid-cols-[1fr_320px] - 卡片组合,不 monolith:主/侧栏内部都用现成小组件(
Card、DescriptionList、StatusChip、Button)拼;同一份加载好的记录喂给不同分区,不是一坨 JSX - 侧栏折叠状态可选放 URL / localStorage —— 用户展开了「参与者」这类偏好跨会话保留;demo 只在内存里,刷新即回默认
蓝本:
add-page-layout.md(open-dashboard @aa9815f,MIT,Invariants 已消化进上面「规矩」)
demo 源码:assets/demo-page-layout.html(自包含、未压缩)
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>形状 demo:多栏布局</title>
<!--
demo-page-layout.html —— 「多栏布局」形状的最小可玩实现(蓝本:open-dashboard 的 split-layout)。
复现的不变量:
- 主内容栏(左,宽)+ 元信息侧栏(右,窄);侧栏是补充,主内容独立可读
- 响应式塌缩:宽屏 720+ 两栏并列,窄屏 < 720 塌缩为单栏(demo 中侧栏在窄屏折叠成手风琴)
- 用现成小组件拼装(Card / 描述列表 / 状态芯片),主/侧栏都不是硬编码大块
简化:demo 用 CSS Grid 内联切换 + `.narrow` 类模拟;真实版做成布局组件,断点靠 Tailwind
(lg:grid-cols-[1fr_320px]),侧栏折叠状态可选放 URL/localStorage。
运行时:/vendor 的 React 18 UMD + htm(免构建),样式共用同级 demo.css。
-->
<link rel="stylesheet" href="demo.css">
<style>
.layout { display: grid; gap: 14px; align-items: start; grid-template-columns: 1fr; }
@media (min-width: 720px) {
.layout:not(.narrow) { grid-template-columns: 1fr 280px; }
}
.layout .col, .layout aside { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
.card-h { padding: 10px 14px; border-bottom: 1px solid var(--border); font-weight: 600; font-size: 13px;
display: flex; justify-content: space-between; align-items: center; }
.card-h.tap { cursor: pointer; user-select: none; }
.card-h .arrow { color: var(--muted); font-size: 11px; }
.card-b { padding: 12px 14px; }
.taskh { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 2px 0 4px; }
.taskh h2 { margin: 0; font-size: 17px; }
.kv { display: grid; grid-template-columns: 72px 1fr; row-gap: 6px; column-gap: 10px; font-size: 13px; margin: 0; }
.kv dt { color: var(--muted); margin: 0; }
.kv dd { margin: 0; }
.thread { display: flex; flex-direction: column; gap: 10px; }
.cmt { display: flex; flex-direction: column; gap: 2px; padding-bottom: 8px; border-bottom: 1px dashed var(--border); }
.cmt:last-of-type { border-bottom: 0; padding-bottom: 0; }
.cmt-h { display: flex; gap: 8px; align-items: center; font-size: 12px; }
.cmt-a { font-weight: 600; color: var(--fg); font-size: 13px; }
.cmt-at { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
.cmt-x { margin-left: auto; }
.cmt-b { margin: 0 0 0 30px; font-size: 13px; color: var(--fg); }
.avatar { display: inline-flex; width: 22px; height: 22px; align-items: center; justify-content: center;
border-radius: 999px; background: var(--chip-off-bg); color: var(--fg); font-size: 11px; font-weight: 600; }
.people, .links { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; font-size: 13px; }
.people li { display: flex; gap: 8px; align-items: center; }
.people .role { color: var(--muted); font-size: 12px; }
.links a { color: var(--primary); text-decoration: none; }
.links a:hover { text-decoration: underline; }
.composer { display: flex; gap: 8px; margin-top: 10px; }
.composer textarea { flex: 1; resize: vertical; }
</style>
</head>
<body>
<div id="root"></div>
<script src="/vendor/react.production.min.js"></script>
<script src="/vendor/react-dom.production.min.js"></script>
<script src="/vendor/htm.umd.js"></script>
<script>
"use strict";
const { useState, useEffect, useMemo } = React;
const html = htm.bind(React.createElement);
const STATUSES = [
{ key: "open", label: "待办", cls: "chip-warn" },
{ key: "doing", label: "进行中", cls: "chip-on" },
{ key: "done", label: "已完成", cls: "chip-off" },
];
const SEED_COMMENTS = [
{ id: 1, author: "陈禾", at: "2026-06-15 10:12", body: "预发复现了:导出 5 万行会卡在 80% 附近,网关超时。" },
{ id: 2, author: "李慕白", at: "2026-06-15 11:48", body: "看着像 worker 超时。我把导出改成流式,别把整个文件先缓到内存里。" },
{ id: 3, author: "王小满", at: "2026-06-16 09:03", body: "优先级抬到高:两家企业客户被卡在这个上。" },
];
function Confirm({ text, onOk, onCancel }) {
return html`
<div class="overlay" onClick=${e => { if (e.target === e.currentTarget) onCancel(); }}>
<div class="modal">
<h2>确认操作</h2>
<p style=${{ margin: 0 }}>${text}</p>
<div class="actions">
<button class="btn" onClick=${onCancel}>取消</button>
<button class="btn primary" onClick=${onOk}>确认</button>
</div>
</div>
</div>`;
}
// 卡片分区:宽屏总是展开;窄屏(侧栏塌缩到主内容下方后)可点头折叠 = 手风琴
function CardSection({ title, collapsible, defaultOpen, children }) {
const [openN, setOpenN] = useState(defaultOpen !== false);
const open = collapsible ? openN : true;
const toggle = collapsible ? () => setOpenN(v => !v) : undefined;
return html`
<section class="card">
<div class=${"card-h" + (collapsible ? " tap" : "")}
onClick=${toggle}
role=${collapsible ? "button" : undefined}
aria-expanded=${collapsible ? String(open) : undefined}
tabIndex=${collapsible ? 0 : undefined}
onKeyDown=${collapsible ? e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(); } } : undefined}>
<span>${title}</span>
${collapsible && html`<span class="arrow">${open ? "▾" : "▸"}</span>`}
</div>
${open && html`<div class="card-b">${children}</div>`}
</section>`;
}
function App() {
const [forceNarrow, setForceNarrow] = useState(false);
const [autoNarrow, setAutoNarrow] = useState(false);
const [status, setStatus] = useState("open");
const [comments, setComments] = useState(SEED_COMMENTS);
const [draft, setDraft] = useState("");
const [toast, setToast] = useState("");
const [confirm, setConfirm] = useState(null);
useEffect(() => {
const mq = window.matchMedia("(max-width: 719px)");
const sync = () => setAutoNarrow(mq.matches);
sync();
mq.addEventListener("change", sync);
return () => mq.removeEventListener("change", sync);
}, []);
useEffect(() => {
if (!toast) return;
const t = setTimeout(() => setToast(""), 2200);
return () => clearTimeout(t);
}, [toast]);
const isNarrow = forceNarrow || autoNarrow;
const sChip = useMemo(() => STATUSES.find(s => s.key === status), [status]);
const changeStatus = k => {
if (k === status) return;
setStatus(k);
setToast(`状态已改为「${STATUSES.find(s => s.key === k).label}」`);
};
const submit = () => {
const body = draft.trim();
if (!body) return;
const now = new Date();
const at = now.toISOString().slice(0, 16).replace("T", " ");
setComments(cs => [...cs, { id: Date.now(), author: "我", at, body }]);
setDraft("");
setToast("评论已发布");
};
const del = (id, snippet) => setConfirm({
text: `删除评论「${snippet}」?此操作不可撤销。`,
onOk: () => {
setComments(cs => cs.filter(c => c.id !== id));
setConfirm(null);
setToast("评论已删除");
},
});
return html`
<div class="page">
<header class="head">
<div>
<h1>任务详情</h1>
<p class="sub">主内容 + 元信息侧栏;点右上切换窄屏预览,看塌缩</p>
</div>
<button class="btn sm" disabled=${autoNarrow}
title=${autoNarrow ? "iframe 已足够窄,正处在自然塌缩下" : ""}
onClick=${() => { const n = !forceNarrow; setForceNarrow(n); setToast(n ? "已切窄屏预览" : "已切回宽屏"); }}>
${forceNarrow ? "切回宽屏" : "切窄屏预览"}
</button>
</header>
<div class=${"layout" + (forceNarrow ? " narrow" : "")}>
<div class="col">
<div class="taskh">
<h2>大数据导出任务超时</h2>
<span class=${"chip " + sChip.cls}>${sChip.label}</span>
</div>
<${CardSection} title="描述" collapsible=${isNarrow} defaultOpen=${true}>
<p style=${{ margin: 0 }}>
导出超过约 5 万行时,CSV 生成任务会卡住,最终因网关超时失败。
小体量导出正常,回归点看起来在缓冲这一步,而不在查询本身。
</p>
<p style=${{ margin: "8px 0 0" }}>
建议改为把行流式写给客户端,别在服务端把整份文件先物化到内存再统一发。
</p>
<//>
<${CardSection} title="详情" collapsible=${isNarrow} defaultOpen=${true}>
<dl class="kv">
<dt>负责人</dt><dd>李慕白</dd>
<dt>报告人</dt><dd>陈禾</dd>
<dt>优先级</dt><dd>高</dd>
<dt>组件</dt><dd>报表 / 导出</dd>
<dt>创建于</dt><dd>2026-06-15</dd>
<dt>截止</dt><dd>2026-06-22</dd>
</dl>
<//>
<${CardSection} title=${"讨论 · " + comments.length} collapsible=${isNarrow} defaultOpen=${true}>
<div class="thread">
${comments.map(c => html`
<div key=${c.id} class="cmt">
<div class="cmt-h">
<span class="avatar">${c.author.slice(0, 1)}</span>
<span class="cmt-a">${c.author}</span>
<span class="cmt-at">${c.at}</span>
<button class="link danger cmt-x"
onClick=${() => del(c.id, c.body.slice(0, 12) + (c.body.length > 12 ? "…" : ""))}>删除</button>
</div>
<p class="cmt-b">${c.body}</p>
</div>`)}
${comments.length === 0 && html`<p class="empty" style=${{ margin: 0, padding: "12px 0" }}>还没有讨论</p>`}
</div>
<div class="composer">
<textarea rows="2" placeholder="写点什么…" value=${draft}
onChange=${e => setDraft(e.target.value)}></textarea>
<button class="btn primary" disabled=${!draft.trim()} onClick=${submit}>发送</button>
</div>
<//>
</div>
<aside>
<${CardSection} title="状态" collapsible=${isNarrow} defaultOpen=${true}>
<div class="seg">
${STATUSES.map(s => html`
<button key=${s.key} class=${status === s.key ? "on" : ""}
onClick=${() => changeStatus(s.key)}>${s.label}</button>`)}
</div>
<//>
<${CardSection} title="参与者" collapsible=${isNarrow} defaultOpen=${false}>
<ul class="people">
<li><span class="avatar">李</span><span>李慕白</span><span class="role">· 负责人</span></li>
<li><span class="avatar">陈</span><span>陈禾</span><span class="role">· 报告人</span></li>
<li><span class="avatar">王</span><span>王小满</span><span class="role">· 关注</span></li>
<li><span class="avatar">赵</span><span>赵青</span><span class="role">· 关注</span></li>
</ul>
<//>
<${CardSection} title="相关链接" collapsible=${isNarrow} defaultOpen=${false}>
<ul class="links">
<li><a href="#" onClick=${e => e.preventDefault()}>PR #482 · 流式导出实现</a></li>
<li><a href="#" onClick=${e => e.preventDefault()}>监控告警:export-timeout</a></li>
<li><a href="#" onClick=${e => e.preventDefault()}>需求文档 v2.4 · 报表增强</a></li>
</ul>
<//>
</aside>
</div>
${confirm && html`<${Confirm} text=${confirm.text} onOk=${confirm.onOk} onCancel=${() => setConfirm(null)} />`}
${toast && html`<div class="toast">${toast}</div>`}
</div>`;
}
ReactDOM.createRoot(document.getElementById("root")).render(html`<${App} />`);
</script>
</body>
</html>