商用網站架設實務

LESSON-25 · solution

頁面開發:服務、關於與聯絡頁

這一頁不是直接複製答案;先逐檔看差異、修改原因與驗收證據,再把需要的改動帶回自己的資料夾。 這頁用 可觀察證據anchor 指出下一個動作。

Starter3 個小成果
Checkpoint一個核心缺口
Solution4 個差異

開始前確認

這一頁的結果:Solution 的服務頁正好三張由 services 陣列產生的卡片;關於頁只有一組背景/工作方式/信任內容,聯絡頁只有一個含三欄的 form;有效送出不重新載入、空白與錯誤 Email 有證據,且 390px/Tab 可操作

工作方式:先把這一階段複製到自己的工作資料夾,開啟 files/index.html,記下修改前的畫面、DOM、Console 或 Network狀態。

會用到的檔案:index.htmlstyle.cssmain.jsservices.htmlabout.htmlcontact.html

本堂焦點:用原生 HTMLCSSJavaScript 完成服務、關於與聯絡三頁

照這個順序做

一次只做一張卡。完成後先重新整理入口,再留下證據,才進入下一張。

差異 1

讓服務頁只負責回答『提供什麼』以及『下一步怎麼做』。

目的

讓服務頁只負責回答『提供什麼』以及『下一步怎麼做』。

修改檔案

services.html

程式碼位置

services practice area

現在要做:先對照差異,再檢查 在 services.html 的「services practice area」對照這項變更:<section class="content-section"><h2>服務方案</h2><div class="service-grid"><article class="service-card"><h3>到府清潔</h3><p>依空間狀況安排清潔流程。</p><strong>NT$1,200 起</strong></article><article class="service-card"><h3>定期維護</h3><p>建立固定且容易追蹤的保養節奏。</p><strong>NT$2,400 起</strong></article><article class="service-card"><h3>安心諮詢</h3><p>先釐清需求,再決定適合的服務。</p><strong>免費諮詢</strong></article></div></section><a href="contact.html">提出諮詢需求</a>

貼上位置:services.html 的 services practice area 區塊

<section class="content-section"><h2>服務方案</h2><div class="service-grid"><article class="service-card"><h3>到府清潔</h3><p>依空間狀況安排清潔流程。</p><strong>NT$1,200 起</strong></article><article class="service-card"><h3>定期維護</h3><p>建立固定且容易追蹤的保養節奏。</p><strong>NT$2,400 起</strong></article><article class="service-card"><h3>安心諮詢</h3><p>先釐清需求,再決定適合的服務。</p><strong>免費諮詢</strong></article></div></section><a href="contact.html">提出諮詢需求</a>

預期結果:服務頁只出現三張卡片,三筆內容都有 title、summary 與 price,且 CTA 可到聯絡頁。

驗收證據:Elements.service-grid 子項目數、三筆文字與 contact.html href。

卡住時先看這裡

常見錯誤:只複製完整答案,卻沒有先理解這一項差異的責任。

第一個檢查:只對照目前差異指定的檔案與 anchor。

差異 2

讓關於頁補足訪客決策需要的背景,不重複服務頁的卡片。

目的

讓關於頁補足訪客決策需要的背景,不重複服務頁的卡片。

修改檔案

about.html

程式碼位置

about practice area

現在要做:先對照差異,再檢查 在 about.html 的「about practice area」對照這項變更:<section class="content-section" aria-labelledby="background-title"><h2 id="background-title">我們是誰</h2><p>安心維護從理解現場開始。</p></section><section class="content-section" aria-labelledby="process-title"><h2 id="process-title">我們如何工作</h2><p>先聽需求,再確認範圍與預算。</p></section><section class="content-section" aria-labelledby="trust-title"><h2 id="trust-title">你可以如何確認</h2><p>每一項服務都會先說明內容與下一步。</p></section>

貼上位置:about.html 的 about practice area 區塊

