/** * [INPUT]: 依赖 react 的 useState/useRef/useEffect;props: src(封面 URL,可空)、fallback(无封面/加载失败时的兜底节点,如星座线稿)、fit(cover|contain)、fadeMs(淡入时长) * [OUTPUT]: 对外提供 CoverImage 组件——封面图三态渲染原语(loading 暗底 / ok 淡入 / error 回退 fallback) * [POS]: components/v4 共享原语,被 CourseCard(综合首页/落地页共用)/SeriesCard/LessonCard/SeriesPage 消费。 * 存在意义=消灭「写死线稿先渲染、后台封面加载完再跳变盖上」的闪跳:有封面时加载期只露父容器暗底, * 绝不先画兜底线稿;线稿只在「未配置封面」或「封面加载失败」时出现(设计兜底,非过渡帧)。 * ★ 本原语【不提供】蒙层槽:封面要按上传原图呈现,叠字的可读性由那颗字自己的 textShadow/底色解决 * (见 CourseCard 编号与 LessonCard 四角元素),不为一行小字去压暗整幅画面 * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ import { useEffect, useRef, useState } from 'react' export function CoverImage({ src, fallback = null, fit = 'cover', fadeMs = 350 }) { // status: loading(字节未到,露父容器底色) / ok(淡入) / error(回退 fallback) // src 变化时在渲染期重置(React 认可的 derive-from-props 写法,避免 effect 一帧闪烁) const [st, setSt] = useState({ src, status: 'loading' }) const imgRef = useRef(null) if (st.src !== src) setSt({ src, status: 'loading' }) // ⚠️ 状态收敛只认 DOM 真相,不能只挂 React 合成 onLoad:img 的 load 是【非委托事件】, // React 要等 commit 才把监听绑到元素上;磁盘缓存命中时字节先于绑定到位,load 早已烧完, // 组件就永久卡在 loading——图片 complete=true 却 opacity:0,回访用户只看见一片黑底。 // 故:挂载/换 src 后先按 el.complete 快照一次,未就绪才补挂【原生】load/error 监听。 useEffect(() => { const el = imgRef.current if (!el || !src) return undefined const settle = () => setSt({ src, status: el.naturalWidth > 0 ? 'ok' : 'error' }) if (el.complete) { settle() return undefined } const fail = () => setSt({ src, status: 'error' }) el.addEventListener('load', settle) el.addEventListener('error', fail) return () => { el.removeEventListener('load', settle) el.removeEventListener('error', fail) } }, [src]) if (!src) return fallback if (st.status === 'error') return fallback const ok = st.status === 'ok' return ( ) }