mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-07 00:36:41 +08:00
Redesign workflow task cards
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user