<section class="content-section" aria-labelledby="background-title"><h2 id="background-title">我們是誰</h2><p>安心維護從理解現場開始。</p></section><section class="content-section" aria-labelledby="process-title"><h2 id="process-title">我們如何工作</h2><p>先聽需求,再確認範圍與預算。</p></section><section class="content-section" aria-labelledby="trust-title"><h2 id="trust-title">你可以如何確認</h2><p>每一項服務都會先說明內容與下一步。</p></section>

預期結果:about.html 只有一組主要內容區塊,內含三個有清楚標題的 section。

驗收證據:Elements 的主要內容區塊數、三個 h2 與頁面文字。

卡住時先看這裡

常見錯誤:只複製完整答案,卻沒有先理解這一項差異的責任。

第一個檢查:只對照目前差異指定的檔案與 anchor。

差異 3

讓聯絡頁可以收集固定三欄,並在本機回饋送出結果。

目的

讓聯絡頁可以收集固定三欄,並在本機回饋送出結果。

修改檔案

contact.html

程式碼位置

contact practice area

現在要做:先對照差異,再檢查 在 contact.html 的「contact practice area」對照這項變更:<div class="form-field"><label for="message">需求</label><textarea id="message" name="message" required></textarea></div><button type="submit">送出諮詢</button><p id="status" role="status" aria-live="polite">尚未送出</p>

貼上位置:contact.html 的 contact practice area 區塊

<div class="form-field"><label for="message">需求</label><textarea id="message" name="message" required></textarea></div><button type="submit">送出諮詢</button><p id="status" role="status" aria-live="polite">尚未送出</p>

預期結果:姓名、Email、需求三個欄位都有 label、唯一 id、name 與 required;送出後可看到狀態。

驗收證據:Elementslabel/forid/name、button type、#status 與畫面狀態。

卡住時先看這裡

常見錯誤:只複製完整答案,卻沒有先理解這一項差異的責任。

第一個檢查:只對照目前差異指定的檔案與 anchor。

差異 4

理解 id 是頁面定位,name 才會成為 FormData 的欄位名稱。

目的

理解 id 是頁面定位,name 才會成為 FormData 的欄位名稱。

修改檔案

contact.html

程式碼位置

message textarea

現在要做:先對照差異,再檢查 在 contact.html 的「message textarea」對照這項變更:<textarea id="message" name="message" required></textarea>

貼上位置:contact.html 的 message textarea 區塊

<textarea id="message" name="message" required></textarea>

預期結果:修正前 payload 缺少 message;修正後 FormData 包含 name、email、message。

驗收證據:Elements 的 textarea attribute 與 Console payload 三個 key。

卡住時先看這裡

常見錯誤:只複製完整答案,卻沒有先理解這一項差異的責任。

第一個檢查:只對照目前差異指定的檔案與 anchor。

階段檔案

完整檔案收在可展開的卡片中;先完成上方微步驟,再用這裡查閱與複製。檔案位置要和目前的相對路徑一致。

開啟 solution 的可執行入口

index.html多頁網站首頁:提供四頁導覽與本階段的操作提示。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

<!doctype html>
<html lang="zh-Hant">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="description" content="安心維護的商用網站範例首頁。">
  <title>安心維護|首頁</title>
  <link rel="icon" href="../../../assets/favicon.svg" type="image/svg+xml">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <header class="site-header">
    <a class="brand" href="index.html">安心維護</a>
    <nav aria-label="主要導覽">
      <a href="index.html" aria-current="page">首頁</a>
      <a href="services.html">服務</a>
      <a href="about.html">關於</a>
      <a href="contact.html">聯絡</a>
    </nav>
  </header>

  <main class="page-shell">
    <p class="eyebrow">LESSON-25 · 多頁商用網站</p>
    <h1>讓日常維護,變成安心的日常。</h1>
    <p class="page-description">先了解服務內容,再認識我們的做法,最後用表單提出需求。</p>

    <section class="content-section" aria-labelledby="home-services-title">
      <h2 id="home-services-title">從這裡開始</h2>
      <div class="link-grid">
        <a class="link-card" href="services.html"><strong>查看服務</strong><span>了解三項服務與適合的情境。</span></a>
        <a class="link-card" href="about.html"><strong>認識我們</strong><span>看看我們如何理解問題並提出方案。</span></a>
        <a class="link-card" href="contact.html"><strong>提出需求</strong><span>填寫姓名、Email 與需求內容。</span></a>
      </div>
    </section>

    <aside class="lesson-note">
      <strong>本頁的學習證據:</strong>四個頁面可以透過導覽互相到達,且目前頁面在導覽列使用 <code>aria-current="page"</code> 標示。
    </aside>
  </main>

  <footer class="site-footer">
    <p>LESSON-25 · 先用 HTML 分配頁面責任,再用 JavaScript 處理資料與表單。</p>
  </footer>
  <script src="main.js" defer></script>
