feat: add TypeScript lessons and learning panel

- Introduced a new script to check TypeScript lesson files for errors.
- Created a main TypeScript file to render lessons and their details.
- Added lesson definitions with starter and answer codes.
- Implemented a user interface for navigating and running lessons.
- Styled the application with CSS for a better user experience.
- Updated README to reflect the new TypeScript section and usage instructions.
This commit is contained in:
charlie
2026-03-19 10:06:11 +08:00
parent 69a4ae3178
commit f3bdaa4e88
146 changed files with 5951 additions and 9 deletions

View File

@@ -0,0 +1,308 @@
import lesson01Starter from "../01-js-vs-ts/starter.ts?raw";
import lesson01Answer from "../01-js-vs-ts/answer.ts?raw";
import lesson02Starter from "../02-basic-type-annotations/starter.ts?raw";
import lesson02Answer from "../02-basic-type-annotations/answer.ts?raw";
import lesson03Starter from "../03-array-and-function-types/starter.ts?raw";
import lesson03Answer from "../03-array-and-function-types/answer.ts?raw";
import lesson04Starter from "../04-interface-object-shape/starter.ts?raw";
import lesson04Answer from "../04-interface-object-shape/answer.ts?raw";
import lesson05Starter from "../05-generic-functions/starter.ts?raw";
import lesson05Answer from "../05-generic-functions/answer.ts?raw";
import lesson06Starter from "../06-union-and-optional-props/starter.ts?raw";
import lesson06Answer from "../06-union-and-optional-props/answer.ts?raw";
import lesson07Starter from "../07-type-safe-renderer/starter.ts?raw";
import lesson07Answer from "../07-type-safe-renderer/answer.ts?raw";
import lesson08Starter from "../08-final-mini-app/starter.ts?raw";
import lesson08Answer from "../08-final-mini-app/answer.ts?raw";
export interface Lesson {
id: string;
title: string;
focus: string;
summary: string;
starterCode: string;
answerCode: string;
keyPoints: string[];
runDemo: () => string[];
}
function runLesson01(): string[] {
function add(a: number, b: number): number {
return a + b;
}
const result = add(1, 2);
return [
`add(1, 2) => ${result}`,
'如果写成 add(1, "2")TypeScript 会在编码阶段报错。',
];
}
function runLesson02(): string[] {
const age: number = 25;
const userName: string = "李青";
const isLearning: boolean = true;
return [
`age 的类型是 number值为 ${age}`,
`userName 的类型是 string值为 ${userName}`,
`isLearning 的类型是 boolean值为 ${isLearning}`,
];
}
function runLesson03(): string[] {
const scoreList: number[] = [82, 90, 95];
function sum(a: number, b: number): number {
return a + b;
}
const total = sum(88, 12);
return [
`scoreList => [${scoreList.join(", ")}]`,
`sum(88, 12) => ${total}`,
];
}
function runLesson04(): string[] {
interface User {
id: number;
name: string;
age: number;
}
const user: User = {
id: 1,
name: "小王",
age: 22,
};
return [
`User.name => ${user.name}`,
`User.age => ${user.age}`,
"interface 让对象结构在写代码时就固定下来。",
];
}
function runLesson05(): string[] {
function getData<T>(data: T): T {
return data;
}
const text = getData("hello");
const count = getData(100);
return [
`getData("hello") => ${text}`,
`getData(100) => ${count}`,
"泛型可以在复用函数时保留输入和输出的类型关系。",
];
}
function runLesson06(): string[] {
type UserId = number | string;
interface Profile {
id: UserId;
name: string;
age?: number;
}
const profileA: Profile = {
id: 1,
name: "周周",
};
const profileB: Profile = {
id: "u-2",
name: "小林",
age: 24,
};
return [
`profileA.id => ${profileA.id}`,
`profileB.id => ${profileB.id}`,
`profileB.age => ${profileB.age}`,
];
}
function runLesson07(): string[] {
interface Course {
title: string;
lessons: number;
finished: boolean;
}
const courses: Course[] = [
{ title: "TypeScript 基础", lessons: 8, finished: true },
{ title: "接口和泛型", lessons: 6, finished: false },
];
function renderCourseLines(list: Course[]): string[] {
return list.map((course) => {
const status = course.finished ? "已完成" : "学习中";
return `${course.title} - ${course.lessons} 节 - ${status}`;
});
}
return renderCourseLines(courses);
}
function runLesson08(): string[] {
type StudentId = number | string;
interface Course {
title: string;
finished: boolean;
}
interface Student {
id: StudentId;
name: string;
age?: number;
courses: Course[];
}
function pickFirst<T>(list: T[]): T {
return list[0];
}
function formatStudent(student: Student): string {
const courseCount = student.courses.length;
return `${student.name} 当前有 ${courseCount} 门课程,编号是 ${student.id}`;
}
const student: Student = {
id: "stu-1",
name: "林晨",
courses: [
{ title: "基本类型", finished: true },
{ title: "接口", finished: false },
],
};
const firstCourse = pickFirst(student.courses);
return [
formatStudent(student),
`第一门课程:${firstCourse.title}`,
`完成状态:${firstCourse.finished ? "已完成" : "学习中"}`,
];
}
export const lessons: Lesson[] = [
{
id: "01",
title: "JavaScript vs TypeScript",
focus: "理解为什么要引入类型系统",
summary: "从一个最简单的加法函数开始,看 TypeScript 如何把错误提前到编码阶段。",
starterCode: lesson01Starter,
answerCode: lesson01Answer,
keyPoints: [
"TypeScript 在写代码阶段检查参数类型",
"运行前发现问题,比运行后排查问题更省成本",
"最常见的第一步就是给函数参数和返回值加类型",
],
runDemo: runLesson01,
},
{
id: "02",
title: "基本类型标注",
focus: "number / string / boolean",
summary: "把最常见的值类型写清楚,建立最基础的类型意识。",
starterCode: lesson02Starter,
answerCode: lesson02Answer,
keyPoints: [
"基础值先学会标注类型",
"看到报错时先分清期望类型和实际类型",
"能写对基本类型后再往对象和函数走",
],
runDemo: runLesson02,
},
{
id: "03",
title: "数组和函数类型",
focus: "number[] 与函数参数/返回值",
summary: "把类型从单个值扩展到数组和函数,是进入业务代码前必须过的一关。",
starterCode: lesson03Starter,
answerCode: lesson03Answer,
keyPoints: [
"数组里的元素类型要保持一致",
"函数参数和返回值都应该尽量明确",
"函数签名是 TypeScript 最高频的使用场景之一",
],
runDemo: runLesson03,
},
{
id: "04",
title: "interface 对象结构",
focus: "用接口约束对象字段",
summary: "当数据结构开始变复杂时,先定义 shape再写数据和逻辑。",
starterCode: lesson04Starter,
answerCode: lesson04Answer,
keyPoints: [
"对象字段越多,越应该先定义 interface",
"接口让团队协作时更容易知道一个对象该长什么样",
"字段缺失、字段类型不匹配都会更早暴露",
],
runDemo: runLesson04,
},
{
id: "05",
title: "泛型函数",
focus: "保持输入输出的类型关系",
summary: "泛型的重点不是复杂,而是让复用函数在不同类型下依然安全。",
starterCode: lesson05Starter,
answerCode: lesson05Answer,
keyPoints: [
"泛型适合处理同类输入输出关系",
"输入是什么类型,输出就跟着保持什么类型",
"先理解例子,再去记住 <T> 这个写法",
],
runDemo: runLesson05,
},
{
id: "06",
title: "联合类型和可选属性",
focus: "number | string 与 age?",
summary: "业务数据并不总是完全整齐,联合类型和可选属性就是为这种情况准备的。",
starterCode: lesson06Starter,
answerCode: lesson06Answer,
keyPoints: [
"联合类型描述多个合法输入分支",
"可选属性适合那些可能缺失的字段",
"真实业务里常常需要同时使用这两类能力",
],
runDemo: runLesson06,
},
{
id: "07",
title: "类型安全渲染",
focus: "把接口用到列表渲染里",
summary: "进入接近真实业务的场景,用类型约束一组列表数据和渲染函数。",
starterCode: lesson07Starter,
answerCode: lesson07Answer,
keyPoints: [
"先定义 Course再定义 Course[]",
"渲染函数应该接收明确的数据结构",
"数据结构一旦变化,相关代码会一起被提醒",
],
runDemo: runLesson07,
},
{
id: "08",
title: "综合小练习",
focus: "把常见类型能力组合起来",
summary: "把联合类型、接口、可选属性和泛型组合起来,做一个完整的小示例。",
starterCode: lesson08Starter,
answerCode: lesson08Answer,
keyPoints: [
"综合练习的重点是把前面知识点串起来",
"能读懂数据模型,通常就能更稳地写业务代码",
"类型不是目的,减少错误和提升可维护性才是目的",
],
runDemo: runLesson08,
},
];

