实现前端开发的SKILL

This commit is contained in:
zeaslity
2026-07-01 16:31:30 +08:00
parent 9cd57b92b8
commit a1f208891d
285 changed files with 48054 additions and 59 deletions

View File

@@ -1,141 +0,0 @@
---
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` 都有类型。每个边界情况都有处理。每个加载状态都很美观。这就是"生产级"的含义。

View File

@@ -1,113 +0,0 @@
// @/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',
}),
}

View File

@@ -1,409 +0,0 @@
<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>

View File

@@ -1,155 +0,0 @@
<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>

View File

@@ -1,238 +0,0 @@
# 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 : []
)
}
```

View File

@@ -1,182 +0,0 @@
# 布局模式参考
## 标准页面骨架
```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>
```

View File

@@ -1,142 +0,0 @@
# 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
```

View File

@@ -1,345 +0,0 @@
# 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>
```

View File

@@ -187,7 +187,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Copyright 2026 Anthropic, PBC.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.

View File

@@ -1,6 +1,6 @@
---
name: skill-creator
description: Create new skills, modify and improve existing skills, and measure skill performance. Use when users want to create a skill from scratch, update or optimize an existing skill, run evals to test a skill, benchmark skill performance with variance analysis, or optimize a skill's description for better triggering accuracy.
description: Create new skills, modify and improve existing skills, and measure skill performance. Use when users want to create a skill from scratch, edit, or optimize an existing skill, run evals to test a skill, benchmark skill performance with variance analysis, or optimize a skill's description for better triggering accuracy.
---
# Skill Creator
@@ -391,7 +391,7 @@ Use the model ID from your system prompt (the one powering the current session)
While it runs, periodically tail the output to give the user updates on which iteration it's on and what the scores look like.
This handles the full optimization loop automatically. It splits the eval set into 60% train and 40% held-out test, evaluates the current description (running each query 3 times to get a reliable trigger rate), then calls Claude with extended thinking to propose improvements based on what failed. It re-evaluates each new description on both train and test, iterating up to 5 times. When it's done, it opens an HTML report in the browser showing the results per iteration and returns JSON with `best_description` — selected by test score rather than train score to avoid overfitting.
This handles the full optimization loop automatically. It splits the eval set into 60% train and 40% held-out test, evaluates the current description (running each query 3 times to get a reliable trigger rate), then calls Claude to propose improvements based on what failed. It re-evaluates each new description on both train and test, iterating up to 5 times. When it's done, it opens an HTML report in the browser showing the results per iteration and returns JSON with `best_description` — selected by test score rather than train score to avoid overfitting.
### How skill triggering works
@@ -435,6 +435,11 @@ In Claude.ai, the core workflow is the same (draft → test → review → impro
**Packaging**: The `package_skill.py` script works anywhere with Python and a filesystem. On Claude.ai, you can run it and the user can download the resulting `.skill` file.
**Updating an existing skill**: The user might be asking you to update an existing skill, not create a new one. In this case:
- **Preserve the original name.** Note the skill's directory name and `name` frontmatter field -- use them unchanged. E.g., if the installed skill is `research-helper`, output `research-helper.skill` (not `research-helper-v2`).
- **Copy to a writeable location before editing.** The installed skill path may be read-only. Copy to `/tmp/skill-name/`, edit there, and package from the copy.
- **If packaging manually, stage in `/tmp/` first**, then copy to the output directory -- direct writes may fail due to permissions.
---
## Cowork-Specific Instructions
@@ -447,6 +452,7 @@ If you're in Cowork, the main things to know are:
- Feedback works differently: since there's no running server, the viewer's "Submit All Reviews" button will download `feedback.json` as a file. You can then read it from there (you may have to request access first).
- Packaging works — `package_skill.py` just needs Python and a filesystem.
- Description optimization (`run_loop.py` / `run_eval.py`) should work in Cowork just fine since it uses `claude -p` via subprocess, not a browser, but please save it until you've fully finished making the skill and the user agrees it's in good shape.
- **Updating an existing skill**: The user might be asking you to update an existing skill, not create a new one. Follow the update guidance in the claude.ai section above.
---

View File

@@ -2,22 +2,52 @@
"""Improve a skill description based on eval results.
Takes eval results (from run_eval.py) and generates an improved description
using Claude with extended thinking.
by calling `claude -p` as a subprocess (same auth pattern as run_eval.py —
uses the session's Claude Code auth, no separate ANTHROPIC_API_KEY needed).
"""
import argparse
import json
import os
import re
import subprocess
import sys
from pathlib import Path
import anthropic
from scripts.utils import parse_skill_md
def _call_claude(prompt: str, model: str | None, timeout: int = 300) -> str:
"""Run `claude -p` with the prompt on stdin and return the text response.
Prompt goes over stdin (not argv) because it embeds the full SKILL.md
body and can easily exceed comfortable argv length.
"""
cmd = ["claude", "-p", "--output-format", "text"]
if model:
cmd.extend(["--model", model])
# Remove CLAUDECODE env var to allow nesting claude -p inside a
# Claude Code session. The guard is for interactive terminal conflicts;
# programmatic subprocess usage is safe. Same pattern as run_eval.py.
env = {k: v for k, v in os.environ.items() if k != "CLAUDECODE"}
result = subprocess.run(
cmd,
input=prompt,
capture_output=True,
text=True,
env=env,
timeout=timeout,
)
if result.returncode != 0:
raise RuntimeError(
f"claude -p exited {result.returncode}\nstderr: {result.stderr}"
)
return result.stdout
def improve_description(
client: anthropic.Anthropic,
skill_name: str,
skill_content: str,
current_description: str,
@@ -99,7 +129,7 @@ Based on the failures, write a new and improved description that is more likely
1. Avoid overfitting
2. The list might get loooong and it's injected into ALL queries and there might be a lot of skills, so we don't want to blow too much space on any given description.
Concretely, your description should not be more than about 100-200 words, even if that comes at the cost of accuracy.
Concretely, your description should not be more than about 100-200 words, even if that comes at the cost of accuracy. There is a hard limit of 1024 characters — descriptions over that will be truncated, so stay comfortably under it.
Here are some tips that we've found to work well in writing these descriptions:
- The skill should be phrased in the imperative -- "Use this skill for" rather than "this skill does"
@@ -111,70 +141,41 @@ I'd encourage you to be creative and mix up the style in different iterations si
Please respond with only the new description text in <new_description> tags, nothing else."""
response = client.messages.create(
model=model,
max_tokens=16000,
thinking={
"type": "enabled",
"budget_tokens": 10000,
},
messages=[{"role": "user", "content": prompt}],
)
text = _call_claude(prompt, model)
# Extract thinking and text from response
thinking_text = ""
text = ""
for block in response.content:
if block.type == "thinking":
thinking_text = block.thinking
elif block.type == "text":
text = block.text
# Parse out the <new_description> tags
match = re.search(r"<new_description>(.*?)</new_description>", text, re.DOTALL)
description = match.group(1).strip().strip('"') if match else text.strip().strip('"')
# Log the transcript
transcript: dict = {
"iteration": iteration,
"prompt": prompt,
"thinking": thinking_text,
"response": text,
"parsed_description": description,
"char_count": len(description),
"over_limit": len(description) > 1024,
}
# If over 1024 chars, ask the model to shorten it
# Safety net: the prompt already states the 1024-char hard limit, but if
# the model blew past it anyway, make one fresh single-turn call that
# quotes the too-long version and asks for a shorter rewrite. (The old
# SDK path did this as a true multi-turn; `claude -p` is one-shot, so we
# inline the prior output into the new prompt instead.)
if len(description) > 1024:
shorten_prompt = f"Your description is {len(description)} characters, which exceeds the hard 1024 character limit. Please rewrite it to be under 1024 characters while preserving the most important trigger words and intent coverage. Respond with only the new description in <new_description> tags."
shorten_response = client.messages.create(
model=model,
max_tokens=16000,
thinking={
"type": "enabled",
"budget_tokens": 10000,
},
messages=[
{"role": "user", "content": prompt},
{"role": "assistant", "content": text},
{"role": "user", "content": shorten_prompt},
],
shorten_prompt = (
f"{prompt}\n\n"
f"---\n\n"
f"A previous attempt produced this description, which at "
f"{len(description)} characters is over the 1024-character hard limit:\n\n"
f'"{description}"\n\n'
f"Rewrite it to be under 1024 characters while keeping the most "
f"important trigger words and intent coverage. Respond with only "
f"the new description in <new_description> tags."
)
shorten_thinking = ""
shorten_text = ""
for block in shorten_response.content:
if block.type == "thinking":
shorten_thinking = block.thinking
elif block.type == "text":
shorten_text = block.text
shorten_text = _call_claude(shorten_prompt, model)
match = re.search(r"<new_description>(.*?)</new_description>", shorten_text, re.DOTALL)
shortened = match.group(1).strip().strip('"') if match else shorten_text.strip().strip('"')
transcript["rewrite_prompt"] = shorten_prompt
transcript["rewrite_thinking"] = shorten_thinking
transcript["rewrite_response"] = shorten_text
transcript["rewrite_description"] = shortened
transcript["rewrite_char_count"] = len(shortened)
@@ -216,9 +217,7 @@ def main():
print(f"Current: {current_description}", file=sys.stderr)
print(f"Score: {eval_results['summary']['passed']}/{eval_results['summary']['total']}", file=sys.stderr)
client = anthropic.Anthropic()
new_description = improve_description(
client=client,
skill_name=name,
skill_content=content,
current_description=current_description,

View File

@@ -15,8 +15,6 @@ import time
import webbrowser
from pathlib import Path
import anthropic
from scripts.generate_report import generate_html
from scripts.improve_description import improve_description
from scripts.run_eval import find_project_root, run_eval
@@ -75,7 +73,6 @@ def run_loop(
train_set = eval_set
test_set = []
client = anthropic.Anthropic()
history = []
exit_reason = "unknown"
@@ -200,7 +197,6 @@ def run_loop(
for h in history
]
new_description = improve_description(
client=client,
skill_name=name,
skill_content=content,
current_description=current_description,

View File

@@ -0,0 +1,258 @@
---
name: wdd-vue3-ts-vuetify3
description: >-
Vue 3 + TypeScript + Vuetify 3 全栈前端开发指导 Skill。强制 Composition API +
<script setup lang="ts">,强制 Vuetify 3 Material Design 组件,禁止 any 类型,
禁止 Options API。涵盖 Vuetify 3 组件选择、明暗主题、响应式布局、容器化防御、
统一 API 请求层axios + 错误码对齐后端、Composable 设计模式、Pinia 状态管理、
Vue Router 4、页面美学与留白。触发场景Vue 3 前端开发、Vuetify 3 组件使用、
TypeScript 类型设计、axios 请求封装、主题切换、响应式布局、Pinia Store、
组件架构设计、页面 UI 设计、前端调试。当用户提及 Vue、Vuetify、TypeScript
前端开发时必须加载此 Skill。
---
# Vue 3 + TypeScript + Vuetify 3 前端开发指导
遵循本 Skill 中的指令集。按顺序执行工作流,除非用户明确要求不同的顺序。
## §1 技术栈约束
| 约束项 | 要求 |
|--------|------|
| 框架 | Vue 3最新稳定版 |
| 语言 | TypeScript严格模式禁止 `any`,禁止 `.js` 文件) |
| UI 库 | Vuetify 3唯一 UI 库,禁止引入 Element Plus / Ant Design Vue 等) |
| API 风格 | Composition API + `<script setup lang="ts">`(禁止 Options API |
| 模板语法 | SFC template禁止 JSX / TSX |
| 状态管理 | PiniaSetup Store 语法) |
| 路由 | Vue Router 4 |
| HTTP | axios统一请求器禁止 fetch |
| 图标 | Material Design Icons (`@mdi/font`) |
## §2 开发工作流
### 2.1 编码前确认(必须)
1. 确认项目使用 Vue 3 + TypeScript + Vuetify 3
2. 规划组件边界:每个组件用一句话定义其职责
3. 设计组件的 Props / Emits 契约
4. 确认数据流方向Props down / Events up
### 2.2 必读参考文档
每次开发任务开始前,确保以下参考已加载到工作上下文中:
| 任务类型 | 必读参考 |
|---------|---------|
| 组件选择 | [vuetify3-components](references/vuetify3-components.md) |
| 主题相关 | [vuetify3-theme](references/vuetify3-theme.md) |
| 布局设计 | [vuetify3-responsive](references/vuetify3-responsive.md) |
| 容器/滚动 | [container-defense](references/container-defense.md) |
| TypeScript | [typescript-strict](references/typescript-strict.md) |
| API 对接 | [api-layer](references/api-layer.md) |
| Composable | [composables-patterns](references/composables-patterns.md) |
| 组件架构 | [component-architecture](references/component-architecture.md) |
| 状态管理 | [pinia-state](references/pinia-state.md) |
| 路由 | [router-patterns](references/router-patterns.md) |
| UI 美学 | [design-aesthetics](references/design-aesthetics.md) |
| 调试排错 | [debug-guide](references/debug-guide.md) |
## §3 核心规范速查
### 3.1 SFC 结构顺序
```vue
<script setup lang="ts">
// 1. 导入
// 2. Props / Emits 定义
// 3. 响应式状态
// 4. 计算属性
// 5. 侦听器
// 6. 方法
// 7. 生命周期钩子
</script>
<template>
<!-- 声明式模板逻辑放到 script -->
</template>
<style scoped>
/* 样式使用 scoped引用 Vuetify CSS 变量 */
</style>
```
### 3.2 TypeScript 核心约束
- 所有函数必须声明返回类型(含 `void`
- 使用 `interface` 定义对象形状,`type` 定义联合/工具类型
- Props 使用 `defineProps<Props>()`Emits 使用 `defineEmits<Emits>()`
- 可变默认值使用工厂函数:`withDefaults(defineProps<P>(), { items: () => [] })`
- 禁止 `any``@ts-ignore``@ts-nocheck`
详细规范 → [typescript-strict](references/typescript-strict.md)
### 3.3 响应式默认值策略
| 场景 | 使用 | 原因 |
|------|------|------|
| 原始值 | `ref` | 简单高效 |
| 对象 / 数组 | `ref` | 深层追踪 |
| 第三方实例 | `shallowRef` | 避免代理破坏 |
| 只读派生 | `computed` | 缓存 + 依赖追踪 |
| 从 getter/props 创建 ref | `toRef` | 保持响应式链接 |
### 3.4 组件拆分触发条件
当满足以下**任一条件**时必须拆分:
- 承担 2+ 个独立职责
- 包含 3+ 个独立 UI 区域
- 模板超 100 行或 script 超 150 行
- UI 模式在多处重复出现
CRUD 功能标准拆分FilterBar + Table + Form + Detail + `useFeatureData.ts`
详细规范 → [component-architecture](references/component-architecture.md)
### 3.5 数据流
```
Props (父 → 子) ─── 只读传递
Events (子 → 父) ── emit 通知
v-model ──────────── 仅用于表单控件双向绑定
provide/inject ───── 仅用于深层嵌套上下文(主题、布局)
Pinia Store ──────── 跨组件共享的全局状态
```
## §4 Vuetify 3 使用规范
### 4.1 组件选择
编码前先查决策表,按问题匹配组件,不要按关键词猜测。
优先使用 Vuetify 组件,禁止在有对应组件时使用原生 HTML。
完整决策表 → [vuetify3-components](references/vuetify3-components.md)
### 4.2 主题配置
- 必须同时提供 light 和 dark 主题
- 所有颜色通过 `createVuetify``theme.themes` 定义
- CSS 中使用 `rgb(var(--v-theme-<color>))`,禁止硬编码颜色
- 主题切换使用 `useTheme()` composable
详细模板 → [vuetify3-theme](references/vuetify3-theme.md)
### 4.3 响应式布局
- 使用 `v-container` + `v-row` + `v-col` 栅格系统
- 编程式断点使用 `useDisplay()` composable
- 移动优先:从 `cols` 开始,逐步增加 `sm` / `md` / `lg`
- 所有页面必须在 xs 断点下可用
详细模式 → [vuetify3-responsive](references/vuetify3-responsive.md)
### 4.4 容器化设计
- 所有内容区域必须有明确容器边界,使用 `min-height` 防御塌陷
- 内容禁止被折断:`break-inside: avoid`
- 超出预设高度时使用 `overflow-y: auto` + `scrollbar-gutter: stable`
- 固定区域使用 `flex-shrink: 0`,滚动区域使用 `flex-grow: 1` + `min-height: 0`
详细模式 → [container-defense](references/container-defense.md)
## §5 统一请求层
前端类型必须与后端 `common-runtime.md` 的 Response 结构对齐:
```typescript
interface ApiResponse<T = null> {
code: number // 0 = 成功1xxx = 通用错误2xxx = 业务错误
message: string
data: T
timestamp: string
request_id: string
}
```
所有 API 调用通过统一请求器axios 实例 + 拦截器),禁止直接 import axios。
详细实现 → [api-layer](references/api-layer.md)
## §6 Composable 设计模式
- 命名 `useXxx`,必须在 setup 同步上下文中调用
- 返回具名对象(非数组),便于按需解构
- 适配性输入:只读用 `MaybeRefOrGetter<T>`,可写用 `MaybeRef<T>`
- 生命周期钩子自动清理资源
详细模式 → [composables-patterns](references/composables-patterns.md)
## §7 组件架构与复用
三层架构:
| 层 | 职责 | 命名 |
|----|------|------|
| Base | 通用 UI 封装(与业务无关) | `BaseXxx.vue` |
| Feature | 业务功能组件 | `{Domain}Xxx.vue` |
| Page | 路由视图,布局编排 | `{Domain}XxxView.vue` |
系统中重复出现的 UI 模式必须提取为 Base 组件。
详细架构 → [component-architecture](references/component-architecture.md)
## §8 状态管理
- 全局共享状态 → Pinia StoreSetup Store 语法)
- 组件内部状态 → `ref` / `reactive`
- URL 可恢复状态 → Vue Router query/params
- Store 解构用 `storeToRefs()`,方法直接解构
- Setup Store 必须 return 所有需要暴露的 ref 和方法
详细模式 → [pinia-state](references/pinia-state.md)
## §9 路由管理
- 导航守卫使用返回值模式(禁止 `next()` 回调)
- 路由参数变化用 `watch(route.params)``:key="$route.fullPath"`
- 路由 Meta 使用 TypeScript 类型扩展
- 异步守卫使用 `async` + `await`
详细模式 → [router-patterns](references/router-patterns.md)
## §10 页面美学
- 间距使用 Vuetify 4px 基数系统(`pa-4` 为标准内边距)
- 响应式间距:`pa-4 pa-md-6 pa-lg-8`
- 字体使用 Vuetify Typography 类(`text-h4` / `text-body-1` / `text-caption`
- 颜色使用语义色(`primary` / `error` / `success`
- 空状态和错误状态必须精心设计,禁止空白页面
- 留白是设计元素,区块间距 `my-6``my-8`
详细规范 → [design-aesthetics](references/design-aesthetics.md)
## §11 调试与陷阱
遇到问题时先查调试索引表,覆盖以下分类:
- 响应式陷阱ref / reactive / watch / computed
- 组件陷阱Props / Emits / Slots / Lifecycle
- TypeScript 陷阱(类型定义 / 模板 ref / defineProps
- Vuetify 3 常见问题(样式 / 图标 / 主题 / 性能)
- 路由常见问题(参数变化 / 守卫 / 清理)
详细索引 → [debug-guide](references/debug-guide.md)
## §12 最终自检清单
每次提交代码前,逐项检查:
- [ ] **UI 统一**:所有组件使用 Vuetify 3无其他 UI 库引入
- [ ] **明暗主题**颜色通过主题系统定义CSS 使用 `--v-theme-*` 变量
- [ ] **响应式**:所有页面在 xs 断点下可用,使用 `useDisplay()` 判断
- [ ] **容器化**:内容不折断,滚动条不影响布局,无高度塌陷
- [ ] **组件复用**:重复 UI 模式已提取为 Base 组件
- [ ] **TypeScript**:无 `any` / `@ts-ignore`,所有函数有返回类型
- [ ] **Composition API**:所有组件使用 `<script setup lang="ts">`
- [ ] **统一请求**:所有 API 通过统一请求器,错误码与后端对齐
- [ ] **美学**:合理留白、空状态/错误状态设计、语义色使用正确

View File

@@ -0,0 +1,341 @@
# 统一请求层
## 核心原则
- 统一使用 axios 作为 HTTP 客户端,禁止使用 fetch 或其他请求库
- 前端类型必须与后端 `common-runtime.md``Response` 结构完全对齐
- 所有 API 调用必须通过统一请求器,禁止直接调用 axios 实例
- 错误处理集中在拦截器中,业务层只处理成功数据
## 后端响应格式(对齐 common-runtime.md
后端统一返回以下 JSON 结构:
```json
{
"code": 0,
"message": "success",
"data": { ... },
"timestamp": "2026-07-01T15:00:00+08:00",
"request_id": "uuid-string"
}
```
分页响应的 `data` 字段结构:
```json
{
"list": [ ... ],
"total": 100,
"page": 1,
"page_size": 20
}
```
## 前端类型定义
```typescript
// src/types/api.ts
/** 后端统一响应结构(对齐 common-runtime.md Response */
interface ApiResponse<T = null> {
code: number
message: string
data: T
timestamp: string
request_id: string
}
/** 分页响应数据结构(对齐 common-runtime.md PageResponse */
interface PageData<T> {
list: T[]
total: number
page: number
page_size: number
}
/** 分页请求参数 */
interface PageParams {
page: number
page_size: number
}
/** 错误码枚举(对齐 common-runtime.md codes.go */
const enum ApiCode {
Success = 0,
// 通用错误 1xxx
ParamError = 1001,
ValidationFail = 1002,
Unauthorized = 1003,
Forbidden = 1004,
NotFound = 1005,
Timeout = 1006,
ServerError = 1007,
Duplicate = 1008,
OperationFail = 1009,
// 业务错误 2xxx
BusinessError = 2001,
DataNotReady = 2002,
StatusInvalid = 2003,
DependencyError = 2004,
ExternalAPIError = 2005,
ResourceLocked = 2006,
QuotaExceeded = 2007,
ConcurrentConflict = 2008,
}
/** 错误码对应的用户友好提示 */
const API_CODE_MESSAGE: Record<number, string> = {
[ApiCode.Success]: '操作成功',
[ApiCode.ParamError]: '参数错误',
[ApiCode.ValidationFail]: '数据验证失败',
[ApiCode.Unauthorized]: '未授权,请先登录',
[ApiCode.Forbidden]: '权限不足,禁止访问',
[ApiCode.NotFound]: '资源不存在',
[ApiCode.Timeout]: '请求超时',
[ApiCode.ServerError]: '服务器内部错误',
[ApiCode.Duplicate]: '数据重复',
[ApiCode.OperationFail]: '操作失败',
[ApiCode.BusinessError]: '业务处理失败',
[ApiCode.DataNotReady]: '数据未就绪',
[ApiCode.StatusInvalid]: '状态不合法',
[ApiCode.DependencyError]: '依赖服务错误',
[ApiCode.ExternalAPIError]: '外部服务调用失败',
[ApiCode.ResourceLocked]: '资源被锁定',
[ApiCode.QuotaExceeded]: '配额超限',
[ApiCode.ConcurrentConflict]: '并发冲突',
}
```
## Axios 实例与拦截器
```typescript
// src/utils/request.ts
import axios, {
type AxiosInstance,
type AxiosResponse,
type InternalAxiosRequestConfig,
} from 'axios'
/** 业务异常(后端返回 code !== 0 */
class BusinessError extends Error {
readonly code: number
readonly requestId: string
constructor(code: number, message: string, requestId: string) {
super(message)
this.name = 'BusinessError'
this.code = code
this.requestId = requestId
}
}
function createRequest(baseURL: string): AxiosInstance {
const instance = axios.create({
baseURL,
timeout: 15_000,
headers: { 'Content-Type': 'application/json' },
})
// 请求拦截器:注入 token
instance.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error: unknown) => Promise.reject(error),
)
// 响应拦截器:统一解包 + 错误处理
instance.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
const { code, message, request_id } = response.data
if (code === ApiCode.Success) {
return response
}
// 未授权 → 跳转登录
if (code === ApiCode.Unauthorized) {
localStorage.removeItem('access_token')
window.location.href = '/login'
return Promise.reject(new BusinessError(code, message, request_id))
}
return Promise.reject(new BusinessError(code, message, request_id))
},
(error: unknown) => {
// 网络错误 / 超时
if (axios.isAxiosError(error)) {
const message = error.response
? `服务异常 (${error.response.status})`
: error.code === 'ECONNABORTED'
? '请求超时'
: '网络连接失败'
return Promise.reject(new Error(message))
}
return Promise.reject(error)
},
)
return instance
}
/** 全局唯一请求实例 */
const request = createRequest(import.meta.env.VITE_API_BASE_URL ?? '/api')
export { request, BusinessError, type ApiResponse, type PageData, type PageParams }
```
## API 模块封装
```typescript
// src/api/user.ts
import { request, type ApiResponse, type PageData, type PageParams } from '@/utils/request'
interface UserInfo {
id: number
username: string
email: string
role: string
created_at: string
}
interface CreateUserParams {
username: string
email: string
role: string
}
/** 获取用户列表 */
async function getUserList(params: PageParams): Promise<PageData<UserInfo>> {
const response = await request.get<ApiResponse<PageData<UserInfo>>>('/users', { params })
return response.data.data
}
/** 获取用户详情 */
async function getUserById(id: number): Promise<UserInfo> {
const response = await request.get<ApiResponse<UserInfo>>(`/users/${id}`)
return response.data.data
}
/** 创建用户 */
async function createUser(data: CreateUserParams): Promise<UserInfo> {
const response = await request.post<ApiResponse<UserInfo>>('/users', data)
return response.data.data
}
/** 删除用户 */
async function deleteUser(id: number): Promise<null> {
const response = await request.delete<ApiResponse<null>>(`/users/${id}`)
return response.data.data
}
export { getUserList, getUserById, createUser, deleteUser }
export type { UserInfo, CreateUserParams }
```
## 组件中使用
```vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getUserList, type UserInfo } from '@/api/user'
import { BusinessError } from '@/utils/request'
import type { PageData } from '@/utils/request'
const loading = ref(false)
const users = ref<UserInfo[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const errorMessage = ref<string | null>(null)
async function fetchUsers(): Promise<void> {
loading.value = true
errorMessage.value = null
try {
const result: PageData<UserInfo> = await getUserList({
page: page.value,
page_size: pageSize.value,
})
users.value = result.list
total.value = result.total
} catch (error: unknown) {
if (error instanceof BusinessError) {
errorMessage.value = error.message
} else if (error instanceof Error) {
errorMessage.value = error.message
}
} finally {
loading.value = false
}
}
onMounted(fetchUsers)
</script>
```
## 全局错误通知
结合 Vuetify 的 `v-snackbar` 实现全局错误提示:
```typescript
// src/composables/useNotification.ts
import { ref } from 'vue'
interface Notification {
message: string
color: 'success' | 'error' | 'warning' | 'info'
timeout?: number
}
const notification = ref<Notification | null>(null)
const visible = ref(false)
export function useNotification() {
function notify(options: Notification): void {
notification.value = { timeout: 3000, ...options }
visible.value = true
}
function notifySuccess(message: string): void {
notify({ message, color: 'success' })
}
function notifyError(message: string): void {
notify({ message, color: 'error' })
}
return { notification, visible, notify, notifySuccess, notifyError }
}
```
## 反模式
### ❌ 直接使用 axios
```typescript
// ❌ 禁止:绕过统一请求器
import axios from 'axios'
const res = await axios.get('/api/users')
```
### ❌ 组件中处理 HTTP 细节
```typescript
// ❌ 禁止:组件中直接处理状态码
if (response.status === 401) { ... }
```
### ❌ 忽略错误处理
```typescript
// ❌ 禁止:吞掉错误
try { await fetchData() } catch { /* 空 catch */ }
```

View File

@@ -0,0 +1,259 @@
# 组件分层架构与复用
## 核心原则
- 组件按职责分为三层Base → Feature → Page
- 系统中重复出现的 UI 模式必须提取为可复用组件
- 组件保持单一职责,当一个组件承担多个独立职责时必须拆分
- 数据流方向Props down / Events up`v-model` 仅用于真正的双向绑定
## 三层组件架构
### Base 组件(基础层)
与业务无关的通用 UI 组件,对 Vuetify 组件的二次封装。
```
src/components/base/
├── BaseCard.vue # 统一卡片样式(防御高度塌陷)
├── BaseDialog.vue # 统一对话框(响应式宽度 + 滚动)
├── BaseTable.vue # 统一数据表格(分页 + 加载状态)
├── BaseForm.vue # 统一表单容器(验证 + 提交)
├── BaseConfirmDialog.vue # 二次确认对话框
├── BaseEmptyState.vue # 空状态占位
└── BasePageHeader.vue # 页面标题 + 面包屑 + 操作栏
```
示例 — BaseDialog
```vue
<!-- src/components/base/BaseDialog.vue -->
<script setup lang="ts">
import { useDisplay } from 'vuetify'
import { computed } from 'vue'
interface Props {
modelValue: boolean
title: string
maxWidth?: string | number
persistent?: boolean
loading?: boolean
}
interface Emits {
(event: 'update:modelValue', value: boolean): void
(event: 'confirm'): void
(event: 'cancel'): void
}
const props = withDefaults(defineProps<Props>(), {
maxWidth: 600,
persistent: false,
loading: false,
})
const emit = defineEmits<Emits>()
const { mobile } = useDisplay()
const dialogFullscreen = computed(() => mobile.value)
function handleCancel(): void {
emit('update:modelValue', false)
emit('cancel')
}
</script>
<template>
<v-dialog
:model-value="modelValue"
:max-width="maxWidth"
:fullscreen="dialogFullscreen"
:persistent="persistent"
@update:model-value="emit('update:modelValue', $event)"
>
<v-card class="d-flex flex-column" style="max-height: 80vh;">
<v-card-title class="flex-shrink-0 d-flex align-center">
<span>{{ title }}</span>
<v-spacer />
<v-btn icon="mdi-close" variant="text" @click="handleCancel" />
</v-card-title>
<v-divider />
<v-card-text class="flex-grow-1 scroll-container" style="min-height: 0;">
<slot />
</v-card-text>
<v-divider />
<v-card-actions class="flex-shrink-0">
<v-spacer />
<slot name="actions">
<v-btn variant="text" :disabled="loading" @click="handleCancel">
取消
</v-btn>
<v-btn
color="primary"
:loading="loading"
@click="emit('confirm')"
>
确认
</v-btn>
</slot>
</v-card-actions>
</v-card>
</v-dialog>
</template>
```
### Feature 组件(业务层)
特定业务功能的组件,组合 Base 组件和业务逻辑。
```
src/components/
├── user/
│ ├── UserList.vue # 用户列表
│ ├── UserForm.vue # 用户表单(新建/编辑)
│ ├── UserDetail.vue # 用户详情卡片
│ └── UserRoleBadge.vue # 角色标签
├── device/
│ ├── DeviceTable.vue # 设备数据表
│ ├── DeviceStatusChip.vue # 状态芯片
│ └── DeviceFilterBar.vue # 筛选栏
```
### Page 组件(页面层)
路由级视图组件,仅做布局编排和数据组装,不包含具体 UI 实现。
```
src/views/
├── user/
│ ├── UserListView.vue # 用户列表页(组装 UserList + 筛选 + 分页)
│ └── UserDetailView.vue # 用户详情页
├── device/
│ └── DeviceManageView.vue # 设备管理页
```
## 组件拆分触发条件
当满足以下**任一条件**时,必须拆分组件:
| 条件 | 说明 |
|------|------|
| 多重职责 | 同时负责数据编排和多个 UI 区域 |
| 3+ 个独立 UI 区域 | 如表单 + 筛选 + 列表 + 分页同时出现 |
| 可复用 UI 模式 | 列表项、卡片、状态标签等在多处出现 |
| 模板超过 100 行 | 可读性下降的信号 |
| script 超过 150 行 | 逻辑应抽取为 Composable |
### CRUD 功能标准拆分
```
feature/
├── FeatureListView.vue # Page 层:布局编排
├── FeatureFilterBar.vue # 筛选条件
├── FeatureTable.vue # 数据表格
├── FeatureForm.vue # 新建/编辑表单
├── FeatureDetail.vue # 详情展示
└── composables/
└── useFeatureData.ts # 数据加载/分页/搜索逻辑
```
## 命名约定
| 类型 | 命名规则 | 示例 |
|------|---------|------|
| Base 组件 | `Base` + 功能名 | `BaseDialog.vue` |
| Feature 组件 | 业务域 + 功能名 | `UserForm.vue` |
| Page 组件 | 业务域 + `View` | `UserListView.vue` |
| Composable | `use` + 功能名 | `useUserData.ts` |
| 类型文件 | 业务域 + `.types.ts` | `user.types.ts` |
| API 文件 | 业务域 + `.ts` | `user.ts`(在 `src/api/` 下) |
## 数据流规范
### Props down / Events up
```vue
<!-- 父组件 -->
<UserForm
:initial-values="formData"
:loading="submitting"
@submit="handleSubmit"
@cancel="showForm = false"
/>
<!-- 子组件 UserForm.vue -->
<script setup lang="ts">
interface Props {
initialValues: UserFormValues
loading: boolean
}
interface Emits {
(event: 'submit', data: UserFormValues): void
(event: 'cancel'): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
</script>
```
### v-model 双向绑定
仅在组件本质上是表单控件(输入值的读写)时使用 `v-model`
```vue
<!-- 适合 v-model搜索输入组件 -->
<SearchInput v-model="searchKeyword" />
<!-- 不适合 v-model列表组件的数据不应双向绑定 -->
<UserList v-model="users" /> <!-- 应使用 :items="users" -->
```
### provide / inject
仅用于深层嵌套场景(如主题、布局上下文),不用于一般数据传递:
```typescript
// 定义 injection key
import { type InjectionKey } from 'vue'
interface LayoutContext {
sidebarCollapsed: Ref<boolean>
toggleSidebar: () => void
}
const LayoutContextKey: InjectionKey<LayoutContext> = Symbol('LayoutContext')
```
## 反模式
### ❌ 万能组件
```vue
<!-- 禁止一个组件包含表格 + 表单 + 筛选 + 详情 -->
<template>
<div>
<filter-section />
<data-table />
<edit-form />
<detail-drawer />
<!-- 全部逻辑在一个组件内 -->
</div>
</template>
```
### ❌ 跨层级直接通信
```typescript
// ❌ 禁止:子组件直接调用父组件方法
const parent = getCurrentInstance()?.parent
parent?.exposed?.refresh()
// ✅ 正确:通过 emit 通知
emit('dataChanged')
```

View File

@@ -0,0 +1,231 @@
# Composable 设计模式
## 核心原则
- Composable 是 Vue 3 中复用有状态逻辑的核心机制
- 命名统一使用 `use` 前缀:`useXxx`
- 必须在 `<script setup>``setup()` 的同步上下文中调用
- 返回值使用具名属性的对象(而非数组),便于按需解构
- 所有参数和返回值必须有明确的 TypeScript 类型
## Composable 设计清单
### 1. 确认职责与 API
```typescript
// ✅ 好的 Composable单一职责API 简洁
export function useCounter(initialValue: number = 0) {
const count = ref(initialValue)
function increment(): void { count.value++ }
function decrement(): void { count.value-- }
function reset(): void { count.value = initialValue }
return { count: readonly(count), increment, decrement, reset }
}
```
### 2. 输入适配性MaybeRef / MaybeRefOrGetter
当 Composable 需要接受外部响应式数据时使用适配性输入类型允许调用者传入普通值、ref 或 getter
```typescript
import { toRef, toValue, watch, type MaybeRefOrGetter, type MaybeRef } from 'vue'
// 只读输入 → MaybeRefOrGetter
export function useDocumentTitle(title: MaybeRefOrGetter<string>): void {
watch(toRef(title), (t) => {
document.title = t
}, { immediate: true })
}
// 可写输入 → MaybeRef
export function useLocalStorage<T>(key: string, defaultValue: MaybeRef<T>) {
const data = toRef(defaultValue)
// 从 localStorage 恢复
const stored = localStorage.getItem(key)
if (stored !== null) {
data.value = JSON.parse(stored) as T
}
// 变化时持久化
watch(data, (val) => {
localStorage.setItem(key, JSON.stringify(val))
}, { deep: true })
return data
}
```
### 选择策略
| 参数类型 | 使用 | 原因 |
|---------|------|------|
| 只读、可计算的输入 | `MaybeRefOrGetter<T>` | 接受 ref / computed / getter / 普通值 |
| 需要可写的输入 | `MaybeRef<T>` | 接受 ref / shallowRef / 普通值 |
| 参数本身是函数(回调/谓词) | 不用 `MaybeRefOrGetter` | 避免被当作 getter 调用 |
### 规范化方法
| 需要响应式追踪 | 使用 `toRef(source)` | watch / computed 的依赖源 |
| 只需当前值快照 | 使用 `toValue(source)` | 非响应式上下文中读取值 |
## 典型 Composable 模式
### 异步数据加载
```typescript
// src/composables/useAsyncData.ts
import { ref, watchEffect, type Ref } from 'vue'
interface AsyncDataResult<T> {
data: Ref<T | null>
loading: Ref<boolean>
error: Ref<string | null>
refresh: () => Promise<void>
}
export function useAsyncData<T>(
fetcher: () => Promise<T>,
): AsyncDataResult<T> {
const data = ref<T | null>(null) as Ref<T | null>
const loading = ref(false)
const error = ref<string | null>(null)
async function refresh(): Promise<void> {
loading.value = true
error.value = null
try {
data.value = await fetcher()
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : '未知错误'
} finally {
loading.value = false
}
}
// 初始加载
void refresh()
return { data, loading, error, refresh }
}
```
### 表单状态管理
```typescript
// src/composables/useFormState.ts
import { ref, computed, type Ref } from 'vue'
interface FormState<T extends Record<string, unknown>> {
values: Ref<T>
isDirty: Ref<boolean>
reset: () => void
setValues: (newValues: Partial<T>) => void
}
export function useFormState<T extends Record<string, unknown>>(
initialValues: T,
): FormState<T> {
const values = ref<T>({ ...initialValues }) as Ref<T>
const original = { ...initialValues }
const isDirty = computed(() =>
JSON.stringify(values.value) !== JSON.stringify(original),
)
function reset(): void {
values.value = { ...original } as T
}
function setValues(newValues: Partial<T>): void {
values.value = { ...values.value, ...newValues } as T
}
return { values, isDirty, reset, setValues }
}
```
### 防抖搜索
```typescript
// src/composables/useDebounce.ts
import { ref, watch, type MaybeRefOrGetter, toRef, type Ref } from 'vue'
export function useDebouncedRef<T>(
source: MaybeRefOrGetter<T>,
delay: number = 300,
): Ref<T> {
const debounced = ref(toValue(source)) as Ref<T>
let timer: ReturnType<typeof setTimeout> | null = null
watch(toRef(source), (val) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
debounced.value = val
}, delay)
})
return debounced
}
```
## 生命周期与清理
```typescript
// Composable 中使用生命周期钩子自动清理资源
import { onMounted, onUnmounted } from 'vue'
export function useWindowResize(callback: (width: number, height: number) => void) {
function handler(): void {
callback(window.innerWidth, window.innerHeight)
}
onMounted(() => {
window.addEventListener('resize', handler)
handler() // 初始调用
})
onUnmounted(() => {
window.removeEventListener('resize', handler)
})
}
```
## 反模式
### ❌ 在异步回调中调用 Composable
```typescript
// ❌ 禁止setup 上下文已丢失
onMounted(async () => {
await someAsyncWork()
const { data } = useAsyncData(fetcher) // ← 错误位置
})
```
### ❌ 返回解构后的普通值
```typescript
// ❌ 禁止:丢失响应式
export function useBad() {
const count = ref(0)
return { count: count.value } // ← 丢失响应式
}
// ✅ 正确:返回 ref 本身
export function useGood() {
const count = ref(0)
return { count } // ← 保持响应式
}
```
### ❌ 隐藏副作用
```typescript
// ❌ 禁止Composable 内部偷偷修改全局状态
export function useBad() {
document.title = '固定标题' // ← 隐藏副作用,调用者不知情
}
```

View File

@@ -0,0 +1,308 @@
# 容器化设计与高度塌陷防御
## 核心原则
- 所有内容区域必须有明确的容器边界,防止高度塌陷
- 容器内容禁止被折断(`break-inside: avoid`),必须完整展示
- 内容超过预设高度时添加垂直滚动条,滚动条不影响布局宽度
- 使用 `min-height` 防御空容器塌陷
## 高度塌陷防御
### 基础防御策略
```css
/* 全局容器防御样式 */
/* 防止内容区域高度塌陷为 0 */
.container-defended {
min-height: 48px;
display: flex;
flex-direction: column;
}
/* 防止 flex 子元素收缩导致内容被截断 */
.flex-no-shrink {
flex-shrink: 0;
}
/* 确保内容区域至少占满剩余空间 */
.flex-fill {
flex: 1 1 auto;
min-height: 0; /* 允许 flex 子元素滚动 */
}
```
### Vuetify 容器防御
```vue
<template>
<!-- v-card 内容防御 -->
<v-card class="d-flex flex-column" style="min-height: 200px;">
<v-card-title class="flex-shrink-0">标题</v-card-title>
<v-card-text class="flex-grow-1 overflow-y-auto">
<!-- 长内容区域 -->
</v-card-text>
<v-card-actions class="flex-shrink-0">
<v-btn>操作</v-btn>
</v-card-actions>
</v-card>
</template>
```
## 内容完整展示(禁止折断)
### CSS 防折断
```css
/* 防止内容在分页/打印/flex 换行时被折断 */
.no-break {
break-inside: avoid;
page-break-inside: avoid;
}
/* Vuetify 卡片组的子项不可折断 */
.card-grid .v-col {
break-inside: avoid;
}
```
### 模板应用
```vue
<template>
<v-row>
<v-col
v-for="item in items"
:key="item.id"
cols="12"
md="6"
class="no-break"
>
<v-card>
<!-- 整张卡片作为不可折断单元 -->
<v-card-title>{{ item.title }}</v-card-title>
<v-card-text>{{ item.content }}</v-card-text>
</v-card>
</v-col>
</v-row>
</template>
```
## 滚动容器设计
### 核心:滚动条不影响布局
```css
/* 滚动容器标准样式 */
.scroll-container {
overflow-y: auto;
/* 预留滚动条空间,防止内容宽度跳变 */
scrollbar-gutter: stable;
}
/* 滚动条美化(不影响布局宽度) */
.scroll-container::-webkit-scrollbar {
width: 6px;
}
.scroll-container::-webkit-scrollbar-track {
background: transparent;
}
.scroll-container::-webkit-scrollbar-thumb {
background-color: rgba(var(--v-theme-on-surface), 0.2);
border-radius: 3px;
}
.scroll-container::-webkit-scrollbar-thumb:hover {
background-color: rgba(var(--v-theme-on-surface), 0.4);
}
```
### 固定高度滚动区域
```vue
<template>
<!-- 固定高度的列表滚动区域 -->
<v-card>
<v-card-title class="flex-shrink-0">消息列表</v-card-title>
<v-card-text
class="scroll-container"
style="max-height: 400px;"
>
<v-list>
<v-list-item
v-for="msg in messages"
:key="msg.id"
:title="msg.title"
:subtitle="msg.content"
/>
</v-list>
</v-card-text>
</v-card>
</template>
```
### 全页面布局滚动
```vue
<template>
<v-app>
<v-app-bar class="flex-shrink-0">
<!-- 固定顶栏 -->
</v-app-bar>
<v-navigation-drawer class="flex-shrink-0">
<!-- 固定侧栏 -->
</v-navigation-drawer>
<v-main>
<!-- 内容区域自适应高度内部滚动 -->
<div class="d-flex flex-column" style="height: 100%;">
<!-- 固定的页面头部 -->
<div class="flex-shrink-0 pa-4">
<h1>页面标题</h1>
</div>
<!-- 滚动的内容区域 -->
<div class="flex-grow-1 scroll-container pa-4" style="min-height: 0;">
<!-- 长内容 -->
</div>
<!-- 固定的页面底部 -->
<div class="flex-shrink-0 pa-4">
<v-btn>保存</v-btn>
</div>
</div>
</v-main>
</v-app>
</template>
```
## 典型容器模式
### 数据表格容器
```vue
<template>
<v-card class="d-flex flex-column" style="height: 100%;">
<!-- 固定工具栏 -->
<v-card-title class="flex-shrink-0 d-flex align-center">
<span>数据列表</span>
<v-spacer />
<v-btn prepend-icon="mdi-plus" color="primary">新建</v-btn>
</v-card-title>
<!-- 固定筛选栏 -->
<v-card-text class="flex-shrink-0 pb-0">
<v-row dense>
<v-col cols="12" sm="4">
<v-text-field
v-model="search"
label="搜索"
density="compact"
hide-details
/>
</v-col>
</v-row>
</v-card-text>
<!-- 滚动表格区域 -->
<v-card-text class="flex-grow-1 scroll-container" style="min-height: 0;">
<v-data-table
:items="items"
:headers="headers"
:search="search"
/>
</v-card-text>
<!-- 固定分页 -->
<v-card-actions class="flex-shrink-0 justify-center">
<v-pagination v-model="page" :length="totalPages" />
</v-card-actions>
</v-card>
</template>
```
### 对话框内滚动容器
```vue
<template>
<v-dialog max-width="600">
<v-card class="d-flex flex-column" style="max-height: 80vh;">
<v-card-title class="flex-shrink-0">编辑表单</v-card-title>
<!-- 表单内容滚动 -->
<v-card-text class="flex-grow-1 scroll-container" style="min-height: 0;">
<v-form>
<!-- 大量表单字段 -->
</v-form>
</v-card-text>
<v-card-actions class="flex-shrink-0">
<v-spacer />
<v-btn variant="text" @click="close">取消</v-btn>
<v-btn color="primary" @click="submit">保存</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
```
## 全局样式建议
在项目入口 CSS 中添加以下防御样式:
```css
/* src/styles/container-defense.css */
/* 所有 Vuetify 卡片默认防御高度塌陷 */
.v-card {
min-height: 48px;
}
/* 滚动容器统一样式 */
.scroll-container {
overflow-y: auto;
scrollbar-gutter: stable;
}
/* 防折断 */
.no-break {
break-inside: avoid;
page-break-inside: avoid;
}
/* flex 布局辅助 */
.flex-shrink-0 {
flex-shrink: 0;
}
```
## 反模式
### ❌ 无限高度容器
```vue
<!-- 禁止内容无限增长没有滚动约束 -->
<v-card>
<v-card-text>
<div v-for="i in 1000" :key="i">{{ i }}</div>
</v-card-text>
</v-card>
```
### ❌ overflow: hidden 截断内容
```css
/* 禁止:截断内容而非提供滚动 */
.container { overflow: hidden; height: 300px; }
```
### ❌ 滚动条影响布局
```css
/* 禁止:不使用 scrollbar-gutter导致滚动条出现时内容跳变 */
.container { overflow-y: scroll; } /* 始终显示滚动条 */
```

View File

@@ -0,0 +1,119 @@
# 调试与常见陷阱索引
本文档汇总 Vue 3 + TypeScript + Vuetify 3 开发中的常见问题和调试技巧。按问题分类索引,遇到问题时先查本表。
## 响应式陷阱
| 问题 | 原因 | 解法 |
|------|------|------|
| ref 值不更新 | 忘记 `.value` | 在 script 中必须通过 `.value` 访问template 中自动解包 |
| 解构 reactive 后不响应 | 解构丢失响应式代理 | 使用 `toRefs()` 解构,或直接用 `ref` |
| 解构 Store 后不响应 | 同上 | 使用 `storeToRefs()` 解构状态 |
| 数组中的 ref 不自动解包 | 集合中 ref 不解包 | 手动 `.value` 访问 |
| `reactive` 包裹第三方实例报错 | Proxy 破坏实例内部结构 | 使用 `shallowRef` + `markRaw` |
| 同 tick 内多次修改只触发一次 watcher | Vue 批处理更新 | 预期行为,如需立即执行用 `flush: 'sync'` |
## 计算属性陷阱
| 问题 | 原因 | 解法 |
|------|------|------|
| computed 中发请求 | computed 应为纯函数 | 移到 watch 或方法中 |
| computed 排序/反转破坏原数组 | `.sort()` / `.reverse()` 修改原数组 | 先 `.slice()` 副本再操作 |
| computed 条件分支后不更新 | 条件短路导致依赖未被追踪 | 确保所有分支都访问响应式依赖 |
| 向 computed 传参 | computed 不接受参数 | 改用返回函数的 computed 或方法 |
## 侦听器陷阱
| 问题 | 原因 | 解法 |
|------|------|------|
| watch reactive 属性不触发 | 未用 getter 函数 | `watch(() => state.prop, ...)` |
| 异步回调中创建 watcher 内存泄漏 | setup 上下文丢失,不自动清理 | 在 setup 同步上下文中创建 |
| deep watch 的 old/new 值相同 | 同一引用,深层修改不产生新引用 | 预期行为,如需 old 值可 clone |
| watchEffect 在 await 后丢失依赖 | await 之后的代码不在同步追踪中 | 将响应式访问放在 await 之前 |
## 组件陷阱
| 问题 | 原因 | 解法 |
|------|------|------|
| 父组件无法访问子组件 ref | `<script setup>` 默认不暴露 | 子组件使用 `defineExpose` |
| 自定义事件未触发 | 未声明 emits | 使用 `defineEmits` 声明 |
| 事件触发两次 | 未声明 emits 导致原生事件叠加 | 声明 emits 覆盖原生事件 |
| 组件命名冲突 | 同名组件覆盖 | 使用明确的命名空间或别名导入 |
## TypeScript 陷阱
| 问题 | 原因 | 解法 |
|------|------|------|
| `withDefaults` 可变默认值泄漏 | 对象默认值被所有实例共享 | 使用工厂函数 `() => ({})` |
| template ref 类型报 null | ref 在挂载前为 null | 类型标注 `ref<T \| null>(null)` + 空检查 |
| `defineProps` 导入类型报错 | 编译器宏限制外部复杂类型 | 使用本地 interface 或 `type ... = ...` |
| DOM 事件 handler 类型不匹配 | strict 模式下 Event 类型 | 显式标注 `(event: MouseEvent) => void` |
| 动态组件 ref 触发 reactive 警告 | Component 对象被 deep reactive | 使用 `shallowRef` 存储动态组件 |
## Vuetify 3 常见问题
| 问题 | 原因 | 解法 |
|------|------|------|
| 样式不生效 | 未导入 Vuetify styles | 确保 `import 'vuetify/styles'` |
| 图标不显示 | 未安装 mdi 图标字体 | 安装 `@mdi/font` 并在入口导入 |
| 主题切换不生效 | 直接修改 theme 对象 | 使用 `theme.global.name.value = 'dark'` |
| v-data-table 性能差 | 大量数据未虚拟化 | 使用 `v-data-table-virtual` |
| 对话框层叠遮罩问题 | 多个 overlay 叠加 | 使用 `z-index` 或避免嵌套 dialog |
| scoped 样式无法覆盖 Vuetify | scoped CSS 优先级不够 | 使用 `:deep()` 选择器 |
## 路由常见问题
| 问题 | 原因 | 解法 |
|------|------|------|
| 参数变化页面不刷新 | 同路由不重新挂载组件 | watch 路由参数或使用 `:key` |
| 导航守卫无限重定向 | 守卫条件未排除目标路由 | 检查 `to.name !== 'Login'` |
| 异步守卫中请求未等待 | 忘记 await | 守卫函数声明为 `async` 并 await |
| 组件卸载后事件监听未清理 | 未在 unmount 中清理 | 使用 `onUnmounted` 清理 |
## 调试技巧
### Vue DevTools
```typescript
// 在 Store 中暴露所有状态以便 DevTools 显示
// Setup Store 必须 return 所有 ref
return { state1, state2, action1 }
```
### 响应式调试
```typescript
import { watch } from 'vue'
// 调试追踪 ref 变化来源
watch(someRef, (newVal, oldVal) => {
console.trace('someRef changed:', oldVal, '→', newVal)
})
```
### 组件渲染调试
```vue
<script setup lang="ts">
import { onRenderTracked, onRenderTriggered } from 'vue'
// 仅开发环境使用
if (import.meta.env.DEV) {
onRenderTracked((event) => {
console.log('render tracked:', event)
})
onRenderTriggered((event) => {
console.log('render triggered:', event)
})
}
</script>
```
### Vuetify 覆盖样式调试
```css
/* 使用 :deep() 穿透 scoped 样式 */
.my-custom-table :deep(.v-data-table-header) {
background-color: rgb(var(--v-theme-surface-variant));
}
```

View File

@@ -0,0 +1,215 @@
# UI 美学与设计规范
## 核心原则
- 页面不是功能的堆砌,而是信息的有序传达
- 留白是设计元素,不是浪费空间
- 颜色、字体、间距的选择必须有意为之,不使用默认模板风格
- 错误状态和空状态同样需要精心设计
## 间距与留白
### Vuetify 间距系统
Vuetify 使用 4px 基数的间距系统(`ma-1` = 4px, `ma-2` = 8px, ...
| 类名 | 尺寸 | 使用场景 |
|------|------|---------|
| `pa-1` / `ma-1` | 4px | 紧密关联的元素间距 |
| `pa-2` / `ma-2` | 8px | 组内元素间距 |
| `pa-3` / `ma-3` | 12px | 小节内间距 |
| `pa-4` / `ma-4` | 16px | 标准内容间距(推荐默认) |
| `pa-6` / `ma-6` | 24px | 区块间距 |
| `pa-8` / `ma-8` | 32px | 大区块间距 |
| `pa-12` / `ma-12` | 48px | 页面级留白 |
### 留白原则
```
页面外边距pa-4移动端→ pa-6平板→ pa-8桌面
卡片内边距pa-4标准→ pa-6宽松
卡片间距ga-4row gap
表单字段间距:通过 v-row dense 控制
标题与内容间距mb-4标题下方
区块间距my-6 或 my-8
```
### 响应式间距
```vue
<template>
<v-container class="pa-4 pa-md-6 pa-lg-8">
<v-row>
<v-col cols="12" class="mb-4 mb-md-6">
<h1 class="text-h4 text-md-h3">页面标题</h1>
</v-col>
</v-row>
</v-container>
</template>
```
## Typography 排版
### 字体层级
使用 Vuetify 内置的 Typography 类,保持一致:
| 类名 | 用途 |
|------|------|
| `text-h3` / `text-h4` | 页面大标题 |
| `text-h5` / `text-h6` | 区块标题 |
| `text-subtitle-1` | 副标题 |
| `text-subtitle-2` | 小副标题 |
| `text-body-1` | 正文(默认) |
| `text-body-2` | 辅助说明 |
| `text-caption` | 标注、时间戳 |
| `text-overline` | 分类标签 |
### 字体颜色层级
```vue
<!-- 主要文字 -->
<span class="text-on-surface">主标题</span>
<!-- 次要文字使用主题自定义色 -->
<span class="text-on-surface-muted">描述文字</span>
<!-- 禁用状态 -->
<span class="text-disabled">不可用</span>
```
## 色彩使用
### 语义色使用场景
| 颜色 | 场景 |
|------|------|
| `primary` | 主按钮、链接、选中状态、品牌标识 |
| `secondary` | 次要操作、辅助装饰 |
| `error` | 错误提示、删除操作、验证失败 |
| `warning` | 警告信息、需要注意的状态 |
| `success` | 成功操作、正常状态 |
| `info` | 一般性提示信息 |
### 状态色复用
```vue
<!-- 状态芯片统一用语义色 -->
<v-chip :color="statusColor" size="small">
{{ statusText }}
</v-chip>
<script setup lang="ts">
import { computed } from 'vue'
type StatusType = 'active' | 'inactive' | 'pending' | 'error'
const props = defineProps<{ status: StatusType }>()
const statusColor = computed(() => {
const colorMap: Record<StatusType, string> = {
active: 'success',
inactive: 'grey',
pending: 'warning',
error: 'error',
}
return colorMap[props.status]
})
</script>
```
## 空状态设计
每个可能为空的列表/表格都必须设计空状态:
```vue
<template>
<!-- 数据加载中 -->
<v-skeleton-loader v-if="loading" type="table" />
<!-- 空状态 -->
<v-card v-else-if="items.length === 0" variant="flat" class="text-center pa-12">
<v-icon icon="mdi-inbox-outline" size="64" color="on-surface-muted" />
<p class="text-h6 mt-4">暂无数据</p>
<p class="text-body-2 text-on-surface-muted mb-4">
还没有任何记录点击下方按钮创建第一条
</p>
<v-btn color="primary" prepend-icon="mdi-plus" @click="emit('create')">
新建
</v-btn>
</v-card>
<!-- 正常数据展示 -->
<v-data-table v-else :items="items" :headers="headers" />
</template>
```
## 错误状态设计
错误提示应当具体、可操作,不使用模糊的"出错了"
```vue
<template>
<v-alert
v-if="error"
type="error"
variant="tonal"
closable
class="mb-4"
@click:close="error = null"
>
<v-alert-title>{{ error }}</v-alert-title>
<template #append>
<v-btn variant="text" size="small" @click="retry">
重试
</v-btn>
</template>
</v-alert>
</template>
```
## 文案写作规范
借鉴 frontend-design skill 的核心原则:
| 原则 | 说明 |
|------|------|
| 用户视角命名 | "通知管理" 而非 "Webhook 配置" |
| 主动语态 | "保存更改" 而非 "提交" |
| 动词一致性 | 按钮说 "发布" → 提示说 "已发布" |
| 错误不道歉 | "用户名已被注册" 而非 "抱歉,出错了" |
| 空状态即邀请 | 引导用户下一步操作 |
| 简洁 | 标签只标注,示例只演示,不让文字身兼多职 |
## 反模式
### ❌ 毫无留白的拥挤界面
```vue
<!-- 禁止所有元素紧贴无间距 -->
<v-card>
<v-card-title>标题</v-card-title>
<v-data-table />
<v-btn>操作</v-btn>
</v-card>
```
### ❌ 颜色乱用
```vue
<!-- 禁止删除按钮用 primary -->
<v-btn color="primary" @click="deleteItem">删除</v-btn>
<!-- 正确危险操作用 error -->
<v-btn color="error" @click="deleteItem">删除</v-btn>
```
### ❌ 模糊的错误提示
```vue
<!-- 禁止 -->
<v-alert type="error">操作失败</v-alert>
<!-- 正确 -->
<v-alert type="error">用户名已被注册请更换后重试</v-alert>
```

View File

@@ -0,0 +1,165 @@
# Pinia 状态管理精要
## 核心原则
- 使用 Pinia 进行全局/跨组件的状态管理
- 优先使用 Setup Store 语法(与 Composition API 一致)
- 组件内部的临时状态使用 `ref` / `reactive`,不上升到 Store
- Store 解构时使用 `storeToRefs()` 保持响应式
## Store 定义
### Setup Store推荐
```typescript
// src/stores/useUserStore.ts
import { defineStore, storeToRefs } from 'pinia'
import { ref, computed } from 'vue'
import { getUserList, type UserInfo } from '@/api/user'
import type { PageData } from '@/utils/request'
export const useUserStore = defineStore('user', () => {
// State
const users = ref<UserInfo[]>([])
const currentUser = ref<UserInfo | null>(null)
const loading = ref(false)
const total = ref(0)
// Getters
const userCount = computed(() => users.value.length)
const isLoggedIn = computed(() => currentUser.value !== null)
// Actions
async function fetchUsers(page: number, pageSize: number): Promise<void> {
loading.value = true
try {
const result: PageData<UserInfo> = await getUserList({
page,
page_size: pageSize,
})
users.value = result.list
total.value = result.total
} finally {
loading.value = false
}
}
function setCurrentUser(user: UserInfo | null): void {
currentUser.value = user
}
function $reset(): void {
users.value = []
currentUser.value = null
loading.value = false
total.value = 0
}
// Setup Store 必须返回所有需要暴露的状态和方法
return {
users,
currentUser,
loading,
total,
userCount,
isLoggedIn,
fetchUsers,
setCurrentUser,
$reset,
}
})
```
## 在组件中使用
### 正确的解构方式
```vue
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/useUserStore'
const userStore = useUserStore()
// ✅ 响应式状态用 storeToRefs 解构
const { users, loading, userCount } = storeToRefs(userStore)
// ✅ 方法直接解构(不需要 storeToRefs
const { fetchUsers, setCurrentUser } = userStore
</script>
```
### ❌ 错误的解构方式
```typescript
// ❌ 禁止:直接解构丢失响应式
const { users, loading } = useUserStore() // users 和 loading 不再响应式
```
## Store 之间通信
```typescript
// src/stores/useAuthStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useUserStore } from './useUserStore'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(null)
const isAuthenticated = computed(() => token.value !== null)
function logout(): void {
token.value = null
localStorage.removeItem('access_token')
// 通知其他 Store 重置
const userStore = useUserStore()
userStore.$reset()
}
return { token, isAuthenticated, logout }
})
```
## 状态分类决策
| 状态类型 | 存放位置 | 示例 |
|---------|---------|------|
| 全局共享状态 | Pinia Store | 用户信息、权限、主题偏好 |
| 页面级共享状态 | Pinia Store 或 Composable | 列表筛选条件、分页状态 |
| 组件内部状态 | `ref` / `reactive` | 表单输入值、对话框开关 |
| URL 可恢复状态 | Vue Router query/params | 搜索关键词、页码、筛选条件 |
| 临时 UI 状态 | `ref` | loading、hover、展开/折叠 |
## 反模式
### ❌ 所有状态都放 Store
```typescript
// ❌ 禁止:对话框开关状态不属于全局状态
export const useDialogStore = defineStore('dialog', () => {
const showCreateDialog = ref(false) // ← 应该放在组件内
})
```
### ❌ Store 中直接操作 DOM
```typescript
// ❌ 禁止
export const useAppStore = defineStore('app', () => {
function showAlert(msg: string): void {
window.alert(msg) // ← Store 不应有 DOM 副作用
}
})
```
### ❌ 忘记在 Setup Store 中返回状态
```typescript
// ❌ 错误DevTools 看不到 internalState
export const useBadStore = defineStore('bad', () => {
const internalState = ref(0) // ← 未返回
return { /* 忘记返回 internalState */ }
})
```

View File

@@ -0,0 +1,216 @@
# Vue Router 4 模式精要
## 核心原则
- 使用 Vue Router 4配合 Vue 3
- 路由配置使用 TypeScript 严格类型
- 导航守卫使用返回值模式(弃用 `next()` 回调)
- 异步数据加载在守卫或组件 `onMounted` 中处理
## 路由配置
```typescript
// src/router/index.ts
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/auth/LoginView.vue'),
meta: { requiresAuth: false, title: '登录' },
},
{
path: '/',
component: () => import('@/layouts/MainLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'Dashboard',
component: () => import('@/views/dashboard/DashboardView.vue'),
meta: { title: '仪表板' },
},
{
path: 'users',
name: 'UserList',
component: () => import('@/views/user/UserListView.vue'),
meta: { title: '用户管理' },
},
{
path: 'users/:id',
name: 'UserDetail',
component: () => import('@/views/user/UserDetailView.vue'),
meta: { title: '用户详情' },
props: true,
},
],
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/error/NotFoundView.vue'),
meta: { title: '页面未找到' },
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
})
export default router
```
## 路由 Meta 类型扩展
```typescript
// src/types/router.d.ts
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
/** 是否需要登录 */
requiresAuth?: boolean
/** 页面标题 */
title?: string
/** 所需权限 */
permissions?: string[]
}
}
```
## 导航守卫
### 全局前置守卫
```typescript
// src/router/guards.ts
import type { Router } from 'vue-router'
import { useAuthStore } from '@/stores/useAuthStore'
export function setupRouterGuards(router: Router): void {
router.beforeEach((to) => {
const authStore = useAuthStore()
// 需要认证但未登录 → 跳登录页
if (to.meta.requiresAuth !== false && !authStore.isAuthenticated) {
return { name: 'Login', query: { redirect: to.fullPath } }
}
// 已登录访问登录页 → 跳首页
if (to.name === 'Login' && authStore.isAuthenticated) {
return { name: 'Dashboard' }
}
// 设置页面标题
if (to.meta.title) {
document.title = `${to.meta.title} - 应用名称`
}
// 放行
return true
})
}
```
### ❌ 弃用的 next() 模式
```typescript
// ❌ 禁止:使用 next() 回调
router.beforeEach((to, from, next) => {
if (authenticated) {
next()
} else {
next('/login')
}
})
// ✅ 正确:使用返回值
router.beforeEach((to) => {
if (!authenticated) {
return { path: '/login' }
}
// 不返回或返回 true 表示放行
})
```
## 路由参数变化不触发生命周期
同一路由不同参数(如 `/users/1``/users/2`)不会触发组件重新挂载。
### 解法一watch 路由参数
```vue
<script setup lang="ts">
import { watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
watch(
() => route.params.id,
(newId) => {
if (newId) {
void fetchUserDetail(Number(newId))
}
},
{ immediate: true },
)
</script>
```
### 解法二:使用 key 强制重新挂载
```vue
<!-- 在父组件的 router-view 上绑定 key -->
<router-view :key="$route.fullPath" />
```
## 异步守卫模式
```typescript
router.beforeEach(async (to) => {
if (to.meta.permissions) {
const authStore = useAuthStore()
const hasPermission = await authStore.checkPermissions(to.meta.permissions)
if (!hasPermission) {
return { name: 'Forbidden' }
}
}
})
```
## 导航后清理
```vue
<script setup lang="ts">
import { onBeforeRouteLeave } from 'vue-router'
// 离开页面前清理(如未保存提示)
onBeforeRouteLeave((to, from) => {
if (isDirty.value) {
const answer = window.confirm('有未保存的更改,确定离开吗?')
if (!answer) return false
}
})
</script>
```
## 反模式
### ❌ 无限重定向循环
```typescript
// ❌ 禁止:每个路由都重定向到另一个需要守卫的路由
router.beforeEach((to) => {
if (!auth) return { name: 'Login' } // Login 也需要 auth→ 死循环
})
// ✅ 正确:排除不需要认证的路由
router.beforeEach((to) => {
if (to.meta.requiresAuth !== false && !auth) {
return { name: 'Login' }
}
})
```

View File

@@ -0,0 +1,246 @@
# TypeScript 严格规范
## 核心原则
- 所有代码必须使用 TypeScript禁止出现 `.js` / `.jsx` 文件
- 禁止使用 `any` 类型,必须提供具体的类型定义
- 所有函数必须声明返回类型(`void` 也要显式标注)
- 使用 `interface` 定义对象形状,使用 `type` 定义联合类型和工具类型
## tsconfig 严格模式
```json
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictPropertyInitialization": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": false,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
}
}
```
## 类型定义规范
### interface vs type 选择
```typescript
// ✅ interface用于对象形状定义可扩展
interface UserInfo {
id: number
username: string
email: string
role: UserRole
createdAt: string
}
// ✅ type用于联合类型、交叉类型、工具类型
type UserRole = 'admin' | 'editor' | 'viewer'
type Nullable<T> = T | null
type AsyncData<T> = {
data: T | null
loading: boolean
error: string | null
}
```
### Props 类型定义
```vue
<script setup lang="ts">
// ✅ 正确:使用 TypeScript 类型定义 props
interface Props {
title: string
count?: number
items: ReadonlyArray<ListItem>
variant?: 'outlined' | 'tonal' | 'elevated'
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
variant: 'tonal',
})
// ✅ 正确:可变默认值使用工厂函数
interface FormProps {
initialValues?: FormValues
}
const formProps = withDefaults(defineProps<FormProps>(), {
initialValues: () => ({ name: '', email: '' }),
})
</script>
```
### Emits 类型定义
```vue
<script setup lang="ts">
interface Emits {
(event: 'update:modelValue', value: string): void
(event: 'submit', data: FormData): void
(event: 'delete', id: number): void
}
const emit = defineEmits<Emits>()
</script>
```
### Ref 类型
```typescript
import { ref, shallowRef, computed, type Ref } from 'vue'
// ✅ 类型推断充分时不需要显式标注
const count = ref(0) // Ref<number>
const name = ref('') // Ref<string>
// ✅ 需要显式标注的场景
const user = ref<UserInfo | null>(null) // 初始值无法推断目标类型
const items = ref<ListItem[]>([]) // 空数组无法推断元素类型
const el = ref<HTMLDivElement | null>(null) // DOM ref
// ✅ shallowRef 用于非 Vue 实例对象(如 ECharts、地图实例等
const chartInstance = shallowRef<ECharts | null>(null)
```
### 响应式默认值策略
选择正确的响应式原语(借鉴 vuetify0 策略):
| 场景 | 使用 | 原因 |
|------|------|------|
| 原始值string / number / boolean | `ref` | 自动 unwrap简单高效 |
| 对象/数组(需要深层响应式) | `ref` | 深层响应式追踪 |
| 非 Vue 管理的外部对象实例 | `shallowRef` | 避免代理破坏第三方实例 |
| 只读派生值 | `computed` | 缓存 + 自动依赖追踪 |
| 派生 ref传递给 watcher | `toRef` | 从 getter/props 创建 ref |
| 读取响应式值的当前快照 | `toValue` | 在需要当前值时解包 |
## 禁止模式
### ❌ 使用 any
```typescript
// ❌ 禁止
const data: any = response.data
function process(input: any): any { ... }
// ✅ 正确:定义具体类型
const data: ApiResponse<UserInfo> = response.data
function process(input: ProcessInput): ProcessResult { ... }
```
### ❌ 使用 @ts-ignore / @ts-nocheck
```typescript
// ❌ 禁止
// @ts-ignore
someFunction(wrongType)
// ✅ 正确:修复类型问题或使用类型守卫
if (isUserInfo(data)) {
someFunction(data)
}
```
### ❌ 类型断言滥用
```typescript
// ❌ 禁止:无依据的类型断言
const user = data as UserInfo
// ✅ 正确:使用类型守卫
function isUserInfo(data: unknown): data is UserInfo {
return (
typeof data === 'object' &&
data !== null &&
'id' in data &&
'username' in data
)
}
```
## 常用类型工具
```typescript
// src/types/utils.ts
/** 将类型 T 的所有属性变为可选 + null */
type Nullable<T> = { [K in keyof T]: T[K] | null }
/** 从类型 T 中排除 null 和 undefined */
type NonNullableFields<T> = { [K in keyof T]: NonNullable<T[K]> }
/** 分页请求参数 */
interface PaginationParams {
page: number
pageSize: number
}
/** 排序参数 */
interface SortParams {
sortBy: string
sortOrder: 'asc' | 'desc'
}
/** 列表查询参数 */
type ListQueryParams = PaginationParams & Partial<SortParams> & {
search?: string
}
/** 表单字段状态 */
interface FieldState<T> {
value: T
error: string | null
dirty: boolean
touched: boolean
}
```
## Vue 3 + TypeScript 特有规范
### template ref 类型
```vue
<script setup lang="ts">
import { useTemplateRef } from 'vue'
// Vue 3.5+ 推荐
const formRef = useTemplateRef<InstanceType<typeof VForm>>('formRef')
// 或使用 ref
const inputRef = ref<HTMLInputElement | null>(null)
</script>
<template>
<v-form ref="formRef">
<input ref="inputRef" />
</v-form>
</template>
```
### provide / inject 类型安全
```typescript
import { type InjectionKey, provide, inject } from 'vue'
// 定义类型安全的 injection key
const UserContextKey: InjectionKey<UserContext> = Symbol('UserContext')
// 提供
provide(UserContextKey, userContext)
// 注入(类型安全)
const userContext = inject(UserContextKey)
if (!userContext) {
throw new Error('UserContext not provided')
}
```

View File

@@ -0,0 +1,158 @@
# Vuetify 3 组件使用规范
## 核心原则
- 所有 UI 必须使用 Vuetify 3 组件构建,禁止引入其他 UI 库Element Plus、Ant Design Vue 等)
- 优先使用 Vuetify 内置组件,只有在 Vuetify 确实不提供时才考虑自定义实现
- 组件 props 使用 kebab-case`:item-value`),事件使用 `@update:model-value` 形式
## 组件选择决策表
编码前先查此表,按问题匹配组件,不要按关键词猜测。
### 布局与容器
| 问题场景 | 使用组件 | 说明 |
|---------|---------|------|
| 页面整体布局 | `v-app` + `v-main` | 应用根容器,必须包裹所有内容 |
| 侧边导航栏 | `v-navigation-drawer` | 支持 `rail` 模式(收缩)和响应式断点 |
| 顶部应用栏 | `v-app-bar` | 支持 `density` 调节高度 |
| 底部导航(移动端) | `v-bottom-navigation` | 移动端底部 Tab 栏 |
| 栅格布局 | `v-container` + `v-row` + `v-col` | 12 列 Grid 系统 |
| 卡片容器 | `v-card` | 标准内容容器,支持 `variant` |
| 通用容器 | `v-sheet` | 轻量级容器,用于自定义布局 |
| 工具栏 | `v-toolbar` | 独立工具栏(非 app-bar |
| 内容分隔线 | `v-divider` | 水平/垂直分隔 |
| 间距控制 | `v-spacer` | Flex 弹性占位 |
### 数据展示
| 问题场景 | 使用组件 | 说明 |
|---------|---------|------|
| 表格数据(带排序/分页) | `v-data-table` | 功能完整的数据表格 |
| 简单表格(无排序) | `v-table` | 原生表格包装 |
| 虚拟滚动长列表 | `v-virtual-scroll` | 大数据量渲染优化 |
| 列表展示 | `v-list` + `v-list-item` | 垂直列表,支持分组 |
| 芯片/标签 | `v-chip` | 状态标签、筛选标签 |
| 徽章/角标 | `v-badge` | 数字或状态角标 |
| 头像 | `v-avatar` | 用户头像或图标容器 |
| 进度指示 | `v-progress-linear` / `v-progress-circular` | 进度条 |
| 骨架屏 | `v-skeleton-loader` | 数据加载占位 |
| 时间线 | `v-timeline` | 时间序列展示 |
| 树形结构 | `v-treeview` | 层级数据展示 |
### 表单与输入
| 问题场景 | 使用组件 | 说明 |
|---------|---------|------|
| 表单容器(带验证) | `v-form` | 统一表单验证入口 |
| 文本输入 | `v-text-field` | 支持 `type``rules``variant` |
| 多行文本 | `v-textarea` | 多行输入 |
| 下拉选择 | `v-select` | 单选/多选下拉 |
| 搜索选择(自动补全) | `v-autocomplete` | 可搜索的下拉 |
| 组合输入(自由输入+选择) | `v-combobox` | 允许输入不在列表中的值 |
| 开关 | `v-switch` | 布尔值切换 |
| 复选框 | `v-checkbox` | 单个/多个复选 |
| 单选 | `v-radio-group` + `v-radio` | 互斥选择 |
| 滑块 | `v-slider` / `v-range-slider` | 数值范围选择 |
| 文件上传 | `v-file-input` | 文件选择 |
| 日期选择 | `v-date-input` + `v-date-picker` | 日期输入 |
| 颜色选择 | `v-color-picker` | 颜色输入 |
### 反馈与交互
| 问题场景 | 使用组件 | 说明 |
|---------|---------|------|
| 模态对话框 | `v-dialog` | 居中弹窗 |
| 底部弹出面板 | `v-bottom-sheet` | 移动端底部弹出 |
| 临时消息通知 | `v-snackbar` | 自动消失的提示 |
| 工具提示 | `v-tooltip` | 悬浮提示 |
| 弹出菜单 | `v-menu` | 下拉/右键菜单 |
| 确认操作 | `v-dialog` + 自定义确认内容 | 危险操作二次确认 |
| 全屏遮罩/加载 | `v-overlay` | 覆盖层 |
| 警告横幅 | `v-alert` | 页面级提示信息 |
| 横幅通知 | `v-banner` | 持续性通知 |
### 导航
| 问题场景 | 使用组件 | 说明 |
|---------|---------|------|
| 页面标签页 | `v-tabs` + `v-tab` + `v-tabs-window` | 标签页切换 |
| 面包屑 | `v-breadcrumbs` | 路径导航 |
| 分页器 | `v-pagination` | 页码导航 |
| 步骤条 | `v-stepper` | 多步骤向导 |
| 手风琴面板 | `v-expansion-panels` | 折叠/展开面板组 |
### 按钮与操作
| 问题场景 | 使用组件 | 说明 |
|---------|---------|------|
| 标准按钮 | `v-btn` | 支持 `variant``color``size` |
| 图标按钮 | `v-btn` + `icon` prop | 仅图标的按钮 |
| 浮动操作按钮 | `v-btn` + `position="fixed"` | FAB 按钮 |
| 按钮组 | `v-btn-toggle` | 互斥/多选按钮组 |
| 图标 | `v-icon` | Material Design Icons |
## 组件使用规范
### variant 选择策略
```
v-btn / v-text-field / v-card 等支持 variant 的组件:
- elevated → 强调操作(主按钮、重要卡片)
- filled → 次要强调
- tonal → 柔和强调(推荐默认)
- outlined → 边框风格
- text → 无背景无边框
- plain → 最低视觉权重
```
### density 选择策略
```
适用于 v-text-field / v-select / v-list 等:
- default → 标准间距
- comfortable → 稍紧凑
- compact → 紧凑模式(数据密集场景)
```
### 图标使用
统一使用 Material Design Iconsmdi
```vue
<v-icon icon="mdi-account" />
<v-btn prepend-icon="mdi-plus">新建</v-btn>
```
禁止混用多种图标库。项目中应在 `vuetify.ts` 插件中统一配置图标集。
## 反模式
### ❌ 混用 UI 库
```vue
<!-- 禁止混用 Element Plus -->
<el-button>提交</el-button>
<v-card>...</v-card>
```
### ❌ 原生 HTML 替代 Vuetify 组件
```vue
<!-- 禁止 Vuetify 对应组件时使用原生 HTML -->
<table>...</table> <!-- 应使用 v-data-table v-table -->
<input type="text" /> <!-- 应使用 v-text-field -->
<select>...</select> <!-- 应使用 v-select -->
<button>...</button> <!-- 应使用 v-btn -->
```
### ❌ 硬编码颜色值
```vue
<!-- 禁止硬编码颜色 -->
<v-card style="background: #1976d2">
<!-- 正确使用 Vuetify 主题色 -->
<v-card color="primary">
```

View File

@@ -0,0 +1,207 @@
# Vuetify 3 响应式设计
## 核心原则
- 使用 Vuetify 的 Grid 系统(`v-container` / `v-row` / `v-col`)构建响应式布局
- 使用 `useDisplay()` composable 进行编程式断点判断
- 移动优先:从小屏开始设计,逐步增强大屏体验
- 禁止使用原生 CSS media query 替代 Vuetify 断点系统
## 断点定义
Vuetify 3 默认断点:
| 断点名 | 范围 | 设备 |
|--------|------|------|
| `xs` | 0 599px | 小型手机 |
| `sm` | 600 959px | 大型手机 / 小型平板 |
| `md` | 960 1279px | 平板 / 小型笔电 |
| `lg` | 1280 1919px | 桌面显示器 |
| `xl` | 1920 2559px | 大型显示器 |
| `xxl` | 2560px+ | 超大屏 |
## Grid 系统
### 基础布局
```vue
<template>
<v-container>
<v-row>
<!-- 手机全宽平板6列桌面4列 -->
<v-col cols="12" sm="6" md="4">
<v-card>内容A</v-card>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-card>内容B</v-card>
</v-col>
<v-col cols="12" sm="12" md="4">
<v-card>内容C</v-card>
</v-col>
</v-row>
</v-container>
</template>
```
### 响应式间距
```vue
<v-row dense> <!-- 紧凑间距 -->
<v-row no-gutters> <!-- 无间距 -->
<v-row :dense="mobile"> <!-- 条件间距 -->
```
### 对齐控制
```vue
<v-row align="center" justify="space-between">
<v-col cols="auto">左侧内容</v-col>
<v-col cols="auto">右侧内容</v-col>
</v-row>
```
## 编程式断点判断
### useDisplay() composable
```typescript
import { useDisplay } from 'vuetify'
import { computed } from 'vue'
const { mobile, mdAndUp, lgAndUp, name, width } = useDisplay()
// 常用判断
const isMobile = computed(() => mobile.value)
const isTabletOrAbove = computed(() => mdAndUp.value)
const isDesktop = computed(() => lgAndUp.value)
```
### 响应式组件属性
```vue
<script setup lang="ts">
import { useDisplay } from 'vuetify'
import { computed } from 'vue'
const { mobile, mdAndUp } = useDisplay()
const drawerRail = computed(() => !mdAndUp.value)
const cardCols = computed(() => mobile.value ? 12 : 6)
const tableDensity = computed<'default' | 'compact'>(() =>
mobile.value ? 'compact' : 'default'
)
</script>
<template>
<v-navigation-drawer :rail="drawerRail" />
<v-col :cols="cardCols">...</v-col>
<v-data-table :density="tableDensity" />
</template>
```
## 响应式典型模式
### 侧边栏模式
```vue
<script setup lang="ts">
import { useDisplay } from 'vuetify'
import { ref, watch } from 'vue'
const { mdAndUp } = useDisplay()
const drawerOpen = ref(true)
// 移动端默认收起侧边栏
watch(mdAndUp, (isDesktop) => {
drawerOpen.value = isDesktop
}, { immediate: true })
</script>
<template>
<v-navigation-drawer
v-model="drawerOpen"
:temporary="!mdAndUp"
:permanent="mdAndUp"
>
<!-- 导航内容 -->
</v-navigation-drawer>
</template>
```
### 列表/卡片切换
```vue
<script setup lang="ts">
import { useDisplay } from 'vuetify'
import { computed } from 'vue'
const { mobile } = useDisplay()
const viewMode = computed(() => mobile.value ? 'list' : 'grid')
</script>
<template>
<!-- 移动端列表视图 -->
<v-list v-if="viewMode === 'list'">
<v-list-item v-for="item in items" :key="item.id" :title="item.name" />
</v-list>
<!-- 桌面端卡片网格 -->
<v-row v-else>
<v-col v-for="item in items" :key="item.id" cols="4">
<v-card :title="item.name" />
</v-col>
</v-row>
</template>
```
### 响应式对话框
```vue
<script setup lang="ts">
import { useDisplay } from 'vuetify'
import { computed } from 'vue'
const { mobile } = useDisplay()
const dialogWidth = computed(() => mobile.value ? '100%' : '600')
const dialogFullscreen = computed(() => mobile.value)
</script>
<template>
<v-dialog
:width="dialogWidth"
:fullscreen="dialogFullscreen"
>
<!-- 对话框内容 -->
</v-dialog>
</template>
```
## 反模式
### ❌ 原生 media query 替代 Vuetify 断点
```css
/* 禁止:使用原生 media query */
@media (max-width: 960px) {
.sidebar { display: none; }
}
```
```vue
<!-- 正确使用 Vuetify 断点 -->
<v-navigation-drawer v-if="mdAndUp" />
```
### ❌ 固定像素宽度
```vue
<!-- 禁止固定像素宽度 -->
<v-col style="width: 400px">
<!-- 正确使用栅格 -->
<v-col cols="12" md="4">
```
### ❌ 忽略移动端适配
所有页面必须在 xs 断点(< 600px下可用且内容可读

View File

@@ -0,0 +1,208 @@
# Vuetify 3 明暗主题配置
## 核心原则
- 所有颜色必须通过 Vuetify 主题系统定义,禁止在组件中硬编码颜色值
- 必须同时提供 light 和 dark 两套主题配色
- 自定义颜色通过 `colors` 扩展,不覆盖 Vuetify 默认语义色primary / secondary / error 等)
- CSS 中引用颜色使用 `rgb(var(--v-theme-<color>))` 变量
## 主题配置模板
### vuetify 插件配置
```typescript
// src/plugins/vuetify.ts
import { createVuetify, type ThemeDefinition } from 'vuetify'
import 'vuetify/styles'
const lightTheme: ThemeDefinition = {
dark: false,
colors: {
background: '#FAFAFA',
surface: '#FFFFFF',
'surface-variant': '#F5F5F5',
primary: '#1867C0',
'primary-darken-1': '#1459A3',
secondary: '#5CBBF6',
'secondary-darken-1': '#4BA3D8',
error: '#B00020',
info: '#2196F3',
success: '#4CAF50',
warning: '#FB8C00',
// 自定义扩展色
'on-surface-muted': '#757575',
'border-color': '#E0E0E0',
},
}
const darkTheme: ThemeDefinition = {
dark: true,
colors: {
background: '#121212',
surface: '#1E1E1E',
'surface-variant': '#2C2C2C',
primary: '#2196F3',
'primary-darken-1': '#1976D2',
secondary: '#54B4EB',
'secondary-darken-1': '#4BA3D8',
error: '#CF6679',
info: '#2196F3',
success: '#4CAF50',
warning: '#FB8C00',
// 自定义扩展色
'on-surface-muted': '#9E9E9E',
'border-color': '#424242',
},
}
export default createVuetify({
theme: {
defaultTheme: 'light',
themes: {
light: lightTheme,
dark: darkTheme,
},
},
})
```
## 主题切换
### 使用 useTheme() composable
```typescript
// src/composables/useThemeToggle.ts
import { useTheme } from 'vuetify'
import { computed } from 'vue'
export function useThemeToggle() {
const theme = useTheme()
const isDark = computed(() => theme.global.current.value.dark)
function toggleTheme(): void {
theme.global.name.value = isDark.value ? 'light' : 'dark'
}
return { isDark, toggleTheme }
}
```
### 模板中使用
```vue
<script setup lang="ts">
import { useThemeToggle } from '@/composables/useThemeToggle'
const { isDark, toggleTheme } = useThemeToggle()
</script>
<template>
<v-btn
:icon="isDark ? 'mdi-weather-sunny' : 'mdi-weather-night'"
@click="toggleTheme"
/>
</template>
```
## CSS 中引用主题色
### 使用 CSS 变量
```css
/* 正确:通过 Vuetify CSS 变量引用主题色 */
.custom-border {
border: 1px solid rgb(var(--v-theme-border-color));
}
.muted-text {
color: rgb(var(--v-theme-on-surface-muted));
}
/* 带透明度 */
.overlay-bg {
background-color: rgba(var(--v-theme-surface), 0.85);
}
```
### 禁止模式
```css
/* ❌ 禁止:硬编码颜色 */
.custom-bg { background: #1976d2; }
/* ❌ 禁止:不走主题系统的 CSS 变量 */
.custom-bg { background: var(--my-custom-color); }
/* ✅ 正确:走 Vuetify 主题 */
.custom-bg { background-color: rgb(var(--v-theme-primary)); }
```
## 组件级主题色使用
```vue
<!-- 通过 color prop 引用主题色 -->
<v-card color="surface-variant">
<v-card-title class="text-primary">标题</v-card-title>
<v-card-text class="text-on-surface-muted">描述文字</v-card-text>
</v-card>
<!-- 使用 Vuetify 工具类 -->
<div class="bg-surface text-on-surface">内容</div>
```
## 主题感知的条件样式
```vue
<script setup lang="ts">
import { useTheme } from 'vuetify'
import { computed } from 'vue'
const theme = useTheme()
const cardElevation = computed(() =>
theme.global.current.value.dark ? 0 : 2
)
</script>
<template>
<v-card :elevation="cardElevation" />
</template>
```
## 主题持久化
将用户主题偏好存储到 localStorage并在应用初始化时恢复
```typescript
// src/composables/useThemeToggle.ts
import { useTheme } from 'vuetify'
import { computed, watch } from 'vue'
const THEME_STORAGE_KEY = 'user-theme-preference'
export function useThemeToggle() {
const theme = useTheme()
const isDark = computed(() => theme.global.current.value.dark)
function toggleTheme(): void {
theme.global.name.value = isDark.value ? 'light' : 'dark'
}
// 持久化
watch(
() => theme.global.name.value,
(themeName) => {
localStorage.setItem(THEME_STORAGE_KEY, themeName)
},
)
return { isDark, toggleTheme }
}
export function restoreThemePreference(): string {
return localStorage.getItem(THEME_STORAGE_KEY) ?? 'light'
}
```