返回笔记首页

Vue3 Skills 实战 Demo

主题配置

这是一个完整的 Vue3 项目示例,展示如何利用 Agent Skills 快速构建高质量组件。

项目结构

plain
vue3-skills-demo/
├── src/
│   ├── components/
│   │   ├── DataTable.vue          # 数据表格组件
│   │   ├── FormBuilder.vue        # 表单构建器
│   │   └── ChartWidget.vue        # 图表组件
│   ├── composables/
│   │   ├── useTable.ts            # 表格逻辑复用
│   │   ├── useForm.ts             # 表单逻辑复用
│   │   └── useApi.ts              # API 请求封装
│   ├── stores/
│   │   ├── user.ts                # 用户状态管理
│   │   └── app.ts                 # 应用状态管理
│   ├── types/
│   │   ├── table.ts               # 表格类型定义
│   │   ├── form.ts                # 表单类型定义
│   │   └── api.ts                 # API 类型定义
│   ├── utils/
│   │   ├── validators.ts          # 验证器
│   │   └── formatters.ts          # 格式化工具
│   ├── App.vue
│   └── main.ts
├── skills/                        # 自定义 Skills
│   └── vue3-enterprise/
│       ├── SKILL.md
│       ├── templates/
│       └── examples/
└── package.json

1. 类型定义 (types/)

types/table.ts

typescript
export interface Column<T = any> {
    key: keyof T
    label: string
    sortable?: boolean
    width?: string
    align?: 'left' | 'center' | 'right'
    formatter?: (value: any, row: T) => string
    render?: (row: T) => VNode
}

export interface TableConfig {
    striped?: boolean
    bordered?: boolean
    hoverable?: boolean
    loading?: boolean
}

export interface PaginationConfig {
    pageSize: number
    currentPage: number
    total: number
    showSizeChanger?: boolean
    pageSizes?: number[]
}

export interface SortConfig {
    key: string
    order: 'asc' | 'desc'
}

export interface FilterConfig {
    searchable?: boolean
    filters?: Record<string, any>
}

types/form.ts

typescript
import type { Component } from 'vue'

export interface FormField {
    name: string
    label: string
    type:
        | 'text'
        | 'number'
        | 'email'
        | 'password'
        | 'select'
        | 'checkbox'
        | 'radio'
        | 'textarea'
        | 'date'
    placeholder?: string
    required?: boolean
    validation?: ValidationRule[]
    options?: SelectOption[]
    defaultValue?: any
    component?: Component
    props?: Record<string, any>
}

export interface ValidationRule {
    validator: (value: any) => boolean | Promise<boolean>
    message: string
}

export interface SelectOption {
    label: string
    value: any
    disabled?: boolean
}

export interface FormConfig {
    layout?: 'horizontal' | 'vertical' | 'inline'
    labelWidth?: string
    showResetButton?: boolean
    submitButtonText?: string
    resetButtonText?: string
}

types/api.ts

typescript
export interface ApiResponse<T = any> {
    code: number
    message: string
    data: T
    timestamp: number
}

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

export interface RequestConfig {
    url: string
    method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
    params?: Record<string, any>
    data?: any
    headers?: Record<string, string>
    timeout?: number
}

2. Composables (可复用逻辑)

composables/useTable.ts

typescript
import { ref, computed, watch } from 'vue'
import type { Column, PaginationConfig, SortConfig } from '@/types/table'