151
06-typescript/src/main.ts Normal file
View File

@@ -0,0 +1,151 @@
import "./style.css";
import { lessons } from "./lessons";
function getRequiredElement<T extends Element>(
selector: string,
parent: ParentNode = document,
): T {
const element = parent.querySelector<T>(selector);
if (!element) {
throw new Error(`Required element was not found: ${selector}`);
}
return element;
}
const app = getRequiredElement<HTMLDivElement>("#app");
app.innerHTML = `
<div class="shell">
<aside class="sidebar">
<div class="brand">
<p class="eyebrow">Vite + TypeScript</p>
<h1>TypeScript Learning Lab</h1>
<p class="intro">
保留原来的 8 个练习目录,同时给这一章补上一个可直接运行的学习面板。
</p>
</div>
<nav class="lesson-nav" aria-label="TypeScript lessons"></nav>
</aside>
<main class="content">
<section class="hero card">
<p class="badge">06-typescript</p>
<h2 id="lesson-title"></h2>
<p id="lesson-focus" class="focus"></p>
<p id="lesson-summary" class="summary"></p>
</section>
<section class="grid">
<article class="card">
<div class="card-head">
<h3>知识点</h3>
</div>
<ul id="lesson-points" class="point-list"></ul>
</article>
<article class="card">
<div class="card-head">
<h3>运行结果</h3>
<button id="run-demo" class="run-button" type="button">运行演示</button>
</div>
<div id="lesson-output" class="output-panel"></div>
</article>
</section>
<section class="grid code-grid">
<article class="card code-card">
<div class="card-head">
<h3>Starter</h3>
</div>
<pre><code id="starter-code"></code></pre>
</article>
<article class="card code-card">
<div class="card-head">
<h3>Answer</h3>
</div>
<pre><code id="answer-code"></code></pre>
</article>
</section>
</main>
</div>
`;
const nav = getRequiredElement<HTMLElement>(".lesson-nav");
const title = getRequiredElement<HTMLElement>("#lesson-title");
const focus = getRequiredElement<HTMLElement>("#lesson-focus");
const summary = getRequiredElement<HTMLElement>("#lesson-summary");
const pointList = getRequiredElement<HTMLElement>("#lesson-points");
const output = getRequiredElement<HTMLElement>("#lesson-output");
const starterCode = getRequiredElement<HTMLElement>("#starter-code");
const answerCode = getRequiredElement<HTMLElement>("#answer-code");
const runButton = getRequiredElement<HTMLButtonElement>("#run-demo");
let activeLessonId = lessons[0]?.id ?? "";
function renderLessonList(): void {
nav.innerHTML = lessons
.map((lesson) => {
const isActive = lesson.id === activeLessonId;
return `
<button
class="lesson-link ${isActive ? "is-active" : ""}"
type="button"
data-id="${lesson.id}"
>
<span class="lesson-index">${lesson.id}</span>
<span class="lesson-meta">
<strong>${lesson.title}</strong>
<small>${lesson.focus}</small>
</span>
</button>
`;
})
.join("");
}
function renderOutput(lines: string[]): void {
output.innerHTML = lines
.map((line) => `<div class="output-line">${line}</div>`)
.join("");
}
function renderActiveLesson(): void {
const lesson = lessons.find((item) => item.id === activeLessonId);
if (!lesson) {
return;
}
title.textContent = `${lesson.id}. ${lesson.title}`;
focus.textContent = lesson.focus;
summary.textContent = lesson.summary;
pointList.innerHTML = lesson.keyPoints
.map((item) => `<li>${item}</li>`)
.join("");
starterCode.textContent = lesson.starterCode;
answerCode.textContent = lesson.answerCode;
renderOutput(lesson.runDemo());
renderLessonList();
}
nav.addEventListener("click", (event) => {
const target = event.target as HTMLElement;
const button = target.closest<HTMLButtonElement>("[data-id]");
if (!button) {
return;
}
activeLessonId = button.dataset.id ?? activeLessonId;
renderActiveLesson();
});
runButton.addEventListener("click", () => {
const lesson = lessons.find((item) => item.id === activeLessonId);
if (!lesson) {
return;
}
renderOutput(lesson.runDemo());
});
renderActiveLesson();

