返回笔记首页

实战项目: 企业级任务管理系统

主题配置

这是一个完整的企业级项目示例,展示如何使用 Agent Skills 构建生产就绪的应用。

项目概述

项目名称: TaskMaster Pro 技术栈: Vue 3 + TypeScript + Pinia + Vite + Element Plus 功能: 企业任务管理、团队协作、数据可视化

涉及的 Skills

bash
skills/
├── vue3-enterprise/          # 前端框架规范
├── typescript-strict/        # 严格类型检查
├── pinia-store-patterns/     # 状态管理模式
├── api-integration/          # API 集成规范
├── form-validation/          # 表单验证
├── data-visualization/       # 数据可视化
└── performance-optimization/ # 性能优化

第一阶段: 项目初始化

1.1 项目结构 (由 vue3-enterprise skill 生成)

plain
taskmaster-pro/
├── src/
│   ├── api/                  # API 调用层
│   │   ├── client.ts         # HTTP 客户端
│   │   ├── tasks.ts          # 任务相关 API
│   │   ├── users.ts          # 用户相关 API
│   │   └── projects.ts       # 项目相关 API
│   ├── components/           # 通用组件
│   │   ├── common/
│   │   │   ├── AppButton.vue
│   │   │   ├── AppInput.vue
│   │   │   ├── AppModal.vue
│   │   │   └── AppTable.vue
│   │   ├── task/
│   │   │   ├── TaskCard.vue
│   │   │   ├── TaskList.vue
│   │   │   ├── TaskForm.vue
│   │   │   └── TaskFilter.vue
│   │   └── chart/
│   │       ├── ProgressChart.vue
│   │       ├── BurndownChart.vue
│   │       └── TeamVelocity.vue
│   ├── composables/          # 可复用逻辑
│   │   ├── useTask.ts
│   │   ├── useProject.ts
│   │   ├── useAuth.ts
│   │   ├── usePagination.ts
│   │   └── useWebSocket.ts
│   ├── stores/               # Pinia 状态管理
│   │   ├── auth.ts
│   │   ├── task.ts
│   │   ├── project.ts
│   │   └── notification.ts
│   ├── types/                # TypeScript 类型
│   │   ├── task.ts
│   │   ├── user.ts
│   │   ├── project.ts
│   │   └── api.ts
│   ├── utils/                # 工具函数
│   │   ├── date.ts
│   │   ├── validators.ts
│   │   ├── formatters.ts
│   │   └── storage.ts
│   ├── views/                # 页面组件
│   │   ├── Dashboard.vue
│   │   ├── TaskBoard.vue
│   │   ├── ProjectList.vue
│   │   └── Settings.vue
│   ├── router/
│   │   └── index.ts
│   ├── App.vue
│   └── main.ts
├── tests/
│   ├── unit/
│   └── e2e/
├── .env.development
├── .env.production
├── tsconfig.json
├── vite.config.ts
└── package.json

1.2 核心类型定义 (typescript-strict skill)

typescript
// types/task.ts
export interface Task {
  id: string
  title: string
  description: string
  status: TaskStatus
  priority: TaskPriority
  assigneeId: string
  projectId: string
  dueDate: Date
  estimatedHours: number
  actualHours: number
  tags: string[]
  createdAt: Date
  updatedAt: Date
  createdBy: string
}

export enum TaskStatus {
  TODO = 'todo',
  IN_PROGRESS = 'in_progress',
  IN_REVIEW = 'in_review',
  DONE = 'done',
  BLOCKED = 'blocked'
}

export enum TaskPriority {
  LOW = 'low',
  MEDIUM = 'medium',
  HIGH = 'high',
  URGENT = 'urgent'
}

export interface TaskFilter {
  status?: TaskStatus[]
  priority?: TaskPriority[]
  assigneeId?: string[]
  projectId?: string[]
  dueDateRange?: [Date, Date]
  searchQuery?: string
}

