把 Day1–Day4 的內容整合,完成一個 無框架的一頁式自我介紹網站,包含:
語義化結構(header / nav / main / section / article / aside / footer)
RWD 版面與 Design Tokens(色彩、字體、間距)
主題切換(亮/暗,含記憶偏好)
導覽錨點與平滑捲動
技能分類篩選
照片切換(正式照/生活照)
簡單表單驗證(姓名、Email、訊息)
完成後,你就擁有一個「無框架也能上線」的作品。明天起把同一份 IA 搬進 Angular。
專案結構與檔案
day5-portfolio/
├─ index.html
├─ styles/
│ └─ style.css # 可先用 CSS;之後你要換 SCSS 也行
└─ scripts/
└─ main.js # 以 TS 思維撰寫(若要 TS 編譯可命名 main.ts)
提醒:如果你偏好 TypeScript,請以 main.ts 撰寫後用 tsc 編譯成 main.js,並在 HTML 載入 main.js 即可(寫法與 Day 3/Day 4 一致)。
完整實作
index.html
語義化骨架 + 錨點導覽 + About / Skills / Projects / Contact 區塊一次就位。
<nav class="site-nav" aria-label="主選單">
<ul>
<li><a href="#about">關於我</a></li>
<li><a href="#skills">技能</a></li>
<li><a href="#projects">作品</a></li>
<li><a href="#contact">聯絡</a></li>
</ul>
</nav>
<button id="theme-toggle" type="button" aria-pressed="false">切換主題</button>
</div>
<!-- About -->
<section id="about" class="container section" aria-labelledby="about-title">
<h2 id="about-title">關於我</h2>
<p>
我是一名前端工程師,喜歡理解使用者需求並把它落地成產品。近期專注於
Angular、TypeScript、前端架構與效能最佳化。
</p>
<blockquote class="quote">「持續學習,讓自己比昨天更強。」</blockquote>
<p id="more-info" hidden>
曾參與金融科技與電商專案,也投入設計系統與可存取性。閒暇時間喜歡健身、魔術與寫作分享。
</p>
<button id="toggle-more" class="btn small" type="button" aria-expanded="false" aria-controls="more-info">
更多介紹
</button>
</section>
<!-- Skills -->
<section id="skills" class="container section" aria-labelledby="skills-title">
<div class="section-header">
<h2 id="skills-title">技能 Skillset</h2>
<div id="skill-filters" role="tablist" aria-label="技能分類">
<button role="tab" data-filter="all" aria-selected="true" class="chip">全部</button>
<button role="tab" data-filter="frontend" class="chip">前端</button>
<button role="tab" data-filter="backend" class="chip">後端</button>
<button role="tab" data-filter="tools" class="chip">工具</button>
</div>
</div>
<ul id="skill-list" class="skill-grid">
<li data-category="frontend">HTML / CSS / SCSS</li>
<li data-category="frontend">TypeScript</li>
<li data-category="frontend">Angular / React / Vue</li>
<li data-category="backend">Node.js / Express</li>
<li data-category="tools">Git / GitHub / Docker</li>
<li data-category="tools">Vite / Webpack</li>
</ul>
</section>
<!-- Projects -->
<section id="projects" class="container section" aria-labelledby="projects-title">
<h2 id="projects-title">作品集 Projects</h2>
<div class="project-grid">
<article class="card">
<h3>毛毛購物(寵物電商)</h3>
<p class="muted">Angular + Node.js|購物車、結帳、RWD</p>
<p>主導前端架構,完成商品列表、購物流程與訂單頁。</p>
<a class="btn small" href="#" aria-label="查看毛毛購物專案">Live Demo</a>
</article>
<article class="card">
<h3>LINE Bot 預約系統</h3>
<p class="muted">Cloud Functions + LINE API|時段預約</p>
<p>整合 LINE 聊天介面與雲端排程,完成會員預約流程。</p>
<a class="btn small" href="#" aria-label="查看 LINE Bot 預約系統">Live Demo</a>
</article>
</div>
</section>
<!-- Contact -->
<section id="contact" class="container section" aria-labelledby="contact-title">
<h2 id="contact-title">聯絡我</h2>
<form id="contact-form" novalidate>
<div class="field">
<label for="name">姓名</label>
<input id="name" name="name" type="text" placeholder="王小明" required />
<small class="error" data-for="name" hidden>請輸入 2–20 個字的姓名</small>
</div>
<div class="field">
<label for="email">Email</label>
<input id="email" name="email" type="email" placeholder="name@example.com" required />
<small class="error" data-for="email" hidden>請輸入正確的 Email</small>
</div>
<div class="field">
<label for="message">訊息</label>
<textarea id="message" name="message" rows="4" placeholder="想合作的內容…" required></textarea>
<small class="error" data-for="message" hidden>訊息至少 10 個字</small>
</div>
<div class="actions">
<button class="btn" type="submit">送出</button>
<button class="btn btn-outline" type="reset">清除</button>
</div>
</form>
<aside class="contact-aside">
<h3>其他聯絡方式</h3>
<address>
Email:<a href="mailto:hotdanton08@hotmail.com">hotdanton08@hotmail.com</a><br />
GitHub:<a href="https://github.com/你的帳號">github.com/你的帳號</a><br />
LinkedIn:<a href="https://linkedin.com/in/你的帳號">linkedin.com/in/你的帳號</a>
</address>
</aside>
</section>
styles/style.css
第一版以 CSS 即可(你也能把它翻成 SCSS,抽成變數與 mixin)。包含 Design Tokens、排版、RWD、主題切換掛鉤。
/* Design Tokens */
:root {
--bg: #ffffff;
--fg: #1f2937;
--muted: #6b7280;
--primary: #2563eb;
--border: #e5e7eb;
--container: 1100px;
--radius: 12px;
--space: 16px;
}
html[data-theme="dark"] {
--bg: #0f172a;
--fg: #e5e7eb;
--muted: #9ca3af;
--primary: #60a5fa;
--border: #1f2937;
}
/* Helpers */
.container { max-width: var(--container); margin: 0 auto; padding: 0 20px; }
.section { padding: 64px 0 48px; }
.muted { color: var(--muted); }
.btn {
display: inline-block; border: 1px solid var(--primary); color: #fff;
background: var(--primary); padding: 10px 16px; border-radius: 8px; text-decoration: none;
}
.btn:hover { opacity: .9; }
.btn-outline { background: transparent; color: var(--primary); }
.btn.small { padding: 6px 10px; font-size: 14px; }
/* Header */
.site-header { position: sticky; top: 0; background: var(--bg); border-bottom: 1px solid var(--border); z-index: 10; }
.site-header .container { display: flex; align-items: center; gap: 16px; padding: 12px 20px; }
.brand { font-weight: 700; color: var(--fg); text-decoration: none; }
.site-nav ul { list-style: none; margin: 0; padding: 0; display: flex; gap: 16px; }
.site-nav a { color: var(--fg); text-decoration: none; }
.site-nav a:hover { color: var(--primary); }
#theme-toggle { margin-left: auto; }
/* Hero */
.hero { display: grid; grid-template-columns: 1.2fr .8fr; align-items: center; gap: 24px; padding: 48px 0; }
.hero-cta { display: flex; gap: 12px; margin-top: 12px; }
.hero-photo { text-align: center; }
.hero-photo img { border-radius: 50%; border: 4px solid var(--border); }
/* Sections */
.section-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.quote { margin: 8px 0 0; padding-left: 12px; border-left: 4px solid var(--primary); color: var(--muted); }
/* Chips / Filters */
#skill-filters { display: flex; gap: 8px; flex-wrap: wrap; }
.chip {
border: 1px solid var(--border); background: transparent; color: var(--fg);
padding: 6px 10px; border-radius: 999px; cursor: pointer;
}
.chip[aria-selected="true"] { border-color: var(--primary); color: var(--primary); }
/* Grids */
.skill-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; padding: 0; list-style: none; }
.skill-grid li {
border: 1px solid var(--border); padding: 10px 12px; border-radius: var(--radius);
}
.project-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
.card { border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; background: rgba(255,255,255,0.02); }
/* Contact */
.field { margin-bottom: 12px; }
label { display: block; margin-bottom: 6px; }
input, textarea {
width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; background: transparent; color: var(--fg);
}
.error { color: #ef4444; display: block; margin-top: 6px; }
/* Aside */
.contact-aside { margin-top: 24px; border-top: 1px solid var(--border); padding-top: 16px; }
/* Footer */
.site-footer { margin-top: 48px; border-top: 1px solid var(--border); }
.site-footer .container { padding: 16px 20px; text-align: center; color: var(--muted); }
/* RWD */
@media (max-width: 900px) {
.hero { grid-template-columns: 1fr; text-align: center; }
.skill-grid { grid-template-columns: repeat(2, 1fr); }
.project-grid { grid-template-columns: 1fr; }
}
@media (max-width: 520px) {
.skill-grid { grid-template-columns: 1fr; }
}
scripts/main.js
以 Day 3、Day 4 的作法組合:主題切換、錨點平滑捲動、照片切換、更多介紹、技能篩選、表單驗證。
// 主題切換
const htmlEl = document.documentElement;
const themeBtn = document.querySelector('#theme-toggle');
function applyTheme(theme) {
htmlEl.setAttribute('data-theme', theme);
themeBtn?.setAttribute('aria-pressed', String(theme === 'dark'));
if (themeBtn) themeBtn.textContent = theme === 'dark' ? '切換為亮色' : '切換為暗色';
localStorage.setItem('theme', theme);
}
applyTheme(localStorage.getItem('theme') || 'light');
themeBtn?.addEventListener('click', () => {
const next = htmlEl.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
applyTheme(next);
});
// 導覽錨點:平滑捲動
document.querySelectorAll('a[href^="#"]').forEach(a => {
a.addEventListener('click', (e) => {
const id = a.getAttribute('href');
if (!id || id === '#') return;
const target = document.querySelector(id);
if (target) {
e.preventDefault();
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
});
// 照片切換(正式/生活)
const avatar = document.querySelector('#avatar');
document.querySelector('#photo-toggle')?.addEventListener('click', () => {
if (!avatar) return;
const current = avatar.getAttribute('src') || '';
const altSrc = avatar.dataset.altSrc || '';
if (!altSrc) return;
avatar.setAttribute('src', altSrc);
avatar.dataset.altSrc = current;
const isFormal = /formal/.test(altSrc);
avatar.alt = isFormal ? 'Chiayu 的正式照片' : 'Chiayu 的生活照片';
});
// 更多介紹展開/收起
const more = document.querySelector('#more-info');
const moreBtn = document.querySelector('#toggle-more');
moreBtn?.addEventListener('click', () => {
if (!more) return;
const isHidden = more.hasAttribute('hidden');
if (isHidden) {
more.removeAttribute('hidden');
moreBtn.setAttribute('aria-expanded', 'true');
moreBtn.textContent = '收起介紹';
} else {
more.setAttribute('hidden', '');
moreBtn.setAttribute('aria-expanded', 'false');
moreBtn.textContent = '更多介紹';
}
});
// 技能分類篩選(事件委派)
const filters = document.querySelector('#skill-filters');
const skillList = document.querySelector('#skill-list');
function applySkillFilter(cat) {
if (!skillList) return;
skillList.querySelectorAll('li').forEach(li => {
const c = li.dataset.category || 'frontend';
li.style.display = (cat === 'all' || c === cat) ? '' : 'none';
});
}
filters?.addEventListener('click', (e) => {
const target = e.target;
if (target.matches('button[data-filter]')) {
filters.querySelectorAll('[aria-selected="true"]').forEach(el => el.setAttribute('aria-selected', 'false'));
target.setAttribute('aria-selected', 'true');
applySkillFilter(target.getAttribute('data-filter'));
}
});
applySkillFilter('all');
// 表單驗證(姓名 2–20、email 格式、訊息 ≥ 10)
const form = document.querySelector('#contact-form');
function setError(name, show) {
const err = form?.querySelector(.error[data-for="${name}"]
);
if (!err) return;
err.hidden = !show;
}
form?.addEventListener('submit', (e) => {
e.preventDefault();
const name = form.querySelector('#name');
const email = form.querySelector('#email');
const message = form.querySelector('#message');
let ok = true;
if (!name.value || name.value.trim().length < 2 || name.value.trim().length > 20) {
setError('name', true); ok = false;
} else setError('name', false);
const emailRe = /^[^\s@]+@[^\s@]+.[^\s@]+$/;
if (!emailRe.test(email.value)) { setError('email', true); ok = false; } else setError('email', false);
if (!message.value || message.value.trim().length < 10) { setError('message', true); ok = false; } else setError('message', false);
if (ok) {
alert('已送出!感謝你的來信。');
form.reset();
}
});
成果檢查清單(你可在文末附圖)
桌機、手機版截圖各一:Hero、Skills、Projects、Contact 區塊
主題切換前後的對比圖
技能分類篩選示意(全部/前端/後端/工具)
照片切換示意
表單錯誤訊息提示示意
小心踩雷(常見誤用 → 正確作法)
用 和 撐版面
錯誤: 多放 讓元素彼此拉開。
正確: 用 CSS 的 margin/padding 控距離,或用網格/彈性排版。
把 當按鈕(沒有 href)
錯誤: 送出。
正確: 導頁用 ;觸發動作用 。
直接寫 innerHTML = userInput
錯誤: 可能造成 XSS。
正確: 使用 textContent ;確實需要插入 HTML 再做消毒/模板化。
忘記處理 null、濫用非空斷言 !
錯誤:
document.querySelector('#x').addEventListener('click', fn);
正確: 先判斷元素存在再綁事件;或在載入尾端 defer。
把樣式全部用行內 style 改
錯誤: JS 裡大量 el.style.xxx = ...。
正確: 以 classList 切換狀態,把視覺交給 CSS。
進一步練習(可選)
在技能區加「關鍵字即時搜尋」輸入框
導覽列在捲動時自動高亮當前區段(IntersectionObserver)
表單送出以 fetch 打到 mock API(或用 Formspree 類服務)