大量更新
This commit is contained in:
141
.agents/skills/frontend-vue3-vuetify/SKILL.md
Normal file
141
.agents/skills/frontend-vue3-vuetify/SKILL.md
Normal file
@@ -0,0 +1,141 @@
|
||||
---
|
||||
name: frontend-vue3-vuetify
|
||||
description: Build production-grade Vue 3 + TypeScript + Vuetify 3 interfaces with architectural rigor. 构建生产级 Vue 3 + TypeScript + Vuetify 3 界面。Use when creating Vue components, pages, layouts, Pinia stores, or API modules. 用于创建 Vue 组件、页面、布局、Pinia 状态管理或 API 模块。Enforces strict typing, Composition API patterns, Material Design 3 aesthetics, and bulletproof data handling.
|
||||
---
|
||||
|
||||
本技能指导构建架构严谨、类型安全、视觉精致的 Vue 3 + Vuetify 3 代码。每个组件都应该达到生产级代码库的标准——让资深工程师也引以为傲。
|
||||
|
||||
用户输入:$ARGUMENTS(组件规格、页面需求、功能请求或架构问题)
|
||||
|
||||
## 架构思维
|
||||
|
||||
动手写代码之前,先建立清晰认知:
|
||||
|
||||
- **组件身份**:这是页面(Page)、布局(Layout)、可复用组件(Component)、组合式函数(Composable)、状态仓库(Store),还是 API 模块?每种都有独特模式。
|
||||
- **数据重力**:状态住在哪里?Props 向下流动,Events 向上冒泡。跨组件状态用 Pinia。深层级传递用 `provide/inject`。
|
||||
- **滚动策略**:哪个容器拥有滚动权?永远不是 body。必须显式声明。必须可控。
|
||||
- **失败模式**:数据为 `null` 时怎么办?空数组?网络超时?先为不幸路径设计。
|
||||
|
||||
**关键原则**:生产代码预判混乱。为一切加类型。为一切加守卫。让一切优雅降级。
|
||||
|
||||
## 核心信条
|
||||
|
||||
### TypeScript 绝对主义
|
||||
- `<script setup lang="ts">` — 唯一可接受的写法
|
||||
- `any` 被禁止 — 使用 `unknown` + 类型守卫、泛型、工具类型
|
||||
- 每个 prop、emit、ref、API 响应都必须穿戴类型
|
||||
- 类型定义放在 `@/types/`,按领域组织:`user.d.ts`、`order.d.ts`
|
||||
|
||||
### Composition API 纯粹性
|
||||
- `ref`、`reactive`、`computed`、`watchEffect` — 掌握这四大金刚
|
||||
- `shallowRef`、`readonly`、`toRaw` — 知道何时使用优化手段
|
||||
- 生命周期用 `onMounted`、`onUnmounted` — 绝不混用 Options API
|
||||
- Pinia stores:类型化的 state、类型化的 getters、类型化的 actions — 无例外
|
||||
|
||||
### Vuetify 3 + Material Design 3
|
||||
- 所有 UI 通过 Vuetify 组件实现 — UI 元素不使用原生 HTML
|
||||
- 始终主题感知 — `rgb(var(--v-theme-surface))`,绝不 `#ffffff`
|
||||
- `useDisplay()` 处理响应式逻辑 — 断点是一等公民
|
||||
- 密度很重要 — 数据密集界面使用 `density="compact"`
|
||||
|
||||
### 布局哲学
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ 工具栏 (flex-shrink-0) │
|
||||
├─────────────────────────────────┤
|
||||
│ │
|
||||
│ 内容区域 │
|
||||
│ (flex-grow-1, overflow-y-auto) │
|
||||
│ (min-height: 0) ← 关键! │
|
||||
│ │
|
||||
├─────────────────────────────────┤
|
||||
│ 底部栏 (flex-shrink-0) │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
- **禁止 body 滚动** — 视口锁定,内容在容器中滚动
|
||||
- **Flexbox 陷阱**:`flex-grow-1` 子元素必须有 `min-height: 0`
|
||||
- **粘性元素**:筛选栏、表头 — 滚动时始终可见
|
||||
|
||||
## 数据健壮性模式
|
||||
|
||||
将所有外部数据视为不可信:
|
||||
|
||||
```typescript
|
||||
// 防御性访问
|
||||
const userName = user?.profile?.name ?? '未知'
|
||||
|
||||
// 数组安全检查
|
||||
const items = Array.isArray(response.data) ? response.data : []
|
||||
|
||||
// 模板中的存在性守卫
|
||||
<template v-if="user">{{ user.name }}</template>
|
||||
<v-empty-state v-else />
|
||||
```
|
||||
|
||||
## UI 状态三位一体
|
||||
|
||||
每个数据驱动视图必须处理三种状态:
|
||||
|
||||
| 状态 | 组件 | 禁止行为 |
|
||||
|------|------|----------|
|
||||
| **加载中** | `v-skeleton-loader` | 显示过期数据或空白屏幕 |
|
||||
| **空数据** | `v-empty-state` + 操作按钮 | 留下白茫茫一片 |
|
||||
| **错误** | Snackbar + 重试选项 | 静默失败 |
|
||||
|
||||
## 表格与列表戒律
|
||||
|
||||
- 每个 `v-data-table` 都要 `fixed-header` — 没有商量余地
|
||||
- 截断文本必须配 `v-tooltip` — 用户有权 hover 看到完整内容
|
||||
- 100+ 条数据?用 `v-virtual-scroll` — DOM 节点数保持恒定
|
||||
- 列宽显式指定 — 不玩布局抽奖
|
||||
|
||||
## 反模式(绝不允许)
|
||||
|
||||
- TypeScript 项目中出现 `.js` 文件
|
||||
- 没有正当理由使用 `any`
|
||||
- 硬编码颜色:`color="#1976d2"` → 应该用 `color="primary"`
|
||||
- SPA 布局中出现 body 级滚动
|
||||
- 表格没有固定表头
|
||||
- 截断文本没有 tooltip
|
||||
- 空状态真的"空空如也"
|
||||
- 加载状态冻结 UI
|
||||
- API 调用没有错误处理
|
||||
|
||||
## 参考文件
|
||||
|
||||
需要实现细节时查阅:
|
||||
|
||||
| 需求 | 文件 |
|
||||
|------|------|
|
||||
| 高级 TypeScript 模式 | `reference/typescript-rules.md` |
|
||||
| 复杂布局结构 | `reference/layout-patterns.md` |
|
||||
| API 客户端架构 | `reference/api-patterns.md` |
|
||||
| 表格、列表、表单、反馈 | `reference/ui-interaction.md` |
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # Axios 实例 + 模块
|
||||
├── components/ # 共享组件
|
||||
├── composables/ # 可复用 hooks
|
||||
├── layouts/ # 页面外壳
|
||||
├── pages/ # 路由视图
|
||||
├── plugins/ # Vuetify, Pinia, Router
|
||||
├── store/ # Pinia stores
|
||||
├── styles/ # 全局 SCSS
|
||||
├── types/ # 类型定义
|
||||
└── utils/ # 纯函数
|
||||
```
|
||||
|
||||
## 输出规范
|
||||
|
||||
1. 陈述架构方案(2-3 句话)
|
||||
2. 列出要创建的文件及其用途
|
||||
3. 完整实现每个文件 — 无占位符,无 TODO
|
||||
4. 对照反模式清单验证
|
||||
5. 指出任何假设或权衡取舍
|
||||
|
||||
---
|
||||
|
||||
记住:你不是在写"能跑的代码"。你是在写能跑、能扩展、能维护、能令人愉悦的代码。每个 `ref` 都有类型。每个边界情况都有处理。每个加载状态都很美观。这就是"生产级"的含义。
|
||||
113
.agents/skills/frontend-vue3-vuetify/examples/api-module.ts
Normal file
113
.agents/skills/frontend-vue3-vuetify/examples/api-module.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
// @/api/modules/user.ts
|
||||
import request from '@/api'
|
||||
import type { PageParams, PageResult } from '@/types/api'
|
||||
|
||||
// ============================================
|
||||
// 类型定义
|
||||
// ============================================
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
avatar?: string
|
||||
status: 'active' | 'disabled'
|
||||
role: 'admin' | 'user' | 'guest'
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateUserDto {
|
||||
name: string
|
||||
email: string
|
||||
password: string
|
||||
role?: User['role']
|
||||
}
|
||||
|
||||
export interface UpdateUserDto {
|
||||
name?: string
|
||||
email?: string
|
||||
status?: User['status']
|
||||
role?: User['role']
|
||||
}
|
||||
|
||||
export interface UserListParams extends PageParams {
|
||||
search?: string
|
||||
status?: User['status']
|
||||
role?: User['role']
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API 封装
|
||||
// ============================================
|
||||
|
||||
export const userApi = {
|
||||
/**
|
||||
* 获取用户分页列表
|
||||
*/
|
||||
getPage: (params: UserListParams) =>
|
||||
request.get<PageResult<User>>('/users', { params }),
|
||||
|
||||
/**
|
||||
* 获取用户列表(无分页,用于下拉选择等场景)
|
||||
*/
|
||||
getList: (params?: Partial<UserListParams>) =>
|
||||
request.get<User[]>('/users/list', { params }),
|
||||
|
||||
/**
|
||||
* 获取用户详情
|
||||
*/
|
||||
getById: (id: string) =>
|
||||
request.get<User>(`/users/${id}`),
|
||||
|
||||
/**
|
||||
* 检查邮箱是否已存在
|
||||
*/
|
||||
checkEmail: (email: string) =>
|
||||
request.get<{ exists: boolean }>('/users/check-email', { params: { email } }),
|
||||
|
||||
/**
|
||||
* 创建用户
|
||||
*/
|
||||
create: (data: CreateUserDto) =>
|
||||
request.post<User>('/users', data),
|
||||
|
||||
/**
|
||||
* 更新用户
|
||||
*/
|
||||
update: (id: string, data: UpdateUserDto) =>
|
||||
request.put<User>(`/users/${id}`, data),
|
||||
|
||||
/**
|
||||
* 删除用户
|
||||
*/
|
||||
remove: (id: string) =>
|
||||
request.delete<void>(`/users/${id}`),
|
||||
|
||||
/**
|
||||
* 批量删除用户
|
||||
*/
|
||||
batchRemove: (ids: string[]) =>
|
||||
request.post<void>('/users/batch-delete', { ids }),
|
||||
|
||||
/**
|
||||
* 启用/禁用用户
|
||||
*/
|
||||
toggleStatus: (id: string, status: User['status']) =>
|
||||
request.patch<User>(`/users/${id}/status`, { status }),
|
||||
|
||||
/**
|
||||
* 重置用户密码
|
||||
*/
|
||||
resetPassword: (id: string) =>
|
||||
request.post<{ tempPassword: string }>(`/users/${id}/reset-password`),
|
||||
|
||||
/**
|
||||
* 导出用户列表
|
||||
*/
|
||||
export: (params?: UserListParams) =>
|
||||
request.get<Blob>('/users/export', {
|
||||
params,
|
||||
responseType: 'blob',
|
||||
}),
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
<template>
|
||||
<div class="d-flex flex-column h-100">
|
||||
<!-- 页面头部 -->
|
||||
<v-toolbar density="compact" class="flex-shrink-0">
|
||||
<v-toolbar-title>订单管理</v-toolbar-title>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
icon="mdi-refresh"
|
||||
:loading="loading"
|
||||
@click="fetchData"
|
||||
/>
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-download"
|
||||
:loading="exporting"
|
||||
@click="exportData"
|
||||
>
|
||||
导出
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
|
||||
<!-- 筛选栏 - 粘性定位 -->
|
||||
<v-sheet class="flex-shrink-0 pa-4 sticky-filter">
|
||||
<v-row dense>
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-text-field
|
||||
v-model="filters.search"
|
||||
label="搜索订单号/客户名"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
clearable
|
||||
hide-details
|
||||
@update:model-value="debouncedFetch"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="2">
|
||||
<v-select
|
||||
v-model="filters.status"
|
||||
:items="statusOptions"
|
||||
label="状态"
|
||||
clearable
|
||||
hide-details
|
||||
@update:model-value="fetchData"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-text-field
|
||||
v-model="filters.dateRange"
|
||||
label="日期范围"
|
||||
prepend-inner-icon="mdi-calendar"
|
||||
readonly
|
||||
hide-details
|
||||
@click="showDatePicker = true"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="2">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
block
|
||||
@click="resetFilters"
|
||||
>
|
||||
重置筛选
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-sheet>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<!-- 主内容区 - 可滚动 -->
|
||||
<div class="flex-grow-1 overflow-y-auto" style="min-height: 0">
|
||||
<!-- 加载状态 -->
|
||||
<v-skeleton-loader
|
||||
v-if="loading && !orders.length"
|
||||
type="table-heading, table-row@8"
|
||||
class="ma-4"
|
||||
/>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<v-empty-state
|
||||
v-else-if="!orders.length"
|
||||
icon="mdi-package-variant"
|
||||
title="暂无订单"
|
||||
text="当前筛选条件下没有找到订单记录"
|
||||
>
|
||||
<template #actions>
|
||||
<v-btn variant="outlined" @click="resetFilters">
|
||||
清除筛选
|
||||
</v-btn>
|
||||
<v-btn color="primary" @click="fetchData">
|
||||
刷新
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-empty-state>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<v-data-table-server
|
||||
v-else
|
||||
v-model:items-per-page="pagination.pageSize"
|
||||
v-model:page="pagination.page"
|
||||
:headers="headers"
|
||||
:items="orders"
|
||||
:items-length="pagination.total"
|
||||
:loading="loading"
|
||||
fixed-header
|
||||
hover
|
||||
@update:options="onOptionsChange"
|
||||
>
|
||||
<!-- 订单号 - 可点击 -->
|
||||
<template #item.orderNo="{ item }">
|
||||
<a
|
||||
href="#"
|
||||
class="text-primary text-decoration-none"
|
||||
@click.prevent="viewDetail(item)"
|
||||
>
|
||||
{{ item.orderNo }}
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<!-- 客户名 - 截断 + Tooltip -->
|
||||
<template #item.customerName="{ value }">
|
||||
<v-tooltip :text="value" location="top">
|
||||
<template #activator="{ props }">
|
||||
<span
|
||||
v-bind="props"
|
||||
class="text-truncate d-inline-block"
|
||||
style="max-width: 120px"
|
||||
>
|
||||
{{ value }}
|
||||
</span>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
|
||||
<!-- 金额 - 格式化 -->
|
||||
<template #item.amount="{ value }">
|
||||
<span class="font-weight-medium">
|
||||
¥{{ formatNumber(value) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- 状态 - Chip -->
|
||||
<template #item.status="{ value }">
|
||||
<v-chip
|
||||
:color="getStatusColor(value)"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ getStatusText(value) }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<!-- 备注 - 多行截断 -->
|
||||
<template #item.remark="{ value }">
|
||||
<div v-if="value" class="remark-cell">
|
||||
<v-tooltip :text="value" location="top" max-width="300">
|
||||
<template #activator="{ props }">
|
||||
<span v-bind="props" class="line-clamp-2">
|
||||
{{ value }}
|
||||
</span>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
<span v-else class="text-grey">-</span>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #item.actions="{ item }">
|
||||
<v-btn
|
||||
icon="mdi-eye"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="viewDetail(item)"
|
||||
/>
|
||||
<v-btn
|
||||
icon="mdi-pencil"
|
||||
size="small"
|
||||
variant="text"
|
||||
:disabled="item.status === 'completed'"
|
||||
@click="editOrder(item)"
|
||||
/>
|
||||
<v-menu>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon="mdi-dots-vertical"
|
||||
size="small"
|
||||
variant="text"
|
||||
/>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item @click="copyOrderNo(item)">
|
||||
<template #prepend>
|
||||
<v-icon size="small">mdi-content-copy</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>复制订单号</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
:disabled="item.status !== 'pending'"
|
||||
@click="cancelOrder(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon size="small" color="error">mdi-cancel</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-error">取消订单</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<!-- 空数据插槽 -->
|
||||
<template #no-data>
|
||||
<v-empty-state
|
||||
icon="mdi-database-off"
|
||||
title="暂无数据"
|
||||
text="请尝试调整筛选条件"
|
||||
/>
|
||||
</template>
|
||||
</v-data-table-server>
|
||||
</div>
|
||||
|
||||
<!-- 底部统计栏 -->
|
||||
<v-sheet class="flex-shrink-0 pa-2 border-t d-flex align-center justify-space-between">
|
||||
<span class="text-body-2 text-grey">
|
||||
共 {{ pagination.total }} 条记录
|
||||
</span>
|
||||
<span class="text-body-2">
|
||||
已选 <strong>{{ selectedCount }}</strong> 条
|
||||
<v-btn
|
||||
v-if="selectedCount > 0"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="primary"
|
||||
@click="batchAction"
|
||||
>
|
||||
批量操作
|
||||
</v-btn>
|
||||
</span>
|
||||
</v-sheet>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import type { Order, OrderStatus } from '@/types/order'
|
||||
import { orderApi } from '@/api/modules/order'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
|
||||
// Composables
|
||||
const snackbar = useSnackbar()
|
||||
|
||||
// State
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
const showDatePicker = ref(false)
|
||||
const orders = ref<Order[]>([])
|
||||
const selectedCount = ref(0)
|
||||
|
||||
const filters = reactive({
|
||||
search: '',
|
||||
status: null as OrderStatus | null,
|
||||
dateRange: '',
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
// Table Headers
|
||||
const headers = [
|
||||
{ title: '订单号', key: 'orderNo', width: 160 },
|
||||
{ title: '客户名称', key: 'customerName', width: 150 },
|
||||
{ title: '金额', key: 'amount', width: 120, align: 'end' as const },
|
||||
{ title: '状态', key: 'status', width: 100 },
|
||||
{ title: '备注', key: 'remark', width: 200 },
|
||||
{ title: '创建时间', key: 'createdAt', width: 170 },
|
||||
{ title: '操作', key: 'actions', width: 140, sortable: false },
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ title: '全部', value: null },
|
||||
{ title: '待处理', value: 'pending' },
|
||||
{ title: '处理中', value: 'processing' },
|
||||
{ title: '已完成', value: 'completed' },
|
||||
{ title: '已取消', value: 'cancelled' },
|
||||
]
|
||||
|
||||
// Methods
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await orderApi.getPage({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
search: filters.search || undefined,
|
||||
status: filters.status || undefined,
|
||||
})
|
||||
orders.value = result.list
|
||||
pagination.total = result.total
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch orders:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedFetch = useDebounceFn(fetchData, 300)
|
||||
|
||||
function onOptionsChange(options: { page: number; itemsPerPage: number }) {
|
||||
pagination.page = options.page
|
||||
pagination.pageSize = options.itemsPerPage
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.search = ''
|
||||
filters.status = null
|
||||
filters.dateRange = ''
|
||||
pagination.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
async function exportData() {
|
||||
exporting.value = true
|
||||
try {
|
||||
const blob = await orderApi.export(filters)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `orders_${Date.now()}.xlsx`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
snackbar.success('导出成功')
|
||||
} catch (error) {
|
||||
snackbar.error('导出失败')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function viewDetail(item: Order) {
|
||||
// Navigate to detail page
|
||||
}
|
||||
|
||||
function editOrder(item: Order) {
|
||||
// Open edit dialog
|
||||
}
|
||||
|
||||
function copyOrderNo(item: Order) {
|
||||
navigator.clipboard.writeText(item.orderNo)
|
||||
snackbar.success('订单号已复制')
|
||||
}
|
||||
|
||||
function cancelOrder(item: Order) {
|
||||
// Show confirm dialog
|
||||
}
|
||||
|
||||
function batchAction() {
|
||||
// Show batch action menu
|
||||
}
|
||||
|
||||
// Helpers
|
||||
function formatNumber(value: number): string {
|
||||
return value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
function getStatusColor(status: OrderStatus): string {
|
||||
const colors: Record<OrderStatus, string> = {
|
||||
pending: 'warning',
|
||||
processing: 'info',
|
||||
completed: 'success',
|
||||
cancelled: 'grey',
|
||||
}
|
||||
return colors[status] || 'grey'
|
||||
}
|
||||
|
||||
function getStatusText(status: OrderStatus): string {
|
||||
const texts: Record<OrderStatus, string> = {
|
||||
pending: '待处理',
|
||||
processing: '处理中',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消',
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sticky-filter {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.remark-cell {
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
155
.agents/skills/frontend-vue3-vuetify/examples/page-layout.vue
Normal file
155
.agents/skills/frontend-vue3-vuetify/examples/page-layout.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<v-container fluid class="d-flex flex-column h-100 pa-0">
|
||||
<!-- 固定工具栏 -->
|
||||
<v-toolbar density="compact" class="flex-shrink-0">
|
||||
<v-toolbar-title>用户管理</v-toolbar-title>
|
||||
<v-spacer />
|
||||
<v-btn icon="mdi-refresh" :loading="loading" @click="fetchData" />
|
||||
<v-btn color="primary" prepend-icon="mdi-plus" @click="openCreate">
|
||||
新建
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
|
||||
<!-- 筛选区域 -->
|
||||
<v-sheet class="flex-shrink-0 pa-4">
|
||||
<v-row dense>
|
||||
<v-col cols="12" md="3">
|
||||
<v-text-field
|
||||
v-model="filters.search"
|
||||
label="搜索"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
clearable
|
||||
hide-details
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-select
|
||||
v-model="filters.status"
|
||||
:items="statusOptions"
|
||||
label="状态"
|
||||
clearable
|
||||
hide-details
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-sheet>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<!-- 可滚动内容区 -->
|
||||
<div class="flex-grow-1 overflow-y-auto" style="min-height: 0">
|
||||
<v-skeleton-loader v-if="loading" type="table-heading, table-row@10" />
|
||||
|
||||
<v-empty-state
|
||||
v-else-if="!users.length"
|
||||
icon="mdi-account-off"
|
||||
title="暂无用户"
|
||||
text="点击新建按钮添加第一个用户"
|
||||
>
|
||||
<template #actions>
|
||||
<v-btn color="primary" @click="openCreate">新建用户</v-btn>
|
||||
</template>
|
||||
</v-empty-state>
|
||||
|
||||
<v-data-table
|
||||
v-else
|
||||
:headers="headers"
|
||||
:items="users"
|
||||
fixed-header
|
||||
hover
|
||||
>
|
||||
<template #item.name="{ value }">
|
||||
<v-tooltip :text="value" location="top">
|
||||
<template #activator="{ props }">
|
||||
<span
|
||||
v-bind="props"
|
||||
class="text-truncate d-inline-block"
|
||||
style="max-width: 150px"
|
||||
>
|
||||
{{ value }}
|
||||
</span>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
|
||||
<template #item.status="{ value }">
|
||||
<v-chip
|
||||
:color="value === 'active' ? 'success' : 'grey'"
|
||||
size="small"
|
||||
>
|
||||
{{ value === 'active' ? '活跃' : '禁用' }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<v-btn
|
||||
icon="mdi-pencil"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="edit(item)"
|
||||
/>
|
||||
<v-btn
|
||||
icon="mdi-delete"
|
||||
size="small"
|
||||
variant="text"
|
||||
color="error"
|
||||
@click="remove(item)"
|
||||
/>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</div>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import type { User } from '@/types/user'
|
||||
import { userApi } from '@/api/modules/user'
|
||||
|
||||
// State
|
||||
const loading = ref(false)
|
||||
const users = ref<User[]>([])
|
||||
const filters = reactive({
|
||||
search: '',
|
||||
status: null as string | null,
|
||||
})
|
||||
|
||||
// Table config
|
||||
const headers = [
|
||||
{ title: '姓名', key: 'name', width: 200 },
|
||||
{ title: '邮箱', key: 'email' },
|
||||
{ title: '状态', key: 'status', width: 120 },
|
||||
{ title: '创建时间', key: 'createdAt', width: 180 },
|
||||
{ title: '操作', key: 'actions', width: 120, sortable: false },
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ title: '全部', value: null },
|
||||
{ title: '活跃', value: 'active' },
|
||||
{ title: '禁用', value: 'disabled' },
|
||||
]
|
||||
|
||||
// Methods
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
users.value = await userApi.getList(filters)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
// TODO: Open create dialog
|
||||
}
|
||||
|
||||
function edit(item: User) {
|
||||
// TODO: Open edit dialog
|
||||
}
|
||||
|
||||
function remove(item: User) {
|
||||
// TODO: Confirm and delete
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
238
.agents/skills/frontend-vue3-vuetify/reference/api-patterns.md
Normal file
238
.agents/skills/frontend-vue3-vuetify/reference/api-patterns.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# API 客户端模式
|
||||
|
||||
## 标准响应类型
|
||||
|
||||
```typescript
|
||||
// @/types/api.d.ts
|
||||
export interface ApiResponse<T = unknown> {
|
||||
code: number
|
||||
status: number
|
||||
timestamp: string
|
||||
data: T
|
||||
message?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface PageParams {
|
||||
page: number
|
||||
pageSize: number
|
||||
sort?: string
|
||||
order?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export interface PageResult<T> {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export enum ApiErrorCode {
|
||||
Success = 0,
|
||||
ServerError = 10001,
|
||||
ParamError = 10002,
|
||||
Unauthorized = 10003,
|
||||
Forbidden = 10004,
|
||||
NotFound = 10005,
|
||||
Timeout = 10006,
|
||||
ValidationFail = 10007,
|
||||
BusinessError = 20001,
|
||||
}
|
||||
```
|
||||
|
||||
## Axios 实例
|
||||
|
||||
```typescript
|
||||
// @/api/index.ts
|
||||
import axios from 'axios'
|
||||
import { setupInterceptors } from './interceptors'
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
setupInterceptors(request)
|
||||
|
||||
export default request
|
||||
```
|
||||
|
||||
## 响应拦截器
|
||||
|
||||
```typescript
|
||||
// @/api/interceptors.ts
|
||||
import type { AxiosInstance, AxiosResponse } from 'axios'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import router from '@/router'
|
||||
import { ApiErrorCode, type ApiResponse } from '@/types/api'
|
||||
|
||||
export function setupInterceptors(instance: AxiosInstance) {
|
||||
// 请求拦截器
|
||||
instance.interceptors.request.use(
|
||||
(config) => {
|
||||
const authStore = useAuthStore()
|
||||
if (authStore.token) {
|
||||
config.headers.Authorization = `Bearer ${authStore.token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
instance.interceptors.response.use(
|
||||
(response: AxiosResponse<ApiResponse>) => {
|
||||
const { code, data, message } = response.data
|
||||
|
||||
// 业务成功
|
||||
if (code === ApiErrorCode.Success) {
|
||||
return data
|
||||
}
|
||||
|
||||
// 业务失败
|
||||
const snackbar = useSnackbar()
|
||||
snackbar.error(message || '操作失败')
|
||||
|
||||
return Promise.reject(new Error(message))
|
||||
},
|
||||
(error) => {
|
||||
const snackbar = useSnackbar()
|
||||
const status = error.response?.status
|
||||
|
||||
switch (status) {
|
||||
case 401:
|
||||
useAuthStore().logout()
|
||||
router.push('/login')
|
||||
snackbar.error('登录已过期,请重新登录')
|
||||
break
|
||||
case 403:
|
||||
snackbar.error('无权访问')
|
||||
break
|
||||
case 404:
|
||||
snackbar.error('请求的资源不存在')
|
||||
break
|
||||
case 500:
|
||||
snackbar.error('服务器错误,请稍后重试')
|
||||
break
|
||||
default:
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
snackbar.error('请求超时,请检查网络')
|
||||
} else if (!error.response) {
|
||||
snackbar.error('网络连接失败')
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## API 模块模板
|
||||
|
||||
```typescript
|
||||
// @/api/modules/[domain].ts
|
||||
import request from '@/api'
|
||||
import type { PageParams, PageResult } from '@/types/api'
|
||||
import type { Entity, CreateDto, UpdateDto } from '@/types/[domain]'
|
||||
|
||||
export const entityApi = {
|
||||
// 分页列表
|
||||
getPage: (params: PageParams) =>
|
||||
request.get<PageResult<Entity>>('/entities', { params }),
|
||||
|
||||
// 详情
|
||||
getById: (id: string) =>
|
||||
request.get<Entity>(`/entities/${id}`),
|
||||
|
||||
// 新增
|
||||
create: (data: CreateDto) =>
|
||||
request.post<Entity>('/entities', data),
|
||||
|
||||
// 更新
|
||||
update: (id: string, data: UpdateDto) =>
|
||||
request.put<Entity>(`/entities/${id}`, data),
|
||||
|
||||
// 删除
|
||||
remove: (id: string) =>
|
||||
request.delete<void>(`/entities/${id}`),
|
||||
}
|
||||
```
|
||||
|
||||
## 请求取消处理
|
||||
|
||||
```typescript
|
||||
// composables/useCancelableRequest.ts
|
||||
import { onUnmounted } from 'vue'
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
|
||||
export function useCancelableRequest() {
|
||||
const controller = new AbortController()
|
||||
|
||||
onUnmounted(() => {
|
||||
controller.abort()
|
||||
})
|
||||
|
||||
function withCancel<T>(
|
||||
requestFn: (config?: AxiosRequestConfig) => Promise<T>
|
||||
): Promise<T> {
|
||||
return requestFn({ signal: controller.signal })
|
||||
}
|
||||
|
||||
return { withCancel, abort: () => controller.abort() }
|
||||
}
|
||||
```
|
||||
|
||||
## 请求重试
|
||||
|
||||
```typescript
|
||||
// utils/retryRequest.ts
|
||||
export async function retryRequest<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: { retries?: number; delay?: number } = {}
|
||||
): Promise<T> {
|
||||
const { retries = 3, delay = 1000 } = options
|
||||
|
||||
for (let attempt = 0; attempt < retries; attempt++) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error) {
|
||||
if (attempt === retries - 1) throw error
|
||||
await new Promise((resolve) => setTimeout(resolve, delay * (attempt + 1)))
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Max retries exceeded')
|
||||
}
|
||||
```
|
||||
|
||||
## 并发请求处理
|
||||
|
||||
```typescript
|
||||
// 使用 Promise.all 并发请求
|
||||
async function fetchDashboardData() {
|
||||
const [users, orders, stats] = await Promise.all([
|
||||
userApi.getList(),
|
||||
orderApi.getRecent(),
|
||||
statsApi.getSummary(),
|
||||
])
|
||||
|
||||
return { users, orders, stats }
|
||||
}
|
||||
|
||||
// 使用 Promise.allSettled 处理部分失败
|
||||
async function fetchWithFallback() {
|
||||
const results = await Promise.allSettled([
|
||||
userApi.getList(),
|
||||
orderApi.getList(),
|
||||
])
|
||||
|
||||
return results.map((result) =>
|
||||
result.status === 'fulfilled' ? result.value : []
|
||||
)
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,182 @@
|
||||
# 布局模式参考
|
||||
|
||||
## 标准页面骨架
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<v-container fluid class="d-flex flex-column h-100 pa-0">
|
||||
<!-- 固定头部 -->
|
||||
<v-toolbar density="compact" class="flex-shrink-0">
|
||||
<v-toolbar-title>页面标题</v-toolbar-title>
|
||||
<v-spacer />
|
||||
<v-btn icon="mdi-refresh" @click="refresh" />
|
||||
</v-toolbar>
|
||||
|
||||
<!-- 可滚动内容区 -->
|
||||
<div class="flex-grow-1 overflow-y-auto pa-4" style="min-height: 0">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- 固定底部(可选) -->
|
||||
<v-footer app class="flex-shrink-0">
|
||||
<v-btn block color="primary">操作</v-btn>
|
||||
</v-footer>
|
||||
</v-container>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Flexbox 滚动陷阱解决方案
|
||||
|
||||
```css
|
||||
/* 问题:子元素撑破父容器 */
|
||||
.parent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex-grow: 1;
|
||||
/* 必须添加以下任一属性 */
|
||||
min-height: 0; /* 推荐 */
|
||||
/* 或 */
|
||||
overflow: hidden;
|
||||
}
|
||||
```
|
||||
|
||||
## 粘性筛选栏
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="flex-grow-1 overflow-y-auto" style="min-height: 0">
|
||||
<!-- 粘性筛选区 -->
|
||||
<div class="sticky-top bg-surface pa-4" style="z-index: 1">
|
||||
<v-row>
|
||||
<v-col cols="4">
|
||||
<v-text-field v-model="search" label="搜索" />
|
||||
</v-col>
|
||||
<v-col cols="4">
|
||||
<v-select v-model="status" :items="statusOptions" label="状态" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- 列表内容 -->
|
||||
<v-list>...</v-list>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sticky-top {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## 分栏布局(侧边栏 + 主内容)
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="d-flex h-100">
|
||||
<!-- 固定宽度侧边栏 -->
|
||||
<v-navigation-drawer permanent width="280">
|
||||
<v-list nav>...</v-list>
|
||||
</v-navigation-drawer>
|
||||
|
||||
<!-- 自适应主内容 -->
|
||||
<div class="flex-grow-1 d-flex flex-column" style="min-width: 0">
|
||||
<v-toolbar>主内容头部</v-toolbar>
|
||||
<div class="flex-grow-1 overflow-y-auto pa-4" style="min-height: 0">
|
||||
主内容区域
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 双栏详情布局
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="d-flex flex-column h-100">
|
||||
<v-toolbar density="compact" class="flex-shrink-0">
|
||||
<v-btn icon="mdi-arrow-left" @click="goBack" />
|
||||
<v-toolbar-title>详情</v-toolbar-title>
|
||||
</v-toolbar>
|
||||
|
||||
<div class="flex-grow-1 d-flex" style="min-height: 0">
|
||||
<!-- 左侧主信息 -->
|
||||
<div class="flex-grow-1 overflow-y-auto pa-4" style="min-width: 0">
|
||||
<v-card>...</v-card>
|
||||
</div>
|
||||
|
||||
<!-- 右侧边栏 -->
|
||||
<v-sheet width="320" class="flex-shrink-0 overflow-y-auto border-s">
|
||||
<v-list>相关信息</v-list>
|
||||
</v-sheet>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Tab 切换布局
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="d-flex flex-column h-100">
|
||||
<v-tabs v-model="activeTab" class="flex-shrink-0">
|
||||
<v-tab value="info">基本信息</v-tab>
|
||||
<v-tab value="logs">操作日志</v-tab>
|
||||
<v-tab value="settings">设置</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-tabs-window v-model="activeTab" class="flex-grow-1" style="min-height: 0">
|
||||
<v-tabs-window-item value="info" class="h-100 overflow-y-auto">
|
||||
<!-- 内容 -->
|
||||
</v-tabs-window-item>
|
||||
<v-tabs-window-item value="logs" class="h-100 overflow-y-auto">
|
||||
<!-- 内容 -->
|
||||
</v-tabs-window-item>
|
||||
<v-tabs-window-item value="settings" class="h-100 overflow-y-auto">
|
||||
<!-- 内容 -->
|
||||
</v-tabs-window-item>
|
||||
</v-tabs-window>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 响应式断点处理
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
const { mobile, mdAndUp, lgAndUp } = useDisplay()
|
||||
|
||||
// 根据屏幕尺寸调整列数
|
||||
const gridCols = computed(() => {
|
||||
if (lgAndUp.value) return 4
|
||||
if (mdAndUp.value) return 3
|
||||
return 2
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-row>
|
||||
<v-col v-for="item in items" :key="item.id" :cols="12 / gridCols">
|
||||
<v-card>...</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 移动端特殊处理 -->
|
||||
<v-bottom-navigation v-if="mobile" grow>
|
||||
<v-btn value="home">
|
||||
<v-icon>mdi-home</v-icon>
|
||||
<span>首页</span>
|
||||
</v-btn>
|
||||
</v-bottom-navigation>
|
||||
</template>
|
||||
```
|
||||
@@ -0,0 +1,142 @@
|
||||
# TypeScript 严格规范
|
||||
|
||||
## 类型定义位置
|
||||
|
||||
```
|
||||
src/types/
|
||||
├── api.d.ts # ApiResponse, PageParams, etc.
|
||||
├── user.d.ts # User domain types
|
||||
├── order.d.ts # Order domain types
|
||||
└── common.d.ts # Shared utilities
|
||||
```
|
||||
|
||||
## 禁止 `any` 的替代方案
|
||||
|
||||
| 场景 | 错误 | 正确 |
|
||||
|------|------|------|
|
||||
| 未知对象 | `any` | `Record<string, unknown>` |
|
||||
| 动态数组 | `any[]` | `unknown[]` + type guard |
|
||||
| 回调函数 | `(x: any) => any` | 泛型 `<T>(x: T) => T` |
|
||||
| 第三方库缺失类型 | `any` | 创建 `.d.ts` 声明文件 |
|
||||
|
||||
## Props 与 Emits 类型化
|
||||
|
||||
```typescript
|
||||
// Props
|
||||
interface Props {
|
||||
user: User
|
||||
mode?: 'view' | 'edit'
|
||||
onSave?: (data: User) => void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
mode: 'view',
|
||||
})
|
||||
|
||||
// Emits
|
||||
interface Emits {
|
||||
(e: 'update', value: User): void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
```
|
||||
|
||||
## 泛型组合式函数
|
||||
|
||||
```typescript
|
||||
// composables/useFetch.ts
|
||||
export function useFetch<T>(
|
||||
fetcher: () => Promise<T>,
|
||||
options?: { immediate?: boolean }
|
||||
) {
|
||||
const data = ref<T | null>(null) as Ref<T | null>
|
||||
const loading = ref(false)
|
||||
const error = ref<Error | null>(null)
|
||||
|
||||
async function execute() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
data.value = await fetcher()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e : new Error(String(e))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.immediate !== false) {
|
||||
onMounted(execute)
|
||||
}
|
||||
|
||||
return { data, loading, error, execute }
|
||||
}
|
||||
```
|
||||
|
||||
## 类型守卫
|
||||
|
||||
```typescript
|
||||
function isUser(obj: unknown): obj is User {
|
||||
return (
|
||||
typeof obj === 'object' &&
|
||||
obj !== null &&
|
||||
'id' in obj &&
|
||||
'name' in obj
|
||||
)
|
||||
}
|
||||
|
||||
// 使用
|
||||
const data: unknown = await fetchData()
|
||||
if (isUser(data)) {
|
||||
console.log(data.name) // TypeScript 知道这是 User
|
||||
}
|
||||
```
|
||||
|
||||
## 联合类型与字面量类型
|
||||
|
||||
```typescript
|
||||
// 状态枚举替代方案
|
||||
type UserStatus = 'active' | 'disabled' | 'pending'
|
||||
|
||||
// 带类型的事件处理
|
||||
type TableAction =
|
||||
| { type: 'edit'; payload: User }
|
||||
| { type: 'delete'; payload: string }
|
||||
| { type: 'view'; payload: User }
|
||||
|
||||
function handleAction(action: TableAction) {
|
||||
switch (action.type) {
|
||||
case 'edit':
|
||||
openEditDialog(action.payload) // payload 是 User
|
||||
break
|
||||
case 'delete':
|
||||
confirmDelete(action.payload) // payload 是 string (id)
|
||||
break
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 工具类型使用
|
||||
|
||||
```typescript
|
||||
// Partial - 所有属性可选
|
||||
type UpdateUserDto = Partial<User>
|
||||
|
||||
// Pick - 选择部分属性
|
||||
type UserPreview = Pick<User, 'id' | 'name' | 'avatar'>
|
||||
|
||||
// Omit - 排除部分属性
|
||||
type CreateUserDto = Omit<User, 'id' | 'createdAt' | 'updatedAt'>
|
||||
|
||||
// Required - 所有属性必填
|
||||
type CompleteUser = Required<User>
|
||||
|
||||
// Record - 键值映射
|
||||
type UserMap = Record<string, User>
|
||||
|
||||
// 自定义工具类型
|
||||
type Nullable<T> = T | null
|
||||
type AsyncReturnType<T extends (...args: any) => Promise<any>> =
|
||||
T extends (...args: any) => Promise<infer R> ? R : never
|
||||
```
|
||||
345
.agents/skills/frontend-vue3-vuetify/reference/ui-interaction.md
Normal file
345
.agents/skills/frontend-vue3-vuetify/reference/ui-interaction.md
Normal file
@@ -0,0 +1,345 @@
|
||||
# UI 交互规范
|
||||
|
||||
## 数据表格
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="items"
|
||||
:loading="loading"
|
||||
fixed-header
|
||||
height="calc(100vh - 200px)"
|
||||
>
|
||||
<!-- 截断文本 + Tooltip -->
|
||||
<template #item.description="{ value }">
|
||||
<v-tooltip :text="value" location="top">
|
||||
<template #activator="{ props }">
|
||||
<span
|
||||
v-bind="props"
|
||||
class="text-truncate d-inline-block"
|
||||
style="max-width: 200px"
|
||||
>
|
||||
{{ value }}
|
||||
</span>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<template #no-data>
|
||||
<v-empty-state
|
||||
icon="mdi-database-off"
|
||||
title="暂无数据"
|
||||
text="请尝试调整筛选条件或新建记录"
|
||||
>
|
||||
<template #actions>
|
||||
<v-btn color="primary" @click="refresh">刷新</v-btn>
|
||||
</template>
|
||||
</v-empty-state>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 虚拟滚动列表
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<v-virtual-scroll :items="largeList" height="400" item-height="64">
|
||||
<template #default="{ item }">
|
||||
<v-list-item :title="item.name" :subtitle="item.description">
|
||||
<template #append>
|
||||
<v-btn icon="mdi-chevron-right" variant="text" />
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-virtual-scroll>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 骨架屏加载
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<!-- 表格骨架 -->
|
||||
<v-skeleton-loader v-if="loading" type="table-heading, table-row@5" />
|
||||
|
||||
<!-- 卡片骨架 -->
|
||||
<v-skeleton-loader v-if="loading" type="card" />
|
||||
|
||||
<!-- 列表骨架 -->
|
||||
<v-skeleton-loader v-if="loading" type="list-item-avatar-two-line@3" />
|
||||
|
||||
<!-- 文章骨架 -->
|
||||
<v-skeleton-loader v-if="loading" type="article" />
|
||||
|
||||
<!-- 自定义组合骨架 -->
|
||||
<v-skeleton-loader
|
||||
v-if="loading"
|
||||
type="heading, list-item-two-line@3, actions"
|
||||
/>
|
||||
|
||||
<!-- 实际内容 -->
|
||||
<template v-else>...</template>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 空状态组件
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<v-empty-state
|
||||
:icon="icon"
|
||||
:title="title"
|
||||
:text="description"
|
||||
>
|
||||
<template #actions>
|
||||
<v-btn v-if="showRefresh" variant="outlined" @click="$emit('refresh')">
|
||||
刷新
|
||||
</v-btn>
|
||||
<v-btn v-if="showCreate" color="primary" @click="$emit('create')">
|
||||
新建
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-empty-state>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
icon?: string
|
||||
title?: string
|
||||
description?: string
|
||||
showRefresh?: boolean
|
||||
showCreate?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
icon: 'mdi-folder-open-outline',
|
||||
title: '暂无数据',
|
||||
description: '当前没有可显示的内容',
|
||||
showRefresh: true,
|
||||
showCreate: false,
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
(e: 'refresh'): void
|
||||
(e: 'create'): void
|
||||
}>()
|
||||
</script>
|
||||
```
|
||||
|
||||
## 多行文本截断
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="line-clamp-container">
|
||||
<p :class="{ 'line-clamp-2': !expanded }">{{ longText }}</p>
|
||||
<v-btn
|
||||
v-if="needsExpand"
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
{{ expanded ? '收起' : '展开' }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## 确认对话框
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<v-dialog v-model="dialog" max-width="400" persistent>
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-icon :color="iconColor" class="mr-2">{{ icon }}</v-icon>
|
||||
{{ title }}
|
||||
</v-card-title>
|
||||
<v-card-text>{{ message }}</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="cancel">取消</v-btn>
|
||||
<v-btn :color="confirmColor" :loading="loading" @click="confirm">
|
||||
{{ confirmText }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title?: string
|
||||
message: string
|
||||
icon?: string
|
||||
iconColor?: string
|
||||
confirmText?: string
|
||||
confirmColor?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
title: '确认操作',
|
||||
icon: 'mdi-alert-circle-outline',
|
||||
iconColor: 'warning',
|
||||
confirmText: '确认',
|
||||
confirmColor: 'primary',
|
||||
})
|
||||
|
||||
const dialog = defineModel<boolean>({ default: false })
|
||||
const loading = ref(false)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'confirm'): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
function confirm() {
|
||||
emit('confirm')
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
dialog.value = false
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 表单验证
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<v-form ref="formRef" v-model="valid" @submit.prevent="submit">
|
||||
<v-text-field
|
||||
v-model="form.name"
|
||||
:rules="rules.name"
|
||||
label="姓名"
|
||||
required
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="form.email"
|
||||
:rules="rules.email"
|
||||
label="邮箱"
|
||||
type="email"
|
||||
/>
|
||||
<v-btn type="submit" :disabled="!valid" :loading="loading">
|
||||
提交
|
||||
</v-btn>
|
||||
</v-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { VForm } from 'vuetify/components'
|
||||
|
||||
const formRef = ref<VForm | null>(null)
|
||||
const valid = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: [
|
||||
(v: string) => !!v || '姓名不能为空',
|
||||
(v: string) => v.length <= 20 || '姓名不能超过20个字符',
|
||||
],
|
||||
email: [
|
||||
(v: string) => !!v || '邮箱不能为空',
|
||||
(v: string) => /.+@.+\..+/.test(v) || '请输入有效的邮箱地址',
|
||||
],
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const { valid } = await formRef.value!.validate()
|
||||
if (!valid) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await api.submit(form)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Snackbar 全局通知
|
||||
|
||||
```typescript
|
||||
// composables/useSnackbar.ts
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface SnackbarOptions {
|
||||
text: string
|
||||
color?: string
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
const snackbar = ref<SnackbarOptions & { show: boolean }>({
|
||||
show: false,
|
||||
text: '',
|
||||
color: 'success',
|
||||
timeout: 3000,
|
||||
})
|
||||
|
||||
export function useSnackbar() {
|
||||
function show(options: SnackbarOptions) {
|
||||
snackbar.value = { ...snackbar.value, ...options, show: true }
|
||||
}
|
||||
|
||||
function success(text: string) {
|
||||
show({ text, color: 'success' })
|
||||
}
|
||||
|
||||
function error(text: string) {
|
||||
show({ text, color: 'error', timeout: 5000 })
|
||||
}
|
||||
|
||||
function warning(text: string) {
|
||||
show({ text, color: 'warning' })
|
||||
}
|
||||
|
||||
function info(text: string) {
|
||||
show({ text, color: 'info' })
|
||||
}
|
||||
|
||||
return { snackbar, show, success, error, warning, info }
|
||||
}
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- App.vue 中使用 -->
|
||||
<template>
|
||||
<v-app>
|
||||
<router-view />
|
||||
|
||||
<v-snackbar
|
||||
v-model="snackbar.show"
|
||||
:color="snackbar.color"
|
||||
:timeout="snackbar.timeout"
|
||||
>
|
||||
{{ snackbar.text }}
|
||||
</v-snackbar>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
|
||||
const { snackbar } = useSnackbar()
|
||||
</script>
|
||||
```
|
||||
Reference in New Issue
Block a user