首页工作流:统一执行开关至发送按钮,最后一张已执行卡片参数可编辑

- 工作流表单卡片移除执行/重跑按钮,执行统一由 InputBar 发送按钮触发
- 会话执行过工作流即为工作流对话,下方输入框永久禁止文本输入
- 执行中发送按钮变停止,复用 teardownRound 按 runTypeMap 区分协议
- 最后一张已执行(done/failed)卡片参数保持可编辑,改参后经发送按钮重新执行(含必填校验)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-21 18:13:47 +08:00
co-authored by Claude
parent cb6792cb29
commit 918ae96f71
5 changed files with 129 additions and 38 deletions
+12 -1
View File
@@ -15,8 +15,10 @@
<!-- 工作流表单卡片不套气泡独立卡片渲染 -->
<WorkflowFormCard
v-if="msg.type === 'form'"
:execute-signal="executeRequest?.msgId === msg.id ? executeRequest : null"
:detail="msg.form"
:readonly="msg.formStatus === 'done' || msg.formStatus === 'failed'"
:editable="lastFormMsg ? msg.id === lastFormMsg.id : false"
:submitting="msg.formStatus === 'running'"
:form-error="msg.formError"
:progress="msg.formProgress"
@@ -95,7 +97,7 @@
</template>
<script setup lang="ts">
import { nextTick, onMounted, reactive, ref, watch } from 'vue';
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { Delete, DocumentCopy, RefreshRight } from '@element-plus/icons-vue';
import 'highlight.js/styles/github-dark.css';
@@ -148,16 +150,25 @@ interface Props {
loadingMore?: boolean;
// 产出相对路径 → 完整 URL 解析函数(由首页传入 buildAssetUrl),内嵌预览媒体需完整地址
resolveUrl?: (url: string) => string;
// InputBar 发送按钮触发执行:父组件定位目标卡片后,经此信号调用卡片内部 submit/retry
// (卡片参数在子组件内部,父组件无法直接取值,故由卡片暴露方法、这里中转调用)
executeRequest?: { msgId: string; action: 'submit' | 'retry'; nonce: number } | null;
}
const props = withDefaults(defineProps<Props>(), {
hasMore: false,
loadingMore: false,
resolveUrl: undefined,
executeRequest: null,
});
const emit = defineEmits<Emits>();
const chatListRef = ref<HTMLElement | null>(null);
// 会话中最后一张已执行的工作流卡片(done/failed):参数保持可编辑,便于改参后经发送按钮重新执行
const lastFormMsg = computed(() =>
[...props.messages].reverse().find((m) => m.type === 'form' && !!m.formStatus && m.formStatus !== 'editing')
);
const isErrorMsg = (msg: ChatMessage): boolean => !msg.isUser && String(msg.content || '').startsWith('❌');
// 思考区折叠:未手动操作时,流式中展开、完成(出现答案)后自动收起(DeepSeek 形态)
+36 -11
View File
@@ -17,7 +17,7 @@
v-model="message"
type="textarea"
:autosize="{ minRows: 1, maxRows: 6 }"
:disabled="generating || isWorkflowRunning || selectedWorkflowId !== null"
:disabled="generating || isWorkflowRunning || selectedWorkflowId !== null || isWorkflowSession"
:placeholder="inputPlaceholder"
class="message-input"
@focus="isFocused = true"
@@ -50,11 +50,11 @@
<span class="hint-text">Shift+Enter 换行</span>
<button
class="send-btn"
:class="{ 'is-stop': generating && !isWorkflowRunning }"
:disabled="selectedWorkflowId !== null || isWorkflowRunning || (!generating && (sendDisabled || !message.trim()))"
@click="generating && !isWorkflowRunning ? emit('stop') : handleSend()"
:class="{ 'is-stop': isStopping }"
:disabled="sendBtnDisabled"
@click="handleSend"
>
<el-icon v-if="generating && !isWorkflowRunning"><VideoPause /></el-icon>
<el-icon v-if="isStopping"><VideoPause /></el-icon>
<el-icon v-else><Top /></el-icon>
</button>
</div>
@@ -92,12 +92,16 @@ interface Props {
generating?: boolean;
// 当前会话模型名称,用于输入栏切换入口展示
currentModelName?: string;
// 工作流正在执行中:输入框/发送按钮整体禁用,停止按钮转移到表单卡片 footer
// 工作流正在执行中:输入框禁用,发送按钮变停止(与普通对话生成共用同一开关)
isWorkflowRunning?: boolean;
// 本会话为工作流会话(已执行过工作流):文本输入永久禁止,发送按钮仅执行工作流
isWorkflowSession?: boolean;
}
interface Emits {
(e: 'send', message: string): void;
// 工作流场景点击发送:父组件定位目标工作流卡片执行(编辑态提交 / 定格态重跑)
(e: 'execute-workflow'): void;
(e: 'workflow-select', workflowId: string | null, isTemplate?: boolean): void;
(e: 'stop'): void;
(e: 'select-model'): void;
@@ -117,6 +121,7 @@ const props = withDefaults(defineProps<Props>(), {
generating: false,
currentModelName: '',
isWorkflowRunning: false,
isWorkflowSession: false,
});
const emit = defineEmits<Emits>();
const message = ref('');
@@ -133,15 +138,26 @@ const visibleWorkflows = computed(() => commonWorkflows.value);
// 输入框整体锁定:正处于生成/工作流执行中(执行中禁止切换工作流)
const lockAll = computed(() => props.isWorkflowRunning || props.generating);
// 输入框占位符:工作流执行中给出明确提示
// 输入框占位符:工作流执行中 / 选中工作流待填 / 工作流会话永久禁输入时给出明确提示
const inputPlaceholder = computed(() =>
props.isWorkflowRunning
? '工作流执行中,请稍候...'
: selectedWorkflowId.value !== null
? '请填写上方工作流表单,提交后开始执行'
: '有什么想聊的?按 Enter 发送,Shift+Enter 换行',
: props.isWorkflowSession
? '本会话为工作流对话,仅可执行工作流'
: '有什么想聊的?按 Enter 发送,Shift+Enter 换行',
);
// 生成中 / 工作流执行中:发送按钮变「停止」(普通对话与工作流共用同一开关)
const isStopping = computed(() => props.generating || props.isWorkflowRunning);
// 发送按钮可用性:执行中可点(停止);工作流场景可点(执行);普通对话需有文本且未禁用
const sendBtnDisabled = computed(() => {
if (props.generating || props.isWorkflowRunning) return false;
if (selectedWorkflowId.value !== null || props.isWorkflowSession) return false;
return props.sendDisabled || !message.value.trim();
});
const fetchWorkflows = async () => {
try {
const res = await getWorkflowList();
@@ -164,10 +180,19 @@ const fetchWorkflows = async () => {
};
const handleSend = () => {
if (props.sendDisabled) return;
// 生成中 / 工作流执行中:发送按钮即「停止」(普通对话与工作流共用同一开关)
if (props.generating || props.isWorkflowRunning) {
emit('stop');
return;
}
// 工作流场景(已选工作流 / 本会话为工作流会话):发送按钮即「执行工作流」
if (selectedWorkflowId.value !== null || props.isWorkflowSession) {
emit('execute-workflow');
return;
}
// 普通对话:发送文本
const msg = message.value.trim();
// 空消息不发送(避免产生空用户气泡与空问题请求)
if (!msg) return;
if (!msg || props.sendDisabled) return;
emit('send', msg);
message.value = '';
// 不清除工作流选择,保持表单可见
@@ -8,6 +8,7 @@
:messages="messages"
:has-more="hasMore"
:loading-more="loadingMore"
:execute-request="executeRequest"
class="chat-list-scroll"
@retry="emit('retry', $event)"
@regenerate="emit('regenerate', $event)"
@@ -64,6 +65,8 @@ interface Props {
results?: VOSessionInfoResult[];
hasMore?: boolean;
loadingMore?: boolean;
// InputBar 发送按钮触发执行信号(透传给 ChatList,定位工作流卡片执行)
executeRequest?: { msgId: string; action: 'submit' | 'retry'; nonce: number } | null;
}
interface Emits {
@@ -87,6 +90,7 @@ const props = withDefaults(defineProps<Props>(), {
results: () => [],
hasMore: false,
loadingMore: false,
executeRequest: null,
});
const emit = defineEmits<Emits>();
+43 -25
View File
@@ -8,7 +8,7 @@
<div class="header-right">
<el-tag v-if="submitting" size="small" type="primary">执行中</el-tag>
<el-tag v-else-if="isFailed" size="small" type="danger">执行失败</el-tag>
<el-tag v-else-if="readonly" size="small" type="success">执行完成</el-tag>
<el-tag v-else-if="formStatus === 'done'" size="small" type="success">执行完成</el-tag>
<!-- 折叠切换明确按钮提示用户可展开/收起已填参数点击标题行同样可切换 -->
<button type="button" class="collapse-toggle" @click.stop="toggleCollapse">
<el-icon class="collapse-arrow" :class="{ collapsed }"><ArrowDown /></el-icon>
@@ -163,29 +163,21 @@
<el-empty v-if="!hasFormFields && !currentWorkflowHasPatchLayout" description="该工作流无需填写参数" :image-size="60" />
</div>
<!-- 底部操作区:editing 提 / running 执行中 / done·failed 定格状态 -->
<!-- 底部操作区:editing 提 / running 执行中 / done·failed 状态标签(执行统一由 InputBar 发送按钮触发) -->
<div class="workflow-form-footer">
<div v-if="isFailed" class="form-error-text">{{ formError }}</div>
<!-- 执行中:节点文本进度推进(后端 node_start/node_complete 事件),无进度时兜底「执行中...」 -->
<!-- 取消执行:工作流执行中的终止入口(InputBar 停止按钮对工作流禁用,改由卡片 footer 提供) -->
<!-- 执行中:节点文本进度推进(后端 node_start/node_complete 事件),无进度时兜底「执行中...」
停止统一由 InputBar 发送按钮(变停止)触发,卡片内不再提供取消入口 -->
<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>
<template v-else>
<el-tag :type="isFailed ? 'danger' : 'success'" size="small" effect="light">
{{ isFailed ? '执行失败' : '执行完成' }}
</el-tag>
<!-- 失败卡片:可直接「重新执行」(复用当前已填值重跑),或「重新编辑」修改参数后再提交 -->
<template v-if="isFailed">
<el-button size="small" type="primary" @click="handleRetry">
<el-icon><RefreshRight /></el-icon>重新执行
</el-button>
<el-button size="small" @click="emit('re-edit')">重新编辑</el-button>
</template>
</template>
<!-- 已执行卡片(done/failed,含可编辑的最后一张):显示状态标签,参数是否可编辑由 editable 决定 -->
<el-tag v-else-if="formStatus === 'done' || formStatus === 'failed'" :type="isFailed ? 'danger' : 'success'" size="small" effect="light">
{{ isFailed ? '执行失败' : '执行完成' }}
</el-tag>
<!-- 编辑态:无执行按钮,提示通过下方发送按钮执行 -->
<span v-else class="form-edit-tip">填写参数后,点击下方发送按钮执行工作流</span>
</div>
</div>
</template>
@@ -193,7 +185,7 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { ArrowDown, CircleCheckFilled, CircleCloseFilled, RefreshRight } from '@element-plus/icons-vue';
import { ArrowDown, CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue';
import PatchTemplateEditor from '/@/components/patchTemplate/PatchTemplateEditor.vue';
import { uploadFile } from '/@/api/common/upload';
import { collectHomeFormFields } from '../utils/flowDsl';
@@ -210,6 +202,10 @@ interface Props {
formError?: string;
// 执行节点进度(node_start/node_complete 事件驱动的步骤列表,渲染执行过程区)
progress?: { nodes: WorkflowNodeStep[] };
// InputBar 发送按钮触发执行信号:仅当本卡片为目标时非 nullaction 决定 submit/retry
executeSignal?: { action: 'submit' | 'retry'; nonce: number } | null;
// 已执行卡片(done/failed)参数可编辑(会话中最后一张执行过的工作流):改参后经发送按钮重新执行
editable?: boolean;
}
interface Emits {
@@ -227,6 +223,8 @@ const props = withDefaults(defineProps<Props>(), {
submitting: false,
formError: '',
progress: () => ({ nodes: [] }),
executeSignal: null,
editable: false,
});
const emit = defineEmits<Emits>();
@@ -237,9 +235,10 @@ const formFileNames = reactive<Record<string, string | string[]>>({});
const uploadingFields = reactive<Record<string, boolean>>({});
const templates = ref<any[]>([]);
// 状态派生:失败 = 携带 formError;禁用 = 定格或执行中
// 状态派生:失败 = 携带 formError;禁用编辑 = 定格done/failed)卡片默认只读,
// 但最后一张已执行卡片(editable)允许改参;执行中(submitting)一律禁用
const isFailed = computed(() => !!props.formError);
const isDisabled = computed(() => props.readonly || props.submitting);
const isDisabled = computed(() => (props.readonly && !props.editable) || props.submitting);
// 表单收起/展开:editing(待提交/回显)默认展开;提交(running)自动收起;
// done/failed 保持提交时状态(默认收起,用户可手动展开查看已填值)
@@ -256,7 +255,8 @@ watch(
if (st === 'editing') {
collapsed.value = false; // 回显/重新编辑:默认展开
} else if (prev === undefined) {
collapsed.value = true; // 初始即非编辑态(执行中/完成/失败)→ 收起
// 初始即非编辑态(执行中/完成/失败):可编辑的最后一张已执行卡片展开供改参,其余收起
collapsed.value = props.editable ? false : true;
} else if (st === 'running') {
collapsed.value = true; // 提交瞬间自动收起
}
@@ -541,7 +541,8 @@ watch(
);
const handleSubmit = () => {
if (props.readonly || props.submitting) return;
// 定格卡片默认只读不可提交;最后一张已执行卡片(editable)允许改参后重新提交
if ((props.readonly && !props.editable) || props.submitting) return;
if (!validateFormFields()) return;
emit('submit', {
formValues: JSON.parse(JSON.stringify(formValues)),
@@ -550,15 +551,27 @@ const handleSubmit = () => {
});
};
// 失败后直接重跑:复用当前已填值(必填此前已校验通过),不进入编辑态原样重新提交
// 直接重跑:复用当前已填值(必填此前已校验通过),不进入编辑态原样重新提交
// 由 InputBar 发送按钮触发(未重新选择工作流时默认重跑最后一个),成功/失败定格卡片均可重跑
const handleRetry = () => {
if (!isFailed.value || props.submitting) return;
if (!props.readonly || props.submitting) return;
emit('retry', {
formValues: JSON.parse(JSON.stringify(formValues)),
formFileNames: JSON.parse(JSON.stringify(formFileNames)),
templates: JSON.parse(JSON.stringify(templates.value || [])),
});
};
// InputBar 发送按钮触发执行:父组件把执行信号下发给目标卡片(本卡片为目标时 executeSignal 非 null),
// 编辑态调用提交(含必填校验)、定格态调用重跑(复用已填值);卡片参数在组件内部,父组件经信号间接触发
watch(
() => props.executeSignal,
(sig) => {
if (!sig) return;
if (sig.action === 'submit') handleSubmit();
else handleRetry();
}
);
</script>
<style scoped lang="scss">
@@ -772,6 +785,11 @@ const handleRetry = () => {
white-space: pre-wrap; /* 失败详情可能含换行(error 字段多行原因),允许换行展示 */
word-break: break-word;
}
.form-edit-tip {
flex: 1;
font-size: 12px;
color: #94a3b8;
}
}
/* 执行进度:spinner + 文本,节点事件推进 */
+34 -1
View File
@@ -28,6 +28,7 @@
:results="currentSessionResults"
:has-more="currentSessionHasMore"
:loading-more="currentSessionLoadingMore"
:execute-request="executeRequest"
@retry="handleRetry"
@regenerate="handleRetry"
@delete="handleDeleteMessage"
@@ -48,8 +49,10 @@
:hide-shortcuts="isPlaceholder"
:generating="isGenerating"
:is-workflow-running="isWorkflowRunning"
:is-workflow-session="isWorkflowSession"
:current-model-name="currentChatModelName"
@send="handleSend"
@execute-workflow="handleExecuteWorkflow"
@workflow-select="handleWorkflowSelect"
@stop="handleStopGenerate"
@select-model="sessionModelDialogVisible = true"
@@ -438,8 +441,15 @@ const runTypeMap: Record<string, boolean> = {};
// 生成中:有活跃会话且该会话正处于发送状态(发送按钮切换为停止按钮)
const isGenerating = computed(() => !!activeHistoryId.value && !!sendingSessions[activeHistoryId.value]);
// 工作流执行中:isGenerating 且当前活跃会话本轮为工作流 → 输入框整体禁用,停止按钮转移到表单卡片 footer
// 工作流执行中:isGenerating 且当前活跃会话本轮为工作流 → 输入框整体禁用,发送按钮变停止(统一开关)
const isWorkflowRunning = computed(() => isGenerating.value && runTypeMap[activeHistoryId.value || ''] === true);
// 会话是否为「工作流会话」:会话中已有执行过(非 editing)的工作流表单 → InputBar 永久禁止文本输入
const isWorkflowSession = computed(() => {
const sid = activeHistoryId.value;
if (!sid) return false;
const list = sessionMessages.value.get(sid);
return !!list && list.some((m) => m.type === 'form' && !!m.formStatus && m.formStatus !== 'editing');
});
const inputBarRef = ref<any>(null);
const templateDialogVisible = ref(false);
const pendingTemplate = ref<any>(null);
@@ -961,6 +971,29 @@ const handleStopGenerate = () => {
teardownRound(sid);
};
// ===== InputBar 发送按钮(工作流场景)执行 =====
// 目标工作流卡片执行信号:下发给目标卡片(ChatList 经 executeRequest → executeSignal),卡片内部提交/重跑。
// 卡片参数在组件内部,父组件经信号间接触发、不直接取值。
const executeRequest = ref<{ msgId: string; action: 'submit' | 'retry'; nonce: number } | null>(null);
// 1) 会话存在编辑态卡片(用户已选工作流在填参数)→ 提交该卡片;
// 2) 否则未重新选择工作流 → 默认重跑最后一张已执行(done/failed)的卡片
const handleExecuteWorkflow = () => {
const sid = activeHistoryId.value;
if (!sid || sendingSessions[sid]) return; // 执行中不重复触发(此时按钮为停止态,点击走 stop)
const list = sessionMessages.value.get(sid) || [];
const reversed = [...list].reverse();
const editing = reversed.find((m) => m.type === 'form' && m.formStatus === 'editing');
const lastExecuted = reversed.find((m) => m.type === 'form' && !!m.formStatus && m.formStatus !== 'editing');
const target = editing || lastExecuted;
if (!target) return;
// 目标卡片均保持可编辑(editing 或最后一张已执行),统一走提交(含必填校验)
executeRequest.value = {
msgId: target.id,
action: 'submit',
nonce: (executeRequest.value?.nonce || 0) + 1,
};
};
// ===== 工作流执行:选中工作流 → 表单页 WS 执行 → 完成后切回对话页 =====
// 工作流节点进度推进:node_start → 该节点置「进行中」;node_complete → 置「完成」。