feat(workflow): 简化工作流卡片并适配编辑器主题

- 工作流卡片去掉按节点进度,改为底部通栏全局进度条,已执行次数合并到执行状态行
- 组件选择栏与边设置面板改为主题变量契约,适配透明/玻璃主题毛玻璃材质
This commit is contained in:
jxxghp
2026-08-17 06:31:18 +08:00
parent 774ef77639
commit 415600c123
9 changed files with 160 additions and 187 deletions
+42 -139
View File
@@ -158,7 +158,6 @@ async function handleReset(item: Workflow) {
}
type WorkflowStatusColor = 'error' | 'info' | 'primary' | 'secondary' | 'success' | 'warning'
type WorkflowActionSegmentState = 'active' | 'complete' | 'failed' | 'pending'
interface WorkflowActionDisplay {
id?: string
@@ -243,20 +242,22 @@ const finishedActionCount = computed(() => {
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
// 全局执行进度(%):优先使用后端下发的整体进度,缺失时按已完成动作数兜底计算
const globalProgressPercent = computed(() => {
const runtimeProgress = Number(props.workflow.execution_state?.runtime?.progress)
if (Number.isFinite(runtimeProgress)) {
return Math.min(Math.max(Math.round(runtimeProgress), 0), 100)
}
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'
if (totalActionCount.value <= 0) return 0
return 'pending'
})
return Math.min(100, Math.round((finishedActionCount.value / totalActionCount.value) * 100))
})
// 已执行次数:大于 0 时追加在执行状态行后展示
const runCountText = computed(() => {
const count = props.workflow.run_count || 0
return count > 0 ? ` · ${t('workflow.task.info.runCount', { count })}` : ''
})
const runningActionName = computed(() => {
@@ -279,21 +280,7 @@ const executionStatus = computed(() => {
}
}
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'),
}
return null
}
if (props.workflow.state === 'F') {
@@ -308,7 +295,7 @@ const executionStatus = computed(() => {
return {
color: undefined,
icon: 'mdi-history',
text: t('workflow.task.info.lastExecuted', { time: formatDateDifference(props.workflow.last_time) }),
text: `${t('workflow.task.info.lastExecuted', { time: formatDateDifference(props.workflow.last_time) })}${runCountText.value}`,
}
}
@@ -460,44 +447,8 @@ const executionStatus = computed(() => {
</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="workflow-task-card__metric">
<span>{{ t('workflow.task.info.runCount') }}</span>
<strong>{{ workflow.run_count || 0 }}</strong>
</div>
</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="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
v-if="executionStatus"
class="workflow-task-card__execution-status"
:class="executionStatus.color ? `text-${executionStatus.color}` : 'text-medium-emphasis'"
:title="executionStatus.text"
@@ -506,6 +457,17 @@ const executionStatus = computed(() => {
<span>{{ executionStatus.text }}</span>
</div>
</VCardText>
<footer
class="workflow-task-card__progress"
role="progressbar"
:aria-label="workflow.name"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="globalProgressPercent"
>
<span class="workflow-task-card__progress-fill" :style="{ inlineSize: `${globalProgressPercent}%` }" />
</footer>
</VCard>
</div>
</VHover>
@@ -530,7 +492,7 @@ const executionStatus = computed(() => {
--workflow-card-header-shadow: none;
display: flex;
min-block-size: 226px;
min-block-size: 176px;
flex-direction: column;
overflow: hidden;
}
@@ -613,8 +575,8 @@ const executionStatus = computed(() => {
min-inline-size: 0;
flex: 1 1 auto;
flex-direction: column;
gap: 10px;
padding: 11px 12px !important;
gap: 8px;
padding: 12px 12px 12px !important;
}
.workflow-task-card__status-row {
@@ -625,77 +587,20 @@ const executionStatus = computed(() => {
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;
flex: 0 0 auto;
block-size: 5px;
inline-size: 100%;
overflow: hidden;
background: rgba(var(--v-theme-on-surface), 0.1);
}
.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;
.workflow-task-card__progress-fill {
display: block;
block-size: 100%;
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);
transition: inline-size 0.3s ease;
}
.workflow-task-card__execution-status {
@@ -704,8 +609,6 @@ const executionStatus = computed(() => {
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 {
@@ -721,7 +624,7 @@ const executionStatus = computed(() => {
@media (width <= 599.98px) {
.workflow-task-card {
min-block-size: 222px;
min-block-size: 170px;
}
.workflow-task-card__header,
@@ -730,7 +633,7 @@ const executionStatus = computed(() => {
}
.workflow-task-card__body {
gap: 9px;
gap: 7px;
}
}
</style>
@@ -108,7 +108,7 @@ describe('WorkflowTaskCard redesign', () => {
expect(container.querySelector('.v-chip')).toHaveTextContent(label)
})
it('renders structured node states as segmented action progress', async () => {
it('renders the global runtime progress on the bottom progress bar', async () => {
const { container } = await renderCard({
execution_state: {
nodes: {
@@ -116,27 +116,52 @@ describe('WorkflowTaskCard redesign', () => {
scrape: { state: 'skipped' },
transfer: { state: 'running' },
},
runtime: { finished_actions: 2 },
runtime: { finished_actions: 2, progress: 66 },
},
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')
const fill = container.querySelector('.workflow-task-card__progress-fill') as HTMLElement
expect(fill.style.inlineSize).toBe('66%')
expect(screen.getByRole('progressbar', { name: '扫描和刮削' })).toHaveAttribute('aria-valuenow', '66')
})
it('falls back to unique legacy current-action ids and clamps the count', async () => {
it('falls back to legacy current-action counts and clamps the progress', 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)
const fill = container.querySelector('.workflow-task-card__progress-fill') as HTMLElement
expect(fill.style.inlineSize).toBe('67%')
expect(screen.getByText('上次执行尚未完成')).toBeInTheDocument()
})
it('shows the run count after the last execution time', async () => {
const lastTime = '2026-08-03 08:00:00'
const { rerender } = await renderCard({ last_time: lastTime, run_count: 3, state: 'S' })
expect(screen.getByText(`上次执行 ${formatDateDifference(lastTime)} · 已执行 3 次`)).toBeInTheDocument()
await rerender({
eventTypes: [],
workflow: createWorkflow({ last_time: lastTime, run_count: 0, state: 'S' }),
})
expect(screen.getByText(`上次执行 ${formatDateDifference(lastTime)}`)).toBeInTheDocument()
})
it('always renders the progress bar at the bottom of the card', async () => {
const { container, rerender } = await renderCard({ state: 'W' })
const idleBar = container.querySelector('.workflow-task-card__progress') as HTMLElement
expect(idleBar).toBeInTheDocument()
expect(idleBar).toHaveAttribute('aria-valuenow', '0')
await rerender({
eventTypes: [],
workflow: createWorkflow({ execution_state: { runtime: { progress: 100 } }, state: 'S' }),
})
expect(container.querySelector('.workflow-task-card__progress')).toHaveAttribute('aria-valuenow', '100')
})
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' })
@@ -589,12 +589,17 @@ const isMacOS = computed(() => {
}
.workflow-edge-panel {
--workflow-edge-panel-background: rgb(var(--v-theme-surface));
--workflow-edge-panel-backdrop-filter: none;
position: absolute;
z-index: 120;
display: flex;
flex-direction: column;
padding: 16px;
background-color: rgb(var(--v-theme-surface));
-webkit-backdrop-filter: var(--workflow-edge-panel-backdrop-filter);
backdrop-filter: var(--workflow-edge-panel-backdrop-filter);
background-color: var(--workflow-edge-panel-background);
gap: 14px;
inline-size: min(360px, calc(100vw - 32px));
inset-block-start: 20px;
+19 -8
View File
@@ -187,10 +187,21 @@ onMounted(() => {
@use 'sass:color';
.workflow-sidebar {
--workflow-sidebar-background: rgb(var(--v-theme-background));
--workflow-sidebar-backdrop-filter: none;
--workflow-sidebar-header-background: rgb(var(--v-theme-background));
--workflow-sidebar-footer-background: rgb(var(--v-theme-background));
--workflow-sidebar-border-color: rgba(var(--v-theme-on-background), 0.06);
--workflow-sidebar-card-background: rgb(var(--v-theme-surface-variant));
--workflow-sidebar-card-background-hover: rgb(var(--v-theme-surface-variant));
--workflow-sidebar-desc-color: rgba(var(--v-theme-on-background), 0.55);
position: absolute;
z-index: 100;
overflow: hidden;
background-color: rgb(var(--v-theme-background));
-webkit-backdrop-filter: var(--workflow-sidebar-backdrop-filter);
backdrop-filter: var(--workflow-sidebar-backdrop-filter);
background-color: var(--workflow-sidebar-background);
box-shadow: 0 0 15px rgba(0, 0, 0, 8%);
inline-size: 280px;
inset-block: 0;
@@ -220,8 +231,8 @@ onMounted(() => {
.sidebar-header {
flex-shrink: 0;
padding: 16px;
background-color: rgb(var(--v-theme-background));
border-block-end: 1px solid rgba(var(--v-theme-on-background), 0.06);
background-color: var(--workflow-sidebar-header-background);
border-block-end: 1px solid var(--workflow-sidebar-border-color);
.header-content {
position: relative;
@@ -281,11 +292,11 @@ onMounted(() => {
display: flex;
align-items: center;
padding: 10px;
background-color: rgb(var(--v-theme-surface-variant));
background-color: var(--workflow-sidebar-card-background);
transition: all 0.2s ease;
&:hover {
background-color: rgb(var(--v-theme-surface-variant));
background-color: var(--workflow-sidebar-card-background-hover);
transform: translateY(-2px);
}
}
@@ -318,7 +329,7 @@ onMounted(() => {
.component-desc {
overflow: hidden;
color: #71717a;
color: var(--workflow-sidebar-desc-color);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
@@ -327,8 +338,8 @@ onMounted(() => {
.sidebar-footer {
flex-shrink: 0;
padding: 12px;
background-color: rgb(var(--v-theme-background));
border-block-start: 1px solid rgba(0, 0, 0, 6%);
background-color: var(--workflow-sidebar-footer-background);
border-block-start: 1px solid var(--workflow-sidebar-border-color);
.drag-btn {
background-color: rgb(var(--v-theme-primary));
+1 -3
View File
@@ -948,16 +948,14 @@ export default {
timer: 'Timer',
status: 'Status',
actionCount: 'Action Count',
runCount: 'Run Count',
runCount: 'Run {count} times',
progress: 'Progress',
actionProgress: 'Action Progress',
error: 'Error Message',
manualTrigger: 'Manual',
lastExecuted: 'Last run {time}',
neverExecuted: 'Never run',
executionIncomplete: 'Previous run is incomplete',
executingAction: 'Running {name}',
preparingAction: 'Preparing action {current}',
noActions: 'No actions',
},
},
+1 -3
View File
@@ -937,16 +937,14 @@ export default {
timer: '定时',
status: '状态',
actionCount: '动作数',
runCount: '已执行次',
runCount: '已执行 {count} 次',
progress: '进度',
actionProgress: '动作进度',
error: '错误信息',
manualTrigger: '手动',
lastExecuted: '上次执行 {time}',
neverExecuted: '从未执行',
executionIncomplete: '上次执行尚未完成',
executingAction: '正在执行 {name}',
preparingAction: '正在准备第 {current} 个动作',
noActions: '暂无动作',
},
},
+1 -3
View File
@@ -937,16 +937,14 @@ export default {
timer: '定時器',
status: '狀態',
actionCount: '動作數量',
runCount: '執行次',
runCount: '執行 {count} 次',
progress: '進度',
actionProgress: '動作進度',
error: '錯誤訊息',
manualTrigger: '手動',
lastExecuted: '上次執行 {time}',
neverExecuted: '從未執行',
executionIncomplete: '上次執行尚未完成',
executingAction: '正在執行 {name}',
preparingAction: '正在準備第 {current} 個動作',
noActions: '暫無動作',
},
},
+18
View File
@@ -1008,6 +1008,24 @@ html[data-theme='glass'] {
--workflow-card-header-shadow: inset 0 1px 0 var(--glass-highlight), inset 0 -1px 0 rgba(2, 6, 16, 14%);
}
// 工作流编辑器组件选择栏
.workflow-sidebar {
--workflow-sidebar-background: var(--glass-surface);
--workflow-sidebar-backdrop-filter: var(--glass-surface-backdrop-filter);
--workflow-sidebar-header-background: transparent;
--workflow-sidebar-footer-background: transparent;
--workflow-sidebar-border-color: var(--glass-border);
--workflow-sidebar-card-background: var(--glass-control);
--workflow-sidebar-card-background-hover: var(--glass-button-surface-hover);
--workflow-sidebar-desc-color: var(--glass-control-placeholder-color);
}
// 工作流编辑器流程边设置面板
.workflow-edge-panel {
--workflow-edge-panel-background: var(--glass-surface);
--workflow-edge-panel-backdrop-filter: var(--glass-surface-backdrop-filter);
}
// 分享卡保留原随机渐变的色相,以半透明染色、吸光和镜面层组成有色玻璃。
.workflow-share-card {
--workflow-share-glass-start-opacity: calc(0.18 + var(--glass-tint-density, 0.65) * 0.38);
+37 -20
View File
@@ -3,7 +3,7 @@
/* stylelint-disable scss/at-rule-no-unknown */
// 透明主题专用样式
html[data-theme="transparent"] {
html[data-theme='transparent'] {
// 定义透明度变量
--transparent-opacity: 0.3;
--transparent-opacity-light: 0.2;
@@ -21,10 +21,13 @@ html[data-theme="transparent"] {
--app-grouped-list-icon-opacity: 0.88;
// 应用布局主内容区域
.v-application, .v-layout, .v-main, .layout-page-content {
.v-application,
.v-layout,
.v-main,
.layout-page-content {
background: transparent;
}
// 侧边导航栏
.layout-vertical-nav {
backdrop-filter: blur(var(--transparent-blur-heavy));
@@ -37,7 +40,7 @@ html[data-theme="transparent"] {
backdrop-filter: blur(var(--transparent-blur));
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity));
}
// 卡片
.v-card:not(.no-blur) {
backdrop-filter: blur(var(--transparent-blur));
@@ -67,6 +70,24 @@ html[data-theme="transparent"] {
--workflow-card-header-shadow: inset 0 1px 0 rgba(var(--v-theme-on-surface), 0.14);
}
// 工作流编辑器组件选择栏
.workflow-sidebar {
--workflow-sidebar-background: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy));
--workflow-sidebar-backdrop-filter: blur(var(--transparent-blur-heavy));
--workflow-sidebar-header-background: transparent;
--workflow-sidebar-footer-background: transparent;
--workflow-sidebar-border-color: rgba(var(--v-theme-on-surface), 0.08);
--workflow-sidebar-card-background: rgba(var(--v-theme-surface), var(--transparent-opacity));
--workflow-sidebar-card-background-hover: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy));
--workflow-sidebar-desc-color: rgba(var(--v-theme-on-surface), 0.55);
}
// 工作流编辑器流程边设置面板
.workflow-edge-panel {
--workflow-edge-panel-background: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy));
--workflow-edge-panel-backdrop-filter: blur(var(--transparent-blur-heavy));
}
// 订阅文件弹窗使用独立的玻璃表面参数避免组件内通过全局选择器穿透主题
.subscribe-files-dialog {
--sfd-accent-opacity: 0.12;
@@ -114,7 +135,7 @@ html[data-theme="transparent"] {
backdrop-filter: blur(var(--transparent-blur));
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity));
}
// 表格
.v-table {
border-radius: 0;
@@ -124,7 +145,7 @@ html[data-theme="transparent"] {
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity));
}
}
// 页脚
.v-footer {
backdrop-filter: blur(var(--transparent-blur));
@@ -136,17 +157,17 @@ html[data-theme="transparent"] {
backdrop-filter: blur(var(--transparent-blur));
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity));
}
// 页面容器
.layout-content-wrapper {
background: transparent;
}
// 无内容区域的背景设为透明
.page-content-container {
background: transparent;
}
// 对话框和菜单蒙层样式
.v-overlay__scrim {
background: rgba(var(--v-overlay-scrim-background), var(--v-overlay-scrim-opacity));
@@ -167,7 +188,7 @@ html[data-theme="transparent"] {
.v-field {
background-color: rgba(var(--v-theme-surface), 0);
}
// 弹出层内容
.v-overlay__content {
border-radius: var(--app-surface-radius);
@@ -206,11 +227,7 @@ html[data-theme="transparent"] {
.theme-customizer-shadow-slider,
.theme-customizer-shadow-slider__sample {
background:
linear-gradient(
180deg,
rgba(var(--v-theme-on-surface), 0.02),
rgba(var(--v-theme-on-surface), 0.05)
),
linear-gradient(180deg, rgba(var(--v-theme-on-surface), 0.02), rgba(var(--v-theme-on-surface), 0.05)),
rgba(var(--v-theme-surface), var(--transparent-opacity-light));
}
@@ -259,9 +276,9 @@ html[data-theme="transparent"] {
),
rgba(var(--v-theme-surface), var(--transparent-opacity-heavy)) !important;
}
}
}
html[data-theme="transparent"].transparent-background-blur-disabled {
html[data-theme='transparent'].transparent-background-blur-disabled {
.global-blur-layer,
.background-image.active {
backdrop-filter: none;
@@ -270,7 +287,7 @@ html[data-theme="transparent"].transparent-background-blur-disabled {
}
}
html[data-theme="transparent"].transparent-glass-lightweight {
html[data-theme='transparent'].transparent-glass-lightweight {
--app-grouped-list-backdrop-filter: none;
.global-blur-layer {
@@ -334,7 +351,7 @@ html[data-theme="transparent"].transparent-glass-lightweight {
}
}
html[data-theme="transparent"].transparent-glass-realtime {
html[data-theme='transparent'].transparent-glass-realtime {
--app-grouped-list-backdrop-filter: none;
.background-image.active {
@@ -379,7 +396,7 @@ html[data-theme="transparent"].transparent-glass-realtime {
}
}
html[data-theme="transparent"].transparent-blur-disabled {
html[data-theme='transparent'].transparent-blur-disabled {
--app-grouped-list-backdrop-filter: none;
.layout-vertical-nav,