优化构架
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseApiJsonResponse, parseJsonWithSafeIntegers } from '@chongchong/shared'
|
||||
|
||||
describe('safe integer API parsing', () => {
|
||||
it('preserves backend long IDs as strings', () => {
|
||||
expect(parseJsonWithSafeIntegers('{"id":2082674456475418625,"page":1}')).toEqual({
|
||||
id: '2082674456475418625',
|
||||
page: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves non-JSON responses untouched', () => {
|
||||
expect(parseApiJsonResponse('plain text')).toBe('plain text')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import axios, { AxiosHeaders } from 'axios'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ApiResponseError,
|
||||
getApiErrorDetails,
|
||||
http,
|
||||
registerAuthRefreshHandler,
|
||||
registerAuthSessionExpiredHandler,
|
||||
unwrapApiResponse,
|
||||
} from './http'
|
||||
|
||||
const originalAdapter = http.defaults.adapter
|
||||
|
||||
afterEach(() => {
|
||||
http.defaults.adapter = originalAdapter
|
||||
registerAuthRefreshHandler(async () => { throw new Error('refresh handler is not configured') })
|
||||
registerAuthSessionExpiredHandler(() => undefined)
|
||||
})
|
||||
|
||||
describe('API error diagnostics', () => {
|
||||
it('treats only body code 200 as success', () => {
|
||||
expect(unwrapApiResponse({ code: '200', message: 'success', data: { id: '1' } }))
|
||||
.toEqual({ id: '1' })
|
||||
expect(() => unwrapApiResponse({ code: '0', message: 'legacy success code', data: null }))
|
||||
.toThrow(ApiResponseError)
|
||||
})
|
||||
|
||||
it('keeps business request and trace identifiers when unwrapping fails', () => {
|
||||
expect(() => unwrapApiResponse({
|
||||
code: 'A0403',
|
||||
message: '无权限',
|
||||
data: null,
|
||||
requestId: 'request-1',
|
||||
traceId: 'trace-1',
|
||||
})).toThrow(ApiResponseError)
|
||||
|
||||
try {
|
||||
unwrapApiResponse({ code: 'A0403', message: '无权限', data: null, requestId: 'request-1', traceId: 'trace-1' })
|
||||
} catch (error) {
|
||||
expect(getApiErrorDetails(error)).toEqual({
|
||||
message: '无权限',
|
||||
code: 'A0403',
|
||||
requestId: 'request-1',
|
||||
traceId: 'trace-1',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('extracts identifiers from an HTTP error envelope', () => {
|
||||
const error = new axios.AxiosError('Request failed', 'ERR_BAD_RESPONSE', undefined, undefined, {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
headers: {},
|
||||
config: { headers: new AxiosHeaders() },
|
||||
data: { code: 'S0503', message: '服务不可用', requestId: 'request-2', traceId: 'trace-2' },
|
||||
})
|
||||
|
||||
expect(getApiErrorDetails(error)).toEqual({
|
||||
message: '服务不可用',
|
||||
code: 'S0503',
|
||||
requestId: 'request-2',
|
||||
traceId: 'trace-2',
|
||||
})
|
||||
})
|
||||
|
||||
it('notifies session expiry once when concurrent 401 requests share a failed refresh', async () => {
|
||||
const refresh = vi.fn(async () => { throw new Error('refresh token expired') })
|
||||
const expired = vi.fn()
|
||||
registerAuthRefreshHandler(refresh)
|
||||
registerAuthSessionExpiredHandler(expired)
|
||||
http.defaults.adapter = async (config) => {
|
||||
throw new axios.AxiosError('Unauthorized', 'ERR_BAD_REQUEST', config, undefined, {
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
headers: {},
|
||||
config,
|
||||
data: { code: 'A0401', message: '登录状态已过期' },
|
||||
})
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
http.get('/platform/v1/orders'),
|
||||
http.get('/platform/v1/warehouses'),
|
||||
])
|
||||
|
||||
expect(results.every((result) => result.status === 'rejected')).toBe(true)
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
expect(expired).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import axios, { type InternalAxiosRequestConfig } from 'axios'
|
||||
import { parseApiJsonResponse } from '@chongchong/shared'
|
||||
|
||||
export interface ApiEnvelope<T> {
|
||||
code: string
|
||||
message: string
|
||||
data: T
|
||||
requestId?: string | null
|
||||
traceId?: string | null
|
||||
timestamp?: string
|
||||
}
|
||||
|
||||
export class ApiResponseError extends Error {
|
||||
readonly code: string
|
||||
readonly status?: number
|
||||
readonly details?: unknown
|
||||
readonly requestId?: string
|
||||
readonly traceId?: string
|
||||
|
||||
constructor(message: string, code: string, status?: number, details?: unknown, requestId?: string | null, traceId?: string | null) {
|
||||
super(message)
|
||||
this.name = 'ApiResponseError'
|
||||
this.code = code
|
||||
this.status = status
|
||||
this.details = details
|
||||
this.requestId = requestId || undefined
|
||||
this.traceId = traceId || undefined
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiErrorDetails {
|
||||
message: string
|
||||
code?: string
|
||||
requestId?: string
|
||||
traceId?: string
|
||||
}
|
||||
|
||||
export const http = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
timeout: 15_000,
|
||||
transformResponse: [(data: unknown) => parseApiJsonResponse(data)],
|
||||
})
|
||||
|
||||
type RetryableRequestConfig = InternalAxiosRequestConfig & { _authRetry?: boolean }
|
||||
type AuthRefreshHandler = () => Promise<string>
|
||||
type AuthSessionExpiredHandler = () => void | Promise<void>
|
||||
|
||||
let authRefreshHandler: AuthRefreshHandler | null = null
|
||||
let authSessionExpiredHandler: AuthSessionExpiredHandler | null = null
|
||||
let activeRefresh: Promise<string> | null = null
|
||||
|
||||
export function registerAuthRefreshHandler(handler: AuthRefreshHandler) {
|
||||
authRefreshHandler = handler
|
||||
}
|
||||
|
||||
export function registerAuthSessionExpiredHandler(handler: AuthSessionExpiredHandler) {
|
||||
authSessionExpiredHandler = handler
|
||||
}
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('cc-admin-token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
http.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
async (error) => {
|
||||
const config = error.config as RetryableRequestConfig | undefined
|
||||
const isAuthEndpoint = typeof config?.url === 'string' && config.url.startsWith('/auth/')
|
||||
if (error.response?.status !== 401 || !config || config._authRetry || isAuthEndpoint || !authRefreshHandler) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
config._authRetry = true
|
||||
try {
|
||||
if (!activeRefresh) {
|
||||
activeRefresh = authRefreshHandler()
|
||||
.catch(async (refreshError) => {
|
||||
try {
|
||||
await authSessionExpiredHandler?.()
|
||||
} catch {
|
||||
// 会话已清理,跳转失败不应覆盖原始认证错误。
|
||||
}
|
||||
throw refreshError
|
||||
})
|
||||
.finally(() => { activeRefresh = null })
|
||||
}
|
||||
const nextToken = await activeRefresh
|
||||
config.headers.Authorization = `Bearer ${nextToken}`
|
||||
return http.request(config)
|
||||
} catch {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export function unwrapApiResponse<T>(response: ApiEnvelope<T>): T {
|
||||
if (response?.code !== '200') {
|
||||
throw new ApiResponseError(
|
||||
response?.message || '接口请求失败',
|
||||
response?.code || 'UNKNOWN',
|
||||
undefined,
|
||||
response?.data,
|
||||
response?.requestId,
|
||||
response?.traceId,
|
||||
)
|
||||
}
|
||||
return response.data
|
||||
}
|
||||
|
||||
export function getApiErrorDetails(error: unknown, fallback = '接口请求失败'): ApiErrorDetails {
|
||||
if (error instanceof ApiResponseError) {
|
||||
return { message: error.message, code: error.code, requestId: error.requestId, traceId: error.traceId }
|
||||
}
|
||||
if (axios.isAxiosError(error)) {
|
||||
const response = error.response?.data as {
|
||||
code?: string
|
||||
message?: string
|
||||
error?: string
|
||||
requestId?: string | null
|
||||
traceId?: string | null
|
||||
} | undefined
|
||||
return {
|
||||
message: response?.message || response?.error || fallback,
|
||||
code: response?.code || (error.response?.status ? `HTTP_${error.response.status}` : undefined),
|
||||
requestId: response?.requestId || undefined,
|
||||
traceId: response?.traceId || undefined,
|
||||
}
|
||||
}
|
||||
return { message: error instanceof Error ? error.message : fallback }
|
||||
}
|
||||
|
||||
export function getApiErrorMessage(error: unknown, fallback = '接口请求失败'): string {
|
||||
return getApiErrorDetails(error, fallback).message
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { ApiErrorDetails } from '@/shared/api/http'
|
||||
import CopyButton from '@/shared/components/CopyButton.vue'
|
||||
|
||||
const props = defineProps<{ details: ApiErrorDetails }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="details.code || details.requestId || details.traceId" class="api-error-meta" aria-label="接口错误追踪信息">
|
||||
<span v-if="details.code" class="api-error-code">{{ details.code }}</span>
|
||||
<CopyButton v-if="details.requestId" class="api-error-copy" :value="props.details.requestId" label="请求 ID">
|
||||
<span class="api-error-copy__label">请求 ID</span><code>{{ props.details.requestId }}</code>
|
||||
</CopyButton>
|
||||
<CopyButton v-if="details.traceId" class="api-error-copy" :value="props.details.traceId" label="Trace ID">
|
||||
<span class="api-error-copy__label">Trace ID</span><code>{{ props.details.traceId }}</code>
|
||||
</CopyButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.api-error-meta { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 8px; }
|
||||
.api-error-code { padding: 3px 7px; border-radius: 4px; background: #fee4e2; color: #912018; font-size: 10px; font-weight: 750; }
|
||||
.api-error-meta :deep(.api-error-copy) { display: inline-flex; min-width: 0; max-width: 100%; min-height: 30px; align-items: center; gap: 6px; padding: 4px 7px; border-color: #fecdca; border-radius: 4px; background: #fff; color: #7a271a; }
|
||||
.api-error-meta :deep(.api-error-copy:hover) { border-color: #fda29b; background: #fff5f4; color: #7a271a; }
|
||||
.api-error-meta :deep(.api-error-copy > span) { min-width: 0; gap: 6px; }
|
||||
.api-error-copy__label { flex: 0 0 auto; font-size: 10px; font-weight: 700; }
|
||||
.api-error-meta code { min-width: 0; overflow: hidden; color: #b42318; font-family: "Cascadia Code", Consolas, monospace; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.api-error-meta :deep(svg) { flex: 0 0 auto; }
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
import { defineComponent } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import AppDialog from './AppDialog.vue'
|
||||
|
||||
const ElDialogStub = defineComponent({
|
||||
props: {
|
||||
modelValue: Boolean,
|
||||
showClose: Boolean,
|
||||
closeOnClickModal: Boolean,
|
||||
closeOnPressEscape: Boolean,
|
||||
},
|
||||
template: '<section data-testid="dialog"><slot name="header" /><slot /><slot name="footer" /></section>',
|
||||
})
|
||||
|
||||
const ElButtonStub = defineComponent({
|
||||
props: {
|
||||
disabled: Boolean,
|
||||
loading: Boolean,
|
||||
},
|
||||
emits: ['click'],
|
||||
template: '<button type="button" :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
|
||||
})
|
||||
|
||||
function mountDialog(props: Record<string, unknown> = {}) {
|
||||
return mount(AppDialog, {
|
||||
props: { modelValue: true, title: '编辑资料', ...props },
|
||||
slots: { default: '<p>弹框内容</p>' },
|
||||
global: {
|
||||
stubs: { ElDialog: ElDialogStub, ElButton: ElButtonStub },
|
||||
directives: { loading: {} },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('AppDialog', () => {
|
||||
it('provides default cancel and confirm actions', async () => {
|
||||
const wrapper = mountDialog()
|
||||
const buttons = wrapper.findAll('button')
|
||||
|
||||
expect(buttons.map((button) => button.text())).toEqual(['取消', '确定'])
|
||||
await buttons[1].trigger('click')
|
||||
expect(wrapper.emitted('confirm')).toHaveLength(1)
|
||||
|
||||
await buttons[0].trigger('click')
|
||||
expect(wrapper.emitted('cancel')).toHaveLength(1)
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
|
||||
})
|
||||
|
||||
it('prevents accidental closing while confirmation is submitting', () => {
|
||||
const wrapper = mountDialog({ confirmLoading: true })
|
||||
const dialog = wrapper.getComponent(ElDialogStub)
|
||||
const cancelButton = wrapper.findAll('button')[0]
|
||||
|
||||
expect(dialog.props('showClose')).toBe(false)
|
||||
expect(dialog.props('closeOnClickModal')).toBe(false)
|
||||
expect(dialog.props('closeOnPressEscape')).toBe(false)
|
||||
expect(cancelButton.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('can render a content-only dialog without footer actions', () => {
|
||||
const wrapper = mountDialog({ showFooter: false })
|
||||
expect(wrapper.findAll('button')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
export type DialogConfirmType = 'primary' | 'success' | 'warning' | 'danger' | 'info'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title: string
|
||||
width?: string | number
|
||||
loading?: boolean
|
||||
confirmLoading?: boolean
|
||||
confirmDisabled?: boolean
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
confirmType?: DialogConfirmType
|
||||
showFooter?: boolean
|
||||
showCancel?: boolean
|
||||
showConfirm?: boolean
|
||||
showClose?: boolean
|
||||
closeOnClickModal?: boolean
|
||||
closeOnPressEscape?: boolean
|
||||
destroyOnClose?: boolean
|
||||
lockWhileSubmitting?: boolean
|
||||
}>(), {
|
||||
width: 'min(560px, calc(100vw - 28px))',
|
||||
loading: false,
|
||||
confirmLoading: false,
|
||||
confirmDisabled: false,
|
||||
confirmText: '确定',
|
||||
cancelText: '取消',
|
||||
confirmType: 'primary',
|
||||
showFooter: true,
|
||||
showCancel: true,
|
||||
showConfirm: true,
|
||||
showClose: true,
|
||||
closeOnClickModal: false,
|
||||
closeOnPressEscape: true,
|
||||
destroyOnClose: false,
|
||||
lockWhileSubmitting: true,
|
||||
})
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const emit = defineEmits<{
|
||||
confirm: []
|
||||
cancel: []
|
||||
}>()
|
||||
const submittingLocked = computed(() => props.lockWhileSubmitting && props.confirmLoading)
|
||||
|
||||
function closeDialog() {
|
||||
if (submittingLocked.value) return
|
||||
open.value = false
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-bind="$attrs"
|
||||
v-model="open"
|
||||
class="app-dialog"
|
||||
:title="title"
|
||||
:width="width"
|
||||
:show-close="showClose && !submittingLocked"
|
||||
:close-on-click-modal="closeOnClickModal && !submittingLocked"
|
||||
:close-on-press-escape="closeOnPressEscape && !submittingLocked"
|
||||
:destroy-on-close="destroyOnClose"
|
||||
>
|
||||
<template v-if="$slots.header" #header><slot name="header" /></template>
|
||||
<div v-loading="loading" class="app-dialog__body" :aria-busy="loading || confirmLoading">
|
||||
<slot />
|
||||
</div>
|
||||
<template v-if="showFooter" #footer>
|
||||
<slot name="footer" :close="closeDialog" :submitting="confirmLoading">
|
||||
<div class="app-dialog__footer">
|
||||
<el-button v-if="showCancel" :disabled="submittingLocked" @click="closeDialog">{{ cancelText }}</el-button>
|
||||
<el-button
|
||||
v-if="showConfirm"
|
||||
:type="confirmType"
|
||||
:loading="confirmLoading"
|
||||
:disabled="loading || confirmDisabled"
|
||||
@click="emit('confirm')"
|
||||
>{{ confirmText }}</el-button>
|
||||
</div>
|
||||
</slot>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-dialog__body { min-height: 0; }
|
||||
.app-dialog__footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
|
||||
.app-dialog__footer :deep(.el-button) { margin-left: 0; }
|
||||
@media (max-width: 720px) { .app-dialog__footer :deep(.el-button) { min-height: 44px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { DialogConfirmType } from './AppDialog.vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title: string
|
||||
size?: string | number
|
||||
loading?: boolean
|
||||
confirmLoading?: boolean
|
||||
confirmDisabled?: boolean
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
confirmType?: DialogConfirmType
|
||||
showFooter?: boolean
|
||||
showCancel?: boolean
|
||||
showConfirm?: boolean
|
||||
showClose?: boolean
|
||||
closeOnClickModal?: boolean
|
||||
closeOnPressEscape?: boolean
|
||||
destroyOnClose?: boolean
|
||||
lockWhileSubmitting?: boolean
|
||||
}>(), {
|
||||
size: 'min(680px, 94vw)',
|
||||
loading: false,
|
||||
confirmLoading: false,
|
||||
confirmDisabled: false,
|
||||
confirmText: '确定',
|
||||
cancelText: '取消',
|
||||
confirmType: 'primary',
|
||||
showFooter: true,
|
||||
showCancel: true,
|
||||
showConfirm: true,
|
||||
showClose: true,
|
||||
closeOnClickModal: false,
|
||||
closeOnPressEscape: true,
|
||||
destroyOnClose: false,
|
||||
lockWhileSubmitting: true,
|
||||
})
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const emit = defineEmits<{
|
||||
confirm: []
|
||||
cancel: []
|
||||
}>()
|
||||
const submittingLocked = computed(() => props.lockWhileSubmitting && props.confirmLoading)
|
||||
|
||||
function closeDrawer() {
|
||||
if (submittingLocked.value) return
|
||||
open.value = false
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-drawer
|
||||
v-bind="$attrs"
|
||||
v-model="open"
|
||||
class="app-drawer"
|
||||
:title="title"
|
||||
:size="size"
|
||||
:show-close="showClose && !submittingLocked"
|
||||
:close-on-click-modal="closeOnClickModal && !submittingLocked"
|
||||
:close-on-press-escape="closeOnPressEscape && !submittingLocked"
|
||||
:destroy-on-close="destroyOnClose"
|
||||
>
|
||||
<template v-if="$slots.header" #header><slot name="header" /></template>
|
||||
<div v-loading="loading" class="app-drawer__body" :aria-busy="loading || confirmLoading">
|
||||
<slot />
|
||||
</div>
|
||||
<template v-if="showFooter" #footer>
|
||||
<slot name="footer" :close="closeDrawer" :submitting="confirmLoading">
|
||||
<div class="app-drawer__footer">
|
||||
<el-button v-if="showCancel" :disabled="submittingLocked" @click="closeDrawer">{{ cancelText }}</el-button>
|
||||
<el-button
|
||||
v-if="showConfirm"
|
||||
:type="confirmType"
|
||||
:loading="confirmLoading"
|
||||
:disabled="loading || confirmDisabled"
|
||||
@click="emit('confirm')"
|
||||
>{{ confirmText }}</el-button>
|
||||
</div>
|
||||
</slot>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global(.app-drawer) { max-width: calc(100vw - 20px); }
|
||||
.app-drawer__body { min-height: 0; }
|
||||
.app-drawer__footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
|
||||
.app-drawer__footer :deep(.el-button) { margin-left: 0; }
|
||||
@media (max-width: 720px) { :global(.app-drawer) { max-width: 94vw; } .app-drawer__footer :deep(.el-button) { min-height: 44px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { Copy } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { copyText } from '@/shared/utils/copyText'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
value: string | number | null | undefined
|
||||
label?: string
|
||||
buttonText?: string
|
||||
successText?: string
|
||||
errorText?: string
|
||||
disabled?: boolean
|
||||
notify?: boolean
|
||||
}>(), {
|
||||
label: '内容',
|
||||
buttonText: '复制',
|
||||
successText: undefined,
|
||||
errorText: undefined,
|
||||
disabled: false,
|
||||
notify: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
copied: [value: string]
|
||||
'copy-error': [error: unknown]
|
||||
}>()
|
||||
|
||||
const copying = ref(false)
|
||||
const copyValue = computed(() => props.value == null ? '' : String(props.value))
|
||||
const isDisabled = computed(() => props.disabled || copying.value || copyValue.value.length === 0)
|
||||
|
||||
async function handleCopy() {
|
||||
if (isDisabled.value) return
|
||||
copying.value = true
|
||||
try {
|
||||
await copyText(copyValue.value)
|
||||
if (props.notify) ElMessage.success(props.successText || `${props.label}已复制`)
|
||||
emit('copied', copyValue.value)
|
||||
} catch (error) {
|
||||
if (props.notify) ElMessage.error(props.errorText || `${props.label}复制失败,请手动选择复制`)
|
||||
emit('copy-error', error)
|
||||
} finally {
|
||||
copying.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-button
|
||||
v-bind="$attrs"
|
||||
:disabled="isDisabled"
|
||||
:aria-label="`复制${label}`"
|
||||
:title="`复制${label}`"
|
||||
@click="handleCopy"
|
||||
>
|
||||
<Copy :size="14" aria-hidden="true" />
|
||||
<slot :copying="copying">{{ buttonText }}</slot>
|
||||
</el-button>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createPaginationItems, normalizePage, normalizePageSizes } from './DataTable.vue'
|
||||
|
||||
describe('DataTable pagination helpers', () => {
|
||||
it('normalizes page-size options', () => {
|
||||
expect(normalizePageSizes([20, 10, 20, 0, -1, 0.8, 10.8])).toEqual([20, 10])
|
||||
expect(normalizePageSizes([])).toEqual([10])
|
||||
})
|
||||
|
||||
it('keeps the page inside the available range', () => {
|
||||
expect(normalizePage(0, 8)).toBe(1)
|
||||
expect(normalizePage(12, 8)).toBe(8)
|
||||
expect(normalizePage(3.8, 8)).toBe(3)
|
||||
expect(normalizePage(Number.NaN, 8)).toBe(1)
|
||||
expect(normalizePage(4, 0)).toBe(1)
|
||||
expect(normalizePage(4, Number.NaN)).toBe(1)
|
||||
})
|
||||
|
||||
it('does not create page buttons for an empty result', () => {
|
||||
expect(createPaginationItems(1, 0)).toEqual([])
|
||||
})
|
||||
|
||||
it('shows all pages for a short result set', () => {
|
||||
expect(createPaginationItems(3, 5)).toEqual([1, 2, 3, 4, 5])
|
||||
})
|
||||
|
||||
it('keeps first and last pages visible for long result sets', () => {
|
||||
expect(createPaginationItems(1, 30)).toEqual([1, 2, 3, 4, 5, 'ellipsis-end', 30])
|
||||
expect(createPaginationItems(15, 30)).toEqual([1, 'ellipsis-start', 14, 15, 16, 'ellipsis-end', 30])
|
||||
expect(createPaginationItems(30, 30)).toEqual([1, 'ellipsis-start', 26, 27, 28, 29, 30])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,180 @@
|
||||
<script lang="ts">
|
||||
export interface DataTableColumn {
|
||||
key?: string | number
|
||||
label?: string
|
||||
prop?: string
|
||||
property?: string
|
||||
columnKey?: string
|
||||
slotName?: string
|
||||
headerSlotName?: string
|
||||
[attribute: string]: unknown
|
||||
}
|
||||
|
||||
export type PaginationItem = number | 'ellipsis-start' | 'ellipsis-end'
|
||||
|
||||
export function normalizePageSizes(values: readonly number[]) {
|
||||
const sizes = values
|
||||
.filter((value) => Number.isFinite(value))
|
||||
.map((value) => Math.trunc(value))
|
||||
.filter((value) => value > 0)
|
||||
const uniqueSizes = [...new Set(sizes)]
|
||||
return uniqueSizes.length > 0 ? uniqueSizes : [10]
|
||||
}
|
||||
|
||||
export function normalizePage(value: number, pageCount: number) {
|
||||
const maximum = Number.isFinite(pageCount) && pageCount > 0 ? Math.trunc(pageCount) : 1
|
||||
if (!Number.isFinite(value)) return 1
|
||||
return Math.min(Math.max(Math.trunc(value), 1), maximum)
|
||||
}
|
||||
|
||||
export function createPaginationItems(page: number, pageCount: number): PaginationItem[] {
|
||||
if (pageCount <= 0) return []
|
||||
if (pageCount <= 7) return Array.from({ length: pageCount }, (_, index) => index + 1)
|
||||
if (page <= 4) return [1, 2, 3, 4, 5, 'ellipsis-end', pageCount]
|
||||
if (page >= pageCount - 3) {
|
||||
return [1, 'ellipsis-start', pageCount - 4, pageCount - 3, pageCount - 2, pageCount - 1, pageCount]
|
||||
}
|
||||
return [1, 'ellipsis-start', page - 1, page, page + 1, 'ellipsis-end', pageCount]
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useSlots, watch } from 'vue'
|
||||
import { ChevronLeft, ChevronRight } from '@lucide/vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
data: object[]
|
||||
columns: DataTableColumn[]
|
||||
loading?: boolean
|
||||
emptyText?: string
|
||||
total?: number
|
||||
pageSizes?: number[]
|
||||
}>(), {
|
||||
loading: false,
|
||||
emptyText: '暂无数据',
|
||||
total: undefined,
|
||||
pageSizes: () => [10, 20, 50, 100],
|
||||
})
|
||||
|
||||
const page = defineModel<number>('page', { default: 1 })
|
||||
const pageSize = defineModel<number>('pageSize', { default: 10 })
|
||||
const slots = useSlots()
|
||||
const normalizedTotal = computed(() => {
|
||||
const value = props.total ?? 0
|
||||
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0
|
||||
})
|
||||
const normalizedPageSizes = computed(() => normalizePageSizes(props.pageSizes))
|
||||
const effectivePageSize = computed(() => (
|
||||
normalizedPageSizes.value.includes(pageSize.value) ? pageSize.value : normalizedPageSizes.value[0]
|
||||
))
|
||||
const pageCount = computed(() => (
|
||||
normalizedTotal.value === 0 ? 0 : Math.ceil(normalizedTotal.value / effectivePageSize.value)
|
||||
))
|
||||
const currentPage = computed(() => normalizePage(page.value, pageCount.value))
|
||||
const paginationItems = computed(() => createPaginationItems(currentPage.value, pageCount.value))
|
||||
|
||||
watch([pageSize, normalizedPageSizes], ([value, sizes]) => {
|
||||
const integerValue = Number.isFinite(value) ? Math.trunc(value) : sizes[0]
|
||||
const nextValue = sizes.includes(integerValue) ? integerValue : sizes[0]
|
||||
if (value === nextValue) return
|
||||
pageSize.value = nextValue
|
||||
page.value = 1
|
||||
}, { immediate: true })
|
||||
|
||||
watch([page, pageCount], ([value, count]) => {
|
||||
const nextValue = normalizePage(value, count)
|
||||
if (value !== nextValue) page.value = nextValue
|
||||
}, { immediate: true })
|
||||
|
||||
function columnAttrs({ key, slotName, headerSlotName, ...attrs }: DataTableColumn) {
|
||||
return attrs
|
||||
}
|
||||
|
||||
function columnRenderKey(column: DataTableColumn, index: number) {
|
||||
return String(column.key ?? column.columnKey ?? column.prop ?? column.property ?? index)
|
||||
}
|
||||
|
||||
function hasSlot(name?: string) {
|
||||
return Boolean(name && slots[name])
|
||||
}
|
||||
|
||||
function updatePageSize(value: number) {
|
||||
const nextValue = normalizedPageSizes.value.includes(value) ? value : normalizedPageSizes.value[0]
|
||||
if (nextValue === pageSize.value) return
|
||||
pageSize.value = nextValue
|
||||
page.value = 1
|
||||
}
|
||||
|
||||
function setPage(value: number) {
|
||||
page.value = normalizePage(value, pageCount.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="data-table">
|
||||
<el-table v-bind="$attrs" v-loading="loading" :data="data" :empty-text="emptyText" :aria-busy="loading">
|
||||
<el-table-column
|
||||
v-for="(column, index) in columns"
|
||||
:key="columnRenderKey(column, index)"
|
||||
v-bind="columnAttrs(column)"
|
||||
>
|
||||
<template v-if="hasSlot(column.headerSlotName)" #header="scope">
|
||||
<slot :name="column.headerSlotName" v-bind="scope" />
|
||||
</template>
|
||||
<template v-if="hasSlot(column.slotName)" #default="scope">
|
||||
<slot :name="column.slotName" v-bind="scope" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template v-if="$slots.empty" #empty><slot name="empty" /></template>
|
||||
</el-table>
|
||||
<footer v-if="total !== undefined" class="data-table__pagination" aria-label="表格分页">
|
||||
<div class="data-table__pagination-left">
|
||||
<slot name="pagination-left" :total="normalizedTotal" :page="currentPage" :page-size="effectivePageSize" :page-count="pageCount">
|
||||
<span v-if="pageCount > 0">共 {{ normalizedTotal }} 条 · 第 {{ currentPage }} / {{ pageCount }} 页</span>
|
||||
<span v-else>共 0 条</span>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="data-table__pagination-controls">
|
||||
<label class="data-table__page-size">
|
||||
<span>每页</span>
|
||||
<el-select :model-value="effectivePageSize" :disabled="loading" aria-label="每页条数" @update:model-value="updatePageSize">
|
||||
<el-option v-for="size in normalizedPageSizes" :key="size" :label="String(size)" :value="size" />
|
||||
</el-select>
|
||||
<span>条</span>
|
||||
</label>
|
||||
<nav v-if="pageCount > 0" class="data-table__page-buttons" aria-label="页码">
|
||||
<el-button class="data-table__page-button" :disabled="loading || currentPage <= 1" aria-label="上一页" title="上一页" @click="setPage(currentPage - 1)"><ChevronLeft :size="16" /></el-button>
|
||||
<template v-for="item in paginationItems" :key="item">
|
||||
<span v-if="typeof item !== 'number'" class="data-table__page-ellipsis" aria-hidden="true">...</span>
|
||||
<el-button
|
||||
v-else
|
||||
class="data-table__page-button"
|
||||
:type="item === currentPage ? 'primary' : undefined"
|
||||
:disabled="loading"
|
||||
:aria-current="item === currentPage ? 'page' : undefined"
|
||||
:aria-label="`第 ${item} 页`"
|
||||
:title="`第 ${item} 页`"
|
||||
@click="setPage(item)"
|
||||
>{{ item }}</el-button>
|
||||
</template>
|
||||
<el-button class="data-table__page-button" :disabled="loading || currentPage >= pageCount" aria-label="下一页" title="下一页" @click="setPage(currentPage + 1)"><ChevronRight :size="16" /></el-button>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.data-table { min-width: 0; }
|
||||
.data-table__pagination { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 12px; }
|
||||
.data-table__pagination-left { display: flex; min-width: 0; flex: 1; align-items: center; gap: 8px; }
|
||||
.data-table__pagination-controls, .data-table__page-size, .data-table__page-buttons { display: flex; align-items: center; gap: 8px; }
|
||||
.data-table__pagination-controls { min-width: 0; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.data-table__page-size :deep(.el-select) { width: 82px; }
|
||||
.data-table__page-buttons { gap: 4px; }
|
||||
.data-table__page-button { width: 32px; min-width: 32px; height: 32px; padding: 0; margin-left: 0; }
|
||||
.data-table__page-ellipsis { display: inline-flex; width: 24px; height: 32px; align-items: center; justify-content: center; }
|
||||
@media (max-width: 720px) { .data-table__pagination { align-items: flex-start; flex-direction: column; } .data-table__pagination-left, .data-table__pagination-controls, .data-table__page-buttons { width: 100%; } .data-table__pagination-controls { justify-content: flex-start; } .data-table__page-buttons { flex-wrap: wrap; } .data-table__page-button { width: 44px; min-width: 44px; height: 44px; } .data-table__page-ellipsis { height: 44px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { CircleAlert } from '@lucide/vue'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
title: string
|
||||
message?: string
|
||||
loading?: boolean
|
||||
retryText?: string
|
||||
showRetry?: boolean
|
||||
compact?: boolean
|
||||
}>(), {
|
||||
message: '',
|
||||
loading: false,
|
||||
retryText: '重新加载',
|
||||
showRetry: true,
|
||||
compact: false,
|
||||
})
|
||||
|
||||
defineEmits<{ retry: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="load-error-state" :class="{ 'is-compact': compact }" role="alert">
|
||||
<CircleAlert class="load-error-state__icon" :size="20" aria-hidden="true" />
|
||||
<div class="load-error-state__content">
|
||||
<strong>{{ title }}</strong>
|
||||
<p v-if="message">{{ message }}</p>
|
||||
<slot name="details" />
|
||||
</div>
|
||||
<div v-if="showRetry || $slots.actions" class="load-error-state__actions">
|
||||
<slot name="actions">
|
||||
<el-button type="primary" plain :loading="loading" @click="$emit('retry')">{{ retryText }}</el-button>
|
||||
</slot>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.load-error-state { display: flex; min-width: 0; align-items: flex-start; gap: 12px; padding: 16px; border: 1px solid var(--el-color-danger-light-7); border-radius: 8px; background: var(--el-color-danger-light-9); color: var(--el-text-color-primary); }
|
||||
.load-error-state.is-compact { padding: 12px; }
|
||||
.load-error-state__icon { flex: 0 0 auto; margin-top: 1px; color: var(--el-color-danger); }
|
||||
.load-error-state__content { min-width: 0; flex: 1; }
|
||||
.load-error-state__content strong { display: block; font-size: 14px; line-height: 1.5; }
|
||||
.load-error-state__content p { margin: 4px 0 0; color: var(--el-text-color-secondary); font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.load-error-state__actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; gap: 8px; }
|
||||
.load-error-state__actions :deep(.el-button) { margin-left: 0; }
|
||||
@media (max-width: 720px) { .load-error-state { flex-wrap: wrap; } .load-error-state__content { flex-basis: calc(100% - 36px); } .load-error-state__actions { width: 100%; padding-left: 32px; } .load-error-state__actions :deep(.el-button) { min-height: 44px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
items: Array<{ label: string; value: string | number; note?: string }>
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="metric-strip" aria-label="数据摘要">
|
||||
<div v-for="item in items" :key="item.label" class="metric-item">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
<small v-if="item.note">{{ item.note }}</small>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { Search, RotateCcw } from '@lucide/vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
export type QueryFieldType = 'input' | 'select' | 'date' | 'daterange'
|
||||
export type QueryValue = string | number | boolean | null | undefined | string[] | number[] | Date | Date[]
|
||||
export type QueryModel = Record<string, QueryValue>
|
||||
|
||||
export interface QueryOption {
|
||||
label: string
|
||||
value: string | number | boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface QueryField {
|
||||
key: string
|
||||
type: QueryFieldType
|
||||
placeholder?: string
|
||||
options?: QueryOption[]
|
||||
props?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
ariaLabel: string
|
||||
fields: QueryField[]
|
||||
searchText?: string
|
||||
resetText?: string
|
||||
searchOnReset?: boolean
|
||||
}>(), {
|
||||
searchText: '查询',
|
||||
resetText: '重置',
|
||||
searchOnReset: true,
|
||||
})
|
||||
|
||||
const model = defineModel<QueryModel>({ required: true })
|
||||
const emit = defineEmits<{
|
||||
search: [model: QueryModel]
|
||||
reset: [model: QueryModel]
|
||||
}>()
|
||||
|
||||
function updateField(key: string, value: QueryValue) {
|
||||
model.value = { ...model.value, [key]: value }
|
||||
}
|
||||
|
||||
function defaultPlaceholder(field: QueryField) {
|
||||
if (field.type === 'input') return '请输入关键词'
|
||||
if (field.type === 'select') return '全部'
|
||||
return field.type === 'daterange' ? '请选择日期范围' : '请选择日期'
|
||||
}
|
||||
|
||||
function datePickerType(field: QueryField): 'date' | 'daterange' {
|
||||
return field.type === 'daterange' ? 'daterange' : 'date'
|
||||
}
|
||||
|
||||
function resetValue(field: QueryField): QueryValue {
|
||||
return field.type === 'daterange' ? [] : ''
|
||||
}
|
||||
|
||||
function search() {
|
||||
emit('search', model.value)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
const next = { ...model.value }
|
||||
for (const field of props.fields) next[field.key] = resetValue(field)
|
||||
model.value = next
|
||||
emit('reset', next)
|
||||
if (props.searchOnReset) emit('search', next)
|
||||
}
|
||||
|
||||
defineExpose({ search, reset })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
v-bind="$attrs"
|
||||
class="query-toolbar"
|
||||
:aria-label="ariaLabel"
|
||||
>
|
||||
<div class="query-toolbar__filters">
|
||||
<template v-for="field in fields" :key="field.key">
|
||||
<el-input
|
||||
v-if="field.type === 'input'"
|
||||
:model-value="model[field.key] as string"
|
||||
:placeholder="field.placeholder || defaultPlaceholder(field)"
|
||||
clearable
|
||||
v-bind="field.props"
|
||||
@update:model-value="updateField(field.key, $event)"
|
||||
>
|
||||
<template #prefix><Search :size="16" /></template>
|
||||
</el-input>
|
||||
<el-select
|
||||
v-else-if="field.type === 'select'"
|
||||
:model-value="model[field.key] as string | number | boolean | undefined"
|
||||
:placeholder="field.placeholder || defaultPlaceholder(field)"
|
||||
clearable
|
||||
v-bind="field.props"
|
||||
@update:model-value="updateField(field.key, $event)"
|
||||
>
|
||||
<el-option v-for="option in field.options || []" :key="String(option.value)" :label="option.label" :value="option.value" :disabled="option.disabled" />
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-else
|
||||
:model-value="model[field.key] as string | string[] | undefined"
|
||||
:type="datePickerType(field)"
|
||||
:placeholder="field.placeholder || defaultPlaceholder(field)"
|
||||
clearable
|
||||
value-format="YYYY-MM-DD"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
v-bind="field.props"
|
||||
@update:model-value="updateField(field.key, $event)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div class="query-toolbar__actions">
|
||||
<el-button type="primary" @click="search"><Search :size="16" />{{ searchText }}</el-button>
|
||||
<el-button @click="reset"><RotateCcw :size="16" />{{ resetText }}</el-button>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.query-toolbar { display: flex; align-items: flex-end; justify-content: flex-start; flex-wrap: wrap; gap: 10px; padding: 16px; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.query-toolbar__filters { display: flex; flex: 0 1 auto; flex-wrap: wrap; gap: 10px; }
|
||||
.query-toolbar__filters :deep(.el-input), .query-toolbar__filters :deep(.el-select), .query-toolbar__filters :deep(.el-date-editor) { width: 260px; max-width: 100%; min-width: 0; }
|
||||
.query-toolbar__actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-start; gap: 8px; }
|
||||
.query-toolbar__actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
@media (max-width: 720px) { .query-toolbar { align-items: stretch; flex-direction: column; } .query-toolbar__filters { width: 100%; flex-direction: column; } .query-toolbar__filters :deep(.el-input), .query-toolbar__filters :deep(.el-select), .query-toolbar__filters :deep(.el-date-editor) { width: 100%; } .query-toolbar__actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); width: 100%; } .query-toolbar__actions :deep(.el-button) { min-height: 40px; margin-left: 0; } }
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{ status: string }>()
|
||||
|
||||
const tone = computed(() => {
|
||||
if (['正常', '启用', '已完成'].includes(props.status)) return 'success'
|
||||
if (['待审核', '草稿', '待处理'].includes(props.status)) return 'warning'
|
||||
if (['已受理', '已订舱', '运输中', '处理中'].includes(props.status)) return 'info'
|
||||
if (['停用', '已取消', '已驳回', '失败'].includes(props.status)) return 'danger'
|
||||
return 'neutral'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="status-badge" :class="`is-${tone}`"><i aria-hidden="true" />{{ status }}</span>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface PlatformPermissionResource {
|
||||
id: number | string
|
||||
tenantId: number | string
|
||||
appCode: 'PLATFORM'
|
||||
permissionType: 'MENU' | 'BUTTON'
|
||||
parentId: number | string
|
||||
permissionCode: string
|
||||
permissionName: string
|
||||
routePath: string | null
|
||||
httpMethod: string | null
|
||||
sortNo: number
|
||||
status: 'ACTIVE' | 'DISABLED' | 'INACTIVE'
|
||||
builtIn?: boolean
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { copyText } from './copyText'
|
||||
|
||||
describe('copyText', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
Reflect.deleteProperty(document, 'execCommand')
|
||||
document.querySelectorAll('textarea[aria-hidden="true"]').forEach((element) => element.remove())
|
||||
})
|
||||
|
||||
it('uses the Clipboard API when it is available', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
vi.stubGlobal('navigator', { clipboard: { writeText } })
|
||||
|
||||
await copyText('request-123')
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith('request-123')
|
||||
})
|
||||
|
||||
it('falls back to a temporary textarea when Clipboard API access is denied', async () => {
|
||||
vi.stubGlobal('navigator', { clipboard: { writeText: vi.fn().mockRejectedValue(new Error('denied')) } })
|
||||
const execCommand = vi.fn().mockReturnValue(true)
|
||||
Object.defineProperty(document, 'execCommand', { configurable: true, value: execCommand })
|
||||
|
||||
await copyText('trace-456')
|
||||
|
||||
expect(execCommand).toHaveBeenCalledWith('copy')
|
||||
expect(document.querySelector('textarea[aria-hidden="true"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
export async function copyText(value: string) {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
return
|
||||
} catch {
|
||||
// In insecure development contexts the Clipboard API may exist but reject.
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined' || !document.body) {
|
||||
throw new Error('Clipboard is unavailable')
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = value
|
||||
textarea.readOnly = true
|
||||
textarea.setAttribute('aria-hidden', 'true')
|
||||
Object.assign(textarea.style, {
|
||||
position: 'fixed',
|
||||
inset: '0 auto auto -9999px',
|
||||
opacity: '0',
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
|
||||
try {
|
||||
if (typeof document.execCommand !== 'function' || !document.execCommand('copy')) {
|
||||
throw new Error('Clipboard copy failed')
|
||||
}
|
||||
} finally {
|
||||
textarea.remove()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user