附录A:代码片段库
可复用的工具函数和组件代码片段,涵盖 Vue3、TypeScript、CSS 的常用模式和最佳实践。
📋 内容目录
🎯 Vue3 组合式函数
数据获取相关
useFetch - 通用数据获取
// composables/useFetch.ts
import { ref, computed, watch } from 'vue'
interface UseFetchOptions<T> {
immediate?: boolean
onSuccess?: (data: T) => void
onError?: (error: Error) => void
retry?: number
retryDelay?: number
}
interface UseFetchReturn<T> {
data: Ref<T | null>
loading: Ref<boolean>
error: Ref<Error | null>
execute: () => Promise<void>
refresh: () => Promise<void>
isReady: ComputedRef<boolean>
}
export function useFetch<T>(
url: string | Ref<string>,
options: UseFetchOptions<T> = {}
): UseFetchReturn<T> {
const {
immediate = true,
onSuccess,
onError,
retry = 0,
retryDelay = 1000
} = options
const data = ref<T | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
const retryCount = ref(0)
const isReady = computed(() => !loading.value && !error.value && data.value !== null)
const execute = async (): Promise<void> => {
if (loading.value) return
loading.value = true
error.value = null
try {
const response = await fetch(unref(url))
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result = await response.json()
data.value = result
retryCount.value = 0
onSuccess?.(result)
} catch (err) {
const errorObj = err instanceof Error ? err : new Error('Unknown error')
error.value = errorObj
onError?.(errorObj)
// 重试逻辑
if (retryCount.value < retry) {
retryCount.value++
setTimeout(() => {
execute()
}, retryDelay)
}
} finally {
loading.value = false
}
}
const refresh = () => execute()
// 监听 URL 变化
watch(url, () => {
if (immediate) {
execute()
}
}, { immediate })
return {
data: readonly(data),
loading: readonly(loading),
error: readonly(error),
execute,
refresh,
isReady
}
}
useAsyncData - 异步数据管理
// composables/useAsyncData.ts
import { ref, computed, watch } from 'vue'
interface UseAsyncDataOptions<T> {
server?: boolean
default?: () => T
transform?: (data: any) => T
watch?: any[]
immediate?: boolean
}
interface UseAsyncDataReturn<T> {
data: Ref<T | null>
pending: Ref<boolean>
error: Ref<Error | null>
refresh: () => Promise<void>
execute: () => Promise<void>
}
export function useAsyncData<T>(
key: string,
handler: () => Promise<T>,
options: UseAsyncDataOptions<T> = {}
): UseAsyncDataReturn<T> {
const {
server = true,
default: defaultValue,
transform,
watch: deps = [],
immediate = true
} = options
const data = ref<T | null>(defaultValue?.() || null)
const pending = ref(false)
const error = ref<Error | null>(null)
const execute = async (): Promise<void> => {
if (pending.value) return
pending.value = true
error.value = null
try {
const result = await handler()
data.value = transform ? transform(result) : result
} catch (err) {
error.value = err instanceof Error ? err : new Error('Unknown error')
} finally {
pending.value = false
}
}
const refresh = () => execute()
// 监听依赖变化
if (deps.length > 0) {
watch(deps, () => {
if (immediate) {
execute()
}
}, { immediate })
} else if (immediate) {
execute()
}
return {
data: readonly(data),
pending: readonly(pending),
error: readonly(error),
refresh,
execute
}
}
状态管理相关
useLocalStorage - 本地存储
// composables/useLocalStorage.ts
import { ref, watch } from 'vue'
export function useLocalStorage<T>(
key: string,
defaultValue: T,
options: {
serializer?: {
read: (value: string) => T
write: (value: T) => string
}
syncAcrossTabs?: boolean
} = {}
): Ref<T> {
const {
serializer = JSON,
syncAcrossTabs = true
} = options
const read = (): T => {
try {
const item = localStorage.getItem(key)
return item ? serializer.read(item) : defaultValue
} catch (error) {
console.warn(`Error reading localStorage key "${key}":`, error)
return defaultValue
}
}
const write = (value: T): void => {
try {
localStorage.setItem(key, serializer.write(value))
} catch (error) {
console.warn(`Error writing localStorage key "${key}":`, error)
}
}
const storedValue = read()
const state = ref<T>(storedValue) as Ref<T>
// 监听状态变化,同步到 localStorage
watch(state, (newValue) => {
write(newValue)
}, { deep: true })
// 监听其他标签页的变化
if (syncAcrossTabs) {
window.addEventListener('storage', (e) => {
if (e.key === key && e.newValue !== null) {
state.value = serializer.read(e.newValue)
}
})
}
return state
}
useSessionStorage - 会话存储
// composables/useSessionStorage.ts
import { ref, watch } from 'vue'
export function useSessionStorage<T>(
key: string,
defaultValue: T,
options: {
serializer?: {
read: (value: string) => T
write: (value: T) => string
}
} = {}
): Ref<T> {
const { serializer = JSON } = options
const read = (): T => {
try {
const item = sessionStorage.getItem(key)
return item ? serializer.read(item) : defaultValue
} catch (error) {
console.warn(`Error reading sessionStorage key "${key}":`, error)
return defaultValue
}
}
const write = (value: T): void => {
try {
sessionStorage.setItem(key, serializer.write(value))
} catch (error) {
console.warn(`Error writing sessionStorage key "${key}":`, error)
}
}
const storedValue = read()
const state = ref<T>(storedValue) as Ref<T>
watch(state, (newValue) => {
write(newValue)
}, { deep: true })
return state
}
交互相关
useDebounce - 防抖
// composables/useDebounce.ts
import { ref, watch, Ref } from 'vue'
export function useDebounce<T>(
value: Ref<T>,
delay: number = 300
): Ref<T> {
const debouncedValue = ref(value.value) as Ref<T>
let timeoutId: number | null = null
watch(value, (newValue) => {
if (timeoutId) {
clearTimeout(timeoutId)
}
timeoutId = setTimeout(() => {
debouncedValue.value = newValue
}, delay)
})
return debouncedValue
}
useThrottle - 节流
// composables/useThrottle.ts
import { ref, watch, Ref } from 'vue'
export function useThrottle<T>(
value: Ref<T>,
delay: number = 300
): Ref<T> {
const throttledValue = ref(value.value) as Ref<T>
let lastUpdate = 0
watch(value, (newValue) => {
const now = Date.now()
if (now - lastUpdate >= delay) {
throttledValue.value = newValue
lastUpdate = now
}
})
return throttledValue
}
useClickOutside - 点击外部
// composables/useClickOutside.ts
import { ref, onMounted, onUnmounted, Ref } from 'vue'
export function useClickOutside(
target: Ref<HTMLElement | null>,
callback: (event: MouseEvent) => void
) {
const isOutside = ref(false)
const handleClick = (event: MouseEvent) => {
if (target.value && !target.value.contains(event.target as Node)) {
isOutside.value = true
callback(event)
} else {
isOutside.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleClick)
})
onUnmounted(() => {
document.removeEventListener('click', handleClick)
})
return { isOutside }
}
useIntersectionObserver - 交叉观察器
// composables/useIntersectionObserver.ts
import { ref, onMounted, onUnmounted, Ref } from 'vue'
export function useIntersectionObserver(
target: Ref<Element | null>,
options: IntersectionObserverInit = {}
) {
const isIntersecting = ref(false)
const intersectionRatio = ref(0)
const entry = ref<IntersectionObserverEntry | null>(null)
let observer: IntersectionObserver | null = null
const stop = () => {
if (observer) {
observer.disconnect()
observer = null
}
}
onMounted(() => {
if (target.value) {
observer = new IntersectionObserver(([entryData]) => {
isIntersecting.value = entryData.isIntersecting
intersectionRatio.value = entryData.intersectionRatio
entry.value = entryData
}, options)
observer.observe(target.value)
}
})
onUnmounted(() => {
stop()
})
return {
isIntersecting: readonly(isIntersecting),
intersectionRatio: readonly(intersectionRatio),
entry: readonly(entry),
stop
}
}
🔧 TypeScript 工具类型
基础工具类型
// types/utils.ts
// 深度只读
export type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P]
}
// 深度可选
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
}
// 深度必需
export type DeepRequired<T> = {
[P in keyof T]-?: T[P] extends object ? DeepRequired<T[P]> : T[P]
}
// 提取函数类型
export type ExtractFunction<T> = T extends (...args: any[]) => any ? T : never
// 提取 Promise 类型
export type ExtractPromise<T> = T extends Promise<infer U> ? U : never
// 提取数组元素类型
export type ExtractArray<T> = T extends (infer U)[] ? U : never
// 提取对象值类型
export type ExtractValues<T> = T extends Record<any, infer U> ? U : never
// 提取对象键类型
export type ExtractKeys<T> = T extends Record<infer K, any> ? K : never
Vue 相关工具类型
// types/vue.ts
// 提取组件 Props 类型
export type ExtractComponentProps<T> = T extends new (...args: any[]) => {
$props: infer P
} ? P : never
// 提取组件 Emits 类型
export type ExtractComponentEmits<T> = T extends new (...args: any[]) => {
$emit: (event: infer E, ...args: any[]) => any
} ? E : never
// 提取组合式函数返回类型
export type ExtractComposableReturn<T> = T extends (...args: any[]) => infer R ? R : never
// 提取 ref 类型
export type ExtractRefType<T> = T extends Ref<infer U> ? U : never
// 提取 computed 类型
export type ExtractComputedType<T> = T extends ComputedRef<infer U> ? U : never
// 提取 reactive 类型
export type ExtractReactiveType<T> = T extends Reactive<infer U> ? U : never
API 相关工具类型
// types/api.ts
// API 响应类型
export interface ApiResponse<T = any> {
data: T
status: number
message: string
success: boolean
}
// API 错误类型
export interface ApiError {
message: string
code: string
details?: any
}
// 分页参数
export interface PaginationParams {
page: number
pageSize: number
total?: number
}
// 分页响应
export interface PaginatedResponse<T> extends ApiResponse<T[]> {
pagination: PaginationParams
}
// 排序参数
export interface SortParams {
field: string
order: 'asc' | 'desc'
}
// 筛选参数
export interface FilterParams {
[key: string]: any
}
// 查询参数
export interface QueryParams extends PaginationParams {
sort?: SortParams
filter?: FilterParams
search?: string
}
🎨 CSS 工具类
布局工具类
/* utilities/layout.css */
/* Flexbox 工具类 */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.flex-row { flex-direction: row; }
.flex-wrap { flex-wrap: wrap; }
.flex-nowrap { flex-wrap: nowrap; }
.justify-start { justify-content: flex-start; }
.justify-center { justify-content: center; }
.justify-end { justify-content: flex-end; }
.justify-between { justify-content: space-between; }
.justify-around { justify-content: space-around; }
.items-start { align-items: flex-start; }
.items-center { align-items: center; }
.items-end { align-items: flex-end; }
.items-stretch { align-items: stretch; }
.items-baseline { align-items: baseline; }
.flex-1 { flex: 1; }
.flex-auto { flex: auto; }
.flex-none { flex: none; }
/* Grid 工具类 */
.grid { display: grid; }
.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.grid-cols-12 { grid-template-columns: repeat(12, minmax(0, 1fr)); }
.col-span-1 { grid-column: span 1 / span 1; }
.col-span-2 { grid-column: span 2 / span 2; }
.col-span-3 { grid-column: span 3 / span 3; }
.col-span-4 { grid-column: span 4 / span 4; }
.col-span-6 { grid-column: span 6 / span 6; }
.col-span-12 { grid-column: span 12 / span 12; }
.gap-1 { gap: 0.25rem; }
.gap-2 { gap: 0.5rem; }
.gap-3 { gap: 0.75rem; }
.gap-4 { gap: 1rem; }
.gap-6 { gap: 1.5rem; }
.gap-8 { gap: 2rem; }
/* 定位工具类 */
.relative { position: relative; }
.absolute { position: absolute; }
.fixed { position: fixed; }
.sticky { position: sticky; }
.top-0 { top: 0; }
.right-0 { right: 0; }
.bottom-0 { bottom: 0; }
.left-0 { left: 0; }
.inset-0 { top: 0; right: 0; bottom: 0; left: 0; }
间距工具类
/* utilities/spacing.css */
/* Margin */
.m-0 { margin: 0; }
.m-1 { margin: 0.25rem; }
.m-2 { margin: 0.5rem; }
.m-3 { margin: 0.75rem; }
.m-4 { margin: 1rem; }
.m-6 { margin: 1.5rem; }
.m-8 { margin: 2rem; }
.mx-auto { margin-left: auto; margin-right: auto; }
.mx-0 { margin-left: 0; margin-right: 0; }
.mx-1 { margin-left: 0.25rem; margin-right: 0.25rem; }
.mx-2 { margin-left: 0.5rem; margin-right: 0.5rem; }
.my-0 { margin-top: 0; margin-bottom: 0; }
.my-1 { margin-top: 0.25rem; margin-bottom: 0.25rem; }
.my-2 { margin-top: 0.5rem; margin-bottom: 0.5rem; }
.mt-0 { margin-top: 0; }
.mt-1 { margin-top: 0.25rem; }
.mt-2 { margin-top: 0.5rem; }
.mt-4 { margin-top: 1rem; }
.mr-0 { margin-right: 0; }
.mr-1 { margin-right: 0.25rem; }
.mr-2 { margin-right: 0.5rem; }
.mr-4 { margin-right: 1rem; }
.mb-0 { margin-bottom: 0; }
.mb-1 { margin-bottom: 0.25rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-4 { margin-bottom: 1rem; }
.ml-0 { margin-left: 0; }
.ml-1 { margin-left: 0.25rem; }
.ml-2 { margin-left: 0.5rem; }
.ml-4 { margin-left: 1rem; }
/* Padding */
.p-0 { padding: 0; }
.p-1 { padding: 0.25rem; }
.p-2 { padding: 0.5rem; }
.p-3 { padding: 0.75rem; }
.p-4 { padding: 1rem; }
.p-6 { padding: 1.5rem; }
.p-8 { padding: 2rem; }
.px-0 { padding-left: 0; padding-right: 0; }
.px-1 { padding-left: 0.25rem; padding-right: 0.25rem; }
.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
.px-4 { padding-left: 1rem; padding-right: 1rem; }
.py-0 { padding-top: 0; padding-bottom: 0; }
.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
.py-4 { padding-top: 1rem; padding-bottom: 1rem; }
.pt-0 { padding-top: 0; }
.pt-1 { padding-top: 0.25rem; }
.pt-2 { padding-top: 0.5rem; }
.pt-4 { padding-top: 1rem; }
.pr-0 { padding-right: 0; }
.pr-1 { padding-right: 0.25rem; }
.pr-2 { padding-right: 0.5rem; }
.pr-4 { padding-right: 1rem; }
.pb-0 { padding-bottom: 0; }
.pb-1 { padding-bottom: 0.25rem; }
.pb-2 { padding-bottom: 0.5rem; }
.pb-4 { padding-bottom: 1rem; }
.pl-0 { padding-left: 0; }
.pl-1 { padding-left: 0.25rem; }
.pl-2 { padding-left: 0.5rem; }
.pl-4 { padding-left: 1rem; }
文本工具类
/* utilities/typography.css */
/* 字体大小 */
.text-xs { font-size: 0.75rem; line-height: 1rem; }
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
.text-base { font-size: 1rem; line-height: 1.5rem; }
.text-lg { font-size: 1.125rem; line-height: 1.75rem; }
.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
.text-2xl { font-size: 1.5rem; line-height: 2rem; }
.text-3xl { font-size: 1.875rem; line-height: 2.25rem; }
.text-4xl { font-size: 2.25rem; line-height: 2.5rem; }
/* 字体粗细 */
.font-thin { font-weight: 100; }
.font-extralight { font-weight: 200; }
.font-light { font-weight: 300; }
.font-normal { font-weight: 400; }
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
.font-extrabold { font-weight: 800; }
.font-black { font-weight: 900; }
/* 文本对齐 */
.text-left { text-align: left; }
.text-center { text-align: center; }
.text-right { text-align: right; }
.text-justify { text-align: justify; }
/* 文本装饰 */
.underline { text-decoration: underline; }
.line-through { text-decoration: line-through; }
.no-underline { text-decoration: none; }
/* 文本转换 */
.uppercase { text-transform: uppercase; }
.lowercase { text-transform: lowercase; }
.capitalize { text-transform: capitalize; }
.normal-case { text-transform: none; }
/* 行高 */
.leading-none { line-height: 1; }
.leading-tight { line-height: 1.25; }
.leading-snug { line-height: 1.375; }
.leading-normal { line-height: 1.5; }
.leading-relaxed { line-height: 1.625; }
.leading-loose { line-height: 2; }
颜色工具类
/* utilities/colors.css */
/* 文本颜色 */
.text-primary { color: var(--color-primary); }
.text-secondary { color: var(--color-secondary); }
.text-success { color: var(--color-success); }
.text-warning { color: var(--color-warning); }
.text-error { color: var(--color-error); }
.text-info { color: var(--color-info); }
.text-gray-100 { color: var(--color-gray-100); }
.text-gray-200 { color: var(--color-gray-200); }
.text-gray-300 { color: var(--color-gray-300); }
.text-gray-400 { color: var(--color-gray-400); }
.text-gray-500 { color: var(--color-gray-500); }
.text-gray-600 { color: var(--color-gray-600); }
.text-gray-700 { color: var(--color-gray-700); }
.text-gray-800 { color: var(--color-gray-800); }
.text-gray-900 { color: var(--color-gray-900); }
/* 背景颜色 */
.bg-primary { background-color: var(--color-primary); }
.bg-secondary { background-color: var(--color-secondary); }
.bg-success { background-color: var(--color-success); }
.bg-warning { background-color: var(--color-warning); }
.bg-error { background-color: var(--color-error); }
.bg-info { background-color: var(--color-info); }
.bg-gray-50 { background-color: var(--color-gray-50); }
.bg-gray-100 { background-color: var(--color-gray-100); }
.bg-gray-200 { background-color: var(--color-gray-200); }
.bg-gray-300 { background-color: var(--color-gray-300); }
.bg-gray-400 { background-color: var(--color-gray-400); }
.bg-gray-500 { background-color: var(--color-gray-500); }
.bg-gray-600 { background-color: var(--color-gray-600); }
.bg-gray-700 { background-color: var(--color-gray-700); }
.bg-gray-800 { background-color: var(--color-gray-800); }
.bg-gray-900 { background-color: var(--color-gray-900); }
/* 边框颜色 */
.border-primary { border-color: var(--color-primary); }
.border-secondary { border-color: var(--color-secondary); }
.border-success { border-color: var(--color-success); }
.border-warning { border-color: var(--color-warning); }
.border-error { border-color: var(--color-error); }
.border-info { border-color: var(--color-info); }
.border-gray-200 { border-color: var(--color-gray-200); }
.border-gray-300 { border-color: var(--color-gray-300); }
.border-gray-400 { border-color: var(--color-gray-400); }
.border-gray-500 { border-color: var(--color-gray-500); }
🧩 常用组件
按钮组件
<!-- components/Button.vue -->
<template>
<button
:class="buttonClass"
:disabled="disabled"
:type="type"
@click="handleClick"
>
<span v-if="loading" class="button__spinner"></span>
<slot v-if="!loading" />
<span v-if="loading" class="button__loading-text">加载中...</span>
</button>
</template>
<script setup lang="ts">
interface Props {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
loading?: boolean
type?: 'button' | 'submit' | 'reset'
block?: boolean
}
interface Emits {
(e: 'click', event: MouseEvent): void
}
const props = withDefaults(defineProps<Props>(), {
variant: 'primary',
size: 'md',
disabled: false,
loading: false,
type: 'button',
block: false
})
const emit = defineEmits<Emits>()
const buttonClass = computed(() => [
'button',
`button--${props.variant}`,
`button--${props.size}`,
{
'button--disabled': props.disabled,
'button--loading': props.loading,
'button--block': props.block
}
])
const handleClick = (event: MouseEvent) => {
if (!props.disabled && !props.loading) {
emit('click', event)
}
}
</script>
<style scoped>
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border: 1px solid transparent;
border-radius: 0.375rem;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
overflow: hidden;
}
.button:focus {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* 变体样式 */
.button--primary {
background-color: var(--color-primary);
color: white;
}
.button--primary:hover:not(.button--disabled) {
background-color: var(--color-primary-hover);
}
.button--secondary {
background-color: var(--color-secondary);
color: white;
}
.button--secondary:hover:not(.button--disabled) {
background-color: var(--color-secondary-hover);
}
.button--outline {
background-color: transparent;
color: var(--color-primary);
border-color: var(--color-primary);
}
.button--outline:hover:not(.button--disabled) {
background-color: var(--color-primary);
color: white;
}
.button--ghost {
background-color: transparent;
color: var(--color-primary);
}
.button--ghost:hover:not(.button--disabled) {
background-color: var(--color-primary-light);
}
.button--danger {
background-color: var(--color-error);
color: white;
}
.button--danger:hover:not(.button--disabled) {
background-color: var(--color-error-hover);
}
/* 尺寸样式 */
.button--sm {
padding: 0.25rem 0.75rem;
font-size: 0.875rem;
}
.button--md {
padding: 0.5rem 1rem;
font-size: 1rem;
}
.button--lg {
padding: 0.75rem 1.5rem;
font-size: 1.125rem;
}
/* 状态样式 */
.button--disabled {
opacity: 0.6;
cursor: not-allowed;
}
.button--loading {
cursor: not-allowed;
}
.button--block {
width: 100%;
}
/* 加载动画 */
.button__spinner {
width: 1rem;
height: 1rem;
border: 2px solid transparent;
border-top: 2px solid currentColor;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.button__loading-text {
opacity: 0.8;
}
</style>
输入框组件
<!-- components/Input.vue -->
<template>
<div class="input-wrapper" :class="wrapperClass">
<label v-if="label" :for="id" class="input__label">
{{ label }}
<span v-if="required" class="input__required">*</span>
</label>
<div class="input__container">
<input
:id="id"
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:required="required"
:readonly="readonly"
:autocomplete="autocomplete"
class="input"
:class="inputClass"
@input="handleInput"
@blur="handleBlur"
@focus="handleFocus"
/>
<div v-if="icon" class="input__icon">
<component :is="icon" />
</div>
</div>
<div v-if="error" class="input__error">
{{ error }}
</div>
<div v-if="hint" class="input__hint">
{{ hint }}
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
modelValue: string | number
type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url'
label?: string
placeholder?: string
hint?: string
error?: string
icon?: any
required?: boolean
disabled?: boolean
readonly?: boolean
autocomplete?: string
size?: 'sm' | 'md' | 'lg'
}
interface Emits {
(e: 'update:modelValue', value: string | number): void
(e: 'blur', event: FocusEvent): void
(e: 'focus', event: FocusEvent): void
}
const props = withDefaults(defineProps<Props>(), {
type: 'text',
size: 'md'
})
const emit = defineEmits<Emits>()
const id = `input-${Math.random().toString(36).substr(2, 9)}`
const wrapperClass = computed(() => ({
'input-wrapper--error': !!props.error,
'input-wrapper--disabled': props.disabled,
'input-wrapper--required': props.required
}))
const inputClass = computed(() => ({
'input--error': !!props.error,
'input--disabled': props.disabled,
[`input--${props.size}`]: true
}))
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement
const value = props.type === 'number' ? Number(target.value) : target.value
emit('update:modelValue', value)
}
const handleBlur = (event: FocusEvent) => emit('blur', event)
const handleFocus = (event: FocusEvent) => emit('focus', event)
</script>
<style scoped>
.input-wrapper {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.input__label {
font-weight: 500;
color: var(--color-text-primary);
font-size: 0.875rem;
}
.input__required {
color: var(--color-error);
}
.input__container {
position: relative;
display: flex;
align-items: center;
}
.input {
width: 100%;
border: 1px solid var(--color-border-primary);
border-radius: 0.375rem;
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
transition: all 0.2s ease;
}
.input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(66, 184, 131, 0.1);
}
.input::placeholder {
color: var(--color-text-tertiary);
}
.input--sm {
padding: 0.25rem 0.75rem;
font-size: 0.875rem;
}
.input--md {
padding: 0.5rem 0.75rem;
font-size: 1rem;
}
.input--lg {
padding: 0.75rem 1rem;
font-size: 1.125rem;
}
.input--error {
border-color: var(--color-error);
}
.input--error:focus {
border-color: var(--color-error);
box-shadow: 0 0 0 3px rgba(244, 67, 54, 0.1);
}
.input--disabled {
background-color: var(--color-bg-tertiary);
color: var(--color-text-tertiary);
cursor: not-allowed;
}
.input__icon {
position: absolute;
right: 0.75rem;
color: var(--color-text-tertiary);
pointer-events: none;
}
.input__error {
color: var(--color-error);
font-size: 0.875rem;
}
.input__hint {
color: var(--color-text-secondary);
font-size: 0.875rem;
}
</style>
模态框组件
<!-- components/Modal.vue -->
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="modelValue"
class="modal-overlay"
@click="handleOverlayClick"
>
<div
ref="modalRef"
class="modal"
:class="modalClass"
role="dialog"
:aria-modal="true"
:aria-labelledby="titleId"
@click.stop
>
<div class="modal__header">
<h2 :id="titleId" class="modal__title">
{{ title }}
</h2>
<button
v-if="closable"
class="modal__close"
@click="handleClose"
aria-label="关闭"
>
<CloseIcon />
</button>
</div>
<div class="modal__content">
<slot />
</div>
<div v-if="$slots.footer" class="modal__footer">
<slot name="footer" />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import CloseIcon from './icons/CloseIcon.vue'
interface Props {
modelValue: boolean
title?: string
size?: 'sm' | 'md' | 'lg' | 'xl'
closable?: boolean
closeOnOverlay?: boolean
closeOnEscape?: boolean
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'close'): void
}
const props = withDefaults(defineProps<Props>(), {
size: 'md',
closable: true,
closeOnOverlay: true,
closeOnEscape: true
})
const emit = defineEmits<Emits>()
const modalRef = ref<HTMLElement>()
const titleId = `modal-title-${Math.random().toString(36).substr(2, 9)}`
const modalClass = computed(() => ({
[`modal--${props.size}`]: true
}))
const handleClose = () => {
emit('update:modelValue', false)
emit('close')
}
const handleOverlayClick = () => {
if (props.closeOnOverlay) {
handleClose()
}
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && props.closeOnEscape) {
handleClose()
}
}
watch(() => props.modelValue, async (isOpen) => {
if (isOpen) {
document.addEventListener('keydown', handleEscape)
await nextTick()
modalRef.value?.focus()
} else {
document.removeEventListener('keydown', handleEscape)
}
})
onUnmounted(() => {
document.removeEventListener('keydown', handleEscape)
})
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-modal);
padding: 1rem;
}
.modal {
background-color: var(--color-bg-primary);
border-radius: 0.5rem;
box-shadow: var(--shadow-xl);
max-width: 100%;
max-height: 100%;
display: flex;
flex-direction: column;
outline: none;
}
.modal--sm {
width: 24rem;
}
.modal--md {
width: 32rem;
}
.modal--lg {
width: 48rem;
}
.modal--xl {
width: 64rem;
}
.modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem 1.5rem 0 1.5rem;
}
.modal__title {
font-size: 1.25rem;
font-weight: 600;
color: var(--color-text-primary);
margin: 0;
}
.modal__close {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: none;
background: none;
color: var(--color-text-secondary);
cursor: pointer;
border-radius: 0.25rem;
transition: all 0.2s ease;
}
.modal__close:hover {
background-color: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.modal__content {
padding: 1.5rem;
flex: 1;
overflow-y: auto;
}
.modal__footer {
padding: 0 1.5rem 1.5rem 1.5rem;
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
/* 过渡动画 */
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-active .modal,
.modal-leave-active .modal {
transition: transform 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from .modal,
.modal-leave-to .modal {
transform: scale(0.95) translateY(-1rem);
}
</style>
🛠 工具函数
日期处理
// utils/date.ts
export function formatDate(date: Date | string, format: string = 'YYYY-MM-DD'): string {
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hours = String(d.getHours()).padStart(2, '0')
const minutes = String(d.getMinutes()).padStart(2, '0')
const seconds = String(d.getSeconds()).padStart(2, '0')
return format
.replace('YYYY', String(year))
.replace('MM', month)
.replace('DD', day)
.replace('HH', hours)
.replace('mm', minutes)
.replace('ss', seconds)
}
export function getRelativeTime(date: Date | string): string {
const now = new Date()
const target = new Date(date)
const diff = now.getTime() - target.getTime()
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
const months = Math.floor(days / 30)
const years = Math.floor(months / 12)
if (years > 0) return `${years}年前`
if (months > 0) return `${months}个月前`
if (days > 0) return `${days}天前`
if (hours > 0) return `${hours}小时前`
if (minutes > 0) return `${minutes}分钟前`
return '刚刚'
}
export function isToday(date: Date | string): boolean {
const today = new Date()
const target = new Date(date)
return today.getFullYear() === target.getFullYear() &&
today.getMonth() === target.getMonth() &&
today.getDate() === target.getDate()
}
export function isYesterday(date: Date | string): boolean {
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
const target = new Date(date)
return yesterday.getFullYear() === target.getFullYear() &&
yesterday.getMonth() === target.getMonth() &&
yesterday.getDate() === target.getDate()
}
字符串处理
// utils/string.ts
export function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
}
export function camelCase(str: string): string {
return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
}
export function kebabCase(str: string): string {
return str.replace(/([A-Z])/g, '-$1').toLowerCase()
}
export function truncate(str: string, length: number, suffix: string = '...'): string {
if (str.length <= length) return str
return str.slice(0, length) + suffix
}
export function slugify(str: string): string {
return str
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '')
}
export function highlight(text: string, query: string): string {
if (!query) return text
const regex = new RegExp(`(${query})`, 'gi')
return text.replace(regex, '<mark>$1</mark>')
}
数组处理
// utils/array.ts
export function unique<T>(array: T[]): T[] {
return [...new Set(array)]
}
export function groupBy<T, K extends string | number>(
array: T[],
key: (item: T) => K
): Record<K, T[]> {
return array.reduce((groups, item) => {
const groupKey = key(item)
if (!groups[groupKey]) {
groups[groupKey] = []
}
groups[groupKey].push(item)
return groups
}, {} as Record<K, T[]>)
}
export function chunk<T>(array: T[], size: number): T[][] {
const chunks: T[][] = []
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size))
}
return chunks
}
export function shuffle<T>(array: T[]): T[] {
const shuffled = [...array]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
}
return shuffled
}
export function sortBy<T>(
array: T[],
key: (item: T) => string | number,
direction: 'asc' | 'desc' = 'asc'
): T[] {
return [...array].sort((a, b) => {
const aVal = key(a)
const bVal = key(b)
if (aVal < bVal) return direction === 'asc' ? -1 : 1
if (aVal > bVal) return direction === 'asc' ? 1 : -1
return 0
})
}
对象处理
// utils/object.ts
export function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
const result = {} as Pick<T, K>
keys.forEach(key => {
if (key in obj) {
result[key] = obj[key]
}
})
return result
}
export function omit<T, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> {
const result = { ...obj }
keys.forEach(key => {
delete result[key]
})
return result
}
export function deepMerge<T extends Record<string, any>>(target: T, source: Partial<T>): T {
const result = { ...target }
for (const key in source) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
result[key] = deepMerge(result[key] || {}, source[key])
} else {
result[key] = source[key] as T[Extract<keyof T, string>]
}
}
return result
}
export function isEmpty(obj: any): boolean {
if (obj == null) return true
if (Array.isArray(obj) || typeof obj === 'string') return obj.length === 0
if (typeof obj === 'object') return Object.keys(obj).length === 0
return false
}
export function get(obj: any, path: string, defaultValue?: any): any {
const keys = path.split('.')
let result = obj
for (const key of keys) {
if (result == null || typeof result !== 'object') {
return defaultValue
}
result = result[key]
}
return result !== undefined ? result : defaultValue
}
验证函数
// utils/validation.ts
export function isEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
export function isPhone(phone: string): boolean {
const phoneRegex = /^1[3-9]\d{9}$/
return phoneRegex.test(phone)
}
export function isUrl(url: string): boolean {
try {
new URL(url)
return true
} catch {
return false
}
}
export function isStrongPassword(password: string): boolean {
// 至少8位,包含大小写字母、数字和特殊字符
const strongPasswordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/
return strongPasswordRegex.test(password)
}
export function isIdCard(idCard: string): boolean {
const idCardRegex = /(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/
return idCardRegex.test(idCard)
}
export function validateForm<T extends Record<string, any>>(
data: T,
rules: Record<keyof T, (value: any) => string | null>
): Record<keyof T, string> {
const errors = {} as Record<keyof T, string>
for (const [field, rule] of Object.entries(rules)) {
const error = rule(data[field as keyof T])
if (error) {
errors[field as keyof T] = error
}
}
return errors
}
📚 使用说明
安装依赖
# 安装必要的依赖
pnpm add @vueuse/core
导入使用
// 在组件中使用组合式函数
import { useFetch, useLocalStorage } from '@/composables'
// 在组件中使用工具函数
import { formatDate, isEmail } from '@/utils'
// 在组件中使用工具类型
import type { ApiResponse, PaginationParams } from '@/types'
自定义扩展
// 扩展组合式函数
export function useCustomHook() {
// 自定义逻辑
return {
// 返回值
}
}
// 扩展工具类型
export type CustomType<T> = {
// 自定义类型定义
}
这个代码片段库提供了 Vue3 + TypeScript + CSS 开发中常用的工具函数、组合式函数、组件和类型定义。你可以根据项目需求进行扩展和定制。记住要保持代码的简洁性和可维护性,遵循最佳实践。