</body>
</html>
style.css共用呈現層:提供服務卡、表單、focus 與 390px 窄版規則。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

:root {
  font-family: system-ui, -apple-system, "Segoe UI", "Noto Sans TC", sans-serif;
  color: #17324d;
  background: #f5f8fb;
}

* {
  box-sizing: border-box;
}

body {
  min-width: 320px;
  min-height: 100vh;
  margin: 0;
  line-height: 1.7;
}

a {
  color: #155e75;
}

a:focus-visible,
button:focus-visible,
input:focus-visible,
textarea:focus-visible {
  outline: 3px solid #f1ab2c;
  outline-offset: 3px;
}

.site-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  width: min(100% - 2rem, 920px);
  margin: 1rem auto 0;
  padding: .75rem 0;
}

.brand {
  color: #17324d;
  font-weight: 800;
  text-decoration: none;
  white-space: nowrap;
}

.site-header nav {
  display: flex;
  flex-wrap: wrap;
  gap: .7rem 1rem;
}

.site-header nav a[aria-current="page"] {
  font-weight: 800;
  text-decoration-thickness: 3px;
  text-underline-offset: .25rem;
}

.page-shell {
  width: min(100% - 2rem, 760px);
  margin: 2rem auto;
  padding: clamp(1.25rem, 4vw, 2rem);
  border: 1px solid #cbd8e4;
  border-radius: 20px;
  background: #fff;
}

.eyebrow {
  margin: 0 0 .5rem;
  color: #176b87;
  font-weight: 800;
  letter-spacing: .06em;
}

h1,
h2 {
  line-height: 1.25;
}

h1 {
  margin: 0;
}

.page-description {
  margin: .75rem 0 0;
  color: #52657d;
}

.content-section {
  margin-top: 2rem;
}

.content-section h2 {
  margin: 0 0 .7rem;
}

.content-section p {
  margin: 0;
}

.link-grid,
.service-grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 1rem;
}

.link-card,
.service-card {
  display: flex;
  flex-direction: column;
  gap: .45rem;
  min-width: 0;
  padding: 1rem;
  border: 1px solid #d8e0ea;
  border-radius: 14px;
  background: #fbfdff;
  text-decoration: none;
}

.link-card span,
.service-card p {
  color: #52657d;
}

.service-card strong {
  color: #17643d;
}

.button-link,
button {
  display: inline-block;
  margin-top: 1.25rem;
  padding: .65rem .9rem;
  border: 0;
  border-radius: 9px;
  background: #176b87;
  color: #fff;
  font: inherit;
  font-weight: 700;
  text-decoration: none;
  cursor: pointer;
}

.button-link:hover,
button:hover {
  background: #0d4053;
}

.contact-form {
  display: grid;
  gap: 1rem;
  margin-top: 1.5rem;
}

.form-field {
  display: grid;
  gap: .35rem;
}

label {
  font-weight: 700;
}

input,
textarea {
  width: 100%;
  padding: .65rem .75rem;
  border: 1px solid #aebdca;
  border-radius: 8px;
  background: #fff;
  color: inherit;
  font: inherit;
}

textarea {
  resize: vertical;
}

.contact-form button {
  justify-self: start;
  margin-top: 0;
}

