Initial commit

This commit is contained in:
fawney19
2025-12-10 20:52:44 +08:00
commit f784106826
485 changed files with 110993 additions and 0 deletions

View File

@@ -0,0 +1,182 @@
import { ref, type Ref } from 'vue'
import { useToast } from './useToast'
import { parseApiError } from '@/utils/errorParser'
/**
* 异步操作通用逻辑
*
* 统一处理:
* - Loading 状态管理
* - try/catch 错误处理
* - Toast 通知(成功/失败)
* - 可选的成功/失败回调
*
* @example
* ```typescript
* const { loading, execute } = useAsyncAction()
*
* // 简单用法
* await execute(() => api.deleteItem(id), {
* successMessage: '删除成功',
* })
*
* // 带回调
* await execute(() => api.createItem(data), {
* successMessage: '创建成功',
* onSuccess: (result) => {
* router.push(`/items/${result.id}`)
* },
* })
*
* // 自定义错误消息
* await execute(() => api.updateItem(id, data), {
* successMessage: '更新成功',
* errorMessage: '更新失败,请重试',
* })
* ```
*/
export interface UseAsyncActionOptions<T> {
/** 成功时显示的消息 */
successMessage?: string
/** 成功消息的标题 */
successTitle?: string
/** 失败时显示的消息(如果不提供,将解析 API 错误) */
errorMessage?: string
/** 失败消息的标题 */
errorTitle?: string
/** 成功时的回调 */
onSuccess?: (result: T) => void
/** 失败时的回调 */
onError?: (error: unknown) => void
/** 是否在失败时显示 toast默认 true */
showErrorToast?: boolean
/** 是否在成功时显示 toast默认有 successMessage 时为 true */
showSuccessToast?: boolean
}
export interface UseAsyncActionReturn {
/** 是否正在执行 */
loading: Ref<boolean>
/** 执行异步操作 */
execute: <T>(
action: () => Promise<T>,
options?: UseAsyncActionOptions<T>
) => Promise<T | undefined>
}
export function useAsyncAction(): UseAsyncActionReturn {
const loading = ref(false)
const { success, error: showError } = useToast()
async function execute<T>(
action: () => Promise<T>,
options?: UseAsyncActionOptions<T>
): Promise<T | undefined> {
const {
successMessage,
successTitle,
errorMessage,
errorTitle = '错误',
onSuccess,
onError,
showErrorToast = true,
showSuccessToast,
} = options || {}
loading.value = true
try {
const result = await action()
// 显示成功消息
const shouldShowSuccess = showSuccessToast ?? !!successMessage
if (shouldShowSuccess && successMessage) {
success(successMessage, successTitle)
}
// 调用成功回调
onSuccess?.(result)
return result
} catch (error) {
// 解析错误消息
const message = errorMessage || parseApiError(error, '操作失败')
// 显示错误消息
if (showErrorToast) {
showError(message, errorTitle)
}
// 调用错误回调
onError?.(error)
return undefined
} finally {
loading.value = false
}
}
return {
loading,
execute,
}
}
/**
* 创建多个独立的异步操作
*
* 当需要在同一个组件中跟踪多个独立的 loading 状态时使用
*
* @example
* ```typescript
* const { actions, isAnyLoading } = useMultipleAsyncActions(['save', 'delete', 'refresh'])
*
* // 使用各自的 loading 状态
* await actions.save.execute(() => api.save(data), { successMessage: '保存成功' })
* await actions.delete.execute(() => api.delete(id), { successMessage: '删除成功' })
*
* // 检查是否有任何操作正在进行
* <Button :disabled="isAnyLoading">操作</Button>
* ```
*/
export function useMultipleAsyncActions<K extends string>(
keys: K[]
): {
actions: Record<K, UseAsyncActionReturn>
isAnyLoading: Ref<boolean>
} {
const actions = {} as Record<K, UseAsyncActionReturn>
const loadingStates: Ref<boolean>[] = []
for (const key of keys) {
const action = useAsyncAction()
actions[key] = action
loadingStates.push(action.loading)
}
const isAnyLoading = ref(false)
// 使用 watchEffect 来监听所有 loading 状态
// 这里简化处理,在每次 execute 时会自动更新
// 如果需要响应式,可以使用 computed
const checkAnyLoading = () => {
isAnyLoading.value = loadingStates.some((state) => state.value)
}
// 包装每个 action 的 execute 以更新 isAnyLoading
for (const key of keys) {
const originalExecute = actions[key].execute
actions[key].execute = async (action, options) => {
checkAnyLoading()
try {
return await originalExecute(action, options)
} finally {
checkAnyLoading()
}
}
}
return {
actions,
isAnyLoading,
}
}

