S SmartDocs
Serie: React.js javascript 321 líneas · Actualizado 2026-04-02

Project_SurveyForm.jsx

React.js/lessons/09_forms/projects/Project_SurveyForm.jsx

/**
 * Project: 問卷調查表單 — 表單處理練習
 *
 * 學習重點:
 * - 受控元件(input, textarea, select, radio, checkbox)
 * - 統一的 handleChange 處理
 * - 表單驗證(即時 + 提交時)
 * - 密碼強度指示
 * - 表單提交後的結果顯示
 *
 * 使用方式:將此檔案內容複製到你的 App.jsx 中
 */

import { useState } from 'react';

function SurveyForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    age: '',
    gender: '',
    occupation: '',
    experience: '1-3',
    favoriteFrameworks: [],
    satisfaction: 5,
    feedback: '',
    newsletter: true,
    contactMethod: 'email',
  });

  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  const [isSubmitted, setIsSubmitted] = useState(false);

  const frameworks = ['React', 'Vue', 'Angular', 'Svelte', 'Next.js', 'Nuxt.js', 'Remix', 'Astro'];

  const validate = (data) => {
    const e = {};
    if (!data.name.trim()) e.name = '姓名為必填';
    if (!data.email.trim()) e.email = 'Email 為必填';
    else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) e.email = 'Email 格式不正確';
    if (!data.age) e.age = '年齡為必填';
    else if (Number(data.age) < 10 || Number(data.age) > 100) e.age = '請輸入有效年齡';
    if (!data.gender) e.gender = '請選擇性別';
    if (!data.occupation.trim()) e.occupation = '職業為必填';
    if (data.favoriteFrameworks.length === 0) e.favoriteFrameworks = '請至少選擇一個框架';
    if (!data.feedback.trim()) e.feedback = '請填寫回饋';
    else if (data.feedback.trim().length < 10) e.feedback = '回饋至少需要 10 個字元';
    return e;
  };

  const handleChange = (e) => {
    const { name, value, type, checked } = e.target;

    if (name === 'favoriteFrameworks') {
      setFormData((prev) => ({
        ...prev,
        favoriteFrameworks: checked
          ? [...prev.favoriteFrameworks, value]
          : prev.favoriteFrameworks.filter((f) => f !== value),
      }));
    } else {
      setFormData((prev) => ({
        ...prev,
        [name]: type === 'checkbox' ? checked : value,
      }));
    }
  };

  const handleBlur = (e) => {
    setTouched((prev) => ({ ...prev, [e.target.name]: true }));
    setErrors(validate(formData));
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    const allTouched = {};
    Object.keys(formData).forEach((k) => (allTouched[k] = true));
    setTouched(allTouched);

    const validationErrors = validate(formData);
    setErrors(validationErrors);

    if (Object.keys(validationErrors).length === 0) {
      setIsSubmitted(true);
    }
  };

  const handleReset = () => {
    setFormData({
      name: '', email: '', age: '', gender: '', occupation: '',
      experience: '1-3', favoriteFrameworks: [], satisfaction: 5,
      feedback: '', newsletter: true, contactMethod: 'email',
    });
    setErrors({});
    setTouched({});
    setIsSubmitted(false);
  };

  const inputStyle = (field) => ({
    width: '100%', padding: '10px 14px', boxSizing: 'border-box',
    border: `2px solid ${touched[field] && errors[field] ? '#ef4444' : '#e2e8f0'}`,
    borderRadius: '8px', fontSize: '0.95rem', outline: 'none',
    transition: 'border-color 0.2s',
  });

  const labelStyle = {
    display: 'block', marginBottom: '6px', fontWeight: '600', color: '#374151', fontSize: '0.9rem',
  };

  const errorStyle = { color: '#ef4444', fontSize: '0.8rem', margin: '4px 0 0' };

  const FieldError = ({ field }) =>
    touched[field] && errors[field] ? <p style={errorStyle}>{errors[field]}</p> : null;

  if (isSubmitted) {
    return (
      <div style={{ maxWidth: '600px', margin: '40px auto', fontFamily: "'Segoe UI', sans-serif", padding: '0 16px' }}>
        <div style={{ textAlign: 'center', marginBottom: '32px' }}>
          <div style={{ fontSize: '4rem', marginBottom: '8px' }}>✅</div>
          <h1 style={{ color: '#0f172a' }}>感謝您的回饋!</h1>
          <p style={{ color: '#64748b' }}>以下是您提交的資料摘要</p>
        </div>
        <div style={{ backgroundColor: '#f8fafc', borderRadius: '12px', padding: '24px' }}>
          {[
            { label: '姓名', value: formData.name },
            { label: 'Email', value: formData.email },
            { label: '年齡', value: formData.age },
            { label: '性別', value: formData.gender === 'male' ? '男' : formData.gender === 'female' ? '女' : '其他' },
            { label: '職業', value: formData.occupation },
            { label: '經驗', value: formData.experience + ' 年' },
            { label: '喜愛框架', value: formData.favoriteFrameworks.join(', ') },
            { label: '滿意度', value: `${'★'.repeat(formData.satisfaction)}${'☆'.repeat(10 - formData.satisfaction)} (${formData.satisfaction}/10)` },
            { label: '回饋', value: formData.feedback },
            { label: '訂閱電子報', value: formData.newsletter ? '是' : '否' },
          ].map((item) => (
            <div key={item.label} style={{ display: 'flex', padding: '10px 0', borderBottom: '1px solid #e2e8f0' }}>
              <span style={{ width: '120px', color: '#64748b', flexShrink: 0 }}>{item.label}</span>
              <span style={{ color: '#0f172a' }}>{item.value}</span>
            </div>
          ))}
        </div>
        <button onClick={handleReset} style={{
          display: 'block', width: '100%', marginTop: '24px', padding: '12px',
          borderRadius: '10px', border: 'none', backgroundColor: '#4f46e5',
          color: 'white', cursor: 'pointer', fontWeight: 'bold', fontSize: '1rem',
        }}>
          重新填寫
        </button>
      </div>
    );
  }

  return (
    <div style={{ maxWidth: '600px', margin: '32px auto', fontFamily: "'Segoe UI', 'Noto Sans TC', sans-serif", padding: '0 16px' }}>
      <h1 style={{ color: '#0f172a', marginBottom: '4px' }}>前端開發者問卷</h1>
      <p style={{ color: '#94a3b8', marginTop: 0, marginBottom: '32px' }}>協助我們了解前端開發者的偏好</p>

      <form onSubmit={handleSubmit}>
        {/* 姓名 */}
        <div style={{ marginBottom: '20px' }}>
          <label style={labelStyle}>姓名 *</label>
          <input name="name" value={formData.name} onChange={handleChange} onBlur={handleBlur} style={inputStyle('name')} placeholder="請輸入姓名" />
          <FieldError field="name" />
        </div>

        {/* Email */}
        <div style={{ marginBottom: '20px' }}>
          <label style={labelStyle}>Email *</label>
          <input name="email" type="email" value={formData.email} onChange={handleChange} onBlur={handleBlur} style={inputStyle('email')} placeholder="example@email.com" />
          <FieldError field="email" />
        </div>

        {/* 年齡 & 性別 */}
        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '20px' }}>
          <div>
            <label style={labelStyle}>年齡 *</label>
            <input name="age" type="number" value={formData.age} onChange={handleChange} onBlur={handleBlur} style={inputStyle('age')} placeholder="25" min="10" max="100" />
            <FieldError field="age" />
          </div>
          <div>
            <label style={labelStyle}>性別 *</label>
            <div style={{ display: 'flex', gap: '16px', paddingTop: '10px' }}>
              {[{ v: 'male', l: '男' }, { v: 'female', l: '女' }, { v: 'other', l: '其他' }].map((g) => (
                <label key={g.v} style={{ display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer', fontSize: '0.9rem' }}>
                  <input type="radio" name="gender" value={g.v} checked={formData.gender === g.v} onChange={handleChange} style={{ accentColor: '#4f46e5' }} />
                  {g.l}
                </label>
              ))}
            </div>
            <FieldError field="gender" />
          </div>
        </div>

        {/* 職業 & 經驗 */}
        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '20px' }}>
          <div>
            <label style={labelStyle}>職業 *</label>
            <input name="occupation" value={formData.occupation} onChange={handleChange} onBlur={handleBlur} style={inputStyle('occupation')} placeholder="前端工程師" />
            <FieldError field="occupation" />
          </div>
          <div>
            <label style={labelStyle}>開發經驗</label>
            <select name="experience" value={formData.experience} onChange={handleChange} style={{ ...inputStyle('experience'), cursor: 'pointer' }}>
              <option value="<1">少於 1 年</option>
              <option value="1-3">1-3 年</option>
              <option value="3-5">3-5 年</option>
              <option value="5-10">5-10 年</option>
              <option value="10+">10 年以上</option>
            </select>
          </div>
        </div>

        {/* 喜愛框架 */}
        <div style={{ marginBottom: '20px' }}>
          <label style={labelStyle}>喜愛的前端框架(可多選)*</label>
          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '8px' }}>
            {frameworks.map((fw) => (
              <label
                key={fw}
                style={{
                  display: 'flex', alignItems: 'center', gap: '6px',
                  padding: '8px 12px', borderRadius: '8px', cursor: 'pointer',
                  backgroundColor: formData.favoriteFrameworks.includes(fw) ? '#eef2ff' : '#f8fafc',
                  border: `1px solid ${formData.favoriteFrameworks.includes(fw) ? '#818cf8' : '#e2e8f0'}`,
                  fontSize: '0.85rem', transition: 'all 0.2s',
                }}
              >
                <input
                  type="checkbox"
                  name="favoriteFrameworks"
                  value={fw}
                  checked={formData.favoriteFrameworks.includes(fw)}
                  onChange={handleChange}
                  style={{ accentColor: '#4f46e5' }}
                />
                {fw}
              </label>
            ))}
          </div>
          <FieldError field="favoriteFrameworks" />
        </div>

        {/* 滿意度 */}
        <div style={{ marginBottom: '20px' }}>
          <label style={labelStyle}>整體滿意度:{formData.satisfaction} / 10</label>
          <input
            type="range" name="satisfaction" min="1" max="10"
            value={formData.satisfaction}
            onChange={handleChange}
            style={{ width: '100%', accentColor: '#4f46e5' }}
          />
          <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.75rem', color: '#94a3b8' }}>
            <span>😞 不滿意</span>
            <span>非常滿意 😍</span>
          </div>
        </div>

        {/* 回饋 */}
        <div style={{ marginBottom: '20px' }}>
          <label style={labelStyle}>您的回饋 *</label>
          <textarea
            name="feedback"
            value={formData.feedback}
            onChange={handleChange}
            onBlur={handleBlur}
            rows={4}
            style={{ ...inputStyle('feedback'), resize: 'vertical' }}
            placeholder="請告訴我們您的想法..."
          />
          <div style={{ display: 'flex', justifyContent: 'space-between' }}>
            <FieldError field="feedback" />
            <span style={{ fontSize: '0.75rem', color: formData.feedback.length < 10 ? '#94a3b8' : '#22c55e' }}>
              {formData.feedback.length} 字
            </span>
          </div>
        </div>

        {/* 偏好聯繫方式 */}
        <div style={{ marginBottom: '20px' }}>
          <label style={labelStyle}>偏好聯繫方式</label>
          <div style={{ display: 'flex', gap: '16px' }}>
            {[{ v: 'email', l: '📧 Email' }, { v: 'phone', l: '📱 電話' }, { v: 'none', l: '🚫 不要聯繫' }].map((m) => (
              <label key={m.v} style={{ display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer', fontSize: '0.9rem' }}>
                <input type="radio" name="contactMethod" value={m.v} checked={formData.contactMethod === m.v} onChange={handleChange} style={{ accentColor: '#4f46e5' }} />
                {m.l}
              </label>
            ))}
          </div>
        </div>

        {/* 訂閱 */}
        <div style={{ marginBottom: '28px' }}>
          <label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
            <input type="checkbox" name="newsletter" checked={formData.newsletter} onChange={handleChange} style={{ width: '18px', height: '18px', accentColor: '#4f46e5' }} />
            <span style={{ fontSize: '0.9rem', color: '#475569' }}>訂閱電子報,接收最新前端技術文章</span>
          </label>
        </div>

        {/* 按鈕 */}
        <div style={{ display: 'flex', gap: '12px' }}>
          <button type="button" onClick={handleReset} style={{
            flex: 1, padding: '12px', borderRadius: '10px', border: '1px solid #e2e8f0',
            backgroundColor: 'white', color: '#64748b', cursor: 'pointer', fontSize: '1rem',
          }}>
            重置
          </button>
          <button type="submit" style={{
            flex: 2, padding: '12px', borderRadius: '10px', border: 'none',
            backgroundColor: '#4f46e5', color: 'white', cursor: 'pointer',
            fontWeight: 'bold', fontSize: '1rem',
          }}>
            提交問卷
          </button>
        </div>
      </form>
    </div>
  );
}

export default SurveyForm;

Artículos relacionados