.status {
  min-height: 2rem;
  margin: 0;
  color: #52657d;
}

.status[data-state="success"] {
  color: #17643d;
}

.status[data-state="error"] {
  color: #a13d2d;
}

.lesson-note {
  margin-top: 1.5rem;
  padding: 1rem;
  border: 1px solid #c8e4eb;
  border-radius: 14px;
  background: #f2fafb;
}

.site-footer {
  padding: 2rem 1rem;
  border-top: 1px solid #d8e0ea;
  color: #52657d;
  text-align: center;
}

.site-footer p {
  max-width: 760px;
  margin: 0 auto;
}

@media (max-width: 680px) {
  .site-header {
    align-items: flex-start;
    flex-direction: column;
  }

  .link-grid,
  .service-grid {
    grid-template-columns: 1fr;
  }
}

@media (max-width: 420px) {
  .site-header,
  .page-shell {
    width: min(100% - 1.25rem, 760px);
  }

  .site-header nav {
    gap: .5rem .75rem;
  }
}
main.js原生 JavaScript 行為:服務陣列、DOM 渲染、FormData 與離線表單狀態。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

const services = [
  {
    title: "到府清潔",
    summary: "依空間狀況安排清潔流程。",
    price: "NT$1,200 起",
  },
  {
    title: "定期維護",
    summary: "建立固定且容易追蹤的保養節奏。",
    price: "NT$2,400 起",
  },
  {
    title: "安心諮詢",
    summary: "先釐清需求,再決定適合的服務。",
    price: "免費諮詢",
  },
];

const serviceList = document.querySelector("#service-list");
const contactForm = document.querySelector("#contact-form");
const status = document.querySelector("#status");

function setStatus(message, state = "idle") {
  if (!status) return;
  status.textContent = message;
  status.dataset.state = state;
}

function renderServices() {
  if (!serviceList) return;

  serviceList.innerHTML = services
    .map(
      (service) => `
        <article class="service-card">
          <h3>${service.title}</h3>
          <p>${service.summary}</p>
          <strong>${service.price}</strong>
        </article>
      `,
    )
    .join("");
}

function handleInvalidField() {
  setStatus("請先修正必填欄位或 Email 格式,再送出表單。", "error");
}

function handleContactSubmit(event) {
  event.preventDefault();

  const formData = new FormData(contactForm);
  const payload = Object.fromEntries(formData.entries());

  console.log("LESSON-25 payload", payload);
  setStatus("已完成前端測試送出;目前沒有連線到正式服務。", "success");
}

renderServices();

if (contactForm) {
  contactForm.addEventListener("invalid", handleInvalidField, true);
  contactForm.addEventListener("submit", handleContactSubmit);
}
services.html服務頁:固定三項服務與前往聯絡頁的 CTA。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

<!doctype html>
<html lang="zh-Hant">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="description" content="安心維護提供到府清潔、定期維護與安心諮詢服務。">
  <title>安心維護|服務方案</title>
  <link rel="icon" href="../../../assets/favicon.svg" type="image/svg+xml">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <header class="site-header">
    <a class="brand" href="index.html">安心維護</a>
    <nav aria-label="主要導覽">
      <a href="index.html">首頁</a>
      <a href="services.html" aria-current="page">服務</a>
      <a href="about.html">關於</a>
      <a href="contact.html">聯絡</a>
    </nav>
  </header>

  <main class="page-shell">
    <p class="eyebrow">LESSON-25 · services.html</p>
    <h1>服務方案</h1>
    <p class="page-description">同一份服務資料由 JavaScript 產生三張卡片。修改資料後,畫面會跟著更新。</p>

    <section class="content-section" aria-labelledby="services-title">
      <h2 id="services-title">我們可以怎麼協助你?</h2>
      <div id="service-list" class="service-grid" aria-live="polite"></div>
    </section>

    <a class="button-link" href="contact.html">提出諮詢需求</a>
  </main>

  <footer class="site-footer">
    <p>服務頁的責任是說明「提供什麼」以及「下一步怎麼做」。</p>
  </footer>
  <script src="main.js" defer></script>