export interface TaskStatistics {
  total: number
  byStatus: Record<TaskStatus, number>
  byPriority: Record<TaskPriority, number>
  completionRate: number
  avgCompletionTime: number
}

// types/project.ts
export interface Project {
  id: string
  name: string
  description: string
  ownerId: string
  memberIds: string[]
  startDate: Date
  endDate: Date
  status: ProjectStatus
  progress: number
  budget: number
  spentBudget: number
}

export enum ProjectStatus {
  PLANNING = 'planning',
  ACTIVE = 'active',
  ON_HOLD = 'on_hold',
  COMPLETED = 'completed',
  CANCELLED = 'cancelled'
}

// types/user.ts
export interface User {
  id: string
  name: string
  email: string
  avatar?: string
  role: UserRole
  department: string
  joinDate: Date
}

export enum UserRole {
  ADMIN = 'admin',
  MANAGER = 'manager',
  MEMBER = 'member',
  GUEST = 'guest'
}

// types/api.ts
export interface ApiResponse<T = unknown> {
  success: boolean
  data: T
  message?: string
  timestamp: number
}

export interface PaginatedResponse<T> {
  items: T[]
  total: number
  page: number
  pageSize: number
  totalPages: number
}

export interface ApiError {
  code: string
  message: string
  details?: unknown
}

第二阶段: API 层实现

2.1 HTTP 客户端 (api-integration skill)

typescript
// api/client.ts
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import type { ApiResponse, ApiError } from '@/types/api'
import { useAuthStore } from '@/stores/auth'
import { useNotificationStore } from '@/stores/notification'

class ApiClient {
  private client: AxiosInstance
  private requestQueue: Map<string, AbortController> = new Map()

  constructor() {
    this.client = axios.create({
      baseURL: import.meta.env.VITE_API_BASE_URL,
      timeout: 30000,
      headers: {
        'Content-Type': 'application/json'
      }
    })

    this.setupInterceptors()
  }

  private setupInterceptors() {
    // 请求拦截器
    this.client.interceptors.request.use(
      (config) => {
        // 添加认证 token
        const authStore = useAuthStore()
        if (authStore.token) {
          config.headers.Authorization = `Bearer ${authStore.token}`
        }

        // 添加请求 ID 用于取消重复请求
        const requestId = this.getRequestId(config)
        if (this.requestQueue.has(requestId)) {
          // 取消之前的相同请求
          this.requestQueue.get(requestId)?.abort()
        }

        const controller = new AbortController()
        config.signal = controller.signal
        this.requestQueue.set(requestId, controller)

        return config
      },
      (error) => {
        return Promise.reject(error)
      }
    )

    // 响应拦截器
    this.client.interceptors.response.use(
      (response: AxiosResponse<ApiResponse>) => {
        // 清理请求队列
        const requestId = this.getRequestId(response.config)
        this.requestQueue.delete(requestId)

        return response
      },
      async (error) => {
        const notificationStore = useNotificationStore()

        // 清理请求队列
        if (error.config) {
          const requestId = this.getRequestId(error.config)
          this.requestQueue.delete(requestId)
        }

        // 处理不同的错误情况
        if (error.response) {
          switch (error.response.status) {
            case 401:
              // 未授权,跳转到登录
              const authStore = useAuthStore()
              authStore.logout()
              window.location.href = '/login'
              break

            case 403:
              notificationStore.error('没有权限执行此操作')
              break

            case 404:
              notificationStore.error('请求的资源不存在')
              break

            case 429:
              notificationStore.error('请求过于频繁,请稍后再试')
              break

            case 500:
              notificationStore.error('服务器错误,请稍后再试')
              break

            default:
              const apiError: ApiError = error.response.data
              notificationStore.error(apiError.message || '请求失败')
          }
        } else if (error.request) {
          notificationStore.error('网络错误,请检查网络连接')
        }

        return Promise.reject(error)
      }
    )
  }