export function useTable<T extends Record<string, any>>(
    data: Ref<T[]>,
    options?: {
        defaultPageSize?: number
        defaultSort?: SortConfig
    }
) {
    // 搜索
    const searchQuery = ref('')
    const filteredData = computed(() => {
        if (!searchQuery.value) return data.value

        const query = searchQuery.value.toLowerCase()
        return data.value.filter((row) =>
            Object.values(row).some((value) =>
                String(value).toLowerCase().includes(query)
            )
        )
    })

    // 排序
    const sortConfig = ref<SortConfig | null>(options?.defaultSort || null)

    const sortedData = computed(() => {
        if (!sortConfig.value) return filteredData.value

        const { key, order } = sortConfig.value
        return [...filteredData.value].sort((a, b) => {
            const aVal = a[key]
            const bVal = b[key]
            const multiplier = order === 'asc' ? 1 : -1

            if (typeof aVal === 'number' && typeof bVal === 'number') {
                return (aVal - bVal) * multiplier
            }

            if (aVal instanceof Date && bVal instanceof Date) {
                return (aVal.getTime() - bVal.getTime()) * multiplier
            }

            return (
                String(aVal).localeCompare(String(bVal), 'zh-CN') * multiplier
            )
        })
    })

    const handleSort = (key: string) => {
        if (sortConfig.value?.key === key) {
            sortConfig.value.order =
                sortConfig.value.order === 'asc' ? 'desc' : 'asc'
        } else {
            sortConfig.value = { key, order: 'asc' }
        }
    }

    const clearSort = () => {
        sortConfig.value = null
    }

    // 分页
    const pagination = ref<PaginationConfig>({
        pageSize: options?.defaultPageSize || 10,
        currentPage: 1,
        total: 0,
    })

    watch(
        () => sortedData.value.length,
        (newTotal) => {
            pagination.value.total = newTotal
        },
        { immediate: true }
    )

    const paginatedData = computed(() => {
        const start =
            (pagination.value.currentPage - 1) * pagination.value.pageSize
        const end = start + pagination.value.pageSize
        return sortedData.value.slice(start, end)
    })

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

    const goToPage = (page: number) => {
        if (page >= 1 && page <= totalPages.value) {
            pagination.value.currentPage = page
        }
    }

    const changePageSize = (size: number) => {
        pagination.value.pageSize = size
        pagination.value.currentPage = 1
    }

    // 选择
    const selectedRows = ref<Set<number>>(new Set())
    const isAllSelected = computed(
        () =>
            paginatedData.value.length > 0 &&
            paginatedData.value.every((_, index) =>
                selectedRows.value.has(index)
            )
    )

    const toggleRow = (index: number) => {
        if (selectedRows.value.has(index)) {
            selectedRows.value.delete(index)
        } else {
            selectedRows.value.add(index)
        }
    }

    const toggleAll = () => {
        if (isAllSelected.value) {
            selectedRows.value.clear()
        } else {
            paginatedData.value.forEach((_, index) => {
                selectedRows.value.add(index)
            })
        }
    }

    const clearSelection = () => {
        selectedRows.value.clear()
    }

    return {
        // 搜索
        searchQuery,
        filteredData,

        // 排序
        sortConfig,
        sortedData,
        handleSort,
        clearSort,

        // 分页
        pagination,
        paginatedData,
        totalPages,
        goToPage,
        changePageSize,

        // 选择
        selectedRows,
        isAllSelected,
        toggleRow,
        toggleAll,
        clearSelection,
    }
}

composables/useForm.ts

typescript
import { ref, reactive, computed } from 'vue'
import type { FormField, ValidationRule } from '@/types/form'

export function useForm(fields: FormField[]) {
    // 表单数据
    const formData = reactive<Record<string, any>>(
        fields.reduce(
            (acc, field) => {
                acc[field.name] = field.defaultValue ?? ''
                return acc
            },
            {} as Record<string, any>
        )
    )

    // 错误信息
    const errors = reactive<Record<string, string>>({})

    // 触摸状态
    const touched = reactive<Record<string, boolean>>({})

    // 验证单个字段
    const validateField = async (fieldName: string): Promise<boolean> => {
        const field = fields.find((f) => f.name === fieldName)
        if (!field) return true

        // 必填验证
        if (field.required && !formData[fieldName]) {
            errors[fieldName] = `${field.label}不能为空`
            return false
        }

        // 自定义验证
        if (field.validation) {
            for (const rule of field.validation) {
                const isValid = await rule.validator(formData[fieldName])
                if (!isValid) {
                    errors[fieldName] = rule.message
                    return false
                }
            }
        }

        delete errors[fieldName]
        return true
    }

    // 验证所有字段
    const validateForm = async (): Promise<boolean> => {
        const results = await Promise.all(
            fields.map((field) => validateField(field.name))
        )
        return results.every(Boolean)
    }

    // 处理字段失焦
    const handleBlur = (fieldName: string) => {
        touched[fieldName] = true
        validateField(fieldName)
    }

    // 重置表单
    const resetForm = () => {
        fields.forEach((field) => {
            formData[field.name] = field.defaultValue ?? ''
            delete errors[field.name]
            touched[field.name] = false
        })
    }

    // 表单是否有效
    const isValid = computed(() => Object.keys(errors).length === 0)

    // 表单是否已修改
    const isDirty = computed(() =>
        fields.some((field) => formData[field.name] !== field.defaultValue)
    )

    return {
        formData,
        errors,
        touched,
        isValid,
        isDirty,
        validateField,
        validateForm,
        handleBlur,
        resetForm,
    }
}

