mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
[Desktop] Startup maintenance screen (#2253)
Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
53
src/components/common/RefreshButton.vue
Normal file
53
src/components/common/RefreshButton.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<!--
|
||||
A refresh button that disables and shows a progress spinner whilst active.
|
||||
|
||||
Usage:
|
||||
```vue
|
||||
<RefreshButton
|
||||
v-model="isRefreshing"
|
||||
:outlined="false"
|
||||
@refresh="refresh"
|
||||
/>
|
||||
```
|
||||
-->
|
||||
<template>
|
||||
<Button
|
||||
class="relative p-button-icon-only"
|
||||
:outlined="props.outlined"
|
||||
:severity="props.severity"
|
||||
:disabled="active || props.disabled"
|
||||
@click="(event) => $emit('refresh', event)"
|
||||
>
|
||||
<span
|
||||
class="p-button-icon pi pi-refresh transition-all"
|
||||
:class="{ 'opacity-0': active }"
|
||||
data-pc-section="icon"
|
||||
></span>
|
||||
<span class="p-button-label" data-pc-section="label"> </span>
|
||||
<ProgressSpinner v-show="active" class="absolute w-1/2 h-1/2" />
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
|
||||
import { VueSeverity } from '@/types/primeVueTypes'
|
||||
|
||||
// Properties
|
||||
interface Props {
|
||||
outlined?: boolean
|
||||
disabled?: boolean
|
||||
severity?: VueSeverity
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
outlined: true,
|
||||
severity: 'secondary'
|
||||
})
|
||||
|
||||
// Model
|
||||
const active = defineModel<boolean>({ required: true })
|
||||
|
||||
// Emits
|
||||
defineEmits(['refresh'])
|
||||
</script>
|
||||
40
src/components/maintenance/StatusTag.vue
Normal file
40
src/components/maintenance/StatusTag.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<Tag :icon :severity :value />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PrimeIcons, type PrimeIconsOptions } from '@primevue/core/api'
|
||||
import Tag, { TagProps } from 'primevue/tag'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
|
||||
// Properties
|
||||
const props = defineProps<{
|
||||
error: boolean
|
||||
refreshing?: boolean
|
||||
}>()
|
||||
|
||||
// Bindings
|
||||
const icon = ref<string>(null)
|
||||
const severity = ref<TagProps['severity']>(null)
|
||||
const value = ref<PrimeIconsOptions[keyof PrimeIconsOptions]>(null)
|
||||
|
||||
const updateBindings = () => {
|
||||
if (props.refreshing) {
|
||||
icon.value = PrimeIcons.QUESTION
|
||||
severity.value = 'info'
|
||||
value.value = t('maintenance.refreshing')
|
||||
} else if (props.error) {
|
||||
icon.value = PrimeIcons.TIMES
|
||||
severity.value = 'danger'
|
||||
value.value = t('g.error')
|
||||
} else {
|
||||
icon.value = PrimeIcons.CHECK
|
||||
severity.value = 'success'
|
||||
value.value = t('maintenance.OK')
|
||||
}
|
||||
}
|
||||
|
||||
watch(props, updateBindings, { deep: true })
|
||||
</script>
|
||||
127
src/components/maintenance/TaskCard.vue
Normal file
127
src/components/maintenance/TaskCard.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div
|
||||
class="task-div max-w-48 min-h-52 grid relative"
|
||||
:class="{ 'opacity-75': isLoading }"
|
||||
>
|
||||
<Card
|
||||
class="max-w-48 relative h-full overflow-hidden"
|
||||
:class="{ 'opacity-65': state.state !== 'error' }"
|
||||
v-bind="(({ onClick, ...rest }) => rest)($attrs)"
|
||||
>
|
||||
<template #header>
|
||||
<i
|
||||
v-if="state.state === 'error'"
|
||||
class="pi pi-exclamation-triangle text-red-500 absolute m-2 top-0 -right-14 opacity-15"
|
||||
style="font-size: 10rem"
|
||||
/>
|
||||
<img
|
||||
v-if="task.headerImg"
|
||||
:src="task.headerImg"
|
||||
class="object-contain w-full h-full opacity-25 pt-4 px-4"
|
||||
/>
|
||||
</template>
|
||||
<template #title>{{ task.name }}</template>
|
||||
<template #content>{{ description }}</template>
|
||||
<template #footer>
|
||||
<div class="flex gap-4 mt-1">
|
||||
<Button
|
||||
:icon="task.button?.icon"
|
||||
:label="task.button?.text"
|
||||
class="w-full"
|
||||
raised
|
||||
icon-pos="right"
|
||||
@click="(event) => $emit('execute', event)"
|
||||
:loading="isExecuting"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<i
|
||||
v-if="!isLoading && state.state === 'OK'"
|
||||
class="task-card-ok pi pi-check"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Card from 'primevue/card'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
|
||||
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
|
||||
import { useMinLoadingDurationRef } from '@/utils/refUtil'
|
||||
|
||||
const taskStore = useMaintenanceTaskStore()
|
||||
const state = computed(() => taskStore.getState(props.task))
|
||||
|
||||
// Properties
|
||||
const props = defineProps<{
|
||||
task: MaintenanceTask
|
||||
}>()
|
||||
|
||||
// Events
|
||||
defineEmits<{
|
||||
execute: [event: MouseEvent]
|
||||
}>()
|
||||
|
||||
// Bindings
|
||||
const description = computed(() =>
|
||||
state.value.state === 'error'
|
||||
? props.task.errorDescription ?? props.task.shortDescription
|
||||
: props.task.shortDescription
|
||||
)
|
||||
|
||||
// Use a minimum run time to ensure tasks "feel" like they have run
|
||||
const reactiveLoading = computed(() => state.value.refreshing)
|
||||
const reactiveExecuting = computed(() => state.value.executing)
|
||||
|
||||
const isLoading = useMinLoadingDurationRef(reactiveLoading, 250)
|
||||
const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-card-ok {
|
||||
@apply text-green-500 absolute -right-4 -bottom-4 opacity-100 row-span-full col-span-full transition-opacity;
|
||||
|
||||
font-size: 4rem;
|
||||
text-shadow: 0.25rem 0 0.5rem black;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.p-card {
|
||||
@apply transition-opacity;
|
||||
|
||||
--p-card-background: var(--p-button-secondary-background);
|
||||
opacity: 0.9;
|
||||
|
||||
&.opacity-65 {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.p-card-header) {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
:deep(.p-card-body) {
|
||||
z-index: 1;
|
||||
flex-grow: 1;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.task-div {
|
||||
> i {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover > i {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
86
src/components/maintenance/TaskListItem.vue
Normal file
86
src/components/maintenance/TaskListItem.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<tr
|
||||
class="border-neutral-700 border-solid border-y"
|
||||
:class="{
|
||||
'opacity-50': state.state === 'resolved',
|
||||
'opacity-75': isLoading && state.state !== 'resolved'
|
||||
}"
|
||||
>
|
||||
<td class="text-center w-16">
|
||||
<TaskListStatusIcon :state="state.state" :loading="isLoading" />
|
||||
</td>
|
||||
<td>
|
||||
<p class="inline-block">{{ task.name }}</p>
|
||||
<Button
|
||||
class="inline-block mx-2"
|
||||
type="button"
|
||||
:icon="PrimeIcons.INFO_CIRCLE"
|
||||
severity="secondary"
|
||||
:text="true"
|
||||
@click="toggle"
|
||||
/>
|
||||
|
||||
<Popover ref="infoPopover" class="block m-1 max-w-64 min-w-32">
|
||||
<span class="whitespace-pre-line">{{ task.description }}</span>
|
||||
</Popover>
|
||||
</td>
|
||||
<td class="text-right px-4">
|
||||
<Button
|
||||
:icon="task.button?.icon"
|
||||
:label="task.button?.text"
|
||||
:severity
|
||||
icon-pos="right"
|
||||
@click="(event) => $emit('execute', event)"
|
||||
:loading="isExecuting"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PrimeIcons } from '@primevue/core/api'
|
||||
import Button from 'primevue/button'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
|
||||
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
|
||||
import { VueSeverity } from '@/types/primeVueTypes'
|
||||
import { useMinLoadingDurationRef } from '@/utils/refUtil'
|
||||
|
||||
import TaskListStatusIcon from './TaskListStatusIcon.vue'
|
||||
|
||||
const taskStore = useMaintenanceTaskStore()
|
||||
const state = computed(() => taskStore.getState(props.task))
|
||||
|
||||
// Properties
|
||||
const props = defineProps<{
|
||||
task: MaintenanceTask
|
||||
}>()
|
||||
|
||||
// Events
|
||||
defineEmits<{
|
||||
execute: [event: MouseEvent]
|
||||
}>()
|
||||
|
||||
// Binding
|
||||
const severity = computed<VueSeverity>(() =>
|
||||
state.value.state === 'error' || state.value.state === 'warning'
|
||||
? 'primary'
|
||||
: 'secondary'
|
||||
)
|
||||
|
||||
// Use a minimum run time to ensure tasks "feel" like they have run
|
||||
const reactiveLoading = computed(() => state.value.refreshing)
|
||||
const reactiveExecuting = computed(() => state.value.executing)
|
||||
|
||||
const isLoading = useMinLoadingDurationRef(reactiveLoading, 250)
|
||||
const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)
|
||||
|
||||
// Popover
|
||||
const infoPopover = ref()
|
||||
|
||||
const toggle = (event: Event) => {
|
||||
infoPopover.value.toggle(event)
|
||||
}
|
||||
</script>
|
||||
115
src/components/maintenance/TaskListPanel.vue
Normal file
115
src/components/maintenance/TaskListPanel.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<!-- Tasks -->
|
||||
<section class="my-4">
|
||||
<template v-if="filter.tasks.length === 0">
|
||||
<!-- Empty filter -->
|
||||
<Divider />
|
||||
<p class="text-neutral-400 w-full text-center">
|
||||
{{ $t('maintenance.allOk') }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- Display: List -->
|
||||
<table
|
||||
v-if="displayAsList === PrimeIcons.LIST"
|
||||
class="w-full border-collapse border-hidden"
|
||||
>
|
||||
<TaskListItem
|
||||
v-for="task in filter.tasks"
|
||||
:key="task.id"
|
||||
:task
|
||||
@execute="(event) => confirmButton(event, task)"
|
||||
/>
|
||||
</table>
|
||||
|
||||
<!-- Display: Cards -->
|
||||
<template v-else>
|
||||
<div class="flex flex-wrap justify-evenly gap-8 pad-y my-4">
|
||||
<TaskCard
|
||||
v-for="task in filter.tasks"
|
||||
:key="task.id"
|
||||
:task
|
||||
@execute="(event) => confirmButton(event, task)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<ConfirmPopup />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PrimeIcons } from '@primevue/core/api'
|
||||
import { useConfirm, useToast } from 'primevue'
|
||||
import ConfirmPopup from 'primevue/confirmpopup'
|
||||
import Divider from 'primevue/divider'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
|
||||
import type {
|
||||
MaintenanceFilter,
|
||||
MaintenanceTask
|
||||
} from '@/types/desktop/maintenanceTypes'
|
||||
|
||||
import TaskCard from './TaskCard.vue'
|
||||
import TaskListItem from './TaskListItem.vue'
|
||||
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
const taskStore = useMaintenanceTaskStore()
|
||||
|
||||
// Properties
|
||||
const props = defineProps<{
|
||||
displayAsList: string
|
||||
filter: MaintenanceFilter
|
||||
isRefreshing: boolean
|
||||
}>()
|
||||
|
||||
const executeTask = async (task: MaintenanceTask) => {
|
||||
let message: string | undefined
|
||||
|
||||
try {
|
||||
// Success
|
||||
if ((await taskStore.execute(task)) === true) return
|
||||
|
||||
message = t('maintenance.error.taskFailed')
|
||||
} catch (error) {
|
||||
message = (error as Error)?.message
|
||||
}
|
||||
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('maintenance.error.toastTitle'),
|
||||
detail: message ?? t('maintenance.error.defaultDescription'),
|
||||
life: 10_000
|
||||
})
|
||||
}
|
||||
|
||||
// Commands
|
||||
const confirmButton = async (event: MouseEvent, task: MaintenanceTask) => {
|
||||
if (!task.requireConfirm) {
|
||||
await executeTask(task)
|
||||
return
|
||||
}
|
||||
|
||||
confirm.require({
|
||||
target: event.currentTarget as HTMLElement,
|
||||
message: task.confirmText ?? t('maintenance.confirmTitle'),
|
||||
icon: 'pi pi-exclamation-circle',
|
||||
rejectProps: {
|
||||
label: t('g.cancel'),
|
||||
severity: 'secondary',
|
||||
outlined: true
|
||||
},
|
||||
acceptProps: {
|
||||
label: task.button?.text ?? t('g.save'),
|
||||
severity: task.severity ?? 'primary'
|
||||
},
|
||||
// TODO: Not awaited.
|
||||
accept: async () => {
|
||||
await executeTask(task)
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
44
src/components/maintenance/TaskListStatusIcon.vue
Normal file
44
src/components/maintenance/TaskListStatusIcon.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<ProgressSpinner v-if="!state || loading" class="h-8 w-8" />
|
||||
<template v-else>
|
||||
<i :class="cssClasses" v-tooltip.top="{ value: tooltip, showDelay: 250 }" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PrimeIcons } from '@primevue/core/api'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { MaybeRef, computed } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
|
||||
// Properties
|
||||
const tooltip = computed(() => {
|
||||
if (props.state === 'error') {
|
||||
return t('g.error')
|
||||
} else if (props.state === 'OK') {
|
||||
return t('maintenance.OK')
|
||||
} else {
|
||||
return t('maintenance.Skipped')
|
||||
}
|
||||
})
|
||||
|
||||
const cssClasses = computed(() => {
|
||||
let classes: string
|
||||
if (props.state === 'error') {
|
||||
classes = `${PrimeIcons.EXCLAMATION_TRIANGLE} text-red-500`
|
||||
} else if (props.state === 'OK') {
|
||||
classes = `${PrimeIcons.CHECK} text-green-500`
|
||||
} else {
|
||||
classes = PrimeIcons.MINUS
|
||||
}
|
||||
|
||||
return `text-3xl pi ${classes}`
|
||||
})
|
||||
|
||||
// Model
|
||||
const props = defineProps<{
|
||||
state: 'warning' | 'error' | 'resolved' | 'OK' | 'skipped' | undefined
|
||||
loading?: MaybeRef<boolean>
|
||||
}>()
|
||||
</script>
|
||||
144
src/constants/desktopMaintenanceTasks.ts
Normal file
144
src/constants/desktopMaintenanceTasks.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { PrimeIcons } from '@primevue/core'
|
||||
|
||||
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
|
||||
const electron = electronAPI()
|
||||
|
||||
const openUrl = (url: string) => {
|
||||
window.open(url, '_blank')
|
||||
return true
|
||||
}
|
||||
|
||||
export const DESKTOP_MAINTENANCE_TASKS: Readonly<MaintenanceTask>[] = [
|
||||
{
|
||||
id: 'basePath',
|
||||
execute: async () => await electron.setBasePath(),
|
||||
name: 'Base path',
|
||||
shortDescription: 'Change the application base path.',
|
||||
errorDescription: 'Unable to open the base path. Please select a new one.',
|
||||
description:
|
||||
'The base path is the default location where ComfyUI stores data. It is the location fo the python environment, and may also contain models, custom nodes, and other extensions.',
|
||||
isInstallationFix: true,
|
||||
button: {
|
||||
icon: PrimeIcons.QUESTION,
|
||||
text: 'Select'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'git',
|
||||
headerImg: '/assets/images/Git-Logo-White.svg',
|
||||
execute: () => openUrl('https://git-scm.com/downloads/'),
|
||||
name: 'Download git',
|
||||
shortDescription: 'Open the git download page.',
|
||||
description:
|
||||
'Git is required to download and manage custom nodes and other extensions. This fixer simply opens the download page in your browser. You must download and install git manually.',
|
||||
button: {
|
||||
icon: PrimeIcons.EXTERNAL_LINK,
|
||||
text: 'Download'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'vcRedist',
|
||||
execute: () => openUrl('https://aka.ms/vs/17/release/vc_redist.x64.exe'),
|
||||
name: 'Download VC++ Redist',
|
||||
shortDescription: 'Download the latest VC++ Redistributable runtime.',
|
||||
description:
|
||||
'The Visual C++ runtime libraries are required to run ComfyUI. You will need to download and install this file.',
|
||||
button: {
|
||||
icon: PrimeIcons.EXTERNAL_LINK,
|
||||
text: 'Download'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'reinstall',
|
||||
severity: 'danger',
|
||||
requireConfirm: true,
|
||||
execute: async () => {
|
||||
await electron.reinstall()
|
||||
return true
|
||||
},
|
||||
name: 'Reinstall ComfyUI',
|
||||
shortDescription:
|
||||
'Deletes the desktop app config and load the welcome screen.',
|
||||
description:
|
||||
'Delete the desktop app config, restart the app, and load the installation screen.',
|
||||
confirmText: 'Delete all saved config and reinstall?',
|
||||
button: {
|
||||
icon: PrimeIcons.EXCLAMATION_TRIANGLE,
|
||||
text: 'Reinstall'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'pythonPackages',
|
||||
requireConfirm: true,
|
||||
execute: async () => {
|
||||
try {
|
||||
await electron.uv.installRequirements()
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
},
|
||||
name: 'Install python packages',
|
||||
shortDescription:
|
||||
'Installs the base python packages required to run ComfyUI.',
|
||||
errorDescription:
|
||||
'Python packages that are required to run ComfyUI are not installed.',
|
||||
description:
|
||||
'This will install the python packages required to run ComfyUI. This includes torch, torchvision, and other dependencies.',
|
||||
usesTerminal: true,
|
||||
isInstallationFix: true,
|
||||
button: {
|
||||
icon: PrimeIcons.DOWNLOAD,
|
||||
text: 'Install'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'uv',
|
||||
execute: () =>
|
||||
openUrl('https://docs.astral.sh/uv/getting-started/installation/'),
|
||||
name: 'uv executable',
|
||||
shortDescription: 'uv installs and maintains the python environment.',
|
||||
description:
|
||||
"This will open the download page for Astral's uv tool. uv is used to install python and manage python packages.",
|
||||
button: {
|
||||
icon: 'pi pi-asterisk',
|
||||
text: 'Download'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'uvCache',
|
||||
severity: 'danger',
|
||||
requireConfirm: true,
|
||||
execute: async () => await electron.uv.clearCache(),
|
||||
name: 'uv cache',
|
||||
shortDescription: 'Remove the Astral uv cache of python packages.',
|
||||
description:
|
||||
'This will remove the uv cache directory and its contents. All downloaded python packages will need to be downloaded again.',
|
||||
confirmText: 'Delete uv cache of python packages?',
|
||||
isInstallationFix: true,
|
||||
button: {
|
||||
icon: PrimeIcons.TRASH,
|
||||
text: 'Clear cache'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'venvDirectory',
|
||||
severity: 'danger',
|
||||
requireConfirm: true,
|
||||
execute: async () => await electron.uv.resetVenv(),
|
||||
name: 'Reset virtual environment',
|
||||
shortDescription:
|
||||
'Remove and recreate the .venv directory. This removes all python packages.',
|
||||
description:
|
||||
'The python environment is where ComfyUI installs python and python packages. It is used to run the ComfyUI server.',
|
||||
confirmText: 'Delete the .venv directory?',
|
||||
usesTerminal: true,
|
||||
isInstallationFix: true,
|
||||
button: {
|
||||
icon: PrimeIcons.FOLDER,
|
||||
text: 'Recreate'
|
||||
}
|
||||
}
|
||||
] as const
|
||||
@@ -692,5 +692,21 @@
|
||||
"UPSCALE_MODEL": "UPSCALE_MODEL",
|
||||
"VAE": "VAE",
|
||||
"WEBCAM": "WEBCAM"
|
||||
},
|
||||
"maintenance": {
|
||||
"allOk": "No issues were detected.",
|
||||
"status": "Status",
|
||||
"detected": "Detected",
|
||||
"refreshing": "Refreshing",
|
||||
"None": "None",
|
||||
"OK": "OK",
|
||||
"Skipped": "Skipped",
|
||||
"showManual": "Show maintenance tasks",
|
||||
"confirmTitle": "Are you sure?",
|
||||
"error": {
|
||||
"toastTitle": "Task error",
|
||||
"taskFailed": "Task failed to run.",
|
||||
"defaultDescription": "An error occurred while running a maintenance task."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -261,6 +261,22 @@
|
||||
"maxLength": "Message trop long"
|
||||
}
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "Aucun",
|
||||
"OK": "OK",
|
||||
"Skipped": "Ignoré",
|
||||
"allOk": "Aucun problème détecté.",
|
||||
"confirmTitle": "Êtes-vous sûr ?",
|
||||
"detected": "Détecté",
|
||||
"error": {
|
||||
"defaultDescription": "Une erreur s'est produite lors de l'exécution d'une tâche de maintenance.",
|
||||
"taskFailed": "La tâche a échoué.",
|
||||
"toastTitle": "Erreur de tâche"
|
||||
},
|
||||
"refreshing": "Actualisation",
|
||||
"showManual": "Afficher les tâches de maintenance",
|
||||
"status": "Statut"
|
||||
},
|
||||
"menu": {
|
||||
"autoQueue": "File d'attente automatique",
|
||||
"batchCount": "Nombre de lots",
|
||||
|
||||
@@ -261,6 +261,22 @@
|
||||
"maxLength": "メッセージが長すぎます"
|
||||
}
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "なし",
|
||||
"OK": "OK",
|
||||
"Skipped": "スキップされました",
|
||||
"allOk": "問題は検出されませんでした。",
|
||||
"confirmTitle": "よろしいですか?",
|
||||
"detected": "検出されました",
|
||||
"error": {
|
||||
"defaultDescription": "メンテナンスタスクの実行中にエラーが発生しました。",
|
||||
"taskFailed": "タスクの実行に失敗しました。",
|
||||
"toastTitle": "タスクエラー"
|
||||
},
|
||||
"refreshing": "更新中",
|
||||
"showManual": "メンテナンスタスクを表示",
|
||||
"status": "ステータス"
|
||||
},
|
||||
"menu": {
|
||||
"autoQueue": "自動キュー",
|
||||
"batchCount": "バッチ数",
|
||||
|
||||
@@ -261,6 +261,22 @@
|
||||
"maxLength": "메시지가 너무 깁니다"
|
||||
}
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "없음",
|
||||
"OK": "확인",
|
||||
"Skipped": "건너뜀",
|
||||
"allOk": "문제가 발견되지 않았습니다.",
|
||||
"confirmTitle": "확실합니까?",
|
||||
"detected": "감지됨",
|
||||
"error": {
|
||||
"defaultDescription": "유지 보수 작업을 실행하는 동안 오류가 발생했습니다.",
|
||||
"taskFailed": "작업 실행에 실패했습니다.",
|
||||
"toastTitle": "작업 오류"
|
||||
},
|
||||
"refreshing": "새로 고침 중",
|
||||
"showManual": "유지 보수 작업 보기",
|
||||
"status": "상태"
|
||||
},
|
||||
"menu": {
|
||||
"autoQueue": "자동 실행 큐",
|
||||
"batchCount": "배치 수",
|
||||
|
||||
@@ -261,6 +261,22 @@
|
||||
"maxLength": "Сообщение слишком длинное"
|
||||
}
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "Нет",
|
||||
"OK": "OK",
|
||||
"Skipped": "Пропущено",
|
||||
"allOk": "Проблем не обнаружено.",
|
||||
"confirmTitle": "Вы уверены?",
|
||||
"detected": "Обнаружено",
|
||||
"error": {
|
||||
"defaultDescription": "Произошла ошибка при выполнении задачи по обслуживанию.",
|
||||
"taskFailed": "Не удалось выполнить задачу.",
|
||||
"toastTitle": "Ошибка задачи"
|
||||
},
|
||||
"refreshing": "Обновление",
|
||||
"showManual": "Показать задачи по обслуживанию",
|
||||
"status": "Статус"
|
||||
},
|
||||
"menu": {
|
||||
"autoQueue": "Автоочередь",
|
||||
"batchCount": "Количество пакетов",
|
||||
|
||||
@@ -261,6 +261,22 @@
|
||||
"maxLength": "消息过长"
|
||||
}
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "无",
|
||||
"OK": "确定",
|
||||
"Skipped": "跳过",
|
||||
"allOk": "未检测到任何问题。",
|
||||
"confirmTitle": "你确定吗?",
|
||||
"detected": "检测到",
|
||||
"error": {
|
||||
"defaultDescription": "运行维护任务时发生错误。",
|
||||
"taskFailed": "任务运行失败。",
|
||||
"toastTitle": "任务错误"
|
||||
},
|
||||
"refreshing": "刷新中",
|
||||
"showManual": "显示维护任务",
|
||||
"status": "状态"
|
||||
},
|
||||
"menu": {
|
||||
"autoQueue": "自动执行",
|
||||
"batchCount": "批次数量",
|
||||
|
||||
@@ -104,6 +104,12 @@ const router = createRouter({
|
||||
name: 'DesktopStartView',
|
||||
component: () => import('@/views/DesktopStartView.vue'),
|
||||
beforeEnter: guardElectronAccess
|
||||
},
|
||||
{
|
||||
path: 'maintenance',
|
||||
name: 'MaintenanceView',
|
||||
component: () => import('@/views/MaintenanceView.vue'),
|
||||
beforeEnter: guardElectronAccess
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
143
src/stores/maintenanceTaskStore.ts
Normal file
143
src/stores/maintenanceTaskStore.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { InstallValidation } from '@comfyorg/comfyui-electron-types'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { DESKTOP_MAINTENANCE_TASKS } from '@/constants/desktopMaintenanceTasks'
|
||||
import type {
|
||||
MaintenanceTask,
|
||||
MaintenanceTaskState
|
||||
} from '@/types/desktop/maintenanceTypes'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
|
||||
/**
|
||||
* User-initiated maintenance tasks. Currently only used by the desktop app maintenance view.
|
||||
*
|
||||
* Includes running state, task list, and execution / refresh logic.
|
||||
* @returns The maintenance task store
|
||||
*/
|
||||
export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
|
||||
/** Refresh should run for at least this long, even if it completes much faster. Ensures refresh feels like it is doing something. */
|
||||
const electron = electronAPI()
|
||||
|
||||
// Reactive state
|
||||
const isRefreshing = ref(false)
|
||||
const isRunningTerminalCommand = computed(() =>
|
||||
tasks.value
|
||||
.filter((task) => task.usesTerminal)
|
||||
.some((task) => getState(task)?.executing)
|
||||
)
|
||||
const isRunningInstallationFix = computed(() =>
|
||||
tasks.value
|
||||
.filter((task) => task.isInstallationFix)
|
||||
.some((task) => getState(task)?.executing)
|
||||
)
|
||||
|
||||
// Task list
|
||||
const tasks = ref(DESKTOP_MAINTENANCE_TASKS)
|
||||
|
||||
const taskStates = ref(
|
||||
new Map<MaintenanceTask['id'], MaintenanceTaskState>(
|
||||
DESKTOP_MAINTENANCE_TASKS.map((x) => [x.id, {}])
|
||||
)
|
||||
)
|
||||
|
||||
/** True if any tasks are in an error state. */
|
||||
const anyErrors = computed(() =>
|
||||
tasks.value.some((task) => getState(task).state === 'error')
|
||||
)
|
||||
|
||||
/** Wraps the execution of a maintenance task, updating state and rethrowing errors. */
|
||||
const execute = async (task: MaintenanceTask) => {
|
||||
const state = getState(task)
|
||||
|
||||
try {
|
||||
state.executing = true
|
||||
const success = await task.execute()
|
||||
if (!success) return false
|
||||
|
||||
state.error = undefined
|
||||
return true
|
||||
} catch (error) {
|
||||
state.error = (error as Error)?.message
|
||||
throw error
|
||||
} finally {
|
||||
state.executing = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the matching state object for a task.
|
||||
* @param task Task to get the matching state object for
|
||||
* @returns The state object for this task
|
||||
*/
|
||||
const getState = (task: MaintenanceTask) => taskStates.value.get(task.id)!
|
||||
|
||||
/**
|
||||
* Updates the task list with the latest validation state.
|
||||
* @param validationUpdate Update details passed in by electron
|
||||
*/
|
||||
const processUpdate = (validationUpdate: InstallValidation) => {
|
||||
// Type not exported by API
|
||||
type ValidationState = InstallValidation['basePath']
|
||||
// Add index to API type
|
||||
type IndexedUpdate = InstallValidation & Record<string, ValidationState>
|
||||
|
||||
const update = validationUpdate as IndexedUpdate
|
||||
isRefreshing.value = true
|
||||
|
||||
// Update each task state
|
||||
for (const task of tasks.value) {
|
||||
const state = getState(task)
|
||||
|
||||
state.refreshing = update[task.id] === undefined
|
||||
// Mark resolved
|
||||
if (state.state === 'error' && update[task.id] === 'OK')
|
||||
state.state = 'resolved'
|
||||
if (update[task.id] === 'OK' && state.state === 'resolved') continue
|
||||
|
||||
if (update[task.id]) state.state = update[task.id]
|
||||
}
|
||||
|
||||
// Final update
|
||||
if (!update.inProgress && isRefreshing.value) {
|
||||
isRefreshing.value = false
|
||||
|
||||
for (const task of tasks.value) {
|
||||
const state = getState(task)
|
||||
state.refreshing = false
|
||||
if (state.state === 'resolved') continue
|
||||
|
||||
state.state = update[task.id] ?? 'skipped'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears the resolved status of tasks (when changing filters) */
|
||||
const clearResolved = () => {
|
||||
for (const task of tasks.value) {
|
||||
const state = getState(task)
|
||||
if (state?.state === 'resolved') state.state = 'OK'
|
||||
}
|
||||
}
|
||||
|
||||
/** @todo Refreshes Electron tasks only. */
|
||||
const refreshDesktopTasks = async () => {
|
||||
isRefreshing.value = true
|
||||
console.log('Refreshing desktop tasks')
|
||||
await electron.Validation.validateInstallation(processUpdate)
|
||||
}
|
||||
|
||||
return {
|
||||
tasks,
|
||||
isRefreshing,
|
||||
isRunningTerminalCommand,
|
||||
isRunningInstallationFix,
|
||||
execute,
|
||||
getState,
|
||||
processUpdate,
|
||||
clearResolved,
|
||||
/** True if any tasks are in an error state. */
|
||||
anyErrors,
|
||||
refreshDesktopTasks
|
||||
}
|
||||
})
|
||||
0
src/types/desktop/index.d.ts
vendored
Normal file
0
src/types/desktop/index.d.ts
vendored
Normal file
62
src/types/desktop/maintenanceTypes.ts
Normal file
62
src/types/desktop/maintenanceTypes.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { VueSeverity } from '../primeVueTypes'
|
||||
|
||||
interface MaintenanceTaskButton {
|
||||
/** The text to display on the button. */
|
||||
text?: string
|
||||
/** CSS classes used for the button icon, e.g. 'pi pi-external-link' */
|
||||
icon?: string
|
||||
}
|
||||
|
||||
/** A maintenance task, used by the maintenance page. */
|
||||
export interface MaintenanceTask {
|
||||
/** ID string used as i18n key */
|
||||
id: string
|
||||
/** The display name of the task, e.g. Git */
|
||||
name: string
|
||||
/** Short description of the task. */
|
||||
shortDescription?: string
|
||||
/** Description of the task when it is in an error state. */
|
||||
errorDescription?: string
|
||||
/** Description of the task when it is in a warning state. */
|
||||
warningDescription?: string
|
||||
/** Full description of the task when it is in an OK state. */
|
||||
description?: string
|
||||
/** URL to the image to show in card mode. */
|
||||
headerImg?: string
|
||||
/** The button to display on the task card / list item. */
|
||||
button?: MaintenanceTaskButton
|
||||
/** Whether to show a confirmation dialog before running the task. */
|
||||
requireConfirm?: boolean
|
||||
/** The text to display in the confirmation dialog. */
|
||||
confirmText?: string
|
||||
/** Called by onClick to run the actual task. */
|
||||
execute: (args?: unknown[]) => boolean | Promise<boolean>
|
||||
/** Show the button with `severity="danger"` */
|
||||
severity?: VueSeverity
|
||||
/** Whether this task should display the terminal window when run. */
|
||||
usesTerminal?: boolean
|
||||
/** If `true`, successful completion of this task will refresh install validation and automatically continue if successful. */
|
||||
isInstallationFix?: boolean
|
||||
}
|
||||
|
||||
/** State of a maintenance task, managed by the maintenance task store. */
|
||||
export interface MaintenanceTaskState {
|
||||
/** The current state of the task. */
|
||||
state?: 'warning' | 'error' | 'resolved' | 'OK' | 'skipped'
|
||||
/** Whether the task state is currently being refreshed. */
|
||||
refreshing?: boolean
|
||||
/** Whether the task is currently running. */
|
||||
executing?: boolean
|
||||
/** The error message that occurred when the task failed. */
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** The filter options for the maintenance task list. */
|
||||
export interface MaintenanceFilter {
|
||||
/** CSS classes used for the filter button icon, e.g. 'pi pi-cross' */
|
||||
icon: string
|
||||
/** The text to display on the filter button. */
|
||||
value: string
|
||||
/** The tasks to display when this filter is selected. */
|
||||
tasks: ReadonlyArray<MaintenanceTask>
|
||||
}
|
||||
10
src/types/primeVueTypes.ts
Normal file
10
src/types/primeVueTypes.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/** Button, Tag, etc severity type is 'string' instead of this list. */
|
||||
export type VueSeverity =
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'success'
|
||||
| 'info'
|
||||
| 'warn'
|
||||
| 'help'
|
||||
| 'danger'
|
||||
| 'contrast'
|
||||
29
src/utils/refUtil.ts
Normal file
29
src/utils/refUtil.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useTimeout } from '@vueuse/core'
|
||||
import { type Ref, computed, ref, watch } from 'vue'
|
||||
|
||||
/**
|
||||
* Vue boolean ref (writable computed) with one difference: when set to `true` it stays that way for at least {@link minDuration}.
|
||||
* If set to `false` before {@link minDuration} has passed, it uses a timer to delay the change.
|
||||
* @param value The default value to set on this ref
|
||||
* @param minDuration The minimum time that this ref must be `true` for
|
||||
* @returns A custom boolean vue ref with a minimum activation time
|
||||
*/
|
||||
export function useMinLoadingDurationRef(
|
||||
value: Ref<boolean>,
|
||||
minDuration = 250
|
||||
) {
|
||||
const current = ref(value.value)
|
||||
|
||||
const { ready, start } = useTimeout(minDuration, {
|
||||
controls: true,
|
||||
immediate: false
|
||||
})
|
||||
|
||||
watch(value, (newValue) => {
|
||||
if (newValue && !current.value) start()
|
||||
|
||||
current.value = newValue
|
||||
})
|
||||
|
||||
return computed(() => current.value || !ready.value)
|
||||
}
|
||||
221
src/views/MaintenanceView.vue
Normal file
221
src/views/MaintenanceView.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<BaseViewTemplate dark>
|
||||
<div
|
||||
class="min-w-full min-h-full font-sans w-screen h-screen grid justify-around text-neutral-300 bg-neutral-900 dark-theme pointer-events-auto overflow-y-auto"
|
||||
>
|
||||
<div class="max-w-screen-sm w-screen m-8 relative">
|
||||
<!-- Header -->
|
||||
<h1 class="backspan pi-wrench text-4xl font-bold">Maintenance</h1>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="w-full flex flex-wrap gap-4 items-center">
|
||||
<span class="grow">
|
||||
Status: <StatusTag :refreshing="isRefreshing" :error="anyErrors" />
|
||||
</span>
|
||||
<div class="flex gap-4 items-center">
|
||||
<SelectButton
|
||||
v-model="displayAsList"
|
||||
:options="[PrimeIcons.LIST, PrimeIcons.TH_LARGE]"
|
||||
:allow-empty="false"
|
||||
>
|
||||
<template #option="opts"><i :class="opts.option" /></template>
|
||||
</SelectButton>
|
||||
<SelectButton
|
||||
v-model="filter"
|
||||
:options="filterOptions"
|
||||
:allow-empty="false"
|
||||
optionLabel="value"
|
||||
dataKey="value"
|
||||
area-labelledby="custom"
|
||||
@change="clearResolved"
|
||||
>
|
||||
<template #option="opts">
|
||||
<i :class="opts.option.icon"></i>
|
||||
<span class="max-sm:hidden">{{ opts.option.value }}</span>
|
||||
</template>
|
||||
</SelectButton>
|
||||
<RefreshButton
|
||||
v-model="isRefreshing"
|
||||
severity="secondary"
|
||||
@refresh="refreshDesktopTasks"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tasks -->
|
||||
<TaskListPanel
|
||||
class="border-neutral-700 border-solid border-x-0 border-y"
|
||||
:filter
|
||||
:displayAsList
|
||||
:isRefreshing
|
||||
/>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-between gap-4 flex-row">
|
||||
<Button
|
||||
label="Console Logs"
|
||||
icon="pi pi-desktop"
|
||||
icon-pos="left"
|
||||
severity="secondary"
|
||||
@click="toggleConsoleDrawer"
|
||||
/>
|
||||
<Button
|
||||
label="Continue"
|
||||
icon="pi pi-arrow-right"
|
||||
icon-pos="left"
|
||||
:severity="anyErrors ? 'secondary' : 'primary'"
|
||||
@click="() => completeValidation()"
|
||||
:loading="isRefreshing"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Drawer
|
||||
v-model:visible="terminalVisible"
|
||||
header="Terminal"
|
||||
position="bottom"
|
||||
style="height: max(50vh, 34rem)"
|
||||
>
|
||||
<BaseTerminal @created="terminalCreated" />
|
||||
</Drawer>
|
||||
<Toast />
|
||||
</div>
|
||||
</BaseViewTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PrimeIcons } from '@primevue/core/api'
|
||||
import Button from 'primevue/button'
|
||||
import Drawer from 'primevue/drawer'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import Toast from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { Ref, computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import BaseTerminal from '@/components/bottomPanel/tabs/terminal/BaseTerminal.vue'
|
||||
import RefreshButton from '@/components/common/RefreshButton.vue'
|
||||
import StatusTag from '@/components/maintenance/StatusTag.vue'
|
||||
import TaskListPanel from '@/components/maintenance/TaskListPanel.vue'
|
||||
import type { useTerminal } from '@/hooks/bottomPanelTabs/useTerminal'
|
||||
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
|
||||
import { MaintenanceFilter } from '@/types/desktop/maintenanceTypes'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import { useMinLoadingDurationRef } from '@/utils/refUtil'
|
||||
|
||||
import BaseViewTemplate from './templates/BaseViewTemplate.vue'
|
||||
|
||||
const electron = electronAPI()
|
||||
const toast = useToast()
|
||||
const taskStore = useMaintenanceTaskStore()
|
||||
const { clearResolved, processUpdate, refreshDesktopTasks } = taskStore
|
||||
|
||||
const terminalVisible = ref(false)
|
||||
|
||||
// Use a minimum run time to ensure tasks "feel" like they have run
|
||||
const reactiveIsRefreshing = computed(() => taskStore.isRefreshing)
|
||||
/** `true` when waiting on tasks to complete. */
|
||||
const isRefreshing = useMinLoadingDurationRef(reactiveIsRefreshing, 250)
|
||||
|
||||
/** True if any tasks are in an error state. */
|
||||
const anyErrors = computed(() => taskStore.anyErrors)
|
||||
|
||||
/** Whether to display tasks as a list or cards. */
|
||||
const displayAsList = ref(PrimeIcons.TH_LARGE)
|
||||
|
||||
const errorFilter = computed(() =>
|
||||
taskStore.tasks.filter((x) => {
|
||||
const { state } = taskStore.getState(x)
|
||||
return state === 'error' || state === 'resolved'
|
||||
})
|
||||
)
|
||||
|
||||
const filterOptions = ref([
|
||||
{ icon: PrimeIcons.FILTER_FILL, value: 'All', tasks: taskStore.tasks },
|
||||
{ icon: PrimeIcons.EXCLAMATION_TRIANGLE, value: 'Errors', tasks: errorFilter }
|
||||
])
|
||||
|
||||
/** Filter binding; can be set to show all tasks, or only errors. */
|
||||
const filter = ref<MaintenanceFilter>(filterOptions.value[1])
|
||||
|
||||
/** If valid, leave the validation window. */
|
||||
const completeValidation = async (alertOnFail = true) => {
|
||||
const isValid = await electron.Validation.complete()
|
||||
if (alertOnFail && !isValid) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Unable to continue - errors remain',
|
||||
life: 5_000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const terminalCreated = (
|
||||
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
|
||||
root: Ref<HTMLElement>
|
||||
) => {
|
||||
useAutoSize({ root, autoRows: true, autoCols: true })
|
||||
electron.onLogMessage((message: string) => {
|
||||
terminal.write(message)
|
||||
})
|
||||
|
||||
terminal.options.cursorBlink = false
|
||||
terminal.options.cursorStyle = 'bar'
|
||||
terminal.options.cursorInactiveStyle = 'bar'
|
||||
terminal.options.disableStdin = true
|
||||
}
|
||||
|
||||
const toggleConsoleDrawer = () => {
|
||||
terminalVisible.value = !terminalVisible.value
|
||||
}
|
||||
|
||||
// Show terminal when in use
|
||||
watch(
|
||||
() => taskStore.isRunningTerminalCommand,
|
||||
(value) => {
|
||||
terminalVisible.value = value
|
||||
}
|
||||
)
|
||||
|
||||
// If we're running a fix that may resolve all issues, auto-recheck and continue if everything is OK
|
||||
watch(
|
||||
() => taskStore.isRunningInstallationFix,
|
||||
(value, oldValue) => {
|
||||
if (!value && oldValue) completeValidation(false)
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
electron.Validation.onUpdate(processUpdate)
|
||||
|
||||
const update = await electron.Validation.getStatus()
|
||||
processUpdate(update)
|
||||
})
|
||||
|
||||
onUnmounted(() => electron.Validation.dispose())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.p-tag) {
|
||||
--p-tag-gap: 0.375rem;
|
||||
}
|
||||
|
||||
.backspan::before {
|
||||
@apply m-0 absolute text-muted;
|
||||
font-family: 'primeicons';
|
||||
top: -2rem;
|
||||
right: -2rem;
|
||||
speak: none;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-variant: normal;
|
||||
text-transform: none;
|
||||
line-height: 1;
|
||||
display: inline-block;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
opacity: 0.02;
|
||||
font-size: min(14rem, 90vw);
|
||||
z-index: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user