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;
相关文章
App.js
App.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/App.js).
阅读文章 →App.test.js
App.test.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/App.test.js).
阅读文章 →index.js
index.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/index.js).
阅读文章 →reportWebVitals.js
reportWebVitals.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/reportWebVitals.js).
阅读文章 →setupTests.js
setupTests.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/setupTests.js).
阅读文章 →Project_HelloReact.jsx
Project_HelloReact.jsx — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/projects/Project_HelloReact.jsx).
阅读文章 →