composables/useApi.ts

typescript
import { ref } from 'vue'
import type { ApiResponse, RequestConfig } from '@/types/api'

export function useApi<T = any>() {
    const loading = ref(false)
    const error = ref<Error | null>(null)
    const data = ref<T | null>(null)

    const request = async (config: RequestConfig): Promise<ApiResponse<T>> => {
        loading.value = true
        error.value = null

        try {
            const response = await fetch(config.url, {
                method: config.method,
                headers: {
                    'Content-Type': 'application/json',
                    ...config.headers,
                },
                body: config.data ? JSON.stringify(config.data) : undefined,
                signal: AbortSignal.timeout(config.timeout || 30000),
            })

            if (!response.ok) {
                throw new Error(
                    `HTTP ${response.status}: ${response.statusText}`
                )
            }

            const result: ApiResponse<T> = await response.json()
            data.value = result.data
            return result
        } catch (err) {
            error.value = err as Error
            throw err
        } finally {
            loading.value = false
        }
    }

    const get = (url: string, params?: Record<string, any>) => {
        const queryString = params
            ? '?' + new URLSearchParams(params).toString()
            : ''
        return request({ url: url + queryString, method: 'GET' })
    }

    const post = (url: string, data?: any) =>
        request({ url, method: 'POST', data })

    const put = (url: string, data?: any) =>
        request({ url, method: 'PUT', data })

    const del = (url: string) => request({ url, method: 'DELETE' })

    return {
        loading,
        error,
        data,
        request,
        get,
        post,
        put,
        del,
    }
}

3. Pinia Stores

stores/user.ts

typescript
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useApi } from '@/composables/useApi'

interface User {
    id: number
    name: string
    email: string
    avatar?: string
    role: 'admin' | 'user'
}

export const useUserStore = defineStore('user', () => {
    // State
    const user = ref<User | null>(null)
    const token = ref<string>(localStorage.getItem('token') || '')

    // Getters
    const isLoggedIn = computed(() => !!token.value)
    const isAdmin = computed(() => user.value?.role === 'admin')
    const userName = computed(() => user.value?.name || 'Guest')

    // Actions
    const api = useApi()

    async function login(email: string, password: string) {
        const response = await api.post('/api/auth/login', { email, password })
        user.value = response.data.user
        token.value = response.data.token
        localStorage.setItem('token', token.value)
    }

    async function fetchProfile() {
        if (!token.value) return

        const response = await api.get('/api/user/profile')
        user.value = response.data
    }

    function logout() {
        user.value = null
        token.value = ''
        localStorage.removeItem('token')
    }

    async function updateProfile(updates: Partial<User>) {
        const response = await api.put('/api/user/profile', updates)
        user.value = { ...user.value!, ...response.data }
    }

    return {
        user,
        token,
        isLoggedIn,
        isAdmin,
        userName,
        login,
        fetchProfile,
        logout,
        updateProfile,
    }
})

4. 完整组件示例

components/DataTable.vue

vue
<script setup lang="ts" generic="T extends Record<string, any>">
import { computed } from 'vue'
import { useTable } from '@/composables/useTable'
import type { Column, TableConfig } from '@/types/table'

interface Props {
    columns: Column<T>[]
    data: T[]
    config?: TableConfig
    selectable?: boolean
    searchable?: boolean
}