</body>
</html>
about.html關於頁:單一頁面責任下的背景、工作方式與信任內容。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

<!doctype html>
<html lang="zh-Hant">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="description" content="認識安心維護的服務背景、工作方式與信任依據。">
  <title>安心維護|關於我們</title>
  <link rel="icon" href="../../../assets/favicon.svg" type="image/svg+xml">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <header class="site-header">
    <a class="brand" href="index.html">安心維護</a>
    <nav aria-label="主要導覽">
      <a href="index.html">首頁</a>
      <a href="services.html">服務</a>
      <a href="about.html" aria-current="page">關於</a>
      <a href="contact.html">聯絡</a>
    </nav>
  </header>

  <main class="page-shell">
    <p class="eyebrow">LESSON-25 · about.html</p>
    <h1>關於安心維護</h1>
    <p class="page-description">這一頁回答三個問題:我們是誰、怎麼工作、為什麼值得信任。</p>

    <section class="content-section" aria-labelledby="background-title">
      <h2 id="background-title">我們是誰</h2>
      <p>安心維護從理解現場開始,協助家庭與小型商家把日常維護整理成可執行的步驟。</p>
    </section>

    <section class="content-section" aria-labelledby="process-title">
      <h2 id="process-title">我們如何工作</h2>
      <p>先聽需求,再確認範圍與預算,最後用清楚的服務方案安排後續工作。</p>
    </section>

    <section class="content-section" aria-labelledby="trust-title">
      <h2 id="trust-title">你可以如何確認</h2>
      <p>每一項服務都會先說明內容、預期結果與下一步,讓你在決定前看得懂,也能留下可追蹤的紀錄。</p>
    </section>
  </main>

  <footer class="site-footer">
    <p>關於頁的責任是建立背景與信任,不重複服務頁的方案清單。</p>
  </footer>
  <script src="main.js" defer></script>
</body>
</html>
contact.html聯絡頁:name、email、message 三欄與可觀察送出狀態。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

<!doctype html>
<html lang="zh-Hant">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="description" content="聯絡安心維護並提出服務需求。">
  <title>安心維護|聯絡與諮詢</title>
  <link rel="icon" href="../../../assets/favicon.svg" type="image/svg+xml">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <header class="site-header">
    <a class="brand" href="index.html">安心維護</a>
    <nav aria-label="主要導覽">
      <a href="index.html">首頁</a>
      <a href="services.html">服務</a>
      <a href="about.html">關於</a>
      <a href="contact.html" aria-current="page">聯絡</a>
    </nav>
  </header>

  <main class="page-shell">
    <p class="eyebrow">LESSON-25 · contact.html</p>
    <h1>聯絡與諮詢</h1>
    <p class="page-description">這份表單目前只做前端測試,不會送到正式服務。</p>

    <form id="contact-form" class="contact-form">
      <div class="form-field">
        <label for="name">姓名</label>
        <input id="name" name="name" type="text" autocomplete="name" required>
      </div>

      <div class="form-field">
        <label for="email">Email</label>
        <input id="email" name="email" type="email" autocomplete="email" required>
      </div>

      <div class="form-field">
        <label for="message">需求</label>
        <textarea id="message" name="message" rows="5" required></textarea>
      </div>

      <button type="submit">送出諮詢</button>
      <p id="status" class="status" role="status" aria-live="polite" data-state="idle">尚未送出</p>
    </form>

    <aside class="lesson-note">
      <strong>本課邊界:</strong>資料只會在瀏覽器中讀取並顯示 Console payload,不會連線到 Apps Script 或 Sheet。
    </aside>
  </main>

  <footer class="site-footer">
    <p>聯絡頁的責任是收集輸入,並清楚回饋目前的狀態。</p>
  </footer>
  <script src="main.js" defer></script>
</body>
</html>

完成驗收

下一步

Solution 對照完成後,回到 README 整理證據,並準備下一堂課的先備知識。

回到 README,整理本堂證據並銜接下一堂