View File

@@ -0,0 +1,43 @@
import { useToast } from './useToast'
export function useClipboard() {
const { success, error: showError } = useToast()
async function copyToClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
success('已复制到剪贴板')
return true
}
// Fallback for non-secure contexts
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
const successful = document.execCommand('copy')
if (successful) {
success('已复制到剪贴板')
return true
}
showError('复制失败,请手动复制')
return false
} finally {
document.body.removeChild(textArea)
}
} catch (err) {
console.error('复制失败:', err)
showError('复制失败,请手动选择文本进行复制')
return false
}
}
return { copyToClipboard }
}

View File

@@ -0,0 +1,112 @@
import { ref } from 'vue'
export type ConfirmVariant = 'danger' | 'destructive' | 'warning' | 'info' | 'question'
export interface ConfirmOptions {
title?: string
message: string
confirmText?: string
cancelText?: string
variant?: ConfirmVariant
}
interface ConfirmState extends ConfirmOptions {
isOpen: boolean
resolve?: (value: boolean) => void
}
const state = ref<ConfirmState>({
isOpen: false,
message: '',
title: '确认操作',
confirmText: '确认',
cancelText: '取消',
variant: 'question'
})
export function useConfirm() {
/**
* 显示确认对话框
* @param options 对话框选项
* @returns Promise<boolean> - true表示确认false表示取消
*/
const confirm = (options: ConfirmOptions): Promise<boolean> => {
return new Promise((resolve) => {
state.value = {
isOpen: true,
title: options.title || '确认操作',
message: options.message,
confirmText: options.confirmText || '确认',
cancelText: options.cancelText || '取消',
variant: options.variant || 'question',
resolve
}
})
}
/**
* 便捷方法:危险操作确认(红色主题)
*/
const confirmDanger = (message: string, title?: string): Promise<boolean> => {
return confirm({
message,
title: title || '危险操作',
confirmText: '删除',
variant: 'danger'
})
}
/**
* 便捷方法:警告确认(黄色主题)
*/
const confirmWarning = (message: string, title?: string): Promise<boolean> => {
return confirm({
message,
title: title || '警告',
confirmText: '继续',
variant: 'warning'
})
}
/**
* 便捷方法:信息确认(蓝色主题)
*/
const confirmInfo = (message: string, title?: string): Promise<boolean> => {
return confirm({
message,
title: title || '提示',
confirmText: '确定',
variant: 'info'
})
}
/**
* 处理确认
*/
const handleConfirm = () => {
if (state.value.resolve) {
state.value.resolve(true)
}
state.value.isOpen = false
}
/**
* 处理取消
*/
const handleCancel = () => {
if (state.value.resolve) {
state.value.resolve(false)
}
state.value.isOpen = false
}
return {
state,
confirm,
confirmDanger,
confirmWarning,
confirmInfo,
handleConfirm,
handleCancel
}
}

View File