const props = withDefaults(defineProps<Props>(), {
    searchable: true,
    selectable: false,
    config: () => ({
        striped: true,
        hoverable: true,
        bordered: false,
        loading: false,
    }),
})

const emit = defineEmits<{
    'row-click': [row: T]
    'selection-change': [rows: T[]]
}>()

const {
    searchQuery,
    paginatedData,
    sortConfig,
    handleSort,
    pagination,
    totalPages,
    goToPage,
    changePageSize,
    selectedRows,
    isAllSelected,
    toggleRow,
    toggleAll,
} = useTable(computed(() => props.data))

const handleRowClick = (row: T) => {
    emit('row-click', row)
}

const getSelectedRows = () => {
    return Array.from(selectedRows.value).map(
        (index) => paginatedData.value[index]
    )
}

watch(selectedRows, () => {
    emit('selection-change', getSelectedRows())
})
</script>

<template>
    <div class="data-table-wrapper">
        <!-- 工具栏 -->
        <div v-if="searchable" class="table-toolbar">
            <input
                v-model="searchQuery"
                type="text"
                placeholder="搜索..."
                class="search-input"
            />
            <div class="toolbar-actions">
                <slot name="toolbar" />
            </div>
        </div>

        <!-- 表格 -->
        <div class="table-container">
            <table
                class="data-table"
                :class="{
                    'table-striped': config.striped,
                    'table-bordered': config.bordered,
                    'table-hoverable': config.hoverable,
                    'table-loading': config.loading,
                }"
            >
                <thead>
                    <tr>
                        <th v-if="selectable" class="select-column">
                            <input
                                type="checkbox"
                                :checked="isAllSelected"
                                @change="toggleAll"
                            />
                        </th>
                        <th
                            v-for="col in columns"
                            :key="String(col.key)"
                            :style="{
                                width: col.width,
                                textAlign: col.align || 'left',
                            }"
                            :class="{
                                sortable: col.sortable,
                                active: sortConfig?.key === col.key,
                            }"
                            @click="col.sortable && handleSort(String(col.key))"
                        >
                            {{ col.label }}
                            <span
                                v-if="sortConfig?.key === col.key"
                                class="sort-icon"
                            >
                                {{ sortConfig.order === 'asc' ? '↑' : '↓' }}
                            </span>
                        </th>
                    </tr>
                </thead>
                <tbody>
                    <tr
                        v-for="(row, index) in paginatedData"
                        :key="index"
                        @click="handleRowClick(row)"
                    >
                        <td v-if="selectable" class="select-column">
                            <input
                                type="checkbox"
                                :checked="selectedRows.has(index)"
                                @click.stop
                                @change="toggleRow(index)"
                            />
                        </td>
                        <td
                            v-for="col in columns"
                            :key="String(col.key)"
                            :style="{ textAlign: col.align || 'left' }"
                        >
                            <component
                                v-if="col.render"
                                :is="col.render(row)"
                            />
                            <template v-else>
                                {{
                                    col.formatter
                                        ? col.formatter(row[col.key], row)
                                        : row[col.key]
                                }}
                            </template>
                        </td>
                    </tr>
                    <tr v-if="paginatedData.length === 0">
                        <td
                            :colspan="columns.length + (selectable ? 1 : 0)"
                            class="empty-row"
                        >
                            暂无数据
                        </td>
                    </tr>
                </tbody>
            </table>

            <!-- Loading 遮罩 -->
            <div v-if="config.loading" class="loading-overlay">
                <div class="spinner"></div>
            </div>
        </div>

        <!-- 分页 -->
        <div class="pagination-wrapper">
            <div class="pagination-info">
                显示
                {{ (pagination.currentPage - 1) * pagination.pageSize + 1 }} -
                {{
                    Math.min(
                        pagination.currentPage * pagination.pageSize,
                        pagination.total
                    )
                }}
                条,共 {{ pagination.total }} 条
            </div>

            <div class="pagination-controls">
                <select
                    :value="pagination.pageSize"
                    @change="
                        changePageSize(
                            Number(($event.target as HTMLSelectElement).value)
                        )
                    "
                    class="page-size-select"
                >
                    <option :value="10">10 条/页</option>
                    <option :value="20">20 条/页</option>
                    <option :value="50">50 条/页</option>
                    <option :value="100">100 条/页</option>
                </select>

                <button
                    @click="goToPage(pagination.currentPage - 1)"
                    :disabled="pagination.currentPage === 1"
                    class="page-btn"
                >
                    上一页
                </button>

                <span class="page-numbers">
                    第 {{ pagination.currentPage }} / {{ totalPages }} 页
                </span>

                <button
                    @click="goToPage(pagination.currentPage + 1)"
                    :disabled="pagination.currentPage === totalPages"
                    class="page-btn"
                >
                    下一页
                </button>
            </div>
        </div>
    </div>
