首页工作流表单卡片化闭环:执行进度、输入禁用与取消入口

- socket 节点事件推进表单卡片执行进度,失败详情展示详细原因
- 工作流执行时禁用输入框,取消入口移到卡片 footer,卡片定格可重试
- 工作流 ModelRequestParams 按详情原样透传后端
- 会话内产出文件卡片展示(预览/下载/删除),移除顶部结果卡片区

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-15 21:36:33 +08:00
co-authored by Claude
parent dc20a6a829
commit bb15c053e6
9 changed files with 495 additions and 376 deletions
+22
View File
@@ -19,7 +19,10 @@
:readonly="msg.formStatus === 'done' || msg.formStatus === 'failed'"
:submitting="msg.formStatus === 'running'"
:form-error="msg.formError"
:progress="msg.formProgress"
@submit="emit('form-submit', msg, $event)"
@re-edit="emit('form-re-edit', msg)"
@cancel="emit('form-cancel', msg)"
/>
<template v-else>
<div class="bubble">
@@ -53,6 +56,14 @@
</button>
</template>
</div>
<!-- 工作流执行产出文件卡片挂在结果消息上预览/下载/删除 -->
<WorkflowOutputCard
v-if="msg.outputs && msg.outputs.length"
:outputs="msg.outputs"
@preview="emit('output-preview', msg, $event)"
@download="emit('output-download', msg, $event)"
@delete="emit('output-delete', msg, $event)"
/>
<!-- AI 消息操作栏hover 显示 -->
<div v-if="!msg.isUser && msg.content && !isErrorMsg(msg)" class="msg-actions">
<button v-if="msg.recordType !== 'workflow'" class="msg-action" title="重新生成" @click="emit('regenerate', msg)">
@@ -84,7 +95,9 @@ import { ElMessage } from 'element-plus';
import { Delete, DocumentCopy, RefreshRight } from '@element-plus/icons-vue';
import 'highlight.js/styles/github-dark.css';
import { renderMarkdown } from '../utils/markdown';
import type { WorkflowOutput } from '../utils/flowDsl';
import WorkflowFormCard from './WorkflowFormCard.vue';
import WorkflowOutputCard from './WorkflowOutputCard.vue';
interface ChatMessage {
id: string;
@@ -103,6 +116,10 @@ interface ChatMessage {
form?: any;
formStatus?: 'editing' | 'running' | 'done' | 'failed';
formError?: string;
// 工作流执行节点进度(后端 node_start/node_complete 事件推进,运行中显示)
formProgress?: { current: number; total: number; nodeName: string };
// 工作流执行产出文件列表(挂在 workflow 结果消息上,渲染产出卡片)
outputs?: WorkflowOutput[];
}
interface Emits {
@@ -110,6 +127,11 @@ interface Emits {
(e: 'regenerate', msg: ChatMessage): void;
(e: 'delete', msg: ChatMessage): void;
(e: 'form-submit', msg: ChatMessage, payload: any): void;
(e: 'form-re-edit', msg: ChatMessage): void;
(e: 'form-cancel', msg: ChatMessage): void;
(e: 'output-preview', msg: ChatMessage, output: WorkflowOutput): void;
(e: 'output-download', msg: ChatMessage, output: WorkflowOutput): void;
(e: 'output-delete', msg: ChatMessage, output: WorkflowOutput): void;
(e: 'load-more'): void;
}
+21 -9
View File
@@ -7,7 +7,7 @@
<div class="selected-tag workflow-tag">
<el-icon><Promotion /></el-icon>
<span>{{ commonWorkflows.find((w) => w.id === selectedWorkflowId)?.name }}</span>
<el-icon v-if="!workflowLocked" class="tag-close" @click="clearWorkflow"><Close /></el-icon>
<el-icon v-if="!lockAll" class="tag-close" @click="clearWorkflow"><Close /></el-icon>
</div>
</div>
</Transition>
@@ -17,7 +17,8 @@
v-model="message"
type="textarea"
:autosize="{ minRows: 1, maxRows: 6 }"
placeholder="有什么想聊的?按 Enter 发送,Shift+Enter 换行"
:disabled="generating || isWorkflowRunning"
:placeholder="inputPlaceholder"
class="message-input"
@focus="isFocused = true"
@blur="isFocused = false"
@@ -49,11 +50,11 @@
<span class="hint-text">Shift+Enter 换行</span>
<button
class="send-btn"
:class="{ 'is-stop': generating }"
:disabled="!generating && sendDisabled"
@click="generating ? emit('stop') : handleSend()"
:class="{ 'is-stop': generating && !isWorkflowRunning }"
:disabled="isWorkflowRunning || (!generating && sendDisabled)"
@click="generating && !isWorkflowRunning ? emit('stop') : handleSend()"
>
<el-icon v-if="generating"><VideoPause /></el-icon>
<el-icon v-if="generating && !isWorkflowRunning"><VideoPause /></el-icon>
<el-icon v-else><Top /></el-icon>
</button>
</div>
@@ -92,6 +93,8 @@ interface Props {
generating?: boolean;
// 当前会话模型名称,用于输入栏切换入口展示
currentModelName?: string;
// 工作流正在执行中:输入框/发送按钮整体禁用,停止按钮转移到表单卡片 footer
isWorkflowRunning?: boolean;
}
interface Emits {
@@ -115,6 +118,7 @@ const props = withDefaults(defineProps<Props>(), {
hideShortcuts: false,
generating: false,
currentModelName: '',
isWorkflowRunning: false,
});
const emit = defineEmits<Emits>();
const message = ref('');
@@ -129,6 +133,14 @@ const visibleWorkflows = computed(() => {
return commonWorkflows.value;
});
// 输入框整体锁定:选择了工作流(不可取消)或正处于生成/工作流执行中
const lockAll = computed(() => props.workflowLocked || props.isWorkflowRunning || props.generating);
// 输入框占位符:工作流执行中给出明确提示
const inputPlaceholder = computed(() =>
props.isWorkflowRunning ? '工作流执行中,请稍候...' : '有什么想聊的?按 Enter 发送,Shift+Enter 换行',
);
const fetchWorkflows = async () => {
try {
const res = await getWorkflowList();
@@ -163,7 +175,7 @@ const handleAttachment = () => {
};
const toggleWorkflow = (id: string) => {
if (props.workflowLocked) return;
if (lockAll.value) return;
const item = commonWorkflows.value.find((w) => w.id === id);
const newId = selectedWorkflowId.value === id ? null : id;
selectedWorkflowId.value = newId;
@@ -177,13 +189,13 @@ const resetAll = () => {
};
const clearWorkflow = () => {
if (props.workflowLocked) return;
if (lockAll.value) return;
selectedWorkflowId.value = null;
emit('workflow-select', null);
};
const selectWorkflow = (id: string | null) => {
if (props.workflowLocked) return;
if (lockAll.value) return;
selectedWorkflowId.value = id;
emit('workflow-select', id, false);
};
+12 -22
View File
@@ -1,17 +1,9 @@
<template>
<div class="main-content">
<Transition name="content-fade" mode="out-in">
<!-- 对话页 有消息或会话结果时展示结果卡片工作流结果时显示+ 消息流
<!-- 对话页 有消息或会话结果时展示消息流工作流表单卡片/结果/产出统一在此
key 绑定会话 id切换会话时重建内容区触发 Transition 过渡动画固定 key 会复用 DOM 不触发 -->
<div v-if="hasMessages || hasResults" :key="activeHistoryId || 'chat'" class="content-body chat-body">
<SessionResults
v-if="hasWorkflowResults"
:results="results"
:loading="resultsLoading"
:has-more="hasMore"
:loading-more="loadingMore"
@load-more="emit('load-more', activeHistoryId)"
/>
<ChatList
:messages="messages"
:has-more="hasMore"
@@ -21,6 +13,11 @@
@regenerate="emit('regenerate', $event)"
@delete="emit('delete', $event)"
@form-submit="(msg: any, payload: any) => emit('form-submit', msg, payload)"
@form-re-edit="(msg: any) => emit('form-re-edit', msg)"
@form-cancel="(msg: any) => emit('form-cancel', msg)"
@output-preview="(msg: any, output: any) => emit('output-preview', msg, output)"
@output-download="(msg: any, output: any) => emit('output-download', msg, output)"
@output-delete="(msg: any, output: any) => emit('output-delete', msg, output)"
@load-more="emit('load-more', activeHistoryId)"
/>
</div>
@@ -56,15 +53,14 @@ import { computed, onMounted, ref } from 'vue';
import { getWorkflowList } from '/@/api/settings/creation';
import { Promotion } from '@element-plus/icons-vue';
import ChatList from './ChatList.vue';
import SessionResults from './SessionResults.vue';
import type { VOSessionInfoResult } from '/@/api/settings/workflow/session';
interface Props {
activeMenu: string;
activeHistoryId?: string | null;
messages?: any[];
// 会话内结果(session/get):仅用于判断「对话页 vs 占位页」,结果渲染统一走消息流
results?: VOSessionInfoResult[];
resultsLoading?: boolean;
hasMore?: boolean;
loadingMore?: boolean;
}
@@ -75,6 +71,11 @@ interface Emits {
(e: 'workflow-select', id: string, isTemplate?: boolean): void;
(e: 'delete', msg: any): void;
(e: 'form-submit', msg: any, payload: any): void;
(e: 'form-re-edit', msg: any): void;
(e: 'form-cancel', msg: any): void;
(e: 'output-preview', msg: any, output: any): void;
(e: 'output-download', msg: any, output: any): void;
(e: 'output-delete', msg: any, output: any): void;
(e: 'load-more', sid: string | null | undefined): void;
}
@@ -82,7 +83,6 @@ const props = withDefaults(defineProps<Props>(), {
activeHistoryId: null,
messages: () => [],
results: () => [],
resultsLoading: false,
hasMore: false,
loadingMore: false,
});
@@ -90,7 +90,6 @@ const emit = defineEmits<Emits>();
const hasMessages = computed(() => Array.isArray(props.messages) && props.messages.length > 0);
const hasResults = computed(() => Array.isArray(props.results) && props.results.length > 0);
const hasWorkflowResults = computed(() => Array.isArray(props.results) && props.results.some((r) => r.type === 'workflow'));
// 占位页工作流卡片(DeepSeek 式引导:居中标题 + 工作流卡片)
const placeWorkflows = ref<any[]>([]);
@@ -159,15 +158,6 @@ onMounted(() => {
&::-webkit-scrollbar { display: none; }
}
/* 会话结果区块:固定在消息流上方,结果多时内部滚动(不挤压消息流) */
.chat-body :deep(.session-results) {
flex-shrink: 0;
max-height: 42%;
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
/* 消息流填充剩余高度并在内部滚动 */
.chat-body :deep(.chat-list-scroll) {
flex: 1;
@@ -1,288 +0,0 @@
<template>
<div ref="resultsRef" class="session-results" @scroll.passive="handleScroll">
<div class="results-header">
<span class="results-title">会话结果</span>
<span v-if="results.length" class="results-count">{{ results.length }}</span>
</div>
<!-- 加载中 -->
<div v-if="loading && results.length === 0" class="results-empty">加载中</div>
<!-- 空态 -->
<div v-else-if="results.length === 0" class="results-empty">暂无会话结果</div>
<!-- 结果列表 -->
<div v-else class="results-list">
<div v-for="r in results" :key="r.id" class="result-card" :class="`is-${statusKey(r)}`">
<div class="result-main">
<span class="result-type">{{ r.type === 'workflow' ? '工作流' : '对话' }}</span>
<span class="result-status" :class="`is-${statusKey(r)}`">
<i v-if="!r.errorMsg && r.status === 1" class="status-dot"></i>{{ statusText(r) }}
</span>
<span v-if="r.totalTokens" class="result-meta">{{ r.totalTokens }} tokens</span>
<span v-if="r.totalFee" class="result-meta">¥{{ r.totalFee }}</span>
<span class="result-time">{{ r.createdAt || '' }}</span>
</div>
<!-- 问题 / 参数摘要 -->
<div v-if="getQuestionText(r)" class="result-question">{{ getQuestionText(r) }}</div>
<!-- AI 回复resultContent 字段即回复文本直接展示 -->
<div v-if="r.resultContent" class="result-reply">
<div class="result-reply-text">{{ r.resultContent }}</div>
</div>
<div v-if="r.errorMsg" class="result-sub">
<span class="result-error"> {{ r.errorMsg }}</span>
</div>
</div>
</div>
<!-- 底部有更多结果时滚动加载 -->
<div v-if="hasMore || loadingMore" class="results-load-more">
{{ loadingMore ? '加载中' : '继续下滚加载更早的结果' }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import type { VOSessionInfoResult } from '/@/api/settings/workflow/session';
interface Props {
results: VOSessionInfoResult[];
loading?: boolean;
hasMore?: boolean;
loadingMore?: boolean;
}
interface Emits {
(e: 'load-more'): void;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
hasMore: false,
loadingMore: false,
});
const emit = defineEmits<Emits>();
const resultsRef = ref<HTMLElement | null>(null);
// 滚动到底部时触发加载更早结果(防重入由父组件 sessionLoadingMore 保证)
const handleScroll = () => {
const el = resultsRef.value;
if (!el || !props.hasMore || props.loadingMore) return;
if (el.scrollHeight - el.scrollTop - el.clientHeight <= 4) emit('load-more');
};
// 状态键:以 errorMsg 为准(部分失败记录 status 仍是 1,仅凭 status 会误判为运行中);
// 无错误时按 status:2-成功, 其余运行中
const statusKey = (r: VOSessionInfoResult): string => {
if (r.errorMsg) return 'failed';
if (r.status === 2) return 'success';
return 'running';
};
const statusText = (r: VOSessionInfoResult): string => {
if (r.errorMsg) return '失败';
if (r.status === 2) return '成功';
return '运行中';
};
// 卡片问题/参数摘要:chat 取 requestParams.questionworkflow 不再平铺 flowContent JSON
// 改为精简节点摘要(完整表单与结果已在消息流中,此处只需让结果卡片保持简洁)
const getQuestionText = (r: VOSessionInfoResult): string => {
const q = r.requestParams?.question;
if (q) return String(q);
if (r.type === 'workflow') {
const nodes = Array.isArray(r.requestParams?.nodes) ? r.requestParams.nodes : [];
return nodes.length > 0 ? `包含 ${nodes.length} 个节点` : '';
}
const params = r.requestParams;
if (params && Object.keys(params).length > 0) {
try {
return JSON.stringify(params);
} catch {
return '';
}
}
return '';
};
</script>
<style scoped lang="scss">
.session-results {
width: 100%;
max-width: 880px;
margin: 0 auto;
padding: 12px 0 4px;
flex-shrink: 0;
}
.results-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.results-title {
font-size: 12px;
font-weight: 600;
color: #94a3b8;
letter-spacing: 0.4px;
}
.results-count {
font-size: 11px;
color: #cbd5e1;
}
.results-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.results-empty {
font-size: 12px;
color: #cbd5e1;
padding: 8px 0;
}
.results-load-more {
font-size: 11px;
color: #cbd5e1;
text-align: center;
padding: 8px 0 4px;
}
.result-card {
background: #fff;
border: 1px solid #eef0f3;
border-radius: 10px;
padding: 8px 12px;
display: flex;
flex-direction: column;
gap: 4px;
transition: border-color 0.15s, box-shadow 0.15s;
&.is-running {
border-color: #dbeafe;
background: #f8fbff;
}
&.is-failed {
border-color: #fee2e2;
background: #fef6f6;
}
}
.result-main {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.result-type {
font-size: 11px;
font-weight: 600;
color: #64748b;
background: #f1f5f9;
border-radius: 4px;
padding: 1px 6px;
}
.result-status {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
&.is-running {
color: #2563eb;
}
&.is-success {
color: #16a34a;
}
&.is-failed {
color: #dc2626;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #2563eb;
animation: status-pulse 1.2s ease-in-out infinite;
}
}
@keyframes status-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.result-meta {
font-size: 11px;
color: #94a3b8;
}
.result-time {
font-size: 11px;
color: #cbd5e1;
margin-left: auto;
}
.result-sub {
display: flex;
align-items: center;
gap: 10px;
}
.result-error {
font-size: 12px;
color: #dc2626;
flex: 1;
word-break: break-all;
}
.result-error {
font-size: 12px;
color: #dc2626;
word-break: break-all;
}
.result-question {
font-size: 13px;
font-weight: 600;
color: #1e293b;
padding: 2px 0;
word-break: break-all;
}
/* AI 回复区(txt 内容) */
.result-reply {
margin-top: 2px;
}
.result-reply-text {
font-size: 13px;
color: #334155;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow-y: auto;
background: #f8fafc;
border: 1px solid #f1f5f9;
border-radius: 8px;
padding: 8px 10px;
&::-webkit-scrollbar { width: 4px; }
&::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
}
</style>
+64 -4
View File
@@ -141,11 +141,23 @@
<!-- 底部操作区:editing 提交 / running 执行中 / done·failed 定格状态 -->
<div class="workflow-form-footer">
<div v-if="isFailed" class="form-error-text">{{ formError }}</div>
<el-button v-if="submitting" type="primary" loading disabled>执行中...</el-button>
<!-- 执行中:节点文本进度推进(后端 node_start/node_complete 事件),无进度时兜底「执行中...」 -->
<!-- 取消执行:工作流执行中的终止入口(InputBar 停止按钮对工作流禁用,改由卡片 footer 提供) -->
<div v-if="submitting" class="executing-progress">
<span class="exec-spinner" />
<span class="exec-text">{{ progressText }}</span>
<el-button size="small" class="exec-cancel-btn" @click="emit('cancel')">取消执行</el-button>
</div>
<el-button v-else-if="!readonly" type="primary" :disabled="submitting" @click="handleSubmit">提交执行</el-button>
<el-tag v-else :type="isFailed ? 'danger' : 'success'" size="small" effect="light">
{{ isFailed ? '执行失败' : '执行完成' }}
</el-tag>
<template v-else>
<el-tag :type="isFailed ? 'danger' : 'success'" size="small" effect="light">
{{ isFailed ? '执行失败' : '执行完成' }}
</el-tag>
<!-- 失败卡片可「重新编辑并执行」:保留已填值切回可编辑,改完重新提交 -->
<el-button v-if="isFailed" size="small" @click="emit('re-edit')">
<el-icon><RefreshRight /></el-icon>重新编辑并执行
</el-button>
</template>
</div>
</div>
</template>
@@ -153,6 +165,7 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { RefreshRight } from '@element-plus/icons-vue';
import PatchTemplateEditor from '/@/components/patchTemplate/PatchTemplateEditor.vue';
import { uploadFile } from '/@/api/common/upload';
import { collectHomeFormFields } from '../utils/flowDsl';
@@ -166,16 +179,23 @@ interface Props {
submitting?: boolean;
// 执行失败信息(有值即 failed 态)
formError?: string;
// 执行进度(后端 node_start/node_complete 事件推进):当前节点序 / 总节点数 / 节点名
progress?: { current: number; total: number; nodeName: string };
}
interface Emits {
(e: 'submit', payload: { formValues: Record<string, any>; formFileNames: Record<string, string | string[]>; templates: any[] }): void;
// 失败卡片「重新编辑并执行」:由父组件把 formStatus 切回 editing,保留已填值
(e: 're-edit'): void;
// 执行中取消:工作流取消入口(InputBar 停止按钮对工作流禁用)
(e: 'cancel'): void;
}
const props = withDefaults(defineProps<Props>(), {
readonly: false,
submitting: false,
formError: '',
progress: () => ({ current: 0, total: 0, nodeName: '' }),
});
const emit = defineEmits<Emits>();
@@ -190,6 +210,16 @@ const templates = ref<any[]>([]);
const isFailed = computed(() => !!props.formError);
const isDisabled = computed(() => props.readonly || props.submitting);
// 执行进度文本:有节点进度(current/total>0)显示「正在执行 N/M:节点名」,否则兜底「执行中...」
const progressText = computed(() => {
const p = props.progress;
if (p && p.total > 0 && p.current > 0) {
const name = p.nodeName ? `${p.nodeName}` : '';
return `正在执行 ${p.current}/${p.total}${name}...`;
}
return '执行中...';
});
const getFieldKey = (node: any, field: any): string => {
const id = node.id || node.nodeCode;
return `${id}|${field.path || field.field || field.label}`;
@@ -551,10 +581,40 @@ const handleSubmit = () => {
font-size: 12px;
color: #ef4444;
line-height: 1.5;
white-space: pre-wrap; /* 失败详情可能含换行(error 字段多行原因),允许换行展示 */
word-break: break-word;
}
}
/* 执行进度:spinner + 文本,节点事件推进 */
.executing-progress {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
.exec-spinner {
width: 14px;
height: 14px;
flex-shrink: 0;
border: 2px solid #bfdbfe;
border-top-color: #2563eb;
border-radius: 50%;
animation: exec-spin 0.8s linear infinite;
}
.exec-text {
font-size: 13px;
color: #1e293b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.exec-cancel-btn {
margin-left: auto;
flex-shrink: 0;
}
}
@keyframes exec-spin {
to { transform: rotate(360deg); }
}
.w100 { width: 100%; }
@@ -0,0 +1,148 @@
<template>
<div class="workflow-output-card">
<div v-for="(out, i) in outputs" :key="i" class="output-item">
<el-icon class="output-icon" :class="`is-${out.type || 'file'}`">
<component :is="getIcon(out.type)" />
</el-icon>
<span class="output-name" :title="out.name">{{ out.name }}</span>
<span class="output-actions">
<button v-if="out.type !== 'text'" type="button" class="output-action" title="预览" @click="emit('preview', out)">
<el-icon><View /></el-icon>
</button>
<button type="button" class="output-action" title="下载" @click="emit('download', out)">
<el-icon><Download /></el-icon>
</button>
<button type="button" class="output-action is-danger" title="删除" @click="emit('delete', out)">
<el-icon><Delete /></el-icon>
</button>
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { Picture, VideoPlay, Headset, Document, View, Download, Delete } from '@element-plus/icons-vue';
// 产出文件:url 文件地址(相对/绝对)、name 展示名、type 预览类型(image/video/audio/text/file)、backendId 执行记录 id
interface WorkflowOutput {
url: string;
name: string;
type: string;
backendId: string;
}
interface Props {
outputs: WorkflowOutput[];
}
interface Emits {
(e: 'preview', output: WorkflowOutput): void;
(e: 'download', output: WorkflowOutput): void;
(e: 'delete', output: WorkflowOutput): void;
}
defineProps<Props>();
const emit = defineEmits<Emits>();
// 按文件类型选图标(与工作空间侧边栏一致)
const getIcon = (type: string) => {
const t = String(type || '').toLowerCase();
if (t === 'image') return Picture;
if (t === 'video') return VideoPlay;
if (t === 'audio') return Headset;
return Document;
};
</script>
<style scoped lang="scss">
.workflow-output-card {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 10px;
background: #f8fafc;
border: 1px solid #f1f5f9;
border-radius: 10px;
padding: 8px;
}
.output-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 8px;
transition: background 0.15s;
&:hover {
background: #eef2f7;
.output-actions {
opacity: 1;
}
}
}
.output-icon {
font-size: 16px;
flex-shrink: 0;
&.is-image {
color: #2563eb;
}
&.is-video {
color: #7c3aed;
}
&.is-audio {
color: #d97706;
}
&.is-file,
&.is-text {
color: #64748b;
}
}
.output-name {
flex: 1;
font-size: 13px;
color: #334155;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.output-actions {
display: flex;
gap: 2px;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s;
}
.output-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
border-radius: 6px;
background: transparent;
color: #94a3b8;
cursor: pointer;
transition: background 0.15s, color 0.15s;
&:hover {
background: #e2e8f0;
color: #475569;
}
&.is-danger:hover {
background: #fef2f2;
color: #dc2626;
}
.el-icon {
font-size: 13px;
}
}
</style>