  private getRequestId(config: AxiosRequestConfig): string {
    return `${config.method}-${config.url}-${JSON.stringify(config.params || {})}`
  }

  async get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
    const response = await this.client.get<ApiResponse<T>>(url, config)
    return response.data.data
  }

  async post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
    const response = await this.client.post<ApiResponse<T>>(url, data, config)
    return response.data.data
  }

  async put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
    const response = await this.client.put<ApiResponse<T>>(url, data, config)
    return response.data.data
  }

  async delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
    const response = await this.client.delete<ApiResponse<T>>(url, config)
    return response.data.data
  }

  async patch<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
    const response = await this.client.patch<ApiResponse<T>>(url, data, config)
    return response.data.data
  }
}

export const apiClient = new ApiClient()

2.2 任务 API (api-integration skill)

typescript
// api/tasks.ts
import { apiClient } from './client'
import type { Task, TaskFilter, TaskStatistics, PaginatedResponse } from '@/types'

export const taskApi = {
  /**
   * 获取任务列表
   */
  async getTasks(filter: TaskFilter = {}, page = 1, pageSize = 20) {
    return apiClient.get<PaginatedResponse<Task>>('/tasks', {
      params: {
        ...filter,
        page,
        pageSize
      }
    })
  },

  /**
   * 获取单个任务
   */
  async getTask(id: string) {
    return apiClient.get<Task>(`/tasks/${id}`)
  },

  /**
   * 创建任务
   */
  async createTask(task: Omit<Task, 'id' | 'createdAt' | 'updatedAt' | 'createdBy'>) {
    return apiClient.post<Task>('/tasks', task)
  },

  /**
   * 更新任务
   */
  async updateTask(id: string, updates: Partial<Task>) {
    return apiClient.patch<Task>(`/tasks/${id}`, updates)
  },

  /**
   * 删除任务
   */
  async deleteTask(id: string) {
    return apiClient.delete<void>(`/tasks/${id}`)
  },

  /**
   * 批量更新任务
   */
  async batchUpdateTasks(taskIds: string[], updates: Partial<Task>) {
    return apiClient.post<Task[]>('/tasks/batch-update', {
      taskIds,
      updates
    })
  },

  /**
   * 获取任务统计
   */
  async getTaskStatistics(projectId?: string) {
    return apiClient.get<TaskStatistics>('/tasks/statistics', {
      params: { projectId }
    })
  },

  /**
   * 分配任务
   */
  async assignTask(taskId: string, assigneeId: string) {
    return apiClient.post<Task>(`/tasks/${taskId}/assign`, { assigneeId })
  },

  /**
   * 更新任务状态
   */
  async updateTaskStatus(taskId: string, status: TaskStatus) {
    return apiClient.patch<Task>(`/tasks/${taskId}/status`, { status })
  }
}

第三阶段: 状态管理

3.1 任务 Store (pinia-store-patterns skill)

typescript
// stores/task.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { taskApi } from '@/api/tasks'
import type { Task, TaskFilter, TaskStatistics } from '@/types'