</template>

<style scoped>
.data-table-wrapper {
    display: flex;
    flex-direction: column;
    gap: 16px;
    background: white;
    border-radius: 8px;
    padding: 16px;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

.table-toolbar {
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: 16px;
}

.search-input {
    flex: 1;
    max-width: 400px;
    padding: 8px 12px;
    border: 1px solid #e5e7eb;
    border-radius: 6px;
    font-size: 14px;
    transition: all 0.2s;
}

.search-input:focus {
    outline: none;
    border-color: #3b82f6;
    box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}

.toolbar-actions {
    display: flex;
    gap: 8px;
}

.table-container {
    position: relative;
    overflow-x: auto;
}

.data-table {
    width: 100%;
    border-collapse: collapse;
}

.data-table th,
.data-table td {
    padding: 12px 16px;
    border-bottom: 1px solid #e5e7eb;
}

.data-table th {
    background-color: #f9fafb;
    font-weight: 600;
    color: #374151;
    font-size: 14px;
    text-align: left;
}

.data-table th.sortable {
    cursor: pointer;
    user-select: none;
    transition: background-color 0.2s;
}

.data-table th.sortable:hover {
    background-color: #f3f4f6;
}

.data-table th.active {
    color: #3b82f6;
}

.sort-icon {
    margin-left: 4px;
    font-weight: bold;
}

.data-table.table-striped tbody tr:nth-child(even) {
    background-color: #f9fafb;
}

.data-table.table-bordered {
    border: 1px solid #e5e7eb;
}

.data-table.table-bordered th,
.data-table.table-bordered td {
    border: 1px solid #e5e7eb;
}

.data-table.table-hoverable tbody tr {
    cursor: pointer;
    transition: background-color 0.15s;
}

.data-table.table-hoverable tbody tr:hover {
    background-color: #f3f4f6;
}

.select-column {
    width: 40px;
    text-align: center;
}

.empty-row {
    text-align: center;
    color: #9ca3af;
    padding: 32px;
}

.loading-overlay {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: rgba(255, 255, 255, 0.8);
    display: flex;
    align-items: center;
    justify-content: center;
}

.spinner {
    width: 40px;
    height: 40px;
    border: 4px solid #e5e7eb;
    border-top-color: #3b82f6;
    border-radius: 50%;
    animation: spin 0.8s linear infinite;
}

@keyframes spin {
    to {
        transform: rotate(360deg);
    }
}

.pagination-wrapper {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding-top: 16px;
    border-top: 1px solid #e5e7eb;
}

.pagination-info {
    font-size: 14px;
    color: #6b7280;
}

.pagination-controls {
    display: flex;
    align-items: center;
    gap: 12px;
}

.page-size-select {
    padding: 6px 12px;
    border: 1px solid #e5e7eb;
    border-radius: 6px;
    font-size: 14px;
    cursor: pointer;
}

.page-btn {
    padding: 6px 16px;
    border: 1px solid #e5e7eb;
    border-radius: 6px;
    background: white;
    font-size: 14px;
    cursor: pointer;
    transition: all 0.2s;
}

.page-btn:hover:not(:disabled) {
    background-color: #f3f4f6;
    border-color: #9ca3af;
}

.page-btn:disabled {
    opacity: 0.5;
    cursor: not-allowed;
}

.page-numbers {
    font-size: 14px;
    color: #374151;
    font-weight: 500;
}
</style>

5. 使用示例

App.vue

vue
<script setup lang="ts">
import { ref, h } from 'vue'
import DataTable from './components/DataTable.vue'
import type { Column } from './types/table'

interface User {
    id: number
    name: string
    email: string
    role: string
    status: 'active' | 'inactive'
    createdAt: string
}

const columns: Column<User>[] = [
    {
        key: 'id',
        label: 'ID',
        sortable: true,
        width: '80px',
        align: 'center',
    },
    {
        key: 'name',
        label: '姓名',
        sortable: true,
    },
    {
        key: 'email',
        label: '邮箱',
        sortable: true,
    },
    {
        key: 'role',
        label: '角色',
        sortable: true,
        formatter: (value) => {
            const roleMap: Record<string, string> = {
                admin: '管理员',
                user: '普通用户',
                guest: '访客',
            }
            return roleMap[value] || value
        },
    },
    {
        key: 'status',
        label: '状态',
        render: (row) =>
            h(
                'span',
                {
                    class: `status-badge status-${row.status}`,
                },
                row.status === 'active' ? '活跃' : '停用'
            ),
    },
    {
        key: 'createdAt',
        label: '创建时间',
        sortable: true,
        formatter: (value) => new Date(value).toLocaleDateString('zh-CN'),
    },
]

const users = ref<User[]>([
    {
        id: 1,
        name: '张三',
        email: 'zhangsan@example.com',
        role: 'admin',
        status: 'active',
        createdAt: '2024-01-15T00:00:00Z',
    },
    {
        id: 2,
        name: '李四',
        email: 'lisi@example.com',
        role: 'user',
        status: 'active',
        createdAt: '2024-01-16T00:00:00Z',
    },
    {
        id: 3,
        name: '王五',
        email: 'wangwu@example.com',
        role: 'user',
        status: 'inactive',
        createdAt: '2024-01-17T00:00:00Z',
    },
])

const handleRowClick = (row: User) => {
    console.log('点击行:', row)
}

const handleSelectionChange = (rows: User[]) => {
    console.log('选中行:', rows)
}
</script>

<template>
    <div class="app">
        <header class="app-header">
            <h1>用户管理系统</h1>
        </header>

        <main class="app-main">
            <DataTable
                :columns="columns"
                :data="users"
                selectable
                searchable
                @row-click="handleRowClick"
                @selection-change="handleSelectionChange"
            >
                <template #toolbar>
                    <button class="btn btn-primary">新增用户</button>
                    <button class="btn btn-secondary">导出</button>
                </template>
            </DataTable>
        </main>
    </div>
</template>

<style>
.app {
    min-height: 100vh;
    background-color: #f3f4f6;
}

.app-header {
    background: white;
    padding: 24px;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

.app-header h1 {
    margin: 0;
    font-size: 24px;
    color: #1f2937;
}

.app-main {
    max-width: 1200px;
    margin: 24px auto;
    padding: 0 24px;
}

.btn {
    padding: 8px 16px;
    border: none;
    border-radius: 6px;
    font-size: 14px;
    font-weight: 500;
    cursor: pointer;
    transition: all 0.2s;
}

.btn-primary {
    background-color: #3b82f6;
    color: white;
}

.btn-primary:hover {
    background-color: #2563eb;
}

.btn-secondary {
    background-color: #6b7280;
    color: white;
}

.btn-secondary:hover {
    background-color: #4b5563;
}

.status-badge {
    padding: 4px 12px;
    border-radius: 12px;
    font-size: 12px;
    font-weight: 500;
}

.status-active {
    background-color: #d1fae5;
    color: #065f46;
}

.status-inactive {
    background-color: #fee2e2;
    color: #991b1b;
}
</style>

6. 总结

这个完整的 Demo 展示了:

  1. 类型安全: 完整的 TypeScript 类型定义
  2. 逻辑复用: 通过 Composables 提取可复用逻辑
  3. 状态管理: 使用 Pinia 管理全局状态
  4. 组件化: 高度可复用的表格组件
  5. 最佳实践: 遵循 Vue3 Composition API 规范

这些代码完全由 Agent Skills 指导生成,确保了代码质量和一致性。