feat(ui): add ErrorBoundary component for error handling in Vue

This commit is contained in:
RainbowBird
2026-04-25 17:23:43 +08:00
parent 7312e471bc
commit f292ab9b99
4 changed files with 139 additions and 2 deletions
+7 -2
View File
@@ -10,7 +10,7 @@ import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/conte
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { useTheme } from '@proj-airi/ui'
import { ErrorBoundary, useTheme } from '@proj-airi/ui'
import { StageTransitionGroup } from '@proj-airi/ui-transitions'
import { storeToRefs } from 'pinia'
import { computed, onMounted, onUnmounted, watch } from 'vue'
@@ -128,7 +128,12 @@ function handleSetupSkipped() {
:use-page-specific-transitions="settings.usePageSpecificTransitions.value"
>
<RouterView v-slot="{ Component }">
<component :is="Component" />
<ErrorBoundary
title="Something went wrong while rendering this page."
@error="(err, _, info) => console.error('[ErrorBoundary]', info, err)"
>
<component :is="Component" />
</ErrorBoundary>
</RouterView>
</StageTransitionGroup>
+14
View File
@@ -126,6 +126,20 @@ Error display with copy/feedback buttons and scrollable stack trace.
**Emits**: `copy(content: string)`, `feedback()`
### ErrorBoundary
Catches synchronous render/setup errors in descendants via `onErrorCaptured` and renders a fallback (built-in `ContainerError` + retry button) instead of letting the error propagate. Use to wrap `<RouterView>` or any subtree where partial failure should not blank the host layout. Async/unhandled rejections are NOT captured — use `app.config.errorHandler` for those.
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `title` | `string?` | — | Optional title shown above error details |
| `retryable` | `boolean?` | `true` | Show built-in retry button |
| `retryLabel` | `string?` | `'Try again'` | Retry button label |
**Slots**: `default`, `fallback({ error, info, retry })`
**Emits**: `error(err, instance, info)`, `retry()`
**Exposed**: `retry()`, `hasError()`
### DoubleCheckButton
Two-stage confirmation button — click once to reveal confirm/cancel.
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { onErrorCaptured, ref } from 'vue'
import ContainerError from './container-error.vue'
/**
* Error boundary that contains exceptions thrown during the render or setup of
* descendant components and renders a fallback UI instead of letting the error
* propagate to the host (which would tear the surrounding layout down).
*
* Use when:
* - Wrapping a `<RouterView>` so a single broken route never blanks the whole app shell.
* - Wrapping any subtree where partial failure is preferable to total failure.
*
* Expects:
* - Children may throw synchronously during render or in `setup`. Async rejections
* that bubble out of unhandled promises are NOT caught Vue does not surface those
* to `onErrorCaptured`. Use `app.config.errorHandler` for those.
*
* Returns:
* - Default slot when there is no captured error.
* - `fallback` slot (or built-in `ContainerError` UI) when an error has been captured.
* The boundary suppresses error propagation by returning `false` from
* `onErrorCaptured`, so the parent stays mounted.
*/
interface ErrorBoundaryProps {
/**
* Optional title shown above the error details. Useful when the boundary
* wraps a recognizable region (e.g. "Stage failed to load").
*/
title?: string
/**
* Whether to show the built-in retry button. Retry remounts the default slot
* by bumping an internal key, giving the subtree a fresh chance to render.
* @default true
*/
retryable?: boolean
/**
* Label for the retry button.
* @default 'Try again'
*/
retryLabel?: string
}
const props = withDefaults(defineProps<ErrorBoundaryProps>(), {
retryable: true,
retryLabel: 'Try again',
})
const emit = defineEmits<{
(e: 'error', err: unknown, instance: unknown, info: string): void
(e: 'retry'): void
}>()
const capturedError = ref<unknown>(null)
const capturedInfo = ref<string>('')
const renderKey = ref(0)
onErrorCaptured((err, instance, info) => {
capturedError.value = err
capturedInfo.value = info
emit('error', err, instance, info)
// Stop propagation so the host layout keeps rendering.
return false
})
function retry() {
capturedError.value = null
capturedInfo.value = ''
renderKey.value += 1
emit('retry')
}
defineExpose({ retry, hasError: () => capturedError.value != null })
</script>
<template>
<template v-if="capturedError == null">
<slot :key="renderKey" />
</template>
<template v-else>
<slot
name="fallback"
:error="capturedError"
:info="capturedInfo"
:retry="retry"
>
<div :class="['flex flex-col gap-3 p-4 max-w-2xl mx-auto']">
<div v-if="props.title" :class="['text-base font-semibold text-red-700 dark:text-red-300']">
{{ props.title }}
</div>
<div v-if="capturedInfo" :class="['text-xs text-neutral-500 dark:text-neutral-400']">
During: {{ capturedInfo }}
</div>
<ContainerError
:error="capturedError"
height-preset="lg"
/>
<div v-if="props.retryable" :class="['flex justify-end']">
<button
type="button"
:class="[
'px-3 py-1.5 rounded-lg text-sm font-medium',
'bg-red-100 hover:bg-red-200 text-red-800',
'dark:bg-red-900/40 dark:hover:bg-red-900/60 dark:text-red-200',
'transition-colors',
]"
@click="retry"
>
{{ props.retryLabel }}
</button>
</div>
</div>
</slot>
</template>
</template>
+1
View File
@@ -2,4 +2,5 @@ export { default as Button } from './button.vue'
export { default as Callout } from './callout.vue'
export { default as ContainerError } from './container-error.vue'
export { default as DoubleCheckButton } from './double-check-button.vue'
export { default as ErrorBoundary } from './error-boundary.vue'
export { default as Progress } from './progress.vue'