iT邦幫忙

2025 iThome 鐵人賽

DAY 6
0
Modern Web

Angular、React、Vue 三框架實戰養成:從零打造高品質前端履歷網站系列 第 6

Day 6 小專案:純原生 HTML/CSS/TS 的自我介紹頁(可交付 MVP) 今日目標

  • 分享至 

  • xImage
  •  

把 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;
}

  • { box-sizing: border-box; }
    html, body { height: 100%; }
    body {
    margin: 0;
    background: var(--bg);
    color: var(--fg);
    font-family: system-ui, -apple-system, "Noto Sans TC", Arial, sans-serif;
    line-height: 1.7;
    }

/* 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 類服務)


上一篇
Day 5 RWD 響應式網頁設計 – 讓自我介紹頁在各裝置都好看
系列文
Angular、React、Vue 三框架實戰養成:從零打造高品質前端履歷網站6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言