export const useTaskStore = defineStore('task', () => {
  // State
  const tasks = ref<Task[]>([])
  const selectedTask = ref<Task | null>(null)
  const filter = ref<TaskFilter>({})
  const statistics = ref<TaskStatistics | null>(null)
  const loading = ref(false)
  const error = ref<Error | null>(null)

  // Pagination
  const currentPage = ref(1)
  const pageSize = ref(20)
  const total = ref(0)

  // Getters
  const filteredTasks = computed(() => {
    let result = tasks.value

    // 状态筛选
    if (filter.value.status?.length) {
      result = result.filter(task => filter.value.status!.includes(task.status))
    }

    // 优先级筛选
    if (filter.value.priority?.length) {
      result = result.filter(task => filter.value.priority!.includes(task.priority))
    }

    // 负责人筛选
    if (filter.value.assigneeId?.length) {
      result = result.filter(task => filter.value.assigneeId!.includes(task.assigneeId))
    }

    // 搜索
    if (filter.value.searchQuery) {
      const query = filter.value.searchQuery.toLowerCase()
      result = result.filter(task =>
        task.title.toLowerCase().includes(query) ||
        task.description.toLowerCase().includes(query)
      )
    }

    return result
  })

  const tasksByStatus = computed(() => {
    return filteredTasks.value.reduce((acc, task) => {
      if (!acc[task.status]) {
        acc[task.status] = []
      }
      acc[task.status].push(task)
      return acc
    }, {} as Record<TaskStatus, Task[]>)
  })

  const totalPages = computed(() => Math.ceil(total.value / pageSize.value))

  // Actions
  async function fetchTasks() {
    loading.value = true
    error.value = null

    try {
      const response = await taskApi.getTasks(
        filter.value,
        currentPage.value,
        pageSize.value
      )

      tasks.value = response.items
      total.value = response.total
    } catch (err) {
      error.value = err as Error
      throw err
    } finally {
      loading.value = false
    }
  }

  async function fetchTask(id: string) {
    loading.value = true
    error.value = null

    try {
      selectedTask.value = await taskApi.getTask(id)
      return selectedTask.value
    } catch (err) {
      error.value = err as Error
      throw err
    } finally {
      loading.value = false
    }
  }

  async function createTask(task: Omit<Task, 'id' | 'createdAt' | 'updatedAt' | 'createdBy'>) {
    loading.value = true
    error.value = null

    try {
      const newTask = await taskApi.createTask(task)
      tasks.value.unshift(newTask)
      total.value++
      return newTask
    } catch (err) {
      error.value = err as Error
      throw err
    } finally {
      loading.value = false
    }
  }

  async function updateTask(id: string, updates: Partial<Task>) {
    loading.value = true
    error.value = null

    try {
      const updatedTask = await taskApi.updateTask(id, updates)

      // 更新列表中的任务
      const index = tasks.value.findIndex(t => t.id === id)
      if (index !== -1) {
        tasks.value[index] = updatedTask
      }

      // 更新选中的任务
      if (selectedTask.value?.id === id) {
        selectedTask.value = updatedTask
      }

      return updatedTask
    } catch (err) {
      error.value = err as Error
      throw err
    } finally {
      loading.value = false
    }
  }

  async function deleteTask(id: string) {
    loading.value = true
    error.value = null

    try {
      await taskApi.deleteTask(id)

      tasks.value = tasks.value.filter(t => t.id !== id)
      total.value--

      if (selectedTask.value?.id === id) {
        selectedTask.value = null
      }
    } catch (err) {
      error.value = err as Error
      throw err
    } finally {
      loading.value = false
    }
  }

  async function fetchStatistics(projectId?: string) {
    try {
      statistics.value = await taskApi.getTaskStatistics(projectId)
    } catch (err) {
      console.error('Failed to fetch statistics:', err)
    }
  }

  function setFilter(newFilter: TaskFilter) {
    filter.value = newFilter
    currentPage.value = 1
    fetchTasks()
  }

  function clearFilter() {
    filter.value = {}
    currentPage.value = 1
    fetchTasks()
  }

  function setPage(page: number) {
    currentPage.value = page
    fetchTasks()
  }

  function reset() {
    tasks.value = []
    selectedTask.value = null
    filter.value = {}
    statistics.value = null
    currentPage.value = 1
    total.value = 0
    loading.value = false
    error.value = null
  }

  return {
    // State
    tasks,
    selectedTask,
    filter,
    statistics,
    loading,
    error,
    currentPage,
    pageSize,
    total,

    // Getters
    filteredTasks,
    tasksByStatus,
    totalPages,

    // Actions
    fetchTasks,
    fetchTask,
    createTask,
    updateTask,
    deleteTask,
    fetchStatistics,
    setFilter,
    clearFilter,
    setPage,
    reset
  }
})

