首页工作流表单卡片化闭环:执行进度、输入禁用与取消入口
- socket 节点事件推进表单卡片执行进度,失败详情展示详细原因 - 工作流执行时禁用输入框,取消入口移到卡片 footer,卡片定格可重试 - 工作流 ModelRequestParams 按详情原样透传后端 - 会话内产出文件卡片展示(预览/下载/删除),移除顶部结果卡片区 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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.question;workflow 不再平铺 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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user