项目代码预览
Reading List
下面是本文引用的完整示例项目。源码在构建时嵌入页面,目录可以折叠,选择文件后可直接查看对应内容。
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>待读清单</title>
</head>
<body>
<main>
<header>
<p>READING QUEUE</p>
<h1>待读清单</h1>
</header>
<form id="reading-form">
<label>
文章标题
<input name="title" autocomplete="off" required />
</label>
<label>
链接
<input name="url" type="url" required />
</label>
<button type="submit">加入清单</button>
</form>
<ul id="reading-list" aria-live="polite"></ul>
</main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
{
"name": "reading-list",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build"
},
"devDependencies": {
"typescript": "^6.0.3",
"vite": "^8.0.0"
}
}
# Reading List
A small browser-only reading list used to demonstrate QinBlog's embedded
project explorer.
## Commands
```bash
pnpm install
pnpm dev
```
Items are stored in `localStorage`, so the example needs no backend.
import { createReadingItem, type ReadingItem } from "./model/reading-item";
import { loadItems, saveItems } from "./store";
import "./styles.css";
const form = document.querySelector<HTMLFormElement>("#reading-form")!;
const list = document.querySelector<HTMLUListElement>("#reading-list")!;
let items = loadItems();
function render(): void {
list.replaceChildren(
...items.map((item) => {
const row = document.createElement("li");
const link = document.createElement("a");
const toggle = document.createElement("button");
link.href = item.url;
link.textContent = item.title;
link.target = "_blank";
link.rel = "noreferrer";
toggle.type = "button";
toggle.textContent = item.completed ? "已读" : "待读";
toggle.setAttribute("aria-pressed", String(item.completed));
toggle.addEventListener("click", () => updateItem(item.id));
row.classList.toggle("completed", item.completed);
row.append(link, toggle);
return row;
}),
);
}
function updateItem(id: ReadingItem["id"]): void {
items = items.map((item) =>
item.id === id ? { ...item, completed: !item.completed } : item,
);
saveItems(items);
render();
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const data = new FormData(form);
const title = String(data.get("title") ?? "").trim();
const url = String(data.get("url") ?? "").trim();
if (!title || !url) return;
items = [createReadingItem(title, url), ...items];
saveItems(items);
form.reset();
render();
});
render();
export interface ReadingItem {
id: string;
title: string;
url: string;
completed: boolean;
}
export function createReadingItem(title: string, url: string): ReadingItem {
return {
id: crypto.randomUUID(),
title,
url,
completed: false,
};
}
import type { ReadingItem } from "./model/reading-item";
const STORAGE_KEY = "reading-list";
export function loadItems(): ReadingItem[] {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return [];
try {
return JSON.parse(stored) as ReadingItem[];
} catch {
return [];
}
}
export function saveItems(items: ReadingItem[]): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
}
:root {
color: #24303b;
font-family: Georgia, "Noto Serif SC", serif;
background: #f2f4f1;
}
body {
margin: 0;
}
main {
width: min(42rem, calc(100% - 2rem));
margin: 4rem auto;
}
header p {
margin: 0;
color: #13766f;
font: 700 0.72rem/1.4 sans-serif;
}
h1 {
margin: 0.25rem 0 2rem;
font-size: 2.5rem;
}
form,
li {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 0.75rem;
align-items: end;
}
label {
display: grid;
gap: 0.35rem;
font-size: 0.8rem;
}
input,
button {
min-height: 2.5rem;
border: 1px solid #b8c0bc;
border-radius: 0.25rem;
}
input {
padding: 0 0.65rem;
}
button {
padding: 0 1rem;
color: white;
background: #13766f;
cursor: pointer;
}
ul {
display: grid;
gap: 0.5rem;
margin: 2rem 0;
padding: 0;
}
li {
grid-template-columns: 1fr auto;
padding: 0.75rem;
background: white;
border-left: 0.2rem solid #d89b3c;
}
li.completed a {
color: #7a837e;
text-decoration: line-through;
}
{
"compilerOptions": {
"lib": ["DOM", "ES2023"],
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"strict": true,
"target": "ES2023"
},
"include": ["src"]
}