Redesign workflow task cards

This commit is contained in:
jxxghp
2026-08-04 09:20:56 +08:00
parent 3ac798ac5e
commit 14a4d9dc9c
5 changed files with 749 additions and 171 deletions
+541 -168
View File
@@ -5,6 +5,7 @@ import { useConfirm } from '@/composables/useConfirm'
import api from '@/api'
import { useI18n } from 'vue-i18n'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { formatDateDifference } from '@/@core/utils/formatters'
const WorkflowActionsDialog = defineAsyncComponent(() => import('@/components/dialog/WorkflowActionsDialog.vue'))
const WorkflowAddEditDialog = defineAsyncComponent(() => import('@/components/dialog/WorkflowAddEditDialog.vue'))
@@ -177,45 +178,175 @@ async function handleReset(item: Workflow) {
}
}
// 计算状态颜色
const resolveStatusVariant = (status: string | undefined) => {
if (status === 'S')
return {
color: 'success',
bgColor: 'linear-gradient(to bottom right, rgba(76, 175, 80, 0.9), rgba(76, 175, 80, 0.7))',
text: t('workflow.task.status.success'),
}
else if (status === 'R')
return {
color: 'primary',
bgColor: 'linear-gradient(to bottom right, rgba(33, 150, 243, 0.9), rgba(33, 150, 243, 0.7))',
text: t('workflow.task.status.running'),
}
else if (status === 'F')
return {
color: 'error',
bgColor: 'linear-gradient(to bottom right, rgba(244, 67, 54, 0.9), rgba(244, 67, 54, 0.7))',
text: t('workflow.task.status.failed'),
}
else if (status === 'P')
return {
color: 'warning',
bgColor: 'linear-gradient(to bottom right, rgba(255, 152, 0, 0.9), rgba(255, 152, 0, 0.7))',
text: t('workflow.task.status.paused'),
}
else
return {
color: 'info',
bgColor: 'linear-gradient(to bottom right, rgba(33, 150, 243, 0.9), rgba(33, 150, 243, 0.7))',
text: t('workflow.task.status.waiting'),
}
type WorkflowStatusColor = 'error' | 'info' | 'primary' | 'secondary' | 'success' | 'warning'
type WorkflowActionSegmentState = 'active' | 'complete' | 'failed' | 'pending'
interface WorkflowActionDisplay {
id?: string
name?: string
type?: string
}
// 计算当前动作占比
const resolveProgress = (item: Workflow) => {
const current_action_length = item.current_action?.split(',').length || 0
return item.actions?.length ? Math.round((current_action_length / (item.actions.length || 1)) * 100) : 0
interface WorkflowExecutionNode {
state?: string
}
const resolveStatusVariant = (status: string | undefined) => {
const variants: Record<
string,
{
color: WorkflowStatusColor
icon: string
text: string
}
> = {
S: { color: 'success', icon: 'mdi-check-circle-outline', text: t('workflow.task.status.success') },
R: { color: 'primary', icon: 'mdi-progress-clock', text: t('workflow.task.status.running') },
F: { color: 'error', icon: 'mdi-alert-circle-outline', text: t('workflow.task.status.failed') },
P: { color: 'secondary', icon: 'mdi-pause-circle-outline', text: t('workflow.task.status.paused') },
W: { color: 'warning', icon: 'mdi-clock-outline', text: t('workflow.task.status.waiting') },
}
return variants[status || 'W'] || variants.W
}
const statusVariant = computed(() => resolveStatusVariant(props.workflow.state))
const triggerDisplay = computed(() => {
if (props.workflow.trigger_type === 'event') {
return {
icon: 'mdi-calendar-check-outline',
text: getEventTypeText(props.workflow.event_type || ''),
}
}
if (props.workflow.trigger_type === 'manual') {
return {
icon: 'mdi-hand-pointing-up',
text: t('workflow.task.info.manualTrigger'),
}
}
return {
icon: 'mdi-clock-outline',
text: props.workflow.timer || t('workflow.task.info.timer'),
}
})
const workflowActions = computed<WorkflowActionDisplay[]>(() =>
Array.isArray(props.workflow.actions) ? props.workflow.actions : [],
)
const totalActionCount = computed(() => workflowActions.value.length)
const currentActionIds = computed(() => {
return new Set(
(props.workflow.current_action || '')
.split(',')
.map(actionId => actionId.trim())
.filter(Boolean),
)
})
const executionNodes = computed<Record<string, WorkflowExecutionNode>>(() => {
const nodes = props.workflow.execution_state?.nodes
return nodes && typeof nodes === 'object' && !Array.isArray(nodes)
? (nodes as Record<string, WorkflowExecutionNode>)
: {}
})
const finishedActionCount = computed(() => {
const runtimeCount = Number(props.workflow.execution_state?.runtime?.finished_actions)
const knownActionIds = new Set(workflowActions.value.map(action => String(action.id || '')).filter(Boolean))
const fallbackCount = [...currentActionIds.value].filter(actionId => knownActionIds.has(actionId)).length
const count = Number.isFinite(runtimeCount) && runtimeCount >= 0 ? Math.trunc(runtimeCount) : fallbackCount
return Math.min(Math.max(count, 0), totalActionCount.value)
})
const actionSegments = computed<WorkflowActionSegmentState[]>(() => {
return workflowActions.value.map((action, index) => {
const actionId = action.id ? String(action.id) : ''
const nodeState = actionId ? executionNodes.value[actionId]?.state : undefined
if (nodeState === 'failed') return 'failed'
if (nodeState === 'running' || nodeState === 'queued') return 'active'
if (nodeState === 'success' || nodeState === 'completed' || nodeState === 'skipped') return 'complete'
if (actionId && currentActionIds.value.has(actionId)) return 'complete'
if (index < finishedActionCount.value) return 'complete'
if (props.workflow.state === 'R' && index === finishedActionCount.value) return 'active'
return 'pending'
})
})
const runningActionName = computed(() => {
const runningAction = workflowActions.value.find(action => {
if (!action.id) return false
const nodeState = executionNodes.value[String(action.id)]?.state
return nodeState === 'running' || nodeState === 'queued'
})
return runningAction?.name || runningAction?.type || ''
})
const executionStatus = computed(() => {
if (props.workflow.state === 'R') {
if (runningActionName.value) {
return {
color: 'primary' as WorkflowStatusColor,
icon: 'mdi-pulse',
text: t('workflow.task.info.executingAction', { name: runningActionName.value }),
}
}
if (totalActionCount.value > 0) {
return {
color: 'primary' as WorkflowStatusColor,
icon: 'mdi-pulse',
text: t('workflow.task.info.preparingAction', {
current: Math.min(finishedActionCount.value + 1, totalActionCount.value),
}),
}
}
return {
color: 'secondary' as WorkflowStatusColor,
icon: 'mdi-vector-polyline-remove',
text: t('workflow.task.info.noActions'),
}
}
if (props.workflow.state === 'F') {
return {
color: 'error' as WorkflowStatusColor,
icon: 'mdi-alert-circle-outline',
text: props.workflow.result || t('workflow.task.status.failed'),
}
}
if (props.workflow.last_time) {
return {
color: undefined,
icon: 'mdi-history',
text: t('workflow.task.info.lastExecuted', { time: formatDateDifference(props.workflow.last_time) }),
}
}
if (finishedActionCount.value > 0) {
return {
color: undefined,
icon: 'mdi-history',
text: t('workflow.task.info.executionIncomplete'),
}
}
return {
color: undefined,
icon: 'mdi-history',
text: t('workflow.task.info.neverExecuted'),
}
})
</script>
<template>
<div class="h-full">
@@ -223,151 +354,179 @@ const resolveProgress = (item: Workflow) => {
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="workflow-task-card-hover-area h-full">
<VCard
class="app-hover-lift-card mx-auto h-full"
class="workflow-task-card app-hover-lift-card mx-auto h-full"
@click="handleFlow(workflow)"
:ripple="false"
:loading="loading"
:class="{ 'app-hover-lift-card--hovering': hover.isHovering }"
:class="[
`workflow-task-card--status-${statusVariant.color}`,
{ 'app-hover-lift-card--hovering': hover.isHovering },
]"
>
<VCardItem
class="px-2 py-2"
:style="{
background: resolveStatusVariant(workflow?.state).bgColor,
}"
>
<template #prepend>
<VAvatar variant="text" size="small">
<VIcon
v-if="workflow?.state === 'P'"
<VCardItem class="workflow-task-card__header">
<template #prepend>
<VAvatar
:color="statusVariant.color"
variant="tonal"
rounded="md"
size="32"
class="workflow-task-card__trigger-icon"
>
<VIcon :icon="triggerDisplay.icon" :data-workflow-trigger-icon="triggerDisplay.icon" />
</VAvatar>
</template>
<VCardTitle class="workflow-task-card__title text-body-1" :title="workflow.description || workflow.name">
{{ workflow.name }}
</VCardTitle>
<VCardSubtitle class="workflow-task-card__trigger-text">
{{ triggerDisplay.text }}
</VCardSubtitle>
<template #append>
<IconBtn
class="workflow-task-card__menu"
size="small"
density="compact"
:aria-label="t('workflow.task.moreActions')"
@click.stop
>
<VIcon icon="mdi-dots-vertical" />
<VTooltip activator="parent" location="top">{{ t('workflow.task.moreActions') }}</VTooltip>
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem base-color="primary" @click="handleEdit(workflow)">
<template #prepend>
<VIcon icon="mdi-note-edit" />
</template>
<VListItemTitle>{{ t('workflow.task.edit') }}</VListItemTitle>
</VListItem>
<VListItem base-color="success" @click="handleFlow(workflow)">
<template #prepend>
<VIcon icon="mdi-vector-polyline" />
</template>
<VListItemTitle>{{ t('workflow.task.editFlow') }}</VListItemTitle>
</VListItem>
<VListItem v-if="workflow.current_action" base-color="info" @click="handleRun(workflow, false)">
<template #prepend>
<VIcon icon="mdi-play-speed" />
</template>
<VListItemTitle>{{ t('workflow.task.continue') }}</VListItemTitle>
</VListItem>
<VListItem v-if="workflow.current_action" base-color="info" @click="handleRun(workflow, true)">
<template #prepend>
<VIcon icon="mdi-replay" />
</template>
<VListItemTitle>{{ t('workflow.task.restart') }}</VListItemTitle>
</VListItem>
<VListItem v-else base-color="info" @click="handleRun(workflow, true)">
<template #prepend>
<VIcon icon="mdi-run" />
</template>
<VListItemTitle>{{ t('workflow.task.run') }}</VListItemTitle>
</VListItem>
<VListItem base-color="warning" @click="handleReset(workflow)">
<template #prepend>
<VIcon icon="mdi-restore-alert" />
</template>
<VListItemTitle>{{ t('workflow.task.reset') }}</VListItemTitle>
</VListItem>
<VListItem base-color="info" @click="handleShare(workflow)">
<template #prepend>
<VIcon icon="mdi-share" />
</template>
<VListItemTitle>{{ t('workflow.task.share') }}</VListItemTitle>
</VListItem>
<VListItem base-color="error" @click="handleDelete(workflow)">
<template #prepend>
<VIcon icon="mdi-delete" />
</template>
<VListItemTitle>{{ t('workflow.task.delete') }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
</VCardItem>
<VCardText class="workflow-task-card__body">
<div class="workflow-task-card__status-row">
<VChip :color="statusVariant.color" :prepend-icon="statusVariant.icon" size="small" variant="tonal">
{{ statusVariant.text }}
</VChip>
<VBtn
v-if="workflow.state === 'P'"
color="success"
icon="mdi-play"
variant="text"
size="small"
density="compact"
prepend-icon="mdi-play"
:aria-label="t('common.enable')"
@click.stop="handleEnable(workflow)"
/>
<VIcon v-else color="warning" icon="mdi-pause" @click.stop="handlePause(workflow)" />
</VAvatar>
</template>
<VCardTitle class="text-white text-lg">
<span :title="workflow?.description">{{ workflow?.name }}</span>
</VCardTitle>
<template #append>
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem base-color="primary" @click="handleEdit(workflow)">
<template #prepend>
<VIcon icon="mdi-note-edit" />
</template>
<VListItemTitle>{{ t('workflow.task.edit') }}</VListItemTitle>
</VListItem>
<VListItem base-color="success" @click="handleFlow(workflow)">
<template #prepend>
<VIcon icon="mdi-vector-polyline" />
</template>
<VListItemTitle>{{ t('workflow.task.editFlow') }}</VListItemTitle>
</VListItem>
<VListItem v-if="workflow.current_action" base-color="info" @click="handleRun(workflow, false)">
<template #prepend>
<VIcon icon="mdi-play-speed" />
</template>
<VListItemTitle>{{ t('workflow.task.continue') }}</VListItemTitle>
</VListItem>
<VListItem v-if="workflow.current_action" base-color="info" @click="handleRun(workflow, true)">
<template #prepend>
<VIcon icon="mdi-replay" />
</template>
<VListItemTitle>{{ t('workflow.task.restart') }}</VListItemTitle>
</VListItem>
<VListItem v-else base-color="info" @click="handleRun(workflow, true)">
<template #prepend>
<VIcon icon="mdi-run" />
</template>
<VListItemTitle>{{ t('workflow.task.run') }}</VListItemTitle>
</VListItem>
<VListItem base-color="warning" @click="handleReset(workflow)">
<template #prepend>
<VIcon icon="mdi-restore-alert" />
</template>
<VListItemTitle>{{ t('workflow.task.reset') }}</VListItemTitle>
</VListItem>
<VListItem base-color="info" @click="handleShare(workflow)">
<template #prepend>
<VIcon icon="mdi-share" />
</template>
<VListItemTitle>{{ t('workflow.task.share') }}</VListItemTitle>
</VListItem>
<VListItem base-color="error" @click="handleDelete(workflow)">
<template #prepend>
<VIcon icon="mdi-delete" />
</template>
<VListItemTitle>{{ t('workflow.task.delete') }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
</VCardItem>
<VDivider />
<VCardText class="pa-3">
<div class="d-flex flex-column gap-y-3">
<div class="d-flex flex-wrap gap-x-3">
<div class="flex-1">
<div class="mb-1">{{ t('workflow.task.info.trigger') }}</div>
<h5>
<span v-if="workflow?.trigger_type === 'timer' || !workflow?.trigger_type">
<VIcon icon="mdi-clock-outline" size="small" class="me-1" />
{{ workflow?.timer }}
</span>
<span v-else-if="workflow?.trigger_type === 'event'">
<VIcon icon="mdi-calendar-check" size="small" class="me-1" />
{{ getEventTypeText(workflow?.event_type || '') }}
</span>
<span v-else-if="workflow?.trigger_type === 'manual'">
<VIcon icon="mdi-hand-pointing-up" size="small" class="me-1" />
{{ t('workflow.task.info.manualTrigger') }}
</span>
</h5>
>
{{ t('common.enable') }}
</VBtn>
<VBtn
v-else
:color="statusVariant.color"
variant="text"
size="small"
density="compact"
prepend-icon="mdi-pause"
:aria-label="t('common.pause')"
@click.stop="handlePause(workflow)"
>
{{ t('common.pause') }}
</VBtn>
</div>
<div class="workflow-task-card__metrics">
<div class="workflow-task-card__metric">
<span>{{ t('workflow.task.info.actionCount') }}</span>
<strong>{{ totalActionCount }}</strong>
</div>
<div class="flex-1">
<div class="mb-1">{{ t('workflow.task.info.status') }}</div>
<h5 :class="`text-${resolveStatusVariant(workflow?.state).color}`">
{{ resolveStatusVariant(workflow?.state).text }}
</h5>
<div class="workflow-task-card__metric">
<span>{{ t('workflow.task.info.runCount') }}</span>
<strong>{{ workflow.run_count || 0 }}</strong>
</div>
</div>
<div class="d-flex flex-wrap gap-x-3">
<div class="flex-1">
<div class="mb-1">{{ t('workflow.task.info.actionCount') }}</div>
<div>
<VAvatar size="24" color="primary" variant="tonal">
<span class="text-xs">{{ workflow?.actions?.length }}</span>
</VAvatar>
</div>
<div class="workflow-task-card__progress">
<div class="workflow-task-card__progress-label">
<span>{{ t('workflow.task.info.actionProgress') }}</span>
<strong>{{ finishedActionCount }} / {{ totalActionCount }}</strong>
</div>
<div class="flex-1">
<div class="mb-1">{{ t('workflow.task.info.runCount') }}</div>
<h5>{{ workflow?.run_count }}</h5>
<div
class="workflow-task-card__action-track"
role="progressbar"
:aria-label="`${t('workflow.task.info.actionProgress')}: ${finishedActionCount} / ${totalActionCount}`"
aria-valuemin="0"
:aria-valuemax="Math.max(totalActionCount, 1)"
:aria-valuenow="finishedActionCount"
>
<span
v-for="(segment, index) in actionSegments"
:key="workflowActions[index]?.id || index"
class="workflow-task-card__action-segment"
:class="`workflow-task-card__action-segment--${segment}`"
/>
<span
v-if="totalActionCount === 0"
class="workflow-task-card__action-segment workflow-task-card__action-segment--empty"
/>
</div>
</div>
<div class="d-flex flex-wrap gap-x-3">
<div class="flex-1">
<div class="mb-1">{{ t('workflow.task.info.progress') }}</div>
<div class="d-flex align-center gap-5">
<div class="flex-grow-1">
<VProgressLinear color="info" rounded :model-value="resolveProgress(workflow)" />
</div>
<span> {{ resolveProgress(workflow) }}% </span>
</div>
</div>
<div
class="workflow-task-card__execution-status"
:class="executionStatus.color ? `text-${executionStatus.color}` : 'text-medium-emphasis'"
:title="executionStatus.text"
>
<VIcon :icon="executionStatus.icon" size="small" />
<span>{{ executionStatus.text }}</span>
</div>
<div class="d-flex flex-wrap gap-x-3" v-if="workflow?.result">
<div class="flex-1">
<div class="mb-1">{{ t('workflow.task.info.error') }}</div>
<div class="text-error">{{ workflow?.result }}</div>
</div>
</div>
</div>
</VCardText>
</VCardText>
</VCard>
</div>
</VHover>
@@ -378,4 +537,218 @@ const resolveProgress = (item: Workflow) => {
.workflow-task-card-hover-area {
inline-size: 100%;
}
.workflow-task-card {
--workflow-status-rgb: var(--v-theme-info);
--workflow-status-on-rgb: var(--v-theme-on-info);
display: flex;
min-block-size: 226px;
flex-direction: column;
overflow: hidden;
}
.workflow-task-card--status-primary {
--workflow-status-rgb: var(--v-theme-primary);
--workflow-status-on-rgb: var(--v-theme-on-primary);
}
.workflow-task-card--status-secondary {
--workflow-status-rgb: var(--v-theme-secondary);
--workflow-status-on-rgb: var(--v-theme-on-secondary);
}
.workflow-task-card--status-info {
--workflow-status-rgb: var(--v-theme-info);
--workflow-status-on-rgb: var(--v-theme-on-info);
}
.workflow-task-card--status-success {
--workflow-status-rgb: var(--v-theme-success);
--workflow-status-on-rgb: var(--v-theme-on-success);
}
.workflow-task-card--status-warning {
--workflow-status-rgb: var(--v-theme-warning);
--workflow-status-on-rgb: var(--v-theme-on-warning);
}
.workflow-task-card--status-error {
--workflow-status-rgb: var(--v-theme-error);
--workflow-status-on-rgb: var(--v-theme-on-error);
}
.workflow-task-card__header {
flex: 0 0 auto;
padding: 6px 10px !important;
border-block-end: 1px solid rgba(var(--workflow-status-on-rgb), 0.18);
background: linear-gradient(
118deg,
color-mix(in srgb, rgb(var(--workflow-status-rgb)) 88%, rgb(var(--v-theme-on-surface)) 12%) 0%,
rgb(var(--workflow-status-rgb)) 52%,
color-mix(in srgb, rgb(var(--workflow-status-rgb)) 78%, rgb(var(--v-theme-surface)) 22%) 100%
);
}
.workflow-task-card__trigger-icon {
flex: 0 0 auto;
color: rgb(var(--workflow-status-on-rgb)) !important;
}
.workflow-task-card__title {
display: -webkit-box;
min-inline-size: 0;
overflow: hidden;
color: rgb(var(--workflow-status-on-rgb));
font-weight: 500;
line-height: 20px !important;
letter-spacing: 0;
overflow-wrap: anywhere;
white-space: normal;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.workflow-task-card__trigger-text {
min-inline-size: 0;
margin-block-start: 1px;
overflow: hidden;
color: rgba(var(--workflow-status-on-rgb), 0.78);
font-size: 0.75rem;
letter-spacing: 0;
line-height: 16px !important;
text-overflow: ellipsis;
white-space: nowrap;
}
.workflow-task-card__menu {
flex: 0 0 auto;
color: rgb(var(--workflow-status-on-rgb)) !important;
}
.workflow-task-card__body {
display: flex;
min-inline-size: 0;
flex: 1 1 auto;
flex-direction: column;
gap: 10px;
padding: 11px 12px !important;
}
.workflow-task-card__status-row {
display: flex;
min-inline-size: 0;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.workflow-task-card__metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.workflow-task-card__metric {
display: grid;
min-inline-size: 0;
gap: 1px;
padding-inline-end: 10px;
}
.workflow-task-card__metric + .workflow-task-card__metric {
padding-inline: 10px 0;
border-inline-start: 1px solid rgba(var(--v-theme-on-surface), var(--v-border-opacity));
}
.workflow-task-card__metric span,
.workflow-task-card__progress-label span {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
}
.workflow-task-card__metric strong,
.workflow-task-card__progress-label strong {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.workflow-task-card__progress {
min-inline-size: 0;
}
.workflow-task-card__progress-label {
display: flex;
min-inline-size: 0;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.workflow-task-card__action-track {
display: flex;
min-inline-size: 0;
gap: 4px;
margin-block-start: 6px;
}
.workflow-task-card__action-segment {
block-size: 6px;
min-inline-size: 2px;
flex: 1 1 0;
border-radius: var(--app-control-radius);
background: rgba(var(--v-theme-on-surface), 0.12);
}
.workflow-task-card__action-segment--complete {
background: rgb(var(--workflow-status-rgb));
}
.workflow-task-card__action-segment--active {
background: rgba(var(--workflow-status-rgb), 0.22);
box-shadow: inset 0 0 0 1px rgb(var(--workflow-status-rgb));
}
.workflow-task-card__action-segment--failed {
background: rgb(var(--v-theme-error));
}
.workflow-task-card__action-segment--empty {
background: rgba(var(--v-theme-on-surface), 0.08);
}
.workflow-task-card__execution-status {
display: flex;
min-inline-size: 0;
align-items: center;
gap: 7px;
margin-block-start: auto;
padding-block-start: 9px;
border-block-start: 1px solid rgba(var(--v-theme-on-surface), var(--v-border-opacity));
}
.workflow-task-card__execution-status .v-icon {
flex: 0 0 auto;
}
.workflow-task-card__execution-status span {
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (width <= 599.98px) {
.workflow-task-card {
min-block-size: 222px;
}
.workflow-task-card__header,
.workflow-task-card__body {
padding-inline: 10px !important;
}
.workflow-task-card__body {
gap: 9px;
}
}
</style>
@@ -0,0 +1,181 @@
import { formatDateDifference } from '@/@core/utils/formatters'
import type { Workflow } from '@/api/types'
import WorkflowTaskCard from '@/components/cards/WorkflowTaskCard.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { readFileSync } from 'node:fs'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiDelete: vi.fn(),
apiPost: vi.fn(),
confirm: vi.fn(),
openSharedDialog: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('@/api', () => ({
default: {
delete: (...args: unknown[]) => mocks.apiDelete(...args),
post: (...args: unknown[]) => mocks.apiPost(...args),
},
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
}))
vi.mock('@/composables/useConfirm', () => ({
useConfirm: () => mocks.confirm,
}))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
function createWorkflow(overrides: Partial<Workflow> = {}): Workflow {
return {
actions: [
{ id: 'scan', name: '扫描目录', type: 'ScanFile' },
{ id: 'scrape', name: '刮削文件', type: 'ScrapeFile' },
{ id: 'transfer', name: '整理文件', type: 'TransferFile' },
],
current_action: undefined,
execution_state: {},
id: 'workflow-1',
name: '扫描和刮削',
run_count: 0,
state: 'W',
trigger_type: 'manual',
...overrides,
}
}
async function renderCard(workflowOverrides: Partial<Workflow> = {}) {
return renderWithProviders(WorkflowTaskCard, {
props: {
eventTypes: [{ title: '下载完成', value: 'download.completed' }],
workflow: createWorkflow(workflowOverrides),
},
})
}
describe('WorkflowTaskCard redesign', () => {
beforeEach(() => {
mocks.apiDelete.mockReset()
mocks.apiPost.mockReset()
mocks.confirm.mockReset()
mocks.openSharedDialog.mockReset()
mocks.toastError.mockReset()
mocks.toastSuccess.mockReset()
mocks.apiPost.mockResolvedValue({ success: true })
mocks.confirm.mockResolvedValue(true)
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
})
it('maps the generated card icon to the workflow trigger type', async () => {
const { container, rerender } = await renderCard()
expect(container.querySelector('[data-workflow-trigger-icon="mdi-hand-pointing-up"]')).toBeInTheDocument()
expect(screen.getByText('手动')).toBeInTheDocument()
await rerender({
eventTypes: [{ title: '下载完成', value: 'download.completed' }],
workflow: createWorkflow({ timer: '10 * * * *', trigger_type: 'timer' }),
})
expect(container.querySelector('[data-workflow-trigger-icon="mdi-clock-outline"]')).toBeInTheDocument()
expect(screen.getByText('10 * * * *')).toBeInTheDocument()
await rerender({
eventTypes: [{ title: '下载完成', value: 'download.completed' }],
workflow: createWorkflow({ event_type: 'download.completed', trigger_type: 'event' }),
})
expect(container.querySelector('[data-workflow-trigger-icon="mdi-calendar-check-outline"]')).toBeInTheDocument()
expect(screen.getByText('下载完成')).toBeInTheDocument()
})
it.each([
['W', 'warning', '待执行'],
['R', 'primary', '运行中'],
['S', 'success', '成功'],
['P', 'secondary', '暂停'],
['F', 'error', '失败'],
] as const)('maps %s to the semantic %s status color', async (state, color, label) => {
const { container } = await renderCard({ state })
expect(container.querySelector('.workflow-task-card')).toHaveClass(`workflow-task-card--status-${color}`)
expect(container.querySelector('.v-chip')).toHaveTextContent(label)
})
it('renders structured node states as segmented action progress', async () => {
const { container } = await renderCard({
execution_state: {
nodes: {
scan: { state: 'success' },
scrape: { state: 'skipped' },
transfer: { state: 'running' },
},
runtime: { finished_actions: 2 },
},
state: 'R',
})
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(container.querySelectorAll('.workflow-task-card__action-segment--complete')).toHaveLength(2)
expect(container.querySelectorAll('.workflow-task-card__action-segment--active')).toHaveLength(1)
expect(screen.getByText('正在执行 整理文件')).toBeInTheDocument()
expect(screen.getByRole('progressbar', { name: '动作进度: 2 / 3' })).toHaveAttribute('aria-valuenow', '2')
})
it('falls back to unique legacy current-action ids and clamps the count', async () => {
const { container } = await renderCard({ current_action: ',scan,,scrape,scan,unknown,', state: 'P' })
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(container.querySelectorAll('.workflow-task-card__action-segment--complete')).toHaveLength(2)
expect(container.querySelectorAll('.workflow-task-card__action-segment--pending')).toHaveLength(1)
expect(screen.getByText('上次执行尚未完成')).toBeInTheDocument()
})
it('shows failure, last-run and never-run execution details at the bottom', async () => {
const lastTime = '2026-08-03 08:00:00'
const { rerender } = await renderCard({ result: '目录无访问权限', state: 'F' })
expect(screen.getByText('目录无访问权限')).toBeInTheDocument()
await rerender({
eventTypes: [],
workflow: createWorkflow({ last_time: lastTime, state: 'S' }),
})
expect(screen.getByText(`上次执行 ${formatDateDifference(lastTime)}`)).toBeInTheDocument()
await rerender({ eventTypes: [], workflow: createWorkflow() })
expect(screen.getByText('从未执行')).toBeInTheDocument()
})
it('keeps card navigation and enable controls as separate actions', async () => {
const { container } = await renderCard({ state: 'P' })
await fireEvent.click(screen.getByRole('button', { name: '启用' }))
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledWith('workflow/workflow-1/start'))
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
await fireEvent.click(container.querySelector('.workflow-task-card') as Element)
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
expect(mocks.openSharedDialog.mock.calls[0][1]).toEqual({ workflow: expect.objectContaining({ id: 'workflow-1' }) })
})
it('derives all custom card colors and geometry from global theme tokens', () => {
const source = readFileSync('src/components/cards/WorkflowTaskCard.vue', 'utf8')
expect(source).toContain('var(--v-theme-primary)')
expect(source).toContain('var(--v-theme-info)')
expect(source).toContain('var(--v-theme-success)')
expect(source).toContain('var(--v-theme-warning)')
expect(source).toContain('var(--v-theme-error)')
expect(source).toContain('var(--v-theme-on-surface)')
expect(source).toContain('var(--app-control-radius)')
expect(source).toContain('linear-gradient(')
expect(source).not.toMatch(/#[\da-f]{3,8}\b/i)
})
})