第四阶段: 核心组件

4.1 任务看板 (vue3-enterprise skill)

vue
<!-- components/task/TaskBoard.vue -->
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useTaskStore } from '@/stores/task'
import { TaskStatus } from '@/types'
import TaskCard from './TaskCard.vue'
import { useDraggable } from '@/composables/useDraggable'

const taskStore = useTaskStore()

const columns = [
  { status: TaskStatus.TODO, title: '待办', color: '#909399' },
  { status: TaskStatus.IN_PROGRESS, title: '进行中', color: '#409EFF' },
  { status: TaskStatus.IN_REVIEW, title: '审核中', color: '#E6A23C' },
  { status: TaskStatus.DONE, title: '已完成', color: '#67C23A' },
  { status: TaskStatus.BLOCKED, title: '阻塞', color: '#F56C6C' }
]

const tasksByColumn = computed(() => {
  return columns.map(col => ({
    ...col,
    tasks: taskStore.tasksByStatus[col.status] || []
  }))
})

const { onDragStart, onDragOver, onDrop } = useDraggable()

const handleDrop = async (status: TaskStatus, event: DragEvent) => {
  const taskId = onDrop(event)
  if (!taskId) return

  try {
    await taskStore.updateTask(taskId, { status })
  } catch (error) {
    console.error('Failed to update task status:', error)
  }
}

onMounted(() => {
  taskStore.fetchTasks()
  taskStore.fetchStatistics()
})
</script>

<template>
  <div class="task-board">
    <!-- 统计面板 -->
    <div v-if="taskStore.statistics" class="statistics-panel">
      <div class="stat-card">
        <div class="stat-value">{{ taskStore.statistics.total }}</div>
        <div class="stat-label">总任务数</div>
      </div>
      <div class="stat-card">
        <div class="stat-value">{{ Math.round(taskStore.statistics.completionRate * 100) }}%</div>
        <div class="stat-label">完成率</div>
      </div>
      <div class="stat-card">
        <div class="stat-value">{{ taskStore.statistics.avgCompletionTime }}h</div>
        <div class="stat-label">平均完成时间</div>
      </div>
    </div>

    <!-- 看板列 -->
    <div class="board-columns">
      <div
        v-for="column in tasksByColumn"
        :key="column.status"
        class="board-column"
        @dragover.prevent="onDragOver"
        @drop="handleDrop(column.status, $event)"
      >
        <div class="column-header" :style="{ borderTopColor: column.color }">
          <h3>{{ column.title }}</h3>
          <span class="task-count">{{ column.tasks.length }}</span>
        </div>

        <div class="column-content">
          <TaskCard
            v-for="task in column.tasks"
            :key="task.id"
            :task="task"
            draggable="true"
            @dragstart="onDragStart($event, task.id)"
          />

          <div v-if="column.tasks.length === 0" class="empty-column">
            暂无任务
          </div>
        </div>
      </div>
    </div>

    <!-- 加载状态 -->
    <div v-if="taskStore.loading" class="loading-overlay">
      <el-loading />
    </div>
  </div>
</template>

<style scoped>
.task-board {
  height: 100%;
  display: flex;
  flex-direction: column;
  gap: 24px;
  padding: 24px;
  background-color: #f5f7fa;
}

.statistics-panel {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 16px;
}

.stat-card {
  background: white;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
  text-align: center;
}

.stat-value {
  font-size: 32px;
  font-weight: bold;
  color: #303133;
  margin-bottom: 8px;
}

.stat-label {
  font-size: 14px;
  color: #909399;
}

.board-columns {
  flex: 1;
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  gap: 16px;
  overflow-x: auto;
}

.board-column {
  display: flex;
  flex-direction: column;
  min-width: 280px;
  background: white;
  border-radius: 8px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
  overflow: hidden;
}

