S SmartDocs
系列: React.js javascript 379 行 · 更新于 2026-04-02

Project_TeamDirectory.jsx

React.js/lessons/04_props/projects/Project_TeamDirectory.jsx

/**
 * Project: 團隊通訊錄 — Props 練習
 *
 * 學習重點:
 * - Props 傳遞各種類型(字串、數字、陣列、物件、函式、布林值)
 * - Props 解構與預設值
 * - children prop
 * - 從子元件傳遞資訊給父元件(callback props)
 * - 展開運算子傳遞 props
 *
 * 使用方式:將此檔案內容複製到你的 App.jsx 中
 */

import { useState } from 'react';

// ============================================================
// 資料
// ============================================================
const teamMembers = [
  {
    id: 1,
    name: 'Alice Chen',
    role: 'Frontend Lead',
    department: 'Engineering',
    email: 'alice@company.com',
    phone: '0912-111-222',
    skills: ['React', 'TypeScript', 'CSS'],
    status: 'online',
    avatar: '👩‍💻',
  },
  {
    id: 2,
    name: 'Bob Wang',
    role: 'Backend Developer',
    department: 'Engineering',
    email: 'bob@company.com',
    phone: '0912-333-444',
    skills: ['Node.js', 'Python', 'PostgreSQL'],
    status: 'busy',
    avatar: '👨‍💻',
  },
  {
    id: 3,
    name: 'Carol Lin',
    role: 'UI/UX Designer',
    department: 'Design',
    email: 'carol@company.com',
    phone: '0912-555-666',
    skills: ['Figma', 'Sketch', 'User Research'],
    status: 'offline',
    avatar: '👩‍🎨',
  },
  {
    id: 4,
    name: 'David Lee',
    role: 'Product Manager',
    department: 'Product',
    email: 'david@company.com',
    phone: '0912-777-888',
    skills: ['Scrum', 'Data Analysis', 'Strategy'],
    status: 'online',
    avatar: '👨‍💼',
  },
  {
    id: 5,
    name: 'Eva Huang',
    role: 'DevOps Engineer',
    department: 'Engineering',
    email: 'eva@company.com',
    phone: '0912-999-000',
    skills: ['Docker', 'K8s', 'AWS', 'CI/CD'],
    status: 'online',
    avatar: '👩‍🔧',
  },
];

// ============================================================
// 元件:Card 容器(使用 children prop)
// ============================================================
const Card = ({ children, highlighted = false, onClick }) => (
  <div
    onClick={onClick}
    style={{
      padding: '20px',
      borderRadius: '12px',
      border: `2px solid ${highlighted ? '#818cf8' : '#e2e8f0'}`,
      backgroundColor: highlighted ? '#eef2ff' : 'white',
      cursor: onClick ? 'pointer' : 'default',
      transition: 'all 0.2s',
      marginBottom: '12px',
    }}
  >
    {children}
  </div>
);

// ============================================================
// 元件:狀態指示燈
// ============================================================
const StatusIndicator = ({ status = 'offline' }) => {
  const config = {
    online: { color: '#22c55e', label: '在線' },
    busy: { color: '#f59e0b', label: '忙碌' },
    offline: { color: '#94a3b8', label: '離線' },
  };
  const { color, label } = config[status] || config.offline;

  return (
    <span style={{ display: 'inline-flex', alignItems: 'center', gap: '4px', fontSize: '0.8rem', color }}>
      <span style={{ width: '8px', height: '8px', borderRadius: '50%', backgroundColor: color, display: 'inline-block' }} />
      {label}
    </span>
  );
};

// ============================================================
// 元件:技能標籤列表
// ============================================================
const SkillTags = ({ skills = [], maxShow = 3 }) => {
  const visible = skills.slice(0, maxShow);
  const remaining = skills.length - maxShow;

  return (
    <div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px', marginTop: '8px' }}>
      {visible.map((skill) => (
        <span
          key={skill}
          style={{
            padding: '2px 10px',
            borderRadius: '12px',
            backgroundColor: '#f1f5f9',
            color: '#475569',
            fontSize: '0.75rem',
          }}
        >
          {skill}
        </span>
      ))}
      {remaining > 0 && (
        <span style={{ padding: '2px 10px', fontSize: '0.75rem', color: '#94a3b8' }}>
          +{remaining}
        </span>
      )}
    </div>
  );
};

// ============================================================
// 元件:成員卡片
// ============================================================
const MemberCard = ({
  name,
  role,
  department,
  email,
  skills,
  status,
  avatar,
  isSelected,
  onSelect,
  onContact,
}) => (
  <Card highlighted={isSelected} onClick={onSelect}>
    <div style={{ display: 'flex', gap: '16px', alignItems: 'flex-start' }}>
      {/* 大頭貼 */}
      <div
        style={{
          width: '56px',
          height: '56px',
          borderRadius: '12px',
          backgroundColor: '#f1f5f9',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          fontSize: '1.8rem',
          flexShrink: 0,
        }}
      >
        {avatar}
      </div>

      {/* 資訊 */}
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
          <h3 style={{ margin: 0, fontSize: '1rem', color: '#0f172a' }}>{name}</h3>
          <StatusIndicator status={status} />
        </div>
        <p style={{ margin: '2px 0', color: '#64748b', fontSize: '0.85rem' }}>
          {role} · {department}
        </p>
        <SkillTags skills={skills} />
      </div>
    </div>

    {/* 操作按鈕 */}
    <div style={{ display: 'flex', gap: '8px', marginTop: '12px', paddingTop: '12px', borderTop: '1px solid #f1f5f9' }}>
      <button
        onClick={(e) => {
          e.stopPropagation();
          onContact('email', email);
        }}
        style={{
          flex: 1,
          padding: '6px',
          borderRadius: '6px',
          border: '1px solid #e2e8f0',
          backgroundColor: 'white',
          color: '#475569',
          cursor: 'pointer',
          fontSize: '0.8rem',
        }}
      >
        📧 Email
      </button>
      <button
        onClick={(e) => {
          e.stopPropagation();
          onContact('chat', name);
        }}
        style={{
          flex: 1,
          padding: '6px',
          borderRadius: '6px',
          border: 'none',
          backgroundColor: '#6366f1',
          color: 'white',
          cursor: 'pointer',
          fontSize: '0.8rem',
        }}
      >
        💬 聊天
      </button>
    </div>
  </Card>
);

// ============================================================
// 元件:篩選按鈕列
// ============================================================
const FilterBar = ({ departments, selectedDept, onFilterChange }) => (
  <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginBottom: '20px' }}>
    {departments.map((dept) => (
      <button
        key={dept}
        onClick={() => onFilterChange(dept)}
        style={{
          padding: '6px 16px',
          borderRadius: '20px',
          border: 'none',
          backgroundColor: selectedDept === dept ? '#4f46e5' : '#f1f5f9',
          color: selectedDept === dept ? 'white' : '#64748b',
          cursor: 'pointer',
          fontSize: '0.85rem',
          fontWeight: selectedDept === dept ? '600' : '400',
        }}
      >
        {dept === 'All' ? '全部' : dept}
      </button>
    ))}
  </div>
);

// ============================================================
// 元件:詳細資訊面板
// ============================================================
const DetailPanel = ({ member }) => {
  if (!member) {
    return (
      <div style={{
        padding: '40px',
        textAlign: 'center',
        color: '#94a3b8',
        backgroundColor: '#f8fafc',
        borderRadius: '12px',
        border: '2px dashed #e2e8f0',
      }}>
        <p style={{ fontSize: '2rem', margin: '0 0 8px' }}>👈</p>
        <p>點擊成員卡片查看詳細資訊</p>
      </div>
    );
  }

  return (
    <div style={{
      padding: '24px',
      backgroundColor: '#f8fafc',
      borderRadius: '12px',
      border: '1px solid #e2e8f0',
    }}>
      <div style={{ textAlign: 'center', marginBottom: '20px' }}>
        <div style={{ fontSize: '3rem', marginBottom: '8px' }}>{member.avatar}</div>
        <h2 style={{ margin: '0 0 4px', color: '#0f172a' }}>{member.name}</h2>
        <p style={{ margin: 0, color: '#64748b' }}>{member.role}</p>
        <StatusIndicator status={member.status} />
      </div>

      <div style={{ borderTop: '1px solid #e2e8f0', paddingTop: '16px' }}>
        <InfoRow label="部門" value={member.department} />
        <InfoRow label="Email" value={member.email} />
        <InfoRow label="電話" value={member.phone} />
        <div style={{ marginTop: '12px' }}>
          <span style={{ color: '#64748b', fontSize: '0.85rem' }}>技能:</span>
          <SkillTags skills={member.skills} maxShow={10} />
        </div>
      </div>
    </div>
  );
};

const InfoRow = ({ label, value }) => (
  <div style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0', fontSize: '0.9rem' }}>
    <span style={{ color: '#64748b' }}>{label}</span>
    <span style={{ color: '#1e293b', fontWeight: '500' }}>{value}</span>
  </div>
);

// ============================================================
// App:組合所有元件
// ============================================================
function App() {
  const [selectedId, setSelectedId] = useState(null);
  const [filterDept, setFilterDept] = useState('All');

  const departments = ['All', ...new Set(teamMembers.map((m) => m.department))];
  const filteredMembers =
    filterDept === 'All'
      ? teamMembers
      : teamMembers.filter((m) => m.department === filterDept);
  const selectedMember = teamMembers.find((m) => m.id === selectedId) || null;

  const handleContact = (method, target) => {
    alert(`透過 ${method} 聯繫 ${target}`);
  };

  return (
    <div
      style={{
        maxWidth: '900px',
        margin: '0 auto',
        padding: '32px 20px',
        fontFamily: "'Segoe UI', 'Noto Sans TC', sans-serif",
      }}
    >
      <h1 style={{ color: '#0f172a', marginBottom: '4px' }}>團隊通訊錄</h1>
      <p style={{ color: '#94a3b8', marginTop: 0 }}>{teamMembers.length} 位成員</p>

      <FilterBar
        departments={departments}
        selectedDept={filterDept}
        onFilterChange={setFilterDept}
      />

      <div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: '24px', alignItems: 'start' }}>
        {/* 成員列表 */}
        <div>
          {filteredMembers.map((member) => (
            <MemberCard
              key={member.id}
              {...member}
              isSelected={selectedId === member.id}
              onSelect={() => setSelectedId(member.id)}
              onContact={handleContact}
            />
          ))}
          {filteredMembers.length === 0 && (
            <p style={{ textAlign: 'center', color: '#94a3b8', padding: '40px' }}>
              沒有找到成員
            </p>
          )}
        </div>

        {/* 詳細面板 */}
        <DetailPanel member={selectedMember} />
      </div>
    </div>
  );
}

export default App;

相关文章