237
06-typescript/src/style.css Normal file
View File

@@ -0,0 +1,237 @@
:root {
font-family: "IBM Plex Sans", "PingFang SC", "Hiragino Sans GB", sans-serif;
line-height: 1.5;
font-weight: 400;
color: #f7f4ec;
background:
radial-gradient(circle at top left, rgba(255, 191, 73, 0.22), transparent 24rem),
radial-gradient(circle at bottom right, rgba(86, 154, 255, 0.16), transparent 26rem),
#11131a;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
button,
code,
pre {
font: inherit;
}
#app {
min-height: 100vh;
}
.shell {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
min-height: 100vh;
}
.sidebar {
padding: 28px 20px;
border-right: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(9, 10, 14, 0.62);
backdrop-filter: blur(18px);
}
.brand {
margin-bottom: 24px;
}
.eyebrow,
.badge {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0 0 12px;
padding: 6px 10px;
border-radius: 999px;
background: rgba(255, 191, 73, 0.14);
color: #ffcc6a;
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.brand h1 {
margin: 0 0 10px;
font-size: 28px;
line-height: 1.1;
}
.intro,
.summary {
margin: 0;
color: rgba(247, 244, 236, 0.72);
}
.lesson-nav {
display: grid;
gap: 10px;
}
.lesson-link {
display: flex;
align-items: flex-start;
gap: 12px;
width: 100%;
padding: 14px 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 18px;
background: rgba(255, 255, 255, 0.03);
color: inherit;
text-align: left;
cursor: pointer;
transition:
transform 180ms ease,
border-color 180ms ease,
background 180ms ease;
}
.lesson-link:hover,
.lesson-link.is-active {
transform: translateY(-1px);
border-color: rgba(255, 191, 73, 0.42);
background: rgba(255, 191, 73, 0.1);
}
.lesson-index {
min-width: 40px;
padding: 8px 0;
border-radius: 12px;
background: rgba(255, 255, 255, 0.06);
text-align: center;
font-weight: 700;
}
.lesson-meta {
display: grid;
gap: 4px;
}
.lesson-meta small {
color: rgba(247, 244, 236, 0.64);
}
.content {
padding: 28px;
}
.hero {
margin-bottom: 20px;
}
.focus {
margin: 0 0 10px;
color: #ffcc6a;
font-weight: 600;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.code-grid {
align-items: start;
}
.card {
padding: 20px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 28px;
background: rgba(15, 17, 24, 0.72);
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.22);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.card-head h3 {
margin: 0;
font-size: 18px;
}
.point-list {
margin: 0;
padding-left: 18px;
color: rgba(247, 244, 236, 0.84);
}
.point-list li + li {
margin-top: 10px;
}
.output-panel {
display: grid;
gap: 10px;
}
.output-line {
padding: 12px 14px;
border-radius: 16px;
background: rgba(86, 154, 255, 0.12);
color: #d8e9ff;
}
.run-button {
padding: 10px 14px;
border: 0;
border-radius: 999px;
background: linear-gradient(135deg, #ffbf49, #ff8f48);
color: #1d1302;
font-weight: 700;
cursor: pointer;
}
.code-card pre {
overflow: auto;
margin: 0;
padding: 18px;
border-radius: 20px;
background: #0b0d12;
}
.code-card code {
font-family: "JetBrains Mono", "SFMono-Regular", monospace;
font-size: 13px;
white-space: pre;
}
@media (max-width: 960px) {
.shell {
grid-template-columns: 1fr;
}
.sidebar {
border-right: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.grid {
grid-template-columns: 1fr;
}
.content {
padding: 20px;
}
}

1
06-typescript/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />