组件 · COMPONENT
封面图 CoverImage
封面三态:加载中只露父容器的暗底,就绪后 350ms 淡入,失败回退到线稿。杜绝「先画线稿、封面到了再跳变盖上」的闪跳。
无封面 → 直接画线稿
正常 → 暗底上淡入 350ms
失效 → 回退线稿
import { CoverImage } from './components/v4/CoverImage.jsx'
const ACCENT = 'var(--zzm-accent, #A6402F)'
const COVER = 'data:image/svg+xml;utf8,' + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180"><rect width="320" height="180" fill="#F6F6F4"/>' +
'<text x="24" y="92" font-family="-apple-system,PingFang SC,sans-serif" font-size="22" font-weight="700" fill="#161616">上传的课程封面</text>' +
'<rect x="24" y="108" width="28" height="3" fill="#A6402F"/></svg>',
)
const Fallback = () => (
<svg viewBox="0 0 240 135" preserveAspectRatio="xMidYMid slice" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', opacity: 0.5 }}>
<polyline points="18,110 62,48 120,92 178,40 224,104" fill="none" stroke="rgba(255,255,255,.2)" strokeWidth="1" />
<circle cx="120" cy="92" r="2.6" fill={ACCENT} />
</svg>
)
const Frame = ({ label, children }) => (
<div>
<div style={{ aspectRatio: '16/9', borderRadius: 12, background: '#131316', position: 'relative', overflow: 'hidden' }}>{children}</div>
<div style={{ marginTop: 8, fontSize: 12, color: '#8B8B85' }}>{label}</div>
</div>
)
export default function CoverImageStates() {
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, width: '100%' }}>
<Frame label="无封面 → 直接画线稿"><CoverImage src={null} fallback={<Fallback />} /></Frame>
<Frame label="正常 → 暗底上淡入 350ms"><CoverImage src={COVER} fallback={<Fallback />} /></Frame>
<Frame label="失效 → 回退线稿"><CoverImage src="/v4/__missing-cover__.png" fallback={<Fallback />} /></Frame>
</div>
)
}<div
style="
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
width: 100%;
"
>
<div>
<div
style="
aspect-ratio: 16/9;
border-radius: 12px;
background: #131316;
position: relative;
overflow: hidden;
"
>
<svg
viewBox="0 0 240 135"
preserveAspectRatio="xMidYMid slice"
style="position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0.5"
>
<polyline
points="18,110 62,48 120,92 178,40 224,104"
fill="none"
stroke="rgba(255,255,255,.2)"
stroke-width="1"
></polyline>
<circle cx="120" cy="92" r="2.6" fill="var(--zzm-accent, #A6402F)"></circle>
</svg>
</div>
<div style="margin-top: 8px; font-size: 12px; color: #8b8b85">无封面 → 直接画线稿</div>
</div>
<div>
<div
style="
aspect-ratio: 16/9;
border-radius: 12px;
background: #131316;
position: relative;
overflow: hidden;
"
>
<img
src="data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20320%20180%22%3E%3Crect%20width%3D%22320%22%20height%3D%22180%22%20fill%3D%22%23F6F6F4%22%2F%3E%3Ctext%20x%3D%2224%22%20y%3D%2292%22%20font-family%3D%22-apple-system%2CPingFang%20SC%2Csans-serif%22%20font-size%3D%2222%22%20font-weight%3D%22700%22%20fill%3D%22%23161616%22%3E%E4%B8%8A%E4%BC%A0%E7%9A%84%E8%AF%BE%E7%A8%8B%E5%B0%81%E9%9D%A2%3C%2Ftext%3E%3Crect%20x%3D%2224%22%20y%3D%22108%22%20width%3D%2228%22%20height%3D%223%22%20fill%3D%22%23A6402F%22%2F%3E%3C%2Fsvg%3E"
alt=""
loading="lazy"
decoding="async"
style="
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 350ms ease;
"
/>
</div>
<div style="margin-top: 8px; font-size: 12px; color: #8b8b85">正常 → 暗底上淡入 350ms</div>
</div>
<div>
<div
style="
aspect-ratio: 16/9;
border-radius: 12px;
background: #131316;
position: relative;
overflow: hidden;
"
>
<img
src="/v4/__missing-cover__.png"
alt=""
loading="lazy"
decoding="async"
style="
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 350ms ease;
"
/>
</div>
<div style="margin-top: 8px; font-size: 12px; color: #8b8b85">失效 → 回退线稿</div>
</div>
</div>/* tokens.css · 用到的令牌 */
:root {
--zzm-accent: #A6402F;
}属性
| 属性 | 取值 | 默认 | 说明 |
|---|---|---|---|
| src | string · null | — | 封面地址;为空直接渲染 fallback |
| fallback | ReactNode | null | 无封面或加载失败时的兜底(通常是星座线稿) |
| fit | 'cover' · 'contain' | 'cover' | 导学封面用 contain 保证完整展示 |
| fadeMs | number | 350 | 淡入时长 |
父容器负责暗底、比例和圆角(aspect-ratio: 16/9; background: #131316; position: relative; overflow: hidden);图片绝对定位铺满。
一个真实踩过的坑
图片的 load 事件不冒泡,React 的 onLoad 要等 commit 后才绑定。磁盘缓存命中时,字节比监听先到,load 已经触发过了,组件会永远卡在「加载中」——图片明明就绪却 opacity: 0,回访用户只看见一片黑。所以这个组件挂载后先检查 el.complete,没就绪才补挂原生的 load / error 监听。复刻时别换回 onLoad。
铁律
✓ 要封面按上传原图呈现
✗ 不要提供「蒙层」参数——叠字可读性由那颗字自己解决