.column-header {
  padding: 16px;
  border-top: 4px solid;
  display: flex;
  justify-content: space-between;
  align-items: center;
  background-color: #fafafa;
}

.column-header h3 {
  margin: 0;
  font-size: 16px;
  font-weight: 600;
  color: #303133;
}

.task-count {
  background-color: #e4e7ed;
  color: #606266;
  padding: 2px 8px;
  border-radius: 12px;
  font-size: 12px;
  font-weight: 500;
}

.column-content {
  flex: 1;
  padding: 16px;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.empty-column {
  text-align: center;
  color: #c0c4cc;
  padding: 40px 20px;
  font-size: 14px;
}

.loading-overlay {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(255, 255, 255, 0.9);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
}
</style>

4.2 任务卡片组件

vue
<!-- components/task/TaskCard.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import type { Task } from '@/types'
import { formatDate, getRelativeTime } from '@/utils/date'
import { getPriorityColor, getStatusColor } from '@/utils/formatters'

interface Props {
  task: Task
}

const props = defineProps<Props>()

const emit = defineEmits<{
  click: [task: Task]
  edit: [task: Task]
  delete: [taskId: string]
}>()

const priorityColor = computed(() => getPriorityColor(props.task.priority))
const statusColor = computed(() => getStatusColor(props.task.status))
const isOverdue = computed(() =>
  props.task.dueDate && new Date(props.task.dueDate) < new Date()
)

const progressPercentage = computed(() => {
  if (props.task.estimatedHours === 0) return 0
  return Math.min((props.task.actualHours / props.task.estimatedHours) * 100, 100)
})
</script>

<template>
  <div class="task-card" @click="emit('click', task)">
    <!-- 优先级标识 -->
    <div class="priority-indicator" :style="{ backgroundColor: priorityColor }" />

    <!-- 任务标题 -->
    <h4 class="task-title">{{ task.title }}</h4>

    <!-- 任务描述 -->
    <p v-if="task.description" class="task-description">
      {{ task.description }}
    </p>

    <!-- 标签 -->
    <div v-if="task.tags.length" class="task-tags">
      <span v-for="tag in task.tags" :key="tag" class="task-tag">
        {{ tag }}
      </span>
    </div>

    <!-- 进度条 -->
    <div v-if="task.estimatedHours > 0" class="progress-section">
      <div class="progress-bar">
        <div
          class="progress-fill"
          :style="{ width: `${progressPercentage}%` }"
        />
      </div>
      <div class="progress-text">
        {{ task.actualHours }}h / {{ task.estimatedHours }}h
      </div>
    </div>

    <!-- 截止日期 -->
    <div class="task-footer">
      <div v-if="task.dueDate" class="due-date" :class="{ overdue: isOverdue }">
        <el-icon><Calendar /></el-icon>
        {{ getRelativeTime(task.dueDate) }}
      </div>

      <!-- 负责人头像 -->
      <div class="assignee-avatar">
        <el-avatar :size="24" :src="task.assignee?.avatar" />
      </div>
    </div>

    <!-- 操作按钮 -->
    <div class="task-actions">
      <el-button
        size="small"
        type="text"
        @click.stop="emit('edit', task)"
      >
        <el-icon><Edit /></el-icon>
      </el-button>
      <el-button
        size="small"
        type="text"
        @click.stop="emit('delete', task.id)"
      >
        <el-icon><Delete /></el-icon>
      </el-button>
    </div>
  </div>
</template>

<style scoped>
.task-card {
  position: relative;
  background: white;
  border: 1px solid #e4e7ed;
  border-radius: 8px;
  padding: 16px;
  cursor: pointer;
  transition: all 0.3s;
}

.task-card:hover {
  border-color: #409eff;
  box-shadow: 0 4px 12px rgba(64, 158, 255, 0.2);
  transform: translateY(-2px);
}

.priority-indicator {
  position: absolute;
  top: 0;
  left: 0;
  width: 4px;
  height: 100%;
  border-radius: 8px 0 0 8px;
}

.task-title {
  margin: 0 0 8px 0;
  font-size: 15px;
  font-weight: 600;
  color: #303133;
  line-height: 1.4;
  padding-left: 12px;
}

.task-description {
  margin: 0 0 12px 0;
  font-size: 13px;
  color: #606266;
  line-height: 1.5;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.task-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-bottom: 12px;
}

.task-tag {
  padding: 2px 8px;
  background-color: #f4f4f5;
  color: #606266;
  border-radius: 4px;
  font-size: 12px;
}

.progress-section {
  margin-bottom: 12px;
}

.progress-bar {
  height: 6px;
  background-color: #e4e7ed;
  border-radius: 3px;
  overflow: hidden;
  margin-bottom: 4px;
}

.progress-fill {
  height: 100%;
  background: linear-gradient(90deg, #409eff, #67c23a);
  transition: width 0.3s;
}

.progress-text {
  font-size: 11px;
  color: #909399;
  text-align: right;
}

.task-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding-top: 12px;
  border-top: 1px solid #f0f0f0;
}

.due-date {
  display: flex;
  align-items: center;
  gap: 4px;
  font-size: 12px;
  color: #909399;
}

.due-date.overdue {
  color: #f56c6c;
}

.task-actions {
  position: absolute;
  top: 12px;
  right: 12px;
  display: none;
  gap: 4px;
}

.task-card:hover .task-actions {
  display: flex;
}
</style>

第五阶段: 性能优化

5.1 虚拟滚动 (performance-optimization skill)

typescript
// composables/useVirtualScroll.ts
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'

interface VirtualScrollOptions {
  itemHeight: number
  bufferSize?: number
}

export function useVirtualScroll<T>(
  items: Ref<T[]>,
  options: VirtualScrollOptions
) {
  const containerRef = ref<HTMLElement>()
  const scrollTop = ref(0)
  const containerHeight = ref(0)

  const { itemHeight, bufferSize = 5 } = options

  // 计算可见区域
  const visibleStart = computed(() =>
    Math.max(0, Math.floor(scrollTop.value / itemHeight) - bufferSize)
  )

  const visibleEnd = computed(() =>
    Math.min(
      items.value.length,
      Math.ceil((scrollTop.value + containerHeight.value) / itemHeight) + bufferSize
    )
  )

  // 可见的项目
  const visibleItems = computed(() =>
    items.value.slice(visibleStart.value, visibleEnd.value).map((item, index) => ({
      data: item,
      index: visibleStart.value + index
    }))
  )

  // 总高度
  const totalHeight = computed(() => items.value.length * itemHeight)

  // 偏移量
  const offsetY = computed(() => visibleStart.value * itemHeight)

  const handleScroll = (event: Event) => {
    const target = event.target as HTMLElement
    scrollTop.value = target.scrollTop
  }

  const updateContainerHeight = () => {
    if (containerRef.value) {
      containerHeight.value = containerRef.value.clientHeight
    }
  }

  onMounted(() => {
    updateContainerHeight()
    window.addEventListener('resize', updateContainerHeight)
  })

  onUnmounted(() => {
    window.removeEventListener('resize', updateContainerHeight)
  })

  return {
    containerRef,
    visibleItems,
    totalHeight,
    offsetY,
    handleScroll
  }
}

这个实战项目展示了:

  1. 完整的项目结构 - 企业级目录组织
  2. 严格的类型系统 - TypeScript 类型定义
  3. API 层设计 - HTTP 客户端和错误处理
  4. 状态管理 - Pinia store 最佳实践
  5. 组件化开发 - 可复用组件设计
  6. 性能优化 - 虚拟滚动、请求去重

所有代码都遵循了对应 Skills 的最佳实践。