/** * [INPUT]: 依赖 react、components/v4/CoverImage;props: ci/si/li(下标)、a(课节位置数组)、plan(usePlanV4 视图)、done(布尔)、catName?、onClick * [OUTPUT]: 对外提供 LessonCard 组件——16:9 暗底课节卡(含试看/付费/拓展角标、时长角标、S/E 编号); * 根节点走 controls.css 的 zzm-cardlink(白卡承托+悬停高亮描边/上浮/柔影,与综合首页课程卡同手感); * 完成态=容器级 zzm-cardlink--done(整卡朱染+朱描边+标题行「✓ 已完成」chip,v20.0.0 取代右上角小✓圆徽); * 有 a[8].cover_url 时经 CoverImage 三态渲染封面(加载暗底/淡入+蒙层/失败回退兜底面)—— * ★ v23.5.0:卡面中央短标题+朱线【迁入 fallback 槽】,只在无封面/封面 404 时出现; * 有封面则整层不渲染(封面图自带课程名,再叠一遍即重复书写),蒙层中段同步收薄让画面亮出来; * 未解锁(canWatchLesson=false)时叠暗底锁头遮罩 * [POS]: screens/v4/course 的共享课节卡,NodePage 与 SeriesPage 共用;tagOf/shortOf/codeOf 为模块私有 * (react-refresh/only-export-components 禁止组件文件混导出函数) * 角标(v21):付费(2)/拓展(3) 用逐课节 canWatchLesson(会员/买父课/单买任一命中即隐形); * 免费档用板块级 canWatchCourse 显朱「试看」;点击放行与否由屏侧 canWatchLesson 逐课节判定 * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ import { CoverImage } from '../../../components/v4/CoverImage' import { Skel } from '../../../components/v4/Skeleton' import { hoverPrefetchProps } from '../../../lib/api/playUrlCache' import { TierPill } from './TierPill' const ACCENT = 'var(--zzm-accent,#A6402F)' // 缩略图外壳单一真相源:真实态(spread + position:relative)与骨架态【同时】引用——改这里两态同变 const LC_THUMB = { aspectRatio: '16/9', borderRadius: 12, background: '#131316', overflow: 'hidden' } // ── 课节编号(DC 行 2871):S{系列} · E{集数,两位} ── const codeOf = (si, li) => 'S' + (si + 1) + ' · E' + String(li + 1).padStart(2, '0') // ── 兜底短标题(DC 行 2948):按':'切首段,>12 字截断加 … // 【只在无封面/封面加载失败】时出现——后台封面自带课程名,再叠一遍就是重复书写 ── const shortOf = (title) => { const s = (title || '').split(':')[0] return s.length > 12 ? s.slice(0, 12) + '…' : s } // ── 角标判定(v21):B站公开片→免费;付费(2)/拓展(3)→需购买(会员/买课/单买任一解锁,用逐课节真闸);免费档→试看 ── // ⚠️ B 站分支必须【最先】判且【无视 a[4]】:canAccessVideo 对 bilibili 源直接放行(公开引流片, // 免登录直接看),而 a[4] 只看 required_level<2、完全 source-blind。角标须与真闸同源。 // v21:付费与拓展同权(is_premium 不再参与鉴权),标签只随课时属性走——2=「付费」/3=「拓展」; // 用逐课节 canWatchLesson(会员/买父课/单买任一命中即可看→隐形),不再有「围墙花园」的独立样式。 function tagOf(plan, ci, a) { // B 站片对【未购者】显朱色「免费」(免登录、点开即看);对【已购者】隐形(已购不打扰)。 if (a[8]?.source === 'bilibili') { return plan.canWatchCourse(ci) ? { tag: '免费', tagOp: 0, tagBg: 'transparent', tagC: 'transparent' } : { tag: '免费', tagOp: 1, tagBg: ACCENT, tagC: '#FFFFFF' } } const lvl = a[8]?.required_level ?? 0 // 会员(2)/拓展(3):需购买课程。逐课节真闸——可看则隐形,否则显灰标。 if (lvl >= 2) { const name = lvl >= 3 ? '拓展' : '会员' return plan.canWatchLesson(a) ? { tag: name, tagOp: 0, tagBg: 'transparent', tagC: 'transparent' } : { tag: name, tagOp: 1, tagBg: 'rgba(0,0,0,.55)', tagC: 'rgba(255,255,255,.92)' } } // 免费(0/1):登录即看;非会员板块显朱「试看」引流,可看则隐形。 return plan.canWatchCourse(ci) ? { tag: '试看', tagOp: 0, tagBg: 'transparent', tagC: 'transparent' } : { tag: '试看', tagOp: 1, tagBg: ACCENT, tagC: '#FFFFFF' } } // 16:9 暗底课节卡。catName 缺省时脚注只显简介(总览变体,DC 行 421-422)。 // tierTag:传入时标题前显「免费/付费/拓展」档位胶囊(课程详情用;学习地图 NodePage 不传,保持原样)。 export function LessonCard({ ci = 0, si, li, a, plan, done, catName, onClick, tierTag, loading = false }) { // ── 骨架态:同一 zzm-cardlink 外壳 + 16:9 暗底缩略 + 标题/脚注两行占位(无 hooks,可顶部早退) ── if (loading) { return (
) } const { tag, tagOp, tagBg, tagC } = tagOf(plan, ci, a) // 锁定态:逐课节真闸(含前 3 节试看/登录/代际),未解锁→暗底锁罩。 // 用 canWatchLesson 而非角标那套板块级 canWatchCourse,否则免费试看会被误锁。 const locked = !plan.canWatchLesson(a) // 封面第一帧:经 CoverImage 三态渲染——加载期只露暗底(不先画线稿再跳变),就绪淡入并叠可读性蒙层, // 加载失败(URL 失效/裂图)自动回退 SVG 线稿;无封面直接渲染线稿。 const cover = a[8]?.cover_url // 悬停预取播放凭证(可看的 Cloudflare/阿里云源;bilibili 无凭证不取):悬停→点击的间隙盖住 Edge Function 往返 const src = a[8]?.source const prefetch = !locked && src && src !== 'bilibili' ? hoverPrefetchProps(a[7], { source: src }) : {} return ( // 卡片化(zzm-cardlink):白卡承托 + 悬停高亮描边/上浮/柔影——与综合首页课程卡同一手感; // 已完成 → 容器级完成态(--done):整卡朱染+朱描边,无需费力找角标即知「这节我看完了」
{shortOf(a[0])}
} /> {/* 编号自持可读性:复用右下时长 chip 的同一词汇(深色小底 + 浅字)。 textShadow 只救得了暗封面上的浅字,白底封面(截图类、浅色海报)照样糊; 自持底色则与封面明暗无关——代价是四角各多一块小底,换来的是永不压暗整幅画面 */}
{codeOf(si, li)}
{a[1]}
{tag}
{/* ── 锁定遮罩(DC 播放页锁罩同款锁头):未解锁→压暗全卡 + 居中锁头 ── */} {locked && (
)}
{tierTag ? ( ) : null} {a[0]} {/* 完成 chip:与整卡朱染互为表里——卡色是远观信号,chip 是近读确认(几何同权限角标 3px 7px/radius 4) */} {done ? ( ✓ 已完成 ) : null}
{catName ? catName + ' · ' + a[2] : a[2]}
) }