refactor(web): 文章预览去壳化 — 375px 纯内容面板替代 iPhone 仿真

- 删除全部 iPhone 外壳代码(Dynamic Island / 状态栏 / 侧边按钮 / 相机条 / Home Indicator)
- 从 620 行精简到 200 行
- 改为 375px 固定宽度纯内容面板,CSS 与小程序 article/detail 完全一致
- 内容区自然流动高度 + 滚动,不再被外壳约束
- 顶部简洁标签栏「小程序端效果预览 · 375px」
- 新增头脑风暴讨论记录 docs/discussions/2026-05-11
This commit is contained in:
iven
2026-05-11 09:36:43 +08:00
parent 9487ccb62e
commit 103c8aa059
2 changed files with 216 additions and 388 deletions

View File

@@ -10,18 +10,10 @@ interface ArticlePhonePreviewProps {
}
/**
* iPhone 17 Pro Max 高保真仿真预览
* 文章内容在小程序端的实时渲染预览
*
* 屏幕宽度 375px接近 iPhone CSS 逻辑像素 430pt 的 87%
* 内容区文字大小和间距与小程序 article/detail 完全一致,
* 保证预览效果 = 实际手机阅读效果。
*
* iPhone 17 Pro Max 参数:
* 物理尺寸 163.4mm × 78.0mm (aspect 2.095)
* 铝合金一体化机身 (7000-series aerospace aluminum)
* 横向相机条 — 3 镜头
* Dynamic Island: 药丸形
* Home Indicator: 底部圆角横条
* 375px 宽度匹配 iPhone CSS 逻辑像素CSS 与小程序 article/detail 完全一致。
* 去掉手机外壳装饰,聚焦内容排版效果验证。
*/
export default function ArticlePhonePreview({
title,
@@ -45,413 +37,218 @@ export default function ArticlePhonePreview({
const today = useMemo(() => new Date().toLocaleDateString('zh-CN'), []);
// ── iPhone 17 Pro Max 缩放参数 ──
// 屏幕 375px 宽 → 接近真机 CSS 像素,内容可读性保证
const BEZEL = 4;
const PHONE_W = 375;
const PHONE_R = 58;
const SCREEN_R = PHONE_R - BEZEL; // 54
// Dynamic Island
const ISLAND_W = 120;
const ISLAND_H = 32;
const ISLAND_R = 16;
const ISLAND_TOP = 14;
// 前置摄像头
const CAM_SIZE = 10;
const CAM_RIGHT = 22;
const SENSOR_SIZE = 4;
const SENSOR_LEFT = 22;
// 状态栏
const STATUS_H = 56;
// Home Indicator
const HOME_W = 120;
const HOME_H = 4;
const HOME_BOTTOM = 10;
// 侧边按钮
const BTN_OUT = 3;
// 铝合金
const AL_BODY = '#B0B2B8';
const AL_DARK = '#9A9CA2';
const AL_LIGHT = '#C4C6CC';
// 外框总宽度(手机 + 左右 padding
const OUTER_W = PHONE_W + BEZEL * 2 + 32; // ~415
return (
<div
style={{
width: OUTER_W,
width: 375,
flexShrink: 0,
background: isDark ? '#0f172a' : '#e8e8ed',
borderLeft: `1px solid ${isDark ? '#1e293b' : '#d2d2d7'}`,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: '12px 0',
overflow: 'auto',
overflow: 'hidden',
}}
>
{/* 顶部标签 */}
<div
style={{
padding: '8px 16px',
fontSize: 11,
color: isDark ? '#64748b' : '#86868b',
marginBottom: 10,
fontWeight: 500,
letterSpacing: 0.3,
borderBottom: `1px solid ${isDark ? '#1e293b' : '#e5e7eb'}`,
display: 'flex',
alignItems: 'center',
gap: 6,
background: isDark ? '#0f172a' : '#f3f4f6',
flexShrink: 0,
}}
>
· iPhone 17 Pro Max
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="5" y="2" width="14" height="20" rx="2" />
<line x1="12" y1="18" x2="12" y2="18.01" />
</svg>
· 375px
</div>
{/* ══════ iPhone 17 Pro Max 外壳 ══════ */}
{/* 内容区域 — 自然流动高度,内部可滚动 */}
<div
style={{
width: PHONE_W + BEZEL * 2,
borderRadius: PHONE_R + BEZEL,
// 高度不设固定值,让屏幕撑开
position: 'relative',
background: `linear-gradient(165deg, ${AL_LIGHT} 0%, ${AL_BODY} 30%, ${AL_DARK} 70%, ${AL_BODY} 100%)`,
boxShadow: [
'0 1px 2px rgba(0,0,0,0.06)',
'0 4px 12px rgba(0,0,0,0.08)',
'0 12px 32px rgba(0,0,0,0.10)',
'0 24px 64px rgba(0,0,0,0.06)',
'inset 0 1px 0 rgba(255,255,255,0.35)',
'inset 1px 0 0 rgba(255,255,255,0.15)',
'inset 0 -1px 0 rgba(0,0,0,0.15)',
'inset -1px 0 0 rgba(0,0,0,0.08)',
].join(', '),
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
background: mpColors.bg,
}}
>
{/* ── 侧边按钮 ── */}
<div
style={{
position: 'absolute', left: -BTN_OUT, top: 170,
width: BTN_OUT + 1, height: 36,
background: `linear-gradient(180deg, ${AL_LIGHT}, ${AL_DARK})`,
borderRadius: '2px 0 0 2px', zIndex: 5,
}}
/>
<div
style={{
position: 'absolute', left: -BTN_OUT, top: 218,
width: BTN_OUT + 1, height: 36,
background: `linear-gradient(180deg, ${AL_LIGHT}, ${AL_DARK})`,
borderRadius: '2px 0 0 2px', zIndex: 5,
}}
/>
<div
style={{
position: 'absolute', left: -BTN_OUT - 1, top: 268,
width: 14, height: 14,
background: `radial-gradient(circle at 40% 40%, ${AL_LIGHT}, ${AL_DARK})`,
borderRadius: '50%', zIndex: 5,
}}
/>
<div
style={{
position: 'absolute', right: -BTN_OUT, top: 210,
width: BTN_OUT + 1, height: 48,
background: `linear-gradient(180deg, ${AL_LIGHT}, ${AL_DARK})`,
borderRadius: '0 2px 2px 0', zIndex: 5,
}}
/>
<style>{`
.mp-preview {
background: ${mpColors.bg};
padding-bottom: 40px;
min-height: 100%;
}
.mp-preview .mp-header {
background: ${mpColors.card};
padding: 20px 16px;
margin-bottom: 8px;
}
.mp-preview .mp-title {
font-size: 20px;
font-weight: 700;
color: ${mpColors.text};
line-height: 1.45;
margin-bottom: 10px;
display: block;
}
.mp-preview .mp-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.mp-preview .mp-category {
font-size: 12px;
color: ${mpColors.primary};
background: ${mpColors.primaryLight};
padding: 3px 10px;
border-radius: 10px;
}
.mp-preview .mp-author {
font-size: 13px;
color: ${mpColors.textSecondary};
}
.mp-preview .mp-date {
font-size: 13px;
color: #9ca3af;
}
.mp-preview .mp-cover {
width: 100%;
border-radius: 8px;
margin: 0 0 10px;
max-height: 180px;
object-fit: cover;
}
.mp-preview .mp-summary {
background: ${mpColors.card};
padding: 14px 16px;
margin-bottom: 8px;
}
.mp-preview .mp-summary-text {
font-size: 14px;
color: ${mpColors.textSecondary};
line-height: 1.75;
border-left: 3px solid ${mpColors.primary};
padding-left: 12px;
}
.mp-preview .mp-content {
background: ${mpColors.card};
padding: 16px;
}
.mp-preview .mp-content p {
font-size: 16px;
color: ${mpColors.text};
line-height: 1.85;
margin-bottom: 12px;
}
.mp-preview .mp-content h1,
.mp-preview .mp-content h2,
.mp-preview .mp-content h3 {
font-weight: 700;
color: ${mpColors.text};
margin: 16px 0 8px;
}
.mp-preview .mp-content h1 { font-size: 20px; }
.mp-preview .mp-content h2 { font-size: 18px; }
.mp-preview .mp-content h3 { font-size: 16px; }
.mp-preview .mp-content img {
max-width: 100%;
border-radius: 8px;
margin: 8px 0;
}
.mp-preview .mp-content blockquote {
border-left: 3px solid ${mpColors.primary};
padding: 6px 12px;
color: ${mpColors.textSecondary};
margin: 12px 0;
}
.mp-preview .mp-content ul,
.mp-preview .mp-content ol {
padding-left: 20px;
margin: 8px 0;
font-size: 16px;
line-height: 1.9;
color: ${mpColors.text};
}
.mp-preview .mp-content table {
width: 100%;
border-collapse: collapse;
margin: 8px 0;
font-size: 14px;
}
.mp-preview .mp-content td,
.mp-preview .mp-content th {
border: 1px solid #e5e7eb;
padding: 6px 8px;
}
.mp-preview .mp-content hr {
border: none;
border-top: 1px dashed #d1d5db;
margin: 14px 0;
}
.mp-preview .mp-empty {
padding: 60px 16px;
text-align: center;
color: #9ca3af;
font-size: 14px;
line-height: 1.8;
}
.mp-preview .mp-content div[data-w-e-type="styled-block"] {
max-width: 100%;
box-sizing: border-box;
}
`}</style>
{/* ── 屏幕容器 ── */}
<div
style={{
margin: BEZEL,
borderRadius: SCREEN_R,
background: '#000',
overflow: 'hidden',
position: 'relative',
}}
>
{/* ── Dynamic Island ── */}
<div
style={{
position: 'absolute', top: ISLAND_TOP, left: '50%',
transform: 'translateX(-50%)',
width: ISLAND_W, height: ISLAND_H,
background: '#000', borderRadius: ISLAND_R,
zIndex: 30, boxShadow: 'inset 0 0.5px 1px rgba(0,0,0,0.5)',
}}
>
<div
style={{
position: 'absolute', left: SENSOR_LEFT, top: '50%',
transform: 'translateY(-50%)',
width: SENSOR_SIZE, height: SENSOR_SIZE,
borderRadius: '50%', background: '#1a1a2e',
boxShadow: 'inset 0 0 1px rgba(80,80,120,0.4)',
}}
/>
<div
style={{
position: 'absolute', right: CAM_RIGHT, top: '50%',
transform: 'translateY(-50%)',
width: CAM_SIZE, height: CAM_SIZE,
borderRadius: '50%',
background: 'radial-gradient(circle at 35% 35%, #3a3a5c, #1a1a2e 50%, #0d0d1a)',
boxShadow: [
'0 0 0 1px rgba(60,60,80,0.5)',
'inset 0 0 2px rgba(100,100,180,0.3)',
'0 0 1px rgba(120,120,200,0.2)',
].join(', '),
}}
/>
</div>
{/* ── 状态栏 ── */}
<div
style={{
position: 'absolute', top: 0, left: 0, right: 0,
height: STATUS_H,
display: 'flex', alignItems: 'center',
justifyContent: 'space-between', padding: '0 30px',
fontSize: 14, fontWeight: 600, color: '#fff', zIndex: 20,
fontFamily: '-apple-system, "SF Pro Text", "Helvetica Neue", sans-serif',
}}
>
<span style={{ width: 50, letterSpacing: 0.2 }}>9:41</span>
<div
style={{
display: 'flex', alignItems: 'center', gap: 6,
width: 95, justifyContent: 'flex-end',
}}
>
<svg width="17" height="12" viewBox="0 0 17 12" fill="none">
<rect x="0" y="9" width="3" height="3" rx="0.6" fill="#fff" />
<rect x="4.5" y="6" width="3" height="6" rx="0.6" fill="#fff" />
<rect x="9" y="3" width="3" height="9" rx="0.6" fill="#fff" />
<rect x="13.5" y="0" width="3" height="12" rx="0.6" fill="#fff" />
</svg>
<svg width="16" height="12" viewBox="0 0 20 15" fill="#fff">
<circle cx="10" cy="13" r="1.4" />
<path d="M6 10.5a5.5 5.5 0 018 0" stroke="#fff" strokeWidth="1.6" fill="none" strokeLinecap="round" />
<path d="M2.5 7a10 10 0 0115 0" stroke="#fff" strokeWidth="1.6" fill="none" strokeLinecap="round" />
</svg>
<svg width="27" height="13" viewBox="0 0 27 13" fill="none">
<rect x="0.5" y="0.5" width="22" height="12" rx="2.5" stroke="rgba(255,255,255,0.55)" strokeWidth="1" />
<rect x="2" y="2" width="17" height="9" rx="1.2" fill="#fff" />
<rect x="23.5" y="3.5" width="2.5" height="6" rx="1" fill="rgba(255,255,255,0.35)" />
</svg>
</div>
</div>
{/* ── 内容区域 — 与小程序 article/detail 完全一致的样式 ── */}
<div
style={{
position: 'absolute', top: STATUS_H - 4, left: 0, right: 0,
bottom: HOME_BOTTOM + HOME_H + 12,
overflowY: 'auto', overflowX: 'hidden',
background: mpColors.bg,
borderRadius: `0 0 ${SCREEN_R - 4}px ${SCREEN_R - 4}px`,
WebkitOverflowScrolling: 'touch',
}}
>
<style>{`
.mp-preview {
background: ${mpColors.bg};
padding-bottom: 40px;
min-height: 100%;
}
.mp-preview .mp-header {
background: ${mpColors.card};
padding: 24px 20px;
margin-bottom: 1px;
}
.mp-preview .mp-title {
font-size: 22px;
font-weight: 700;
color: ${mpColors.text};
line-height: 1.45;
margin-bottom: 12px;
display: block;
}
.mp-preview .mp-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.mp-preview .mp-category {
font-size: 12px;
color: ${mpColors.primary};
background: ${mpColors.primaryLight};
padding: 3px 10px;
border-radius: 10px;
}
.mp-preview .mp-author {
font-size: 13px;
color: ${mpColors.textSecondary};
}
.mp-preview .mp-date {
font-size: 13px;
color: #9ca3af;
}
.mp-preview .mp-cover {
width: 100%;
border-radius: 10px;
margin: 0 0 12px;
max-height: 200px;
object-fit: cover;
}
.mp-preview .mp-summary {
background: ${mpColors.card};
padding: 16px 20px;
margin-bottom: 1px;
}
.mp-preview .mp-summary-text {
font-size: 15px;
color: ${mpColors.textSecondary};
line-height: 1.75;
border-left: 3px solid ${mpColors.primary};
padding-left: 14px;
}
.mp-preview .mp-content {
background: ${mpColors.card};
padding: 20px;
}
.mp-preview .mp-content p {
font-size: 16px;
color: ${mpColors.text};
line-height: 1.85;
margin-bottom: 14px;
}
.mp-preview .mp-content h1,
.mp-preview .mp-content h2,
.mp-preview .mp-content h3 {
font-weight: 700;
color: ${mpColors.text};
margin: 18px 0 10px;
}
.mp-preview .mp-content h1 { font-size: 20px; }
.mp-preview .mp-content h2 { font-size: 18px; }
.mp-preview .mp-content h3 { font-size: 16px; }
.mp-preview .mp-content img {
max-width: 100%;
border-radius: 8px;
margin: 10px 0;
}
.mp-preview .mp-content blockquote {
border-left: 3px solid ${mpColors.primary};
padding: 8px 14px;
color: ${mpColors.textSecondary};
margin: 14px 0;
}
.mp-preview .mp-content ul,
.mp-preview .mp-content ol {
padding-left: 22px;
margin: 10px 0;
font-size: 16px;
line-height: 1.9;
color: ${mpColors.text};
}
.mp-preview .mp-content table {
width: 100%;
border-collapse: collapse;
margin: 10px 0;
font-size: 14px;
}
.mp-preview .mp-content td,
.mp-preview .mp-content th {
border: 1px solid #e5e7eb;
padding: 8px 10px;
}
.mp-preview .mp-content hr {
border: none;
border-top: 1px dashed #d1d5db;
margin: 16px 0;
}
.mp-preview .mp-empty {
padding: 60px 20px;
text-align: center;
color: #9ca3af;
font-size: 15px;
line-height: 1.8;
}
.mp-preview .mp-content div[data-w-e-type="styled-block"] {
max-width: 100%;
box-sizing: border-box;
}
`}</style>
<div className="mp-preview">
<div className="mp-header">
{coverImage && (
<img
className="mp-cover"
src={coverImage}
alt="封面"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
<span className="mp-title">
{title || '文章标题'}
</span>
<div className="mp-meta">
{category && (
<span className="mp-category">{category}</span>
)}
<span className="mp-author"></span>
<span className="mp-date">{today}</span>
</div>
</div>
{summary && (
<div className="mp-summary">
<div className="mp-summary-text">{summary}</div>
</div>
<div className="mp-preview">
<div className="mp-header">
{coverImage && (
<img
className="mp-cover"
src={coverImage}
alt="封面"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
<span className="mp-title">
{title || '文章标题'}
</span>
<div className="mp-meta">
{category && (
<span className="mp-category">{category}</span>
)}
<div className="mp-content">
{content && content !== '<p><br></p>' ? (
<div dangerouslySetInnerHTML={{ __html: content }} />
) : (
<div className="mp-empty">
<br />
</div>
)}
</div>
<span className="mp-author"></span>
<span className="mp-date">{today}</span>
</div>
</div>
{/* ── Home Indicator ── */}
<div
style={{
position: 'absolute', bottom: HOME_BOTTOM,
left: '50%', transform: 'translateX(-50%)',
width: HOME_W, height: HOME_H,
background: 'rgba(255,255,255,0.22)',
borderRadius: 99, zIndex: 20,
}}
/>
</div>
{summary && (
<div className="mp-summary">
<div className="mp-summary-text">{summary}</div>
</div>
)}
{/* ── 背面横向相机条 ── */}
<div
style={{
position: 'absolute', top: BEZEL + 12, left: BEZEL + 8,
width: 150, height: 36, borderRadius: 10,
boxShadow: 'inset 0 0 4px rgba(0,0,0,0.05), 0 0 2px rgba(0,0,0,0.02)',
zIndex: 0, pointerEvents: 'none',
}}
>
<div style={{ position: 'absolute', top: 7, left: 12, width: 22, height: 22, borderRadius: '50%', background: 'radial-gradient(circle, rgba(0,0,0,0.06), transparent)' }} />
<div style={{ position: 'absolute', top: 7, left: 42, width: 22, height: 22, borderRadius: '50%', background: 'radial-gradient(circle, rgba(0,0,0,0.06), transparent)' }} />
<div style={{ position: 'absolute', top: 7, left: 72, width: 22, height: 22, borderRadius: '50%', background: 'radial-gradient(circle, rgba(0,0,0,0.06), transparent)' }} />
<div style={{ position: 'absolute', top: 11, right: 10, width: 14, height: 14, borderRadius: '50%', background: 'radial-gradient(circle, rgba(0,0,0,0.03), transparent)' }} />
<div className="mp-content">
{content && content !== '<p><br></p>' ? (
<div dangerouslySetInnerHTML={{ __html: content }} />
) : (
<div className="mp-empty">
<br />
</div>
)}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,31 @@
# 文章编辑器手机预览方案头脑风暴
> 日期: 2026-05-11 | 参与者: 用户 + Claude
## 背景
文章编辑器右栏的手机预览iPhone 17 Pro Max 仿真)效果差:
1. 手机外壳占据大量像素,内容区被压缩
2. 手机高度不可控,内容多了无限拉长
3. styled-block 模板在窄屏下渲染错乱
4. 用户目标是看「小程序上文章长什么样」,不是看一台手机
## 5 个候选方案
| 方案 | 用户体验 | 实现复杂度 | 维护成本 | 推荐度 |
|------|---------|-----------|---------|--------|
| A: 去壳化纯内容预览 | 内容完整可读 | 极低(删代码) | 最低 | ⭐⭐⭐⭐⭐ |
| B: 等比缩放 scale | 比例正确但文字小 | 中等 | 中高 | ⭐⭐ |
| C: iframe 独立页面 | 理论最接近实际 | 高 | 高 | ⭐ |
| D: 迷你手机+点击放大 | 工作流被割裂 | 中高 | 高 | ⭐⭐ |
| E: 标签切换多设备 | 当前无桌面端需求 | 低 | 低 | ⭐⭐⭐(渐进扩展) |
## 结论
**推荐方案 A去壳化**:去掉 iPhone 外壳,保留 375px 窄屏内容面板CSS 与小程序 article/detail 完全一致。渐进可扩展方案 E标签切换多设备宽度
核心论据:
- 编辑器预览的目标是验证内容排版,不是仿真设备
- 去掉外壳后内容区从 ~285px 扩展到 375px可读性直接提升
- 公众号后台、飞书文档、Notion 的移动端预览全部采用去壳化方案
- 实现成本 = 删除 200 行外壳代码 + 微调布局