feat: 全栈教程站(AI 时代版)——OUTLINE 大纲(skill 配方:靶子先行/步骤分类/积木三句话/指派三样/脱靶/认知基础)+ Vite MPA 七页(书架+六章),跨文档 View Transitions 防闪白(书架相框↔章头共享元素 morph),组件复用 design-lab 孟菲斯×拟物卡
This commit is contained in:
1 parent
9aebbbbde3
commit
690ec5eaf5
101 files changed
+118478
-1
No files matched your search
@@ -0,0 +1,139 @@
|
||||
/** 章节页——一章的完整教学结构(对齐 ai-era-tutorial):
|
||||
* 章头靶子卡(▲目标 ○预期 ●积木,view-transition-name 与书架相框同名 morph)
|
||||
* → 步骤列(价值区荧光标记 / 代理区波浪,每步=目标+验收产物)
|
||||
* → 积木三句话(是什么/与靶子的关系/带一族)
|
||||
* → 认知基础(脚下的地,就地露出)→ 指派学习三样+脱靶提醒 */
|
||||
import type { CSSProperties } from 'react'
|
||||
import { Page, Section } from '../lib/ui'
|
||||
import { CHAPTERS, CONTENT } from '../lib/chapters'
|
||||
import type { ChapterId } from '../lib/types'
|
||||
import { AssignBlock } from './Ticket'
|
||||
import { BrickDots } from './MemphisCard'
|
||||
import { Mark, Ring, Tri, Wave } from './shapes'
|
||||
|
||||
const ALL: ChapterId[] = [1, 2, 3, 4, 5, 6]
|
||||
|
||||
export function ChapterPage({ id }: { id: ChapterId }) {
|
||||
const ch = CHAPTERS[id]
|
||||
const c = CONTENT[id]
|
||||
const prev = ALL[id - 2]
|
||||
const next = ALL[id]
|
||||
|
||||
return (
|
||||
<Page active={id}>
|
||||
{/* 章头:与书架相框共享 view-transition-name —— 点卡片进来时相框飞进来变成章头 */}
|
||||
<div
|
||||
className="flex items-center gap-4 rounded-2xl border-2 border-ink p-5 shadow-hard"
|
||||
style={{ background: ch.color, viewTransitionName: `ch-${id}` } as CSSProperties}
|
||||
>
|
||||
<span className="text-[44px] leading-none">{ch.emoji}</span>
|
||||
<div className="flex-1">
|
||||
<h1 className="m-0 text-[24px] font-extrabold text-ink">
|
||||
第 {id} 章 · {ch.title}
|
||||
</h1>
|
||||
<div className="mt-1.5 flex flex-wrap gap-x-6 gap-y-1 text-[12px] font-semibold text-ink">
|
||||
<span>▲ 目标:{ch.target.goal}</span>
|
||||
<span>○ 预期:{ch.target.expectation}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
● 积木 <BrickDots bricks={ch.target.bricks} />
|
||||
{ch.target.bricks.length} 块
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<Section label="步骤" note="每步先分类:这一步的过程里有没有学习价值">
|
||||
<ol className="m-0 list-none space-y-3 p-0">
|
||||
{c.steps.map((s, i) => (
|
||||
<li key={s.title} className="rounded-xl border-2 border-ink bg-paper p-4 shadow-hard">
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full border-2 border-ink bg-white text-[12px] font-extrabold">
|
||||
{i + 1}
|
||||
</span>
|
||||
{s.kind === 'value' ? (
|
||||
<Mark className="text-[13.5px]">{s.title}</Mark>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-[13.5px] font-bold text-ink">
|
||||
<Wave size={30} />
|
||||
{s.title}
|
||||
<span className="rounded-full bg-clash3b/15 px-2 py-0.5 text-[10.5px]">代理区 · 交给 agent</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="m-0 mt-2 text-[12.5px] leading-relaxed text-body">{s.what}</p>
|
||||
<p className="m-0 mt-1.5 text-[12px] text-ink">
|
||||
<b>验收</b>:{s.accept}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Section>
|
||||
|
||||
<Section label="积木清单" note="从靶子倒推、每块三句话、教一块带一族">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
{c.bricks.map((b) => (
|
||||
<div key={b.name} className="rounded-xl border-2 border-ink bg-paper p-3.5 shadow-hard">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tri size={13} />
|
||||
<b className="text-[13px]">{b.name}</b>
|
||||
</div>
|
||||
<p className="m-0 mt-1.5 text-[11.5px] leading-relaxed text-body">
|
||||
<b>是什么</b>:{b.what}
|
||||
<br />
|
||||
<b>为何在这</b>:{b.why}
|
||||
<br />
|
||||
<b>带一族</b>:{b.family}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{c.base && (
|
||||
<Section label="认知基础" note="脚下的地(env/库/运行环境)——第一次用到就地露出">
|
||||
<div className="flex items-start gap-3 rounded-xl border-2 border-dashed border-ink/40 bg-clash3b/15 p-4">
|
||||
<Ring size={18} className="mt-0.5 shrink-0" />
|
||||
<p className="m-0 text-[12.5px] leading-relaxed text-body">{c.base}</p>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{c.assign && (
|
||||
<Section label="指派学习" note="位置给坐标、预期当尺子、场景管巩固;讲解交给你的 agent">
|
||||
<AssignBlock {...c.assign} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{prev ? (
|
||||
<a
|
||||
href={`/ch${prev}.html`}
|
||||
className="rounded-xl border-2 border-ink bg-paper px-4 py-2 text-[12.5px] font-bold text-ink no-underline shadow-hard"
|
||||
>
|
||||
← 第 {prev} 章 · {CHAPTERS[prev].title}
|
||||
</a>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<a
|
||||
href="/index.html"
|
||||
className="rounded-xl border-2 border-ink bg-ink px-4 py-2 text-[12.5px] font-bold text-paper no-underline"
|
||||
>
|
||||
返回书架
|
||||
</a>
|
||||
{next ? (
|
||||
<a
|
||||
href={`/ch${next}.html`}
|
||||
className="rounded-xl border-2 border-ink bg-paper px-4 py-2 text-[12.5px] font-bold text-ink no-underline shadow-hard"
|
||||
>
|
||||
第 {next} 章 · {CHAPTERS[next].title} →
|
||||
</a>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/** 入口梳理 —— 读者画像推荐路径:是推荐不是强制(无锁) */
|
||||
import { CHAPTERS, PROFILES } from '../lib/chapters'
|
||||
|
||||
export function EntryGuide() {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-5">
|
||||
{PROFILES.map((p) => (
|
||||
<div key={p.name} data-comp="entry-card" className="w-[252px] rounded-xl border-2 border-ink bg-paper p-3 shadow-hard">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="flex h-[26px] w-[26px] items-center justify-center rounded-lg border-2 border-ink text-[14px]"
|
||||
style={{ background: CHAPTERS[p.entry].color }}
|
||||
>
|
||||
{p.icon}
|
||||
</span>
|
||||
<b className="text-[13px]">{p.name}</b>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{p.path.map((id, i) => (
|
||||
<span key={id} className="flex items-center gap-1.5">
|
||||
{i > 0 && <span className="text-[10px] text-mute">→</span>}
|
||||
<a
|
||||
href={`/ch${id}.html`}
|
||||
className={`rounded-full border-[1.5px] border-ink px-2.5 py-0.5 text-[10.5px] no-underline ${id === p.entry ? 'bg-clash2a font-extrabold' : 'bg-white'}`}
|
||||
>
|
||||
{id === p.entry && ['①', '②', '③'][PROFILES.indexOf(p)]} {CHAPTERS[id].title}
|
||||
</a>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="m-0 mt-2 text-[10px] leading-relaxed text-mute">{p.note}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/** CourseCard —— 教程的入口载体(孟菲斯骨架 × 拟物纸感)
|
||||
* 卡头三件套=靶子:▲目标 + ○预期结果(16:9 相框) + ●积木点列
|
||||
* 交互:hover/聚焦拿起(浮起+投影加深)并播放 GIF;desc ⇄ GIF 每 8000ms 翻转;
|
||||
* hover/Tab 锁定;触屏与 prefers-reduced-motion 不翻转。 */
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import type { Chapter } from '../lib/types'
|
||||
import { Ring, Tri } from './shapes'
|
||||
|
||||
const FLIP_MS = 8000
|
||||
|
||||
export function BrickDots({ bricks }: { bricks: Chapter['target']['bricks'] }) {
|
||||
return (
|
||||
<span className="flex items-center gap-1" aria-label={`积木 ${bricks.filter((b) => b.learned).length}/${bricks.length}`}>
|
||||
{bricks.map((b, i) => (
|
||||
<i
|
||||
key={i}
|
||||
className="inline-block h-[7px] w-[7px] rounded-full border-[1.5px] border-ink"
|
||||
style={{ background: b.learned ? 'var(--color-ink)' : '#fff' }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function EntryBadge({ n }: { n: 1 | 2 | 3 }) {
|
||||
return (
|
||||
<span
|
||||
className="absolute -left-[11px] -top-[11px] z-10 flex h-6 w-6 items-center justify-center rounded-full border-2 border-paper bg-ink text-[12px] font-extrabold text-clash2a"
|
||||
aria-label={`推荐入口 ${n}`}
|
||||
>
|
||||
{'①②③'[n - 1]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function MemphisCard({
|
||||
chapter,
|
||||
entry,
|
||||
compact = false,
|
||||
zoneText,
|
||||
}: {
|
||||
chapter: Chapter
|
||||
entry?: 1 | 2 | 3
|
||||
compact?: boolean
|
||||
/** 静置态相框文字(可含 <Mark> 荧光标记):教程卡片的价值区用法示例 */
|
||||
zoneText?: ReactNode
|
||||
}) {
|
||||
const [gifOn, setGifOn] = useState(false)
|
||||
const [locked, setLocked] = useState(false)
|
||||
const boxRef = useRef<HTMLAnchorElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const coarse = matchMedia('(pointer: coarse)').matches
|
||||
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
if (coarse || reduced) return
|
||||
const io = new IntersectionObserver(
|
||||
([e]) => {
|
||||
if (e.isIntersecting) setGifOn(true)
|
||||
else setGifOn(false)
|
||||
},
|
||||
{ threshold: 0.3 },
|
||||
)
|
||||
if (boxRef.current) io.observe(boxRef.current)
|
||||
return () => io.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!gifOn) return
|
||||
const t = setInterval(() => {
|
||||
setFlip((f) => !f)
|
||||
}, FLIP_MS)
|
||||
return () => clearInterval(t)
|
||||
}, [gifOn])
|
||||
|
||||
const [flip, setFlip] = useState(false)
|
||||
const showGif = gifOn && (locked ? true : flip)
|
||||
|
||||
return (
|
||||
<a
|
||||
ref={boxRef}
|
||||
href={`/ch${chapter.id}.html`}
|
||||
data-comp="course-card"
|
||||
className="group relative block rounded-[14px] border-2 border-ink bg-paper p-[11px] no-underline
|
||||
shadow-hard transition-transform duration-150 focus-visible:outline-none
|
||||
hover:-translate-y-[3px] hover:shadow-hard-lift
|
||||
focus-visible:-translate-y-[3px] focus-visible:shadow-hard-lift"
|
||||
onMouseEnter={() => setLocked(true)}
|
||||
onMouseLeave={() => setLocked(false)}
|
||||
onFocus={() => setLocked(true)}
|
||||
onBlur={() => setLocked(false)}
|
||||
aria-label={`${chapter.title}:${chapter.target.goal}`}
|
||||
>
|
||||
{entry && <EntryBadge n={entry} />}
|
||||
{/* ○ 预期结果:16:9 相框(章节撞色只进这里) */}
|
||||
<div
|
||||
className="relative flex aspect-video items-center justify-center overflow-hidden rounded-lg border-[3px] border-ink"
|
||||
style={{
|
||||
background: chapter.color,
|
||||
boxShadow: 'inset 0 3px 8px rgba(26,26,26,0.30), inset 0 -1px 0 rgba(255,255,255,0.55)',
|
||||
viewTransitionName: `ch-${chapter.id}`,
|
||||
}}
|
||||
>
|
||||
{showGif ? (
|
||||
chapter.gifUrl ? (
|
||||
<img src={chapter.gifUrl} alt={chapter.target.expectation} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<span className="px-3 text-center text-[11px] font-bold leading-snug text-paper">
|
||||
▶ {chapter.target.expectation}
|
||||
</span>
|
||||
)
|
||||
) : flip ? (
|
||||
<span className="px-3 text-center text-[11px] leading-snug text-ink/80 mix-blend-multiply">
|
||||
{chapter.target.goal}
|
||||
</span>
|
||||
) : zoneText ? (
|
||||
/* 静置态(用法示例):过程文字骑在相框里,价值区短语带荧光标记 */
|
||||
<span className="px-3 text-center text-[11.5px] font-semibold leading-snug text-ink">{zoneText}</span>
|
||||
) : (
|
||||
/* 静置态:章节 emoji 是相框的视觉主体(对齐设计稿解剖卡) */
|
||||
<span style={{ fontSize: compact ? '28px' : '40px' }}>{chapter.emoji}</span>
|
||||
)}
|
||||
<span className="absolute left-1.5 top-1 text-[9px] font-bold tracking-wide text-paper">
|
||||
预期结果{showGif ? ' · GIF' : ''}
|
||||
</span>
|
||||
<Ring size={16} className="absolute bottom-1.5 right-1.5" />
|
||||
</div>
|
||||
{/* 卡头:▲ 目标 + 标题 + ● 积木点列 */}
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Tri size={14} />
|
||||
<b className="flex-1 text-[14px] text-[#2b1d10] [text-shadow:0_1px_1px_rgba(255,255,255,0.9)]">
|
||||
{chapter.title}
|
||||
</b>
|
||||
{!compact && <BrickDots bricks={chapter.target.bricks} />}
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/** RouteLine —— 关系线:线形即含义(实=先后 虚=相关 点=同族)
|
||||
* 拓扑:横平竖直 + R8 直角步进;并行不合并(4px);站点 r6 半嵌卡边
|
||||
* (圆心骑左右描边,各半在卡上);线层 z-10 置于卡之上,站点不被卡遮挡;
|
||||
* 同口多线自动聚合胶囊(线止于胶囊边界)。 */
|
||||
import type { ChapterId, Edge } from '../lib/types'
|
||||
|
||||
const STATION_COLORS = ['var(--color-clash2a)', 'var(--color-clash1b)', 'var(--color-clash3a)', 'var(--color-clash1a)']
|
||||
|
||||
export interface LaneNode {
|
||||
id: ChapterId
|
||||
borderLeft: number // 左描边 x(入口圆心贴这里,点半嵌)
|
||||
right: number // 右描边 x(出口圆心贴这里,点半嵌——不接阴影)
|
||||
y: number // 卡中线 y
|
||||
}
|
||||
|
||||
/** 水平出发 → R8 双弯直角步进 → 水平到达;同 y 时直线 */
|
||||
function stepPath(x0: number, y0: number, x1: number, y1: number, r = 8): string {
|
||||
if (Math.abs(y1 - y0) < 1) return `M ${x0} ${y0} H ${x1}`
|
||||
const down = y1 > y0
|
||||
const sweep1 = down ? 1 : 0
|
||||
const sweep2 = down ? 0 : 1
|
||||
const cornerX = Math.max(x0 + r, x1 - 2 * r)
|
||||
return `M ${x0} ${y0} H ${cornerX} A ${r} ${r} 0 0 ${sweep1} ${cornerX + r} ${y0 + (down ? r : -r)} V ${y1 - (down ? r : -r)} A ${r} ${r} 0 0 ${sweep2} ${cornerX + 2 * r} ${y1} H ${x1}`
|
||||
}
|
||||
|
||||
export function RouteLine({
|
||||
edges,
|
||||
nodes,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
edges: Edge[]
|
||||
nodes: Partial<Record<ChapterId, LaneNode>>
|
||||
width: number
|
||||
height: number
|
||||
}) {
|
||||
const stationColor = (i: number) => STATION_COLORS[i % STATION_COLORS.length]
|
||||
|
||||
const seqEdges = edges.filter((e) => e.kind === 'seq')
|
||||
const insByTo = new Map<ChapterId, Edge[]>()
|
||||
seqEdges.forEach((e) => insByTo.set(e.to, [...(insByTo.get(e.to) ?? []), e]))
|
||||
const firstIn = new Set<ChapterId>()
|
||||
insByTo.forEach((list) => firstIn.add(list[0].to))
|
||||
|
||||
let si = 0
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="pointer-events-none absolute left-0 top-0 z-10"
|
||||
aria-hidden
|
||||
>
|
||||
{edges.map((e, i) => {
|
||||
const a = nodes[e.from]
|
||||
const b = nodes[e.to]
|
||||
if (!a || !b) return null
|
||||
const d = stepPath(a.right, a.y, b.borderLeft, b.y)
|
||||
if (e.kind === 'rel') return <path key={i} d={d} stroke="var(--color-ink)" strokeWidth={2} fill="none" strokeDasharray="4 2" />
|
||||
if (e.kind === 'family') return <path key={i} d={d} stroke="var(--color-ink)" strokeWidth={2.4} fill="none" strokeDasharray="0.1 6" strokeLinecap="round" />
|
||||
return <path key={i} d={d} stroke="var(--color-ink)" strokeWidth={2.5} fill="none" />
|
||||
})}
|
||||
{seqEdges.map((e) => {
|
||||
const a = nodes[e.from]
|
||||
const b = nodes[e.to]
|
||||
if (!a || !b) return null
|
||||
const ins = insByTo.get(e.to)!
|
||||
const capsule = ins.length > 1 && firstIn.has(e.to)
|
||||
// 源/目标站点:圆心骑卡描边,各半在卡上;目标:单线=圆点,多线=胶囊(只画一次)
|
||||
return (
|
||||
<g key={`st-${e.from}-${e.to}`}>
|
||||
<circle cx={a.right} cy={a.y} r={6} fill={stationColor(si++)} stroke="var(--color-ink)" strokeWidth={2} />
|
||||
{capsule ? (
|
||||
<rect x={b.borderLeft - 3} y={b.y - 10} width={6} height={20} rx={3} fill="var(--color-clash1a)" stroke="var(--color-ink)" strokeWidth={2} />
|
||||
) : ins.length === 1 ? (
|
||||
<circle cx={b.borderLeft} cy={b.y} r={6} fill={stationColor(si++)} stroke="var(--color-ink)" strokeWidth={2} />
|
||||
) : null}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/** 票券 —— 只表达完成状态:完成=沿撕线打孔(半圆缺口),未开始=实线无孔。
|
||||
* 外层 wrapper 出阴影(drop-shadow 跟随挖空后的轮廓,box-shadow 会被 mask 裁掉);
|
||||
* 内层票体裁切式:头部色块无独立描边,由票体 overflow-hidden 裁出单一描边圆角。 */
|
||||
import type { Chapter } from '../lib/types'
|
||||
|
||||
export function Ticket({ chapter, done }: { chapter: Chapter; done: boolean }) {
|
||||
return (
|
||||
<div data-comp="ticket" className={`w-fit drop-shadow-hard ${done ? 'tk-pk' : ''}`}>
|
||||
<div
|
||||
className={`relative flex w-[216px] items-stretch overflow-hidden rounded-xl border-2 border-ink bg-paper ${done ? 'ticket-pk' : ''}`}
|
||||
>
|
||||
{/* 头部媒体块:章节色纯填充,无独立边框(由票体裁切) */}
|
||||
<div className="flex w-14 shrink-0 items-center justify-center text-[22px]" style={{ background: chapter.color }}>{chapter.emoji}</div>
|
||||
<div className="flex-1 py-2 pl-2.5 pr-2">
|
||||
<b className="text-[12.5px]">{chapter.title}</b>
|
||||
<div className="mt-0.5 font-mono text-[8.5px] text-mute">
|
||||
No. {String(chapter.id).padStart(3, '0')} · {done ? '已完成(打孔)' : '未开始(无孔)'}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`flex w-[42px] items-center justify-center font-mono text-[9.5px] text-mute ${done ? 'border-l-[1.5px] border-dashed border-ink' : 'border-l-[1.5px] border-solid border-ink'}`}
|
||||
>
|
||||
№{String(chapter.id).padStart(2, '0')}
|
||||
</div>
|
||||
</div>
|
||||
{/* 孔缘弧线:mask 只减形不补线,弧线补上挖空边缘的 ink 描边 */}
|
||||
{done && (
|
||||
<>
|
||||
<i className="pk-arc pk-arc-t" aria-hidden />
|
||||
<i className="pk-arc pk-arc-b" aria-hidden />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 指派学习块 —— 普通内容块(非票据):+ 位置/预期/场景三样 + 脱靶标记 */
|
||||
export function AssignBlock({
|
||||
title,
|
||||
pos,
|
||||
expect,
|
||||
scenes,
|
||||
miss,
|
||||
}: {
|
||||
title: string
|
||||
pos: string
|
||||
expect: string
|
||||
scenes: string[]
|
||||
miss: { how: string; looks: string }
|
||||
}) {
|
||||
return (
|
||||
<div data-comp="assign" className="w-[260px] rounded-xl border-2 border-ink bg-paper p-3 shadow-hard">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg width="16" height="16" viewBox="0 0 18 18" aria-hidden>
|
||||
<path d="M9 1 v16 M1 9 h16" stroke="var(--color-clash2b)" strokeWidth="3.5" strokeLinecap="round" />
|
||||
<path d="M9 1 v16 M1 9 h16" stroke="var(--color-ink)" strokeWidth="1.2" />
|
||||
</svg>
|
||||
<b className="text-[13px]">{title}</b>
|
||||
</div>
|
||||
<div className="mt-1.5 space-y-0.5 text-[11px] leading-relaxed text-body">
|
||||
<div>
|
||||
<b>位置</b>:{pos}
|
||||
</div>
|
||||
<div>
|
||||
<b>预期</b>:{expect}
|
||||
</div>
|
||||
<div>
|
||||
<b>场景</b>:{scenes.join(' / ')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 border-t border-dashed border-ink/20 pt-2">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<svg width="20" height="28" viewBox="0 0 24 34" className="mt-0.5 shrink-0" aria-hidden>
|
||||
<path d="M 12 32 L 8 27 L 16 22 L 8 17 L 16 12 L 12 7 L 14 2" fill="none" stroke="var(--color-clash1a)" strokeWidth="3" strokeLinejoin="round" strokeLinecap="round" />
|
||||
</svg>
|
||||
<div>
|
||||
<b className="text-[11.5px]">脱靶 · {miss.how}</b>
|
||||
<p className="m-0 mt-0.5 text-[10.5px] leading-relaxed text-body">{miss.looks}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/** 七个语义形状——装饰即语义(取自 ai-era-tutorial 词汇表) */
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
|
||||
interface ShapeProps {
|
||||
size?: number
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
}
|
||||
|
||||
/** ▲ 靶子:目标,卡头打头 */
|
||||
export const Tri = ({ size = 24, className, style }: ShapeProps) => (
|
||||
<svg width={size} height={size * 0.92} viewBox="0 0 26 24" className={className} style={style} aria-hidden>
|
||||
<path d="M13 2 L25 22 L1 22 Z" fill="var(--color-clash1a)" stroke="var(--color-ink)" strokeWidth="2" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ● 积木:清单点列,实心=已学 */
|
||||
export const BrickDot = ({ size = 14, on = false, className, style }: ShapeProps & { on?: boolean }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 14 14" className={className} style={style} aria-hidden>
|
||||
<circle cx="7" cy="7" r="5" fill={on ? 'var(--color-clash2a)' : 'var(--color-paper)'} stroke="var(--color-ink)" strokeWidth="2" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ○ 预期:可核对的结果 */
|
||||
export const Ring = ({ size = 24, className, style }: ShapeProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 26 24" className={className} style={style} aria-hidden>
|
||||
<circle cx="13" cy="12" r="8" fill="none" stroke="var(--color-clash1b)" strokeWidth="4" />
|
||||
<circle cx="13" cy="12" r="8" fill="none" stroke="var(--color-ink)" strokeWidth="1.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ~ 代理区:横波,交给 agent */
|
||||
export const Wave = ({ size = 40, className, style }: ShapeProps) => (
|
||||
<svg width={size} height={size * 0.5} viewBox="0 0 40 20" className={className} style={style} aria-hidden>
|
||||
<path d="M2 12 q5 -16 10 0 t10 0 t10 0" fill="none" stroke="var(--color-clash3b)" strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** 荧光标记 价值区:色块骑在文字背后——是标记不是形状,
|
||||
* 无描边、微斜、两端比字宽(荧光笔划过的手感),过程就地写清 */
|
||||
export const Mark = ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<mark
|
||||
className={`-rotate-1 rounded-[3px] bg-clash2a px-[4px] py-[1px] font-bold text-ink [box-decoration-break:clone] ${className ?? ''}`}
|
||||
>
|
||||
{children}
|
||||
</mark>
|
||||
)
|
||||
|
||||
/** ⌇ 脱靶:竖立颤抖——图形入口,读者追随图形进入阅读 */
|
||||
export const Tremble = ({ size = 24, className, style }: ShapeProps) => (
|
||||
<svg width={size} height={size * 1.4} viewBox="0 0 24 34" className={className} style={style} aria-hidden>
|
||||
<path
|
||||
d="M 12 32 L 8 27 L 16 22 L 8 17 L 16 12 L 12 7 L 14 2"
|
||||
fill="none"
|
||||
stroke="var(--color-clash1a)"
|
||||
strokeWidth="3.5"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** + 指派学习:位置/预期/场景三样 */
|
||||
export const Cross = ({ size = 18, className, style }: ShapeProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 18 18" className={className} style={style} aria-hidden>
|
||||
<path d="M9 1 v16 M1 9 h16" stroke="var(--color-clash2b)" strokeWidth="4" strokeLinecap="round" />
|
||||
<path d="M9 1 v16 M1 9 h16" stroke="var(--color-ink)" strokeWidth="1.2" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** 脱靶标记:图形 + 文字,无容器(图形是第一视觉) */
|
||||
export function MissMark({ how, looks }: { how: string; looks: string }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<Tremble size={22} className="mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<b className="text-[13px]">脱靶 · {how}</b>
|
||||
<p className="m-0 mt-0.5 text-[12px] leading-relaxed text-body">{looks}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
/** 教程内容数据——大纲见 OUTLINE.md,词汇与结构对齐 ai-era-tutorial skill */
|
||||
import type { Assignment, Chapter, ChapterId, Edge, Profile } from './types'
|
||||
|
||||
export const CHAPTERS: Record<ChapterId, Chapter> = {
|
||||
1: {
|
||||
id: 1,
|
||||
title: '快速开始',
|
||||
emoji: '🚀',
|
||||
color: 'var(--color-ch1)',
|
||||
target: {
|
||||
goal: '让第一个网页从你的服务器跑起来——不是双击打开的文件',
|
||||
expectation: '地址栏是 http://localhost:端口,Network 面板有一次 200',
|
||||
bricks: [
|
||||
{ name: 'HTTP 请求/响应', learned: true },
|
||||
{ name: '静态文件服务', learned: true },
|
||||
{ name: 'HTML 骨架', learned: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
2: {
|
||||
id: 2,
|
||||
title: 'React',
|
||||
emoji: '⚛️',
|
||||
color: 'var(--color-ch2)',
|
||||
target: {
|
||||
goal: '用组件拼出可增删的 todo',
|
||||
expectation: '增删待办界面即时变化,全程不手改 DOM',
|
||||
bricks: [
|
||||
{ name: '组件', learned: true },
|
||||
{ name: 'props', learned: true },
|
||||
{ name: 'state', learned: false },
|
||||
{ name: 'Vite 工程', learned: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
3: {
|
||||
id: 3,
|
||||
title: '更多示例',
|
||||
emoji: '🧪',
|
||||
color: 'var(--color-ch3)',
|
||||
target: {
|
||||
goal: '遇到布局/效果问题,知道缺哪块积木',
|
||||
expectation: '同一张卡片用三种布局(文档流/flex/grid)各摆一遍',
|
||||
bricks: [
|
||||
{ name: '文档流', learned: false },
|
||||
{ name: 'flex', learned: false },
|
||||
{ name: 'grid', learned: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
4: {
|
||||
id: 4,
|
||||
title: '更多 · 构建',
|
||||
emoji: '📦',
|
||||
color: 'var(--color-ch4)',
|
||||
target: {
|
||||
goal: '看懂源码怎么变成产物',
|
||||
expectation: '能指出 dist 里每个文件对应源码哪部分,说清 dev 与 prod 的差别',
|
||||
bricks: [
|
||||
{ name: '打包', learned: false },
|
||||
{ name: '静态产物', learned: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
5: {
|
||||
id: 5,
|
||||
title: '全栈基础',
|
||||
emoji: '🌐',
|
||||
color: 'var(--color-ch5)',
|
||||
target: {
|
||||
goal: '给 todo 接上数据库与 API——数据存下来',
|
||||
expectation: '刷新页面数据还在;API 返回 JSON;数据落在 SQLite 文件',
|
||||
bricks: [
|
||||
{ name: 'REST 路由', learned: false },
|
||||
{ name: 'ORM(Prisma)', learned: false },
|
||||
{ name: '联调(CORS)', learned: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
6: {
|
||||
id: 6,
|
||||
title: '高级教程',
|
||||
emoji: '🧠',
|
||||
color: 'var(--color-ch6)',
|
||||
target: {
|
||||
goal: '读得懂 agent 给的代码,错的你看得出来',
|
||||
expectation: '拿一段 agent 生成的代码,能指出数据流向与不纯之处',
|
||||
bricks: [
|
||||
{ name: '数据流', learned: false },
|
||||
{ name: '纯函数', learned: false },
|
||||
{ name: '协作流程', learned: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/** 关系边:seq 实线先后 / rel 虚线相关 / family 点线同族 */
|
||||
export const EDGES: Edge[] = [
|
||||
{ from: 1, to: 2, kind: 'seq' },
|
||||
{ from: 2, to: 5, kind: 'seq' },
|
||||
{ from: 2, to: 5, kind: 'family' },
|
||||
{ from: 4, to: 5, kind: 'rel' },
|
||||
]
|
||||
|
||||
export const PROFILES: Profile[] = [
|
||||
{
|
||||
icon: '🌱',
|
||||
name: '零基础入门',
|
||||
entry: 1,
|
||||
path: [1, 2, 3, 4, 5],
|
||||
note: '完整链按序走;每步先核对预期再前进。',
|
||||
},
|
||||
{
|
||||
icon: '⚡',
|
||||
name: '写过页面的人',
|
||||
entry: 2,
|
||||
path: [2, 5, 6],
|
||||
note: '跳过装环境;从组件拼接进入。',
|
||||
},
|
||||
{
|
||||
icon: '🧠',
|
||||
name: '重度 AI 依赖',
|
||||
entry: 6,
|
||||
path: [6, 5],
|
||||
note: '从指挥 agent 做整件事切入,回头补认知基础。',
|
||||
},
|
||||
]
|
||||
|
||||
/* ===== 章节正文(章节页渲染) ===== */
|
||||
|
||||
/** 步骤:目标 + 验收产物,不写键位;先分类 */
|
||||
export interface Step {
|
||||
title: string
|
||||
kind: 'value' | 'agent' // 价值区(过程就地写清)/ 代理区(交给 agent)
|
||||
what: string // 这一步产出什么
|
||||
accept: string // 读者亲自核对什么
|
||||
}
|
||||
|
||||
/** 积木三句话:是什么 / 与靶子的关系 / 家族 */
|
||||
export interface Brick3 {
|
||||
name: string
|
||||
what: string
|
||||
why: string
|
||||
family: string
|
||||
}
|
||||
|
||||
export interface ChapterContent {
|
||||
steps: Step[]
|
||||
bricks: Brick3[]
|
||||
/** 认知基础:脚下的地(env/库/运行环境),第一次用到就地露出 */
|
||||
base?: string
|
||||
assign?: Assignment
|
||||
}
|
||||
|
||||
export const CONTENT: Record<ChapterId, ChapterContent> = {
|
||||
1: {
|
||||
steps: [
|
||||
{
|
||||
title: '写第一个页面',
|
||||
kind: 'value',
|
||||
what: '一个浏览器能渲染的 HTML 文件:标题 + 一句话。',
|
||||
accept: '浏览器打开,看到标题与一句话。',
|
||||
},
|
||||
{
|
||||
title: '起最简服务器',
|
||||
kind: 'value',
|
||||
what: '一个小服务:把你的文件按地址返回给浏览器。',
|
||||
accept: '地址栏是 http://localhost:端口,Network 面板有一次 200。',
|
||||
},
|
||||
{
|
||||
title: '环境安装(node / 编辑器)',
|
||||
kind: 'agent',
|
||||
what: '交给 agent 完成;教程不给安装过程。',
|
||||
accept: '真机验收:node -v 有版本号、服务器能启动——跑必须真跑。',
|
||||
},
|
||||
],
|
||||
bricks: [
|
||||
{
|
||||
name: 'HTTP 请求/响应',
|
||||
what: '浏览器问一句、服务器答一句的一来一回',
|
||||
why: '页面是服务器「答」出来的,不是文件自己会显示',
|
||||
family: '同族还有 WebSocket(长连着说);要「拉一次数据」用 HTTP,要「持续推」用 WebSocket',
|
||||
},
|
||||
{
|
||||
name: '静态文件服务',
|
||||
what: '把目录里的文件原样返回的那层服务',
|
||||
why: '第一个页面只需要它,不需要数据库与逻辑',
|
||||
family: '同族是反向代理/CDN;自己玩用静态服务,上量了前面加代理',
|
||||
},
|
||||
{
|
||||
name: 'HTML 骨架',
|
||||
what: '页面内容的最小可渲染结构',
|
||||
why: '靶子的「长相」由它定义',
|
||||
family: '同族有 Markdown/JSX;给浏览器看的是 HTML,写起来顺手的是后两者',
|
||||
},
|
||||
],
|
||||
base: '终端、node、编辑器不是积木,是脚下的地——第一次用到就在这里露出:终端是你指挥机器的嘴,node 是让 JavaScript 在机器上跑的运行时,编辑器是看代码的眼睛。',
|
||||
assign: {
|
||||
title: '指派学习 · HTTP 协议',
|
||||
pos: 'Web 通信的地基,旁边是 DNS(找机器)与 TCP(铺线路)',
|
||||
expect: '能向 agent 讲清一次请求的往返:URL → DNS → 连接 → 请求行/头/体 → 响应 → 渲染',
|
||||
scenes: ['打开 Network 面板复述一次页面加载', '用 curl 拿一个网页的原始响应'],
|
||||
miss: {
|
||||
how: '装了但没跑',
|
||||
looks: '命令成功、页面空白——缺「真机验收」这块积木',
|
||||
},
|
||||
},
|
||||
},
|
||||
2: {
|
||||
steps: [
|
||||
{
|
||||
title: '建 Vite 工程',
|
||||
kind: 'agent',
|
||||
what: '交给 agent 完成;脚手架过程无学习价值。',
|
||||
accept: 'dev 服务器起来,改一行代码浏览器立刻变化。',
|
||||
},
|
||||
{
|
||||
title: '组件拆分',
|
||||
kind: 'value',
|
||||
what: '输入框 / 列表 / 按钮三个组件,各司其职。',
|
||||
accept: '删掉列表组件,输入框与按钮不塌。',
|
||||
},
|
||||
{
|
||||
title: 'state 驱动界面',
|
||||
kind: 'value',
|
||||
what: '待办数据放在 state,界面是数据的影子。',
|
||||
accept: '增删待办后不碰 DOM,界面也更新。',
|
||||
},
|
||||
],
|
||||
bricks: [
|
||||
{
|
||||
name: '组件',
|
||||
what: '把一块界面连同它的行为打包成零件',
|
||||
why: 'todo 由零件拼成,新零件不碰旧零件',
|
||||
family: '同族是函数组件/类组件;新项目一律函数组件',
|
||||
},
|
||||
{
|
||||
name: 'props / state',
|
||||
what: 'props 是外面递进来的,state 是自己肚里的',
|
||||
why: '增删待办 = 改 state;父传子用 props',
|
||||
family: '同族是 context/store;两层传参用 props,跨层共享再上 store',
|
||||
},
|
||||
{
|
||||
name: 'Vite 工程',
|
||||
what: '让你写源码、浏览器收成品的开发机',
|
||||
why: '「改一行立刻见」由它提供',
|
||||
family: '同族 webpack/Rsbuild;判断特征是 dev 速度与生态,新手期不换',
|
||||
},
|
||||
],
|
||||
base: 'node_modules / package.json 不是积木,是认知基础:node_modules 是这台机器的零件仓库,package.json 是购物清单——平台会替你装,但你要知道「装了什么、装到哪」。',
|
||||
assign: {
|
||||
title: '指派学习 · 受控 vs 非受控',
|
||||
pos: '表单一族;旁边是校验与重置',
|
||||
expect: '两种写法各写一遍,能说清数据住谁家',
|
||||
scenes: ['把输入框两种写法互换', '给输入框加初始值再对比行为'],
|
||||
miss: {
|
||||
how: '组件跑通但答不出数据从哪来往哪去',
|
||||
looks: '增删能用、一刷新全丢——缺 state 数据流这块积木',
|
||||
},
|
||||
},
|
||||
},
|
||||
3: {
|
||||
steps: [
|
||||
{
|
||||
title: '三种布局各摆一遍',
|
||||
kind: 'value',
|
||||
what: '同一张卡片,分别用文档流、flex、grid 摆出来。',
|
||||
accept: '三种摆法结果一致,且能说出各自适合的场景。',
|
||||
},
|
||||
],
|
||||
bricks: [
|
||||
{
|
||||
name: '文档流',
|
||||
what: '元素从上往下、从左往右的默认排法',
|
||||
why: '它是底座,flex/grid 都是在改写它',
|
||||
family: '布局一族的基础款;先看懂它再学另外两个',
|
||||
},
|
||||
{
|
||||
name: 'flex',
|
||||
what: '一条轴上的排布(横或纵)',
|
||||
why: '侧栏、工具条这类一维排列的首选',
|
||||
family: '一维用 flex,二维用 grid',
|
||||
},
|
||||
{
|
||||
name: 'grid',
|
||||
what: '行与列的网格排布',
|
||||
why: '等宽多列、整体骨架的首选',
|
||||
family: '二维用 grid;「区域划分」感强时优先它',
|
||||
},
|
||||
],
|
||||
assign: {
|
||||
title: '指派学习 · 布局一族',
|
||||
pos: 'CSS 排版;旁边是定位(position)与响应式',
|
||||
expect: '同一张卡片三种布局各摆一遍,判断特征说得出口',
|
||||
scenes: ['侧栏布局', '水平垂直居中', '等宽三列'],
|
||||
miss: {
|
||||
how: '抄了 CSS 能用但换张卡就摆不平',
|
||||
looks: '样式能抄、布局说不清——没有把布局当一族学',
|
||||
},
|
||||
},
|
||||
},
|
||||
4: {
|
||||
steps: [
|
||||
{
|
||||
title: '构建并解剖产物',
|
||||
kind: 'value',
|
||||
what: '跑一次构建,把 dist 里的文件逐个对回源码。',
|
||||
accept: '每个产物文件能指出对应源码哪部分。',
|
||||
},
|
||||
{
|
||||
title: '部署脚本',
|
||||
kind: 'agent',
|
||||
what: '交给 agent 完成。',
|
||||
accept: '产物在静态服务器上可访问。',
|
||||
},
|
||||
],
|
||||
bricks: [
|
||||
{
|
||||
name: '打包',
|
||||
what: '把源码合并、压缩、改名成浏览器吃得下的文件',
|
||||
why: '浏览器只认产物,不认你的源码工程',
|
||||
family: '同族是 tree-shaking/分包;产物里找不到的代码就是没打进去',
|
||||
},
|
||||
{
|
||||
name: '静态产物',
|
||||
what: '一堆不需要服务端运算的文件',
|
||||
why: '部署=把这堆文件放到能被访问的地方',
|
||||
family: '同族是 SSR 产物;纯静态放哪都能跑,SSR 要算力',
|
||||
},
|
||||
],
|
||||
base: 'dev server 与构建产物是两回事:dev 是「现做现吃」,prod 是「做好装盒」——就地露出,别混。',
|
||||
},
|
||||
5: {
|
||||
steps: [
|
||||
{
|
||||
title: '设计 API',
|
||||
kind: 'value',
|
||||
what: 'GET /todos、POST /todos、DELETE /todos/:id 三条路由。',
|
||||
accept: 'curl 三条全通,返回都是 JSON。',
|
||||
},
|
||||
{
|
||||
title: '建 Prisma 工程',
|
||||
kind: 'agent',
|
||||
what: '交给 agent 完成。',
|
||||
accept: 'migration 生成、SQLite 库文件出现。',
|
||||
},
|
||||
{
|
||||
title: '前后端联调',
|
||||
kind: 'value',
|
||||
what: '前端的读写改走 API,跨端口的门是 CORS。',
|
||||
accept: 'Network 面板能看到 todos 请求,刷新页面数据还在。',
|
||||
},
|
||||
],
|
||||
bricks: [
|
||||
{
|
||||
name: 'REST 路由',
|
||||
what: '「地址 + 动词」对应一件事的约定',
|
||||
why: '前后端只靠这份约定说话',
|
||||
family: '同族 GraphQL/RPC;资源型增删改查用 REST 起步最省',
|
||||
},
|
||||
{
|
||||
name: 'ORM(Prisma)',
|
||||
what: '用代码对象读写数据库的那层',
|
||||
why: 'todo 存下来靠它,不写裸 SQL 也能起步',
|
||||
family: '同族 TypeORM/Drizzle/裸 SQL;要类型提示与迁移流水线时用它',
|
||||
},
|
||||
{
|
||||
name: '联调(CORS)',
|
||||
what: '浏览器跨端口拿数据时要带的通行证头',
|
||||
why: '前端 5173、后端 3000,不配就互相不认',
|
||||
family: '同族是代理转发;开发期用 CORS 头或代理都行,别两头乱配',
|
||||
},
|
||||
],
|
||||
base: 'SQLite 是一个文件当数据库——零安装、看得见摸得着(库文件就在目录里),是认知基础里最直观的一块地。',
|
||||
assign: {
|
||||
title: '指派学习 · 数据库迁移一族',
|
||||
pos: 'schema 演进;旁边是种子数据与回滚',
|
||||
expect: '给 todo 加一个字段,走完「改 schema → 迁移 → 验证」全流程',
|
||||
scenes: ['给 todo 加截止日期', '把完成状态改成时间戳'],
|
||||
miss: {
|
||||
how: '前端直连数据库「也能跑」',
|
||||
looks: '页面能写库、换台设备就看不到——架构上少了 API 这层,撞上并发/换端就疼',
|
||||
},
|
||||
},
|
||||
},
|
||||
6: {
|
||||
steps: [
|
||||
{
|
||||
title: '读一段 agent 生成的代码',
|
||||
kind: 'value',
|
||||
what: '拿 agent 写的 todo 数据流,画出状态从哪来、到哪去。',
|
||||
accept: '图能画全,且能指出一处可纯化的计算。',
|
||||
},
|
||||
{
|
||||
title: '纯函数改造',
|
||||
kind: 'value',
|
||||
what: '把一处带副作用的计算改成纯函数。',
|
||||
accept: '同输入同输出,搬家到任何文件行为不变。',
|
||||
},
|
||||
],
|
||||
bricks: [
|
||||
{
|
||||
name: '数据流',
|
||||
what: '状态沿着「事件 → 改状态 → 重渲染」的流动路径',
|
||||
why: '读代码就是读它的数据流',
|
||||
family: '同族是单向数据流/响应式;判断特征:状态只有一个真源吗',
|
||||
},
|
||||
{
|
||||
name: '纯函数',
|
||||
what: '同输入必同输出、不碰外界的函数',
|
||||
why: '值得信赖的计算块,agent 代码里的定海针',
|
||||
family: '同族是副作用隔离;IO 推到边角,核心保持纯',
|
||||
},
|
||||
{
|
||||
name: '协作流程',
|
||||
what: '与人/agent 分工的边界与交接物',
|
||||
why: '识别力就是「分工时分得清谁干了什么」',
|
||||
family: '同族是 code review 流;验收产物越清楚,协作越轻',
|
||||
},
|
||||
],
|
||||
assign: {
|
||||
title: '指派学习 · 协同编程探索',
|
||||
pos: '工程协作;旁边是 code review 与分支策略',
|
||||
expect: '和 agent 结对完成一个小功能,全程你出靶子它出过程,交接物可核对',
|
||||
scenes: ['让 agent 重构一段你写的代码,你验收', '让 agent 出两版方案,你选并说理由'],
|
||||
miss: {
|
||||
how: 'agent 说什么信什么',
|
||||
looks: '代码能跑、讲不出为什么这样写——识别力没长,缺的正是本章',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/** 设计系统数据类型——对齐 ai-era-tutorial 词汇表 */
|
||||
|
||||
/** 章程序号(票号=章程序号) */
|
||||
export type ChapterId = 1 | 2 | 3 | 4 | 5 | 6
|
||||
|
||||
/** 卡头三件套之一:靶子 = ▲目标 + ○预期结果 + ●积木清单 */
|
||||
export interface Target {
|
||||
/** ▲ 目标:这一章做成什么(一句话) */
|
||||
goal: string
|
||||
/** ○ 预期结果:做成长什么样、怎么核对(GIF 的 alt) */
|
||||
expectation: string
|
||||
/** ● 积木清单:要用到的零件——只列用到的;实心数 = 已学 */
|
||||
bricks: { name: string; learned: boolean }[]
|
||||
}
|
||||
|
||||
/** 章节数据(MemphisCard / Ticket 共用) */
|
||||
export interface Chapter {
|
||||
id: ChapterId
|
||||
title: string
|
||||
emoji: string
|
||||
color: string // 章节色(只进 16:9 相框与图标瓦)
|
||||
target: Target
|
||||
/** 预期结果 GIF(16:9 无声循环);缺省用静态占位 */
|
||||
gifUrl?: string
|
||||
}
|
||||
|
||||
/** 关系边——线形即含义 */
|
||||
export type EdgeKind = 'seq' | 'rel' | 'family'
|
||||
/** seq 实线=先后(唯一带站点);rel 虚线=相关;family 点线=同族 */
|
||||
|
||||
export interface Edge {
|
||||
from: ChapterId
|
||||
to: ChapterId
|
||||
kind: EdgeKind
|
||||
}
|
||||
|
||||
/** 入口画像(推荐路径,非强制——无锁) */
|
||||
export interface Profile {
|
||||
icon: string
|
||||
name: string
|
||||
entry: ChapterId // ①②③ 推荐起点
|
||||
path: ChapterId[]
|
||||
note: string
|
||||
}
|
||||
|
||||
/** 指派学习块三样:位置给坐标、预期当尺子、场景管巩固 */
|
||||
export interface Assignment {
|
||||
title: string
|
||||
pos: string
|
||||
expect: string
|
||||
scenes: string[]
|
||||
/** 脱靶标记:可能性 + 长相(撞上时认出缺哪块) */
|
||||
miss: { how: string; looks: string }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/** 教程站页面骨架:站头导航跨页常驻(view-transition-name 共享元素,页间不闪烁) */
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ChapterId } from './types'
|
||||
import { CHAPTERS } from './chapters'
|
||||
import '../styles/tokens.css'
|
||||
|
||||
const NAV: { href: string; label: string; id?: ChapterId }[] = [
|
||||
{ href: '/index.html', label: '书架' },
|
||||
...([1, 2, 3, 4, 5, 6] as ChapterId[]).map((id) => ({
|
||||
href: `/ch${id}.html`,
|
||||
label: `${id}·${CHAPTERS[id].title}`,
|
||||
id,
|
||||
})),
|
||||
]
|
||||
|
||||
export function Page({
|
||||
title,
|
||||
desc,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
/** 章节页标题在章头横幅里,可省 */
|
||||
title?: string
|
||||
desc?: string
|
||||
/** 站头高亮的章节;书架页不传 */
|
||||
active?: ChapterId
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<main className="mx-auto max-w-[1200px] px-6 py-8">
|
||||
<header
|
||||
className="flex flex-wrap items-center gap-1.5 rounded-2xl border-2 border-ink bg-paper p-2.5 shadow-hard"
|
||||
style={{ viewTransitionName: 'site-header' }}
|
||||
>
|
||||
<a href="/index.html" className="mr-2 rounded-lg bg-ink px-2.5 py-1 font-mono text-[13px] font-bold text-paper no-underline">
|
||||
stack·教程
|
||||
</a>
|
||||
{NAV.map((n) => (
|
||||
<a
|
||||
key={n.href}
|
||||
href={n.href}
|
||||
className={`rounded-lg px-2.5 py-1 text-[12.5px] font-bold no-underline transition-colors ${
|
||||
n.id === active ? 'bg-clash2a text-ink' : 'text-body hover:bg-black/5'
|
||||
}`}
|
||||
>
|
||||
{n.label}
|
||||
</a>
|
||||
))}
|
||||
</header>
|
||||
{(title || desc) && (
|
||||
<div className="mb-7 mt-7">
|
||||
{title && <h1 className="m-0 text-[28px] font-extrabold text-ink">{title}</h1>}
|
||||
{desc && <p className="m-0 mt-1 text-[14.5px] text-[#6b6b6b]">{desc}</p>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
<footer className="mt-12 border-t-2 border-dashed border-ink/15 pt-3 text-[12px] text-[#8a8a8a]">
|
||||
stack·教程 · 孟菲斯 × 拟物卡 · 靶子先行,步骤=目标+验收
|
||||
</footer>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export function Section({ label, note, children }: { label: string; note?: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="mb-8">
|
||||
<h2 className="mb-2 text-[13.5px] font-bold text-ink">
|
||||
{label}
|
||||
{note && <i className="ml-2 text-[11.5px] font-normal not-italic text-[#6b6b6b]">{note}</i>}
|
||||
</h2>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ChapterPage } from '../../components/ChapterPage'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<ChapterPage id={1} />)
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ChapterPage } from '../../components/ChapterPage'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<ChapterPage id={2} />)
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ChapterPage } from '../../components/ChapterPage'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<ChapterPage id={3} />)
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ChapterPage } from '../../components/ChapterPage'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<ChapterPage id={4} />)
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ChapterPage } from '../../components/ChapterPage'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<ChapterPage id={5} />)
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ChapterPage } from '../../components/ChapterPage'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<ChapterPage id={6} />)
|
||||
@@ -0,0 +1,65 @@
|
||||
/** 课程书架——教程首页:入口画像 → 六章卡片 → 关系与进度 */
|
||||
import { Page, Section } from '../../lib/ui'
|
||||
import { CHAPTERS } from '../../lib/chapters'
|
||||
import { MemphisCard } from '../../components/MemphisCard'
|
||||
import { EntryGuide } from '../../components/EntryGuide'
|
||||
import { RouteLine } from '../../components/RouteLine'
|
||||
import { Ticket } from '../../components/Ticket'
|
||||
import type { ChapterId } from '../../lib/types'
|
||||
|
||||
/* 场景坐标(节点=卡描边真实边缘):卡1(40,110,250) 卡2(380,44,250,rot) 卡5(700,44,140) */
|
||||
const nodes = {
|
||||
1: { id: 1, borderLeft: 40, right: 290, y: 180 },
|
||||
2: { id: 2, borderLeft: 380, right: 630, y: 108 },
|
||||
5: { id: 5, borderLeft: 700, right: 840, y: 108 },
|
||||
} as const
|
||||
|
||||
const GRID: { id: ChapterId; entry?: 1 | 2 | 3 }[] = [
|
||||
{ id: 1, entry: 1 },
|
||||
{ id: 2, entry: 2 },
|
||||
{ id: 3 },
|
||||
{ id: 4 },
|
||||
{ id: 5 },
|
||||
{ id: 6, entry: 3 },
|
||||
]
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<Page
|
||||
title="全栈工程师教程 · 书架"
|
||||
desc="靶子先行:每章先给目标、预期结果、积木清单,步骤只写「产出什么+核对什么」;过程有没有学习价值,每步先分类。"
|
||||
>
|
||||
<Section label="入口画像" note="推荐不是强制——无锁">
|
||||
<EntryGuide />
|
||||
</Section>
|
||||
<Section label="六章" note="卡片是入口;点相框进入章节">
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{GRID.map(({ id, entry }) => (
|
||||
<MemphisCard key={id} chapter={CHAPTERS[id]} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
<Section label="主线与进度" note="实线=先后;点线=同族;票券=完成状态">
|
||||
<div className="relative h-[520px] overflow-hidden rounded-2xl bg-paper">
|
||||
<RouteLine
|
||||
edges={[
|
||||
{ from: 1, to: 2, kind: 'seq' },
|
||||
{ from: 2, to: 5, kind: 'seq' },
|
||||
{ from: 2, to: 5, kind: 'family' },
|
||||
]}
|
||||
nodes={nodes}
|
||||
width={1160}
|
||||
height={520}
|
||||
/>
|
||||
<div className="absolute left-[40px] top-[110px] w-[250px]"><MemphisCard chapter={CHAPTERS[1]} /></div>
|
||||
<div className="absolute left-[380px] top-[44px] w-[250px] -rotate-1"><MemphisCard chapter={CHAPTERS[2]} /></div>
|
||||
<div className="absolute left-[700px] top-[44px] w-[140px]"><MemphisCard chapter={CHAPTERS[5]} compact /></div>
|
||||
<div className="absolute left-[60px] top-[380px] flex gap-4">
|
||||
<Ticket chapter={CHAPTERS[1]} done />
|
||||
<Ticket chapter={CHAPTERS[5]} done={false} />
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { App } from './App'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<App />)
|
||||
@@ -0,0 +1,84 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* ===== 设计系统 tokens(孟菲斯 × 拟物卡)===== */
|
||||
@theme {
|
||||
/* 撞色对(鲜艳的全部来源,相邻必撞) */
|
||||
--color-clash1a: #ff6b6b; /* coral · ch1 */
|
||||
--color-clash1b: #4ecdc4; /* teal · ch4 */
|
||||
--color-clash2a: #ffd93d; /* mustard · ch3 */
|
||||
--color-clash2b: #a78bfa; /* violet · ch6 */
|
||||
--color-clash3a: #ff8fab; /* pink · ch2 */
|
||||
--color-clash3b: #5b8def; /* blue · ch5 */
|
||||
|
||||
/* 中性阶(纸的世界) */
|
||||
--color-ink: #111111;
|
||||
--color-paper: #fff9f0;
|
||||
--color-mute: #8a7a5c;
|
||||
--color-body: #3d3d3d;
|
||||
|
||||
/* 统一硬投影——唯一投影配方,所有基础组件复用(rest / 拿起) */
|
||||
--shadow-hard: 5px 5px 0 var(--color-ink);
|
||||
--shadow-hard-lift: 8px 8px 0 var(--color-ink);
|
||||
/* 同配方的 drop-shadow 形态:给挖空轮廓用(票券)——box-shadow 会被 mask 裁掉 */
|
||||
--drop-shadow-hard: 5px 5px 0 var(--color-ink);
|
||||
|
||||
/* 章节色别名 */
|
||||
--color-ch1: #ff6b6b;
|
||||
--color-ch2: #ff8fab;
|
||||
--color-ch3: #ffd93d;
|
||||
--color-ch4: #4ecdc4;
|
||||
--color-ch5: #5b8def;
|
||||
--color-ch6: #a78bfa;
|
||||
}
|
||||
|
||||
/* 票券打孔(完成票专用),三件分工:
|
||||
* 挖空 = 票体 mask 差集(两端 1/4 圆硬停剪切,孔穿透露出页面底色);
|
||||
* 阴影 = 外层 wrapper drop-shadow-hard——drop-shadow 跟随 alpha 轮廓,
|
||||
* 孔挖到哪阴影咬到哪(box-shadow 会被 mask 的 border-box 裁切整体吃掉,不可用);
|
||||
* 孔缘 = .pk-arc 半圆环补 ink 边线(mask 只减形不补线),圆心骑撕线两端。 */
|
||||
.tk-pk {
|
||||
--hole-x: 172.75px; /* 撕线中心:216 − 右描边 2 − stub 42 + 撕线半宽 */
|
||||
}
|
||||
.ticket-pk {
|
||||
-webkit-mask-image:
|
||||
radial-gradient(circle 7px at var(--hole-x) 0, transparent 6px, #000 6.5px),
|
||||
radial-gradient(circle 7px at var(--hole-x) 100%, transparent 6px, #000 6.5px);
|
||||
-webkit-mask-composite: source-in;
|
||||
mask-image:
|
||||
radial-gradient(circle 7px at var(--hole-x) 0, transparent 6px, #000 6.5px),
|
||||
radial-gradient(circle 7px at var(--hole-x) 100%, transparent 6px, #000 6.5px);
|
||||
mask-composite: intersect;
|
||||
}
|
||||
/* 孔缘弧线:16px 圆环(描边带 r6–8),clip-path 只留票内一半,不进阴影轮廓 */
|
||||
.pk-arc {
|
||||
position: absolute;
|
||||
left: calc(var(--hole-x) - 8px);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--color-ink);
|
||||
border-radius: 9999px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.pk-arc-t {
|
||||
top: -8px;
|
||||
clip-path: inset(50% 0 0 0);
|
||||
}
|
||||
.pk-arc-b {
|
||||
bottom: -8px;
|
||||
clip-path: inset(0 0 50% 0);
|
||||
}
|
||||
|
||||
/* ===== MPA 跨文档 View Transitions:页面切换交叉淡入,不闪白 =====
|
||||
* 同名 view-transition-name 的元素(site-header 站头、ch-<id> 相框↔章头)
|
||||
* 在导航时做共享元素变形;其余内容默认交叉淡入。 */
|
||||
@view-transition {
|
||||
navigation: auto;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
::view-transition-group(*),
|
||||
::view-transition-old(*),
|
||||
::view-transition-new(*) {
|
||||
animation-duration: 0.01ms;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user