@@ -0,0 +1,132 @@
import { effectScope, ref, watch, computed } from 'vue'
const THEME_STORAGE_KEY = 'theme'
// 主题模式类型
export type ThemeMode = 'system' | 'light' | 'dark'
// 全局共享的状态
const themeMode = ref<ThemeMode>('system')
const isDark = ref(false)
let initialized = false
let scope: ReturnType<typeof effectScope> | null = null
let mediaQuery: MediaQueryList | null = null
const applyDarkMode = (value: boolean) => {
if (typeof document === 'undefined') {
return
}
document.documentElement.classList.toggle('dark', value)
if (document.body) {
document.body.setAttribute('theme-mode', value ? 'dark' : 'light')
}
}
const getSystemPreference = (): boolean => {
if (typeof window === 'undefined') {
return false
}
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
const updateDarkMode = () => {
if (themeMode.value === 'system') {
isDark.value = getSystemPreference()
} else {
isDark.value = themeMode.value === 'dark'
}
applyDarkMode(isDark.value)
}
const handleSystemChange = (e: MediaQueryListEvent) => {
if (themeMode.value === 'system') {
isDark.value = e.matches
applyDarkMode(isDark.value)
}
}
const ensureWatcher = () => {
if (scope) {
return
}
scope = effectScope(true)
scope.run(() => {
watch(
themeMode,
(value) => {
updateDarkMode()
if (typeof window !== 'undefined') {
localStorage.setItem(THEME_STORAGE_KEY, value)
}
},
{ flush: 'post' }
)
})
}
const initialize = () => {
if (initialized) {
return
}
initialized = true
ensureWatcher()
if (typeof window !== 'undefined') {
const storedTheme = localStorage.getItem(THEME_STORAGE_KEY) as ThemeMode | null
if (storedTheme === 'dark' || storedTheme === 'light' || storedTheme === 'system') {
themeMode.value = storedTheme
} else {
// 兼容旧版本存储格式,旧版本直接存储 'dark' 或 'light'
themeMode.value = 'system'
}
// 监听系统主题变化
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', handleSystemChange)
}
updateDarkMode()
}
export function useDarkMode() {
initialize()
ensureWatcher()
applyDarkMode(isDark.value)
const setDarkMode = (value: boolean) => {
themeMode.value = value ? 'dark' : 'light'
}
const setThemeMode = (mode: ThemeMode) => {
themeMode.value = mode
}
const toggleDarkMode = () => {
// 循环切换system -> light -> dark -> system
if (themeMode.value === 'system') {
themeMode.value = 'light'
} else if (themeMode.value === 'light') {
themeMode.value = 'dark'
} else {
themeMode.value = 'system'
}
}
// 是否为跟随系统模式
const isSystemMode = computed(() => themeMode.value === 'system')
return {
isDark,
themeMode,
isSystemMode,
toggleDarkMode,
setDarkMode,
setThemeMode
}
}

View File

@@ -0,0 +1,115 @@
import { ref, computed, watch, type Ref, type ComputedRef } from 'vue'
/**
* 表单对话框通用逻辑
*
* 统一处理:
* - 编辑/创建模式切换
* - 对话框打开/关闭
* - 表单重置
* - 数据加载
* - Loading 状态阻止关闭
*
* @example
* ```typescript
* const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
* isOpen: () => props.modelValue,
* entity: () => props.provider,
* isLoading: loading,
* onClose: () => emit('update:modelValue', false),
* loadData: loadProviderData,
* resetForm,
* })
* ```
*/
export interface UseFormDialogOptions<E> {
/** 对话框是否打开getter 函数) */
isOpen: () => boolean
/** 编辑的实体getter 函数,返回 null/undefined 表示创建模式) */
entity: () => E | null | undefined
/** 是否处于加载状态 */
isLoading: Ref<boolean> | ComputedRef<boolean>
/** 关闭对话框的回调 */
onClose: () => void
/** 加载实体数据的函数(编辑模式时调用) */
loadData: () => void
/** 重置表单的函数 */
resetForm: () => void
/** 额外的加载状态(如清理缓存等),可选 */
extraLoadingStates?: Array<Ref<boolean> | ComputedRef<boolean>>
}
export interface UseFormDialogReturn {
/** 是否为编辑模式 */
isEditMode: ComputedRef<boolean>
/** 处理对话框更新事件(用于 @update:model-value */
handleDialogUpdate: (value: boolean) => void
/** 处理取消按钮点击 */
handleCancel: () => void
}
export function useFormDialog<E>(
options: UseFormDialogOptions<E>
): UseFormDialogReturn {
const {
isOpen,
entity,
isLoading,
onClose,
loadData,
resetForm,
extraLoadingStates = [],
} = options
// 是否为编辑模式
const isEditMode = computed(() => !!entity())
// 检查是否有任何加载状态
const isAnyLoading = computed(() => {
if (isLoading.value) return true
return extraLoadingStates.some((state) => state.value)
})
// 处理对话框更新事件
function handleDialogUpdate(value: boolean) {
// 加载中不允许关闭
if (!value && isAnyLoading.value) {
return
}
// 关闭时重置表单
if (!value) {
resetForm()
}
onClose()
}
// 处理取消按钮
function handleCancel() {
if (isAnyLoading.value) return
onClose()
}
// 监听打开状态变化
watch(isOpen, (val) => {
if (val) {
if (isEditMode.value && entity()) {
loadData()
} else {
resetForm()
}
}
})
// 监听实体变化(编辑模式切换)
watch(entity, (newEntity) => {
if (newEntity && isOpen()) {
loadData()
}
}, { immediate: true, deep: true })
return {
isEditMode,
handleDialogUpdate,
handleCancel,
}
}

View File

@@ -0,0 +1,64 @@
import { ref } from 'vue'
/**
* 处理表格行点击的 composable
* 用于在点击行时打开详情抽屉,同时排除文本选择操作
*/
export function useRowClick() {
// 记录 mousedown 时的位置和选中状态
const mouseDownPos = ref<{ x: number; y: number } | null>(null)
const hadSelectionOnMouseDown = ref(false)
/**
* 处理 mousedown 事件,记录位置和选中状态
*/
function handleMouseDown(event: MouseEvent) {
mouseDownPos.value = { x: event.clientX, y: event.clientY }
const selection = window.getSelection()
hadSelectionOnMouseDown.value = !!(selection && selection.toString().trim().length > 0)
}
/**
* 检查是否应该触发行点击事件
* 如果用户正在选择文本或取消选中,则返回 false
*/
function shouldTriggerRowClick(event?: MouseEvent): boolean {
// 如果 mousedown 时已有选中文本,说明用户可能是在取消选中
if (hadSelectionOnMouseDown.value) {
hadSelectionOnMouseDown.value = false
mouseDownPos.value = null
return false
}
// 如果鼠标移动超过阈值,说明用户在拖动选择
if (event && mouseDownPos.value) {
const dx = Math.abs(event.clientX - mouseDownPos.value.x)
const dy = Math.abs(event.clientY - mouseDownPos.value.y)
if (dx > 5 || dy > 5) {
mouseDownPos.value = null
return false
}
}
mouseDownPos.value = null
return true
}
/**
* 创建一个行点击处理函数
* @param callback 当应该触发点击时执行的回调
*/
function createRowClickHandler<T>(callback: (item: T) => void) {
return (event: MouseEvent, item: T) => {
if (shouldTriggerRowClick(event)) {
callback(item)
}
}
}
return {
handleMouseDown,
shouldTriggerRowClick,
createRowClickHandler
}
}

View File

@@ -0,0 +1,75 @@
import { ref } from 'vue'
import { TOAST_CONFIG } from '@/config/constants'
export type ToastVariant = 'success' | 'error' | 'warning' | 'info'
export interface Toast {
id: string
title?: string
message?: string
variant?: ToastVariant
duration?: number
}
const toasts = ref<Toast[]>([])
export function useToast() {
function showToast(options: Omit<Toast, 'id'>) {
const toast: Toast = {
id: Date.now().toString(),
variant: 'info',
duration: 5000,
...options
}
toasts.value.push(toast)
// 注释掉这里的 setTimeout因为现在由组件自己处理
// if (toast.duration && toast.duration > 0) {
// setTimeout(() => {
// removeToast(toast.id)
// }, toast.duration)
// }
return toast.id
}
function removeToast(id: string) {
const index = toasts.value.findIndex(t => t.id === id)
if (index > -1) {
toasts.value.splice(index, 1)
}
}
function success(message: string, title?: string) {
return showToast({ message, title, variant: 'success', duration: TOAST_CONFIG.SUCCESS_DURATION })
}
function error(message: string, title?: string) {
return showToast({ message, title, variant: 'error', duration: TOAST_CONFIG.ERROR_DURATION })
}
function warning(message: string, title?: string) {
return showToast({ message, title, variant: 'warning', duration: TOAST_CONFIG.WARNING_DURATION })
}
function info(message: string, title?: string) {
return showToast({ message, title, variant: 'info', duration: TOAST_CONFIG.INFO_DURATION })
}
function clearAll() {
toasts.value = []
}
return {
toasts,
showToast,
removeToast,
success,
error,
warning,
info,
clearAll
}
}