视频信息、字幕、AI 总结、思维导图、AI 问答五类内容挤进一个页面,最难的是让它们互不拥挤。第一版我把它们从上到下排成一个长列表,结果用户想找总结要滚三屏,上线没几天评论区就出现了不少”总结在哪”的反馈。我先后对比了上下流式、左右两栏、弹层抽屉三种布局,最终敲定”左栏固定视频信息 + 右栏 Tab 切换内容”的两栏结构:左侧用 Sticky 钉住视频信息,右侧用折叠面板收纳四类内容。改造后,找总结的操作从三次点击变成一次,移动端也不再打架。下面把这套页面设计的完整思路和踩过的坑写出来。
一、整体布局
整体布局决定用户第一眼看到什么,也决定了后续所有代码的骨架。动手前我拿三个候选方案过了一轮,对比维度是信息密度、实现成本和移动端表现。上下流式最省事,但信息密度低,手机上第一屏只能看到标题;弹层抽屉能把辅助内容藏起来,可实现成本高,还容易让用户找不到入口;两栏方案信息密度高、桌面端体验直观,代价是移动端必须折叠处理。我最终选了左右两栏,当时的对比结论如下表。
| 布局方案 | 信息密度 | 实现成本 | 移动端表现 | 适用场景 |
|---|---|---|---|---|
| 上下流式 | 低 | 低 | 自然 | 内容少的简单页 |
| 左右两栏 + Sticky | 高 | 中 | 需折叠处理 | 信息型详情页 |
| 弹层抽屉 | 中 | 高 | 一般 | 辅助信息补充 |
表格里两栏方案在移动端写的是”需折叠”,这正是后期投入精力最多的地方。折叠不是简单隐藏,涉及 Tab 状态记忆、隐藏容器尺寸归零导致思维导图渲染异常等一系列问题,这些会在后面的章节逐个展开。整体结构示意如下。
┌─────────────────────────────────────────────────┐
│ Header (导航 + 配额) │
├─────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ Video │ │ Tabs: 总结 | 思维导图 │ │
│ │ - 缩略图 │ │ | 字幕 | 问答 │ │
│ │ - 标题 │ │ │ │
│ │ - 时长 │ │ 总结内容(流式生成) │ │
│ │ - 下载 │ │ │ │
│ │ - 操作 │ │ │ │
│ │ (Sticky) │ │ │ │
│ └─────────────┘ └─────────────────────────┘ │
│ ┌─────────────────────────────────────────┐ │
│ │ AI 问答(追问) │ │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
二、HTML 结构
布局定了之后要先搭骨架再谈样式。这段 HTML 解决的核心问题是:把页面拆成”左信息、右内容”两个独立区域,同时保证语义清晰,方便后续接入交互逻辑。结构上我把视频信息放进 aside,把四类内容放进 main,主次一目了然,屏幕阅读器和搜索引擎也能正确识别层级。
<div class="detail-page">
<!-- 顶部进度条 -->
<div id="progressBar" class="progress-bar">
<div class="progress-fill" style="width: 0%"></div>
<span id="progressText">准备中...</span>
</div>
<div class="detail-grid">
<!-- 左栏:视频信息(Sticky) -->
<aside class="video-info">
img 标签显示视频缩略图
<h2 id="videoTitle">视频标题</h2>
<div class="video-meta">
<span class="badge" id="videoPlatform">YouTube</span>
<span id="videoUploader">作者</span>
<span id="videoDuration">12:30</span>
</div>
<div class="video-actions">
<button class="btn-primary" id="btnSummary">
开始总结
</button>
<button class="btn-secondary" id="btnDownload">
下载视频
</button>
<button class="btn-secondary" id="btnCopyLink">
复制链接
</button>
</div>
<div class="quota-info" id="quotaInfo">
今日已用 1/3 次
</div>
</aside>
<!-- 右栏:内容 Tab -->
<main class="content">
<nav class="tabs">
<button class="tab active" data-tab="summary">
AI 总结
</button>
<button class="tab" data-tab="mindmap">思维导图</button>
<button class="tab" data-tab="subtitle">字幕</button>
<button class="tab" data-tab="qa">问答</button>
</nav>
<div class="tab-content" id="tabSummary">
<article id="summary" class="markdown"></article>
<div id="summaryActions" class="actions" hidden>
<button data-on-click="copySummary()">📋 复制</button>
<button data-on-click="exportMD()">⬇ 导出 Markdown</button>
<button data-on-click="exportPDF()">📄 导出 PDF</button>
</div>
</div>
<div class="tab-content" id="tabMindmap" hidden>
<div id="mindmap" class="mindmap-container"></div>
<div class="mindmap-toolbar">
<button data-on-click="mm.fit()">适应</button>
<button data-on-click="mm.rescale(1.2)">放大</button>
<button data-on-click="exportMindmapPNG()">导出</button>
</div>
</div>
<div class="tab-content" id="tabSubtitle" hidden>
<pre id="subtitle" class="subtitle"></pre>
<button data-on-click="downloadSubtitle()">下载字幕</button>
</div>
<div class="tab-content" id="tabQa" hidden>
<div id="chatHistory" class="chat-history"></div>
<form id="qaForm" class="qa-input">
<input type="text" id="qaInput"
placeholder="向 AI 提问...">
<button type="submit">发送</button>
</form>
</div>
</main>
</div>
</div>
有几个细节容易忽略:进度条放在 body 最外层而不是内容区内,避免被滚动容器带走;左栏按钮统一用 btn-primary 和 btn-secondary 两级样式,视觉上一眼分清主操作。另外 Tab 内容用 hidden 属性控制显隐,而不是手动改 display,隐藏的元素不参与布局计算,切换更干净。
三、CSS 布局
样式部分我在 flex 和 grid 之间纠结了一阵。导航栏这种一行排开的组件用 flex 顺手,但详情页这种”固定侧栏 + 自适应内容”的二维结构,grid 的模板列更直白,所以 .detail-grid 用 grid,内部的按钮和元信息用 flex 排。
.detail-page {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.detail-grid {
display: grid;
grid-template-columns: 360px 1fr;
gap: 24px;
}
@media (max-width: 768px) {
.detail-grid {
grid-template-columns: 1fr;
}
}
.video-info {
position: sticky;
top: 80px; /* 顶部导航高度 */
align-self: start;
background: white;
border-radius: 12px;
padding: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.video-thumb {
width: 100%;
aspect-ratio: 16/9;
object-fit: cover;
border-radius: 8px;
margin-bottom: 12px;
}
.content {
background: white;
border-radius: 12px;
padding: 24px;
min-height: 600px;
}
.tabs {
display: flex;
gap: 8px;
border-bottom: 1px solid #e5e7eb;
margin-bottom: 16px;
}
.tab {
padding: 8px 16px;
background: none;
border: none;
cursor: pointer;
color: #6b7280;
border-bottom: 2px solid transparent;
}
.tab.active {
color: #3b82f6;
border-bottom-color: #3b82f6;
}
这段 CSS 用 grid-template-columns 定死 360px 的左栏宽度,右侧 1fr 吃掉剩余空间。移动端在 768px 以下退化成单列,媒体查询里只需要覆盖这一条模板列,其余样式全部继承,这是 grid 比手写浮动布局省心的地方。
四、Tab 切换
Tab 切换最初考虑过纯 CSS 锚点方案,用 :target 就能实现,但遇到两个问题:一是切换时无法触发思维导图的重新布局,二是状态不留在 URL 里,刷新就丢。最后改成 JS 统一管理。
// tabs.js
function setupTabs() {
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
const target = tab.dataset.tab
// 更新按钮状态
document.querySelectorAll('.tab').forEach(t =>
t.classList.remove('active'))
tab.classList.add('active')
// 切换内容
document.querySelectorAll('.tab-content').forEach(c =>
c.hidden = true)
document.getElementById(`tab${capitalize(target)}`).hidden = false
// 触发布局更新(如思维导图 fit)
if (target === 'mindmap' && window.mm) {
setTimeout(() => window.mm.fit(), 100)
}
})
})
}
function capitalize(s) {
return s[0].toUpperCase() + s.slice(1)
}
这段代码用 data-tab 约定内容区 id,切换时先清掉所有 active 和 hidden,再点亮目标项。注意思维导图在隐藏 Tab 里 offsetWidth 是 0,所以切到 mindmap 时要等渲染完成再调 fit(),这也是第十二节踩坑清单里的老问题。
五、进度条
AI 总结是流式生成的,短则十几秒长则一两分钟,干等最劝退。我对比过转圈 spinner 和顶部进度条:spinner 只告诉用户”在等”,进度条还能展示当前进行到哪一步,用户的焦虑感明显更低。进度条要占的位置也很讲究,放进内容区会被滚动带走,所以做成固定定位贴在顶部导航下方。
<div id="progressBar" class="progress-bar" hidden>
<div class="progress-fill" id="progressFill"></div>
<div class="progress-text" id="progressText">准备中...</div>
</div>
.progress-bar {
position: fixed;
top: 64px; /* 顶部导航下方 */
left: 0;
right: 0;
height: 4px;
background: #e5e7eb;
z-index: 50;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
transition: width 0.3s;
}
.progress-text {
position: absolute;
right: 20px;
top: 8px;
font-size: 12px;
color: #6b7280;
}
function updateProgress(stage, percent) {
const bar = document.getElementById('progressBar')
const fill = document.getElementById('progressFill')
const text = document.getElementById('progressText')
bar.hidden = false
fill.style.width = `${percent}%`
const stageNames = {
downloading: '下载视频',
transcribing: '提取字幕',
summarizing: 'AI 总结',
generating_mindmap: '生成思维导图'
}
text.textContent = `${stageNames[stage] || stage} ${percent}%`
}
function hideProgress() {
document.getElementById('progressBar').hidden = true
}
percent 由后端按下载、转字幕、总结、导图四个阶段上报,前端只负责渲染,这样即使接口变慢,用户也能看到进度在推进。注意 stage 名和 percent 要一起更新,否则会出现卡在 100% 不动的错觉。
六、左右栏交互
左栏 Sticky 是这套布局的灵魂。用户滚动查看总结时,视频标题始终停在视野里,随时知道自己看的是哪条视频。第一次实现时 Sticky 怎么都不生效,排查半天发现是父容器加了 overflow: hidden,把粘性定位直接废掉了,这个坑后面专门记了一笔。
.video-info {
position: sticky;
top: 80px;
align-self: start;
max-height: calc(100vh - 100px);
overflow-y: auto;
}
这段代码把左栏固定在顶部导航下方 80px 处,并限制最大高度、配合 overflow-y: auto,保证缩略图很高时也不会顶出屏幕。移动端单列布局里 Sticky 没有意义,直接在媒体查询里恢复 static。
@media (max-width: 768px) {
.video-info {
position: static;
}
}
七、Markdown 内容样式
AI 返回的总结是 Markdown 文本,前端渲染成 HTML 后如果没有样式,标题、列表、引用会堆成一团。我一开始直接用了浏览器默认样式,桌面看着还行,手机上标题和正文混在一起,可读性很差。后来给 .markdown 容器定制了一套排版规则,把常见元素的间距、配色、层级都收进来。
.markdown {
line-height: 1.7;
color: #1f2937;
font-size: 16px;
}
.markdown h1 {
font-size: 2em;
font-weight: bold;
margin: 0.67em 0;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 0.3em;
}
.markdown h2 {
font-size: 1.5em;
font-weight: bold;
margin: 1em 0 0.5em;
}
.markdown p {
margin: 0.8em 0;
}
.markdown code {
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.9em;
}
.markdown pre {
background: #1e293b;
color: #e2e8f0;
padding: 16px;
border-radius: 8px;
overflow-x: auto;
}
.markdown pre code {
background: transparent;
color: inherit;
padding: 0;
}
.markdown blockquote {
border-left: 4px solid #3b82f6;
padding-left: 16px;
color: #6b7280;
margin: 1em 0;
}
.markdown ul, .markdown ol {
padding-left: 2em;
margin: 0.8em 0;
}
核心是把行高、字体、代码块背景统一起来。pre 用深色底浅色字,和页面白底拉开对比;行内 code 用浅灰底。注意这些样式都挂在 .markdown 类名下,别污染页面里其他组件。
八、AI 问答区
问答区本来想跳转到独立页面,让用户在新页面追问,后来发现跳出详情页会打断阅读节奏,用户追完两轮就回不来了。于是改成详情页底部内嵌一个对话区,提问时带着视频 ID 一起提交,回答直接渲染在下方。
<div class="chat-history" id="chatHistory">
<div class="chat-empty">
<p>💡 试着问 AI:</p>
<ul>
<li>这个视频讲了什么?</li>
<li>主要观点是什么?</li>
<li>有什么实际应用?</li>
</ul>
</div>
</div>
<form id="qaForm" class="qa-input">
<input type="text" id="qaInput"
placeholder="向 AI 提问关于视频的问题..."
autocomplete="off">
<button type="submit">发送</button>
</form>
async function askQuestion(question) {
// 1) 展示用户问题
appendChatMessage('user', question)
// 2) 流式 AI 回答
const aiMessageEl = appendChatMessage('ai', '')
const response = await fetch('/api/qa', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
video_id: currentVideoId,
question
})
})
const reader = response.body.getReader()
const decoder = new TextDecoder()
let answer = ''
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const events = buffer.split('\n\n')
buffer = events.pop()
for (const evt of events) {
const lines = evt.split('\n')
let data = ''
for (const line of lines) {
if (line.startsWith('data:')) data += line.slice(5).trim()
}
if (data) {
const obj = JSON.parse(data)
answer += obj.text
aiMessageEl.textContent = answer
}
}
}
}
后端接口用 SSE 流式返回,前端读 reader 时要按 \n\n 分段解析 data: 行,把增量拼接到 AI 消息节点上。解析 buffer 要留一个尾巴,否则最后一段数据会丢,这个 bug 我调了整整一个下午。
九、复制/导出
用户总结完大概率想把内容带走,复制和导出是刚需。导出格式支持 Markdown 和 PDF 两种,Markdown 走 turndown 反解析,PDF 交给后端生成,前端只发一个请求。
function copySummary() {
const text = document.getElementById('summary').textContent
navigator.clipboard.writeText(text)
.then(() => showToast('已复制到剪贴板'))
}
function exportMD() {
const html = document.getElementById('summary').innerHTML
// 简单反解析回 Markdown(用 turndown.js)
const md = turndown.turndown(html)
downloadFile(md, 'summary.md', 'text/markdown')
}
复制这里有个坑:如果直接复制 innerHTML,会把富文本里的 HTML 标签一起带进剪贴板,粘贴到笔记软件全是乱码。改成 textContent 后问题消失,顺带也规避了脚本注入风险。
十、响应式
详情页在手机上最容易翻车,两栏宽度不够就得重新排。这个章节的 CSS 把断点分成两档:1024px 以下左栏收窄到 300px,768px 以下整体单列。收窄比直接单列更平滑,平板竖屏时还能保留双栏的观感。
@media (max-width: 1024px) {
.detail-grid {
grid-template-columns: 300px 1fr;
}
}
@media (max-width: 768px) {
.detail-grid {
grid-template-columns: 1fr;
}
.video-info {
position: static;
}
.tabs {
overflow-x: auto;
white-space: nowrap;
}
}
移动端额外处理了 Tab 的横向滑动:四个 Tab 在窄屏上放不下时允许横滑而不是换行,保住桌面端的一行布局,视觉上也不会多出一截空白。
十一、可访问性
详情页信息密度高,不加语义标记的话,用屏幕阅读器的用户会迷失。给 Tab 加上 role 和 aria-selected,给内容面板加 role=tabpanel 和 aria-labelledby,读屏器就能正确播报当前状态。这块起初没在意,后来在无障碍走查时被单独列了一条问题才补上。
<button class="tab" data-tab="summary"
role="tab" aria-selected="true">总结</button>
<article id="summary" role="tabpanel"
aria-labelledby="tab-summary"></article>
键盘用户习惯用方向键在 Tab 间移动,所以额外监听 keydown,按左右方向键时切换 Tab。焦点管理要跟上,切到新面板后把焦点移到面板标题上,否则读屏器会停在原地。
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight') {
// 切换到下一个 tab
}
})
})
十二、踩过的坑
这一节列的是真实上线后修过的问题,每条背后都是一次线上事故或测试反馈。最典型的是 Sticky 不生效和隐藏 Tab 里容器尺寸为 0,前者是父级 overflow 问题,后者需要在切换时主动 fit。按踩到的频率排了序。
- Sticky 不生效:父元素
overflow: hidden会让 sticky 失效。 - 思维导图容器大小为 0:隐藏 tab 时
offsetWidth = 0。切换时 fit。 - 流式渲染跳动:内容高度变化导致布局跳动。
min-height锁定。 - 图片撑破布局:CSS
max-width: 100%。 - 长总结滚动条:内容区加
overflow-y: auto。 - 移动端 Sticky 抖动:禁用 Sticky 用普通布局。
- Tab 状态丢失:URL hash 记录当前 tab,刷新恢复。
- 复制丢失格式:用
textContent而非innerHTML。
清单里每一条都能回溯到前面某个章节的实现细节,建议对照着读,大部分问题在第一版就能避开,省得后期返工。
十三、最佳实践清单
如果时间紧,没法把全部细节实现,优先保下面这七条,它们是性价比高的部分,覆盖了布局、交互、适配三个最影响体验的层面。
- 两栏布局(信息 + 内容);
- 视频信息 Sticky;
- Tab 切换保持状态;
- 进度条 + 阶段提示;
- 移动端单列布局;
- 内容区 min-height 锁定;
- 复制/导出辅助功能。
按这份清单做出来的详情页,至少能在桌面和手机两端保持可用,剩下的优化可以后续迭代。
常见问题(FAQ)
Q1:要不要加无限滚动?
不要。AI 总结是一次性内容,分页不自然。
Q2:思维导图放在哪里?
独立 tab 切换。内容区放不下。
Q3:要不要支持暗黑模式?
要。prefers-color-scheme: dark。