首页工作流与历史会话:修复执行后表单清空/默认展开,工作流最多展示9个+更多入口,历史会话分页加载更多
- 修复:执行时把用户填的表单值写回消息对象,会话认领重建后能恢复,不再清空;执行完成/历史回显卡片默认收起,可手动展开改参 - 功能:首页工作流最多展示 9 个(用户优先、模板补足),超出显示「更多」入口跳转工作流管理页 - 功能:历史会话分页「加载更多」,逐页追加去重 - 重构:工作流执行信号统一走提交(移除 retry/re-edit/cancel),失败卡片重跑/重编辑并入提交路径 - 其他:删除消息时终止进行中的轮;Markdown 渲染缓存设上限 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -23,9 +23,6 @@
|
||||
:form-error="msg.formError"
|
||||
:progress="msg.formProgress"
|
||||
@submit="emit('form-submit', msg, $event)"
|
||||
@retry="emit('form-retry', msg, $event)"
|
||||
@re-edit="emit('form-re-edit', msg)"
|
||||
@cancel="emit('form-cancel', msg)"
|
||||
/>
|
||||
<template v-else>
|
||||
<div class="bubble">
|
||||
@@ -65,7 +62,7 @@
|
||||
@delete="emit('output-delete', msg, $event)"
|
||||
/>
|
||||
<!-- AI 消息操作栏(hover 显示) -->
|
||||
<div v-if="!msg.isUser && msg.content && !isErrorMsg(msg)" class="msg-actions">
|
||||
<div v-if="!msg.isUser && msg.content" class="msg-actions">
|
||||
<button class="msg-action" title="复制回答" @click="copyText(msg.content)">
|
||||
<el-icon><DocumentCopy /></el-icon>
|
||||
</button>
|
||||
@@ -122,9 +119,6 @@ interface ChatMessage {
|
||||
interface Emits {
|
||||
(e: 'delete', msg: ChatMessage): void;
|
||||
(e: 'form-submit', msg: ChatMessage, payload: any): void;
|
||||
(e: 'form-retry', 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;
|
||||
@@ -137,9 +131,9 @@ interface Props {
|
||||
loadingMore?: boolean;
|
||||
// 产出相对路径 → 完整 URL 解析函数(由首页传入 buildAssetUrl),内嵌预览媒体需完整地址
|
||||
resolveUrl?: (url: string) => string;
|
||||
// InputBar 发送按钮触发执行:父组件定位目标卡片后,经此信号调用卡片内部 submit/retry
|
||||
// InputBar 发送按钮触发执行:父组件定位目标卡片后,经此信号调用卡片内部提交
|
||||
// (卡片参数在子组件内部,父组件无法直接取值,故由卡片暴露方法、这里中转调用)
|
||||
executeRequest?: { msgId: string; action: 'submit' | 'retry'; nonce: number } | null;
|
||||
executeRequest?: { msgId: string; action: 'submit'; nonce: number } | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -169,8 +163,11 @@ const toggleThinking = (msg: ChatMessage) => {
|
||||
};
|
||||
|
||||
// Markdown 渲染缓存(content 在作答完成后固定,避免重复解析)
|
||||
// 设上限:防止长会话渲染结果无限累积(超限清空重建,量级可控)
|
||||
const mdCache = new Map<string, string>();
|
||||
const MD_CACHE_MAX = 300;
|
||||
const renderAnswer = (content: string): string => {
|
||||
if (mdCache.size >= MD_CACHE_MAX) mdCache.clear();
|
||||
if (!mdCache.has(content)) mdCache.set(content, renderMarkdown(content));
|
||||
return mdCache.get(content)!;
|
||||
};
|
||||
|
||||
@@ -29,15 +29,6 @@
|
||||
<!-- 底部工具栏 -->
|
||||
<div class="input-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<button class="tool-icon-btn" title="上传文件" @click="handleAttachment">
|
||||
<el-icon><Paperclip /></el-icon>
|
||||
</button>
|
||||
|
||||
<!-- 技能暂已禁用 -->
|
||||
<button class="tool-icon-btn" title="技能暂不可用" disabled>
|
||||
<el-icon><MagicStick /></el-icon>
|
||||
<span class="tool-label">技能</span>
|
||||
</button>
|
||||
|
||||
<!-- 切换会话模型入口 -->
|
||||
<button class="tool-icon-btn" :class="{ active: !!currentModelName }" title="切换会话模型" @click="emit('select-model')">
|
||||
@@ -75,6 +66,16 @@
|
||||
{{ wf.name }}
|
||||
<span v-if="wf.isTemplate" class="pill-tag">模板</span>
|
||||
</button>
|
||||
<!-- 工作流数量超出展示上限时提供「更多」入口:跳转工作流管理页查看/选择全部 -->
|
||||
<button
|
||||
v-if="commonWorkflows.length > MAX_WORKFLOWS"
|
||||
class="shortcut-pill shortcut-more"
|
||||
title="查看全部工作流"
|
||||
@click="handleOpenWorkflowManage"
|
||||
>
|
||||
<el-icon><More /></el-icon>
|
||||
更多
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
@@ -82,8 +83,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { Top, MagicStick, Promotion, Close, Paperclip, VideoPause, Cpu } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { Top, Promotion, Close, VideoPause, Cpu, More } from '@element-plus/icons-vue';
|
||||
import { getWorkflowList } from '/@/api/settings/creation';
|
||||
|
||||
interface Props {
|
||||
@@ -124,6 +125,14 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
isWorkflowSession: false,
|
||||
});
|
||||
const emit = defineEmits<Emits>();
|
||||
const router = useRouter();
|
||||
// 首页展示工作流上限:用户工作流优先,不足用系统模板补足,最多 9 个
|
||||
const MAX_WORKFLOWS = 9;
|
||||
|
||||
// 「更多」入口:工作流数量超出展示上限时跳转工作流管理页查看/选择全部
|
||||
const handleOpenWorkflowManage = () => {
|
||||
router.push('/settings/workflow');
|
||||
};
|
||||
const message = ref('');
|
||||
const isFocused = ref(false);
|
||||
const selectedWorkflowId = ref<string | null>(null);
|
||||
@@ -131,9 +140,9 @@ const commonWorkflows = ref<Workflow[]>([]);
|
||||
// 取消工作流前的选择:父组件确认「继续编辑」时回滚,避免 InputBar 先置空、父侧保留草稿造成状态脱钩
|
||||
const previousSelectedWorkflowId = ref<string | null>(null);
|
||||
|
||||
// 工作流胶囊始终展示全部工作流:历史工作流会话回显预选上次执行的工作流,
|
||||
// 但仍允许自由切换/取消(不锁定为单个工作流)
|
||||
const visibleWorkflows = computed(() => commonWorkflows.value);
|
||||
// 工作流胶囊最多展示 MAX_WORKFLOWS 个:commonWorkflows 用户工作流在前、模板在后,
|
||||
// 截断前 MAX_WORKFLOWS 个即「用户优先、不足模板补足」;完整列表仍保留供父组件匹配
|
||||
const visibleWorkflows = computed(() => commonWorkflows.value.slice(0, MAX_WORKFLOWS));
|
||||
|
||||
// 输入框整体锁定:正处于生成/工作流执行中(执行中禁止切换工作流)
|
||||
const lockAll = computed(() => props.isWorkflowRunning || props.generating);
|
||||
@@ -163,6 +172,7 @@ const fetchWorkflows = async () => {
|
||||
const res = await getWorkflowList();
|
||||
const userList = res.data?.listFlowUserRes?.list || [];
|
||||
const tplList = res.data?.listFlowTemplateRes?.list || [];
|
||||
// commonWorkflows 保留完整列表:父组件按 id 匹配工作流名称/预选历史会话,不能被展示截断影响
|
||||
const workflows = [
|
||||
...userList.map((w) => ({ ...w, isTemplate: false })),
|
||||
...tplList.map((w) => ({ ...w, isTemplate: true })),
|
||||
@@ -198,10 +208,6 @@ const handleSend = () => {
|
||||
// 不清除工作流选择,保持表单可见
|
||||
};
|
||||
|
||||
const handleAttachment = () => {
|
||||
ElMessage.info('附件上传功能开发中...');
|
||||
};
|
||||
|
||||
const toggleWorkflow = (id: string) => {
|
||||
if (lockAll.value) return;
|
||||
const item = commonWorkflows.value.find((w) => w.id === id);
|
||||
@@ -511,6 +517,19 @@ const onCapsuleAfterLeave = (el: Element) => {
|
||||
}
|
||||
}
|
||||
|
||||
/* 「更多」入口:与普通胶囊区分,虚线边框 + 更轻的视觉重量,提示可进入管理页查看全部 */
|
||||
.shortcut-more {
|
||||
border-style: dashed;
|
||||
color: #94a3b8;
|
||||
|
||||
&:hover {
|
||||
border-style: dashed;
|
||||
border-color: #93c5fd;
|
||||
color: #2563eb;
|
||||
background: rgba(239, 246, 255, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.pill-tag {
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
|
||||
@@ -12,9 +12,6 @@
|
||||
class="chat-list-scroll"
|
||||
@delete="emit('delete', $event)"
|
||||
@form-submit="(msg: any, payload: any) => emit('form-submit', msg, payload)"
|
||||
@form-retry="(msg: any, payload: any) => emit('form-retry', 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)"
|
||||
@@ -40,6 +37,17 @@
|
||||
<span class="wf-card-name">{{ wf.name }}</span>
|
||||
<span v-if="wf.isTemplate" class="wf-card-tag">模板</span>
|
||||
</button>
|
||||
<!-- 工作流数量超出展示上限时提供「更多」入口:跳转工作流管理页 -->
|
||||
<button
|
||||
v-if="hasMorePlaceWorkflows"
|
||||
type="button"
|
||||
class="workflow-card wf-card-more"
|
||||
title="查看全部工作流"
|
||||
@click="goWorkflowManage"
|
||||
>
|
||||
<el-icon class="wf-card-icon"><More /></el-icon>
|
||||
<span class="wf-card-name">更多工作流</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="placeholder-empty">暂无工作流,可在工作流管理中创建</div>
|
||||
</div>
|
||||
@@ -50,8 +58,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { getWorkflowList } from '/@/api/settings/creation';
|
||||
import { Promotion } from '@element-plus/icons-vue';
|
||||
import { Promotion, More } from '@element-plus/icons-vue';
|
||||
import ChatList from './ChatList.vue';
|
||||
import type { VOSessionInfoResult } from '/@/api/settings/workflow/session';
|
||||
|
||||
@@ -64,16 +73,13 @@ interface Props {
|
||||
hasMore?: boolean;
|
||||
loadingMore?: boolean;
|
||||
// InputBar 发送按钮触发执行信号(透传给 ChatList,定位工作流卡片执行)
|
||||
executeRequest?: { msgId: string; action: 'submit' | 'retry'; nonce: number } | null;
|
||||
executeRequest?: { msgId: string; action: 'submit'; nonce: number } | null;
|
||||
}
|
||||
|
||||
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-retry', 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;
|
||||
@@ -89,28 +95,39 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
executeRequest: null,
|
||||
});
|
||||
const emit = defineEmits<Emits>();
|
||||
const router = useRouter();
|
||||
|
||||
const hasMessages = computed(() => Array.isArray(props.messages) && props.messages.length > 0);
|
||||
const hasResults = computed(() => Array.isArray(props.results) && props.results.length > 0);
|
||||
|
||||
// 占位页工作流卡片(DeepSeek 式引导:居中标题 + 工作流卡片)
|
||||
// 展示上限与 InputBar 胶囊保持一致:用户工作流优先,不足用模板补足,最多 9 个
|
||||
const MAX_PLACE_WORKFLOWS = 9;
|
||||
// 工作流总数超过展示上限时显示「更多」入口(跳转工作流管理页)
|
||||
const hasMorePlaceWorkflows = ref(false);
|
||||
const placeWorkflows = ref<any[]>([]);
|
||||
const loadPlaceWorkflows = async () => {
|
||||
try {
|
||||
const res: any = await getWorkflowList();
|
||||
const userList = res.data?.listFlowUserRes?.list || [];
|
||||
const tplList = res.data?.listFlowTemplateRes?.list || [];
|
||||
hasMorePlaceWorkflows.value = userList.length + tplList.length > MAX_PLACE_WORKFLOWS;
|
||||
placeWorkflows.value = [
|
||||
...userList.map((w: any) => ({ id: String(w.id), name: w.flowName || '未命名', isTemplate: false })),
|
||||
...tplList.map((w: any) => ({ id: String(w.id), name: w.flowTemplateName || '未命名', isTemplate: true })),
|
||||
];
|
||||
].slice(0, MAX_PLACE_WORKFLOWS);
|
||||
} catch {
|
||||
placeWorkflows.value = [];
|
||||
hasMorePlaceWorkflows.value = false;
|
||||
}
|
||||
};
|
||||
const handlePlaceSelect = (wf: any) => {
|
||||
emit('workflow-select', wf.id, wf.isTemplate);
|
||||
};
|
||||
// 「更多」入口:工作流数量超出展示上限时跳转工作流管理页查看/选择全部
|
||||
const goWorkflowManage = () => {
|
||||
router.push('/settings/workflow');
|
||||
};
|
||||
onMounted(() => {
|
||||
loadPlaceWorkflows();
|
||||
});
|
||||
@@ -228,6 +245,32 @@ onMounted(() => {
|
||||
border: 1px solid #fde68a;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* 「更多」入口:与普通工作流卡片区分,虚线边框 + 更轻的视觉重量,提示可进入管理页查看全部 */
|
||||
.wf-card-more {
|
||||
border-style: dashed;
|
||||
justify-content: center;
|
||||
|
||||
.wf-card-icon {
|
||||
background: #f8fafc;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.wf-card-name {
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
}
|
||||
&:hover {
|
||||
border-style: dashed;
|
||||
border-color: #93c5fd;
|
||||
.wf-card-icon {
|
||||
background: #eaf1ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
.wf-card-name {
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder-empty {
|
||||
font-size: 13px;
|
||||
color: #cbd5e1;
|
||||
|
||||
@@ -45,6 +45,12 @@
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 历史会话分页「加载更多」:还有更多会话时展示,点击加载下一页 -->
|
||||
<div v-if="historyHasMore" class="history-load-more">
|
||||
<button class="load-more-btn" :disabled="historyLoadingMore" @click="emit('load-more-history')">
|
||||
{{ historyLoadingMore ? '加载中...' : '加载更多' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div v-else-if="historyFailed" class="panel-error">
|
||||
@@ -108,6 +114,9 @@ interface Props {
|
||||
historyList: HistoryItem[];
|
||||
// 会话记录 / 工作空间加载失败标记:失败时显示「加载失败 + 重试」错误态
|
||||
historyFailed?: boolean;
|
||||
// 历史会话分页:是否还有更多可加载、加载更多请求中
|
||||
historyHasMore?: boolean;
|
||||
historyLoadingMore?: boolean;
|
||||
workspaceFailed?: boolean;
|
||||
// 工作空间是否已成功加载:懒加载成功后才不再触发;失败保持 false → 切回 tab 自动重试
|
||||
workspaceLoaded?: boolean;
|
||||
@@ -125,11 +134,14 @@ interface Emits {
|
||||
(e: 'delete-node', data: TreeNode): void;
|
||||
(e: 'load-workspace'): void;
|
||||
(e: 'retry-history'): void;
|
||||
(e: 'load-more-history'): void;
|
||||
(e: 'retry-workspace'): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
historyFailed: false,
|
||||
historyHasMore: false,
|
||||
historyLoadingMore: false,
|
||||
workspaceFailed: false,
|
||||
workspaceLoaded: false,
|
||||
});
|
||||
@@ -511,6 +523,35 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 历史会话分页「加载更多」按钮 */
|
||||
.history-load-more {
|
||||
padding: 10px 0 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.load-more-btn {
|
||||
border: 1px solid #e5e8ee;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
padding: 5px 20px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
outline: none;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: #93c5fd;
|
||||
color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
|
||||
@@ -202,20 +202,14 @@ interface Props {
|
||||
formError?: string;
|
||||
// 执行节点进度(node_start/node_complete 事件驱动的步骤列表,渲染执行过程区)
|
||||
progress?: { nodes: WorkflowNodeStep[] };
|
||||
// InputBar 发送按钮触发执行信号:仅当本卡片为目标时非 null(action 决定 submit/retry)
|
||||
executeSignal?: { action: 'submit' | 'retry'; nonce: number } | null;
|
||||
// InputBar 发送按钮触发执行信号:仅当本卡片为目标时非 null(统一走提交,含必填校验)
|
||||
executeSignal?: { action: 'submit'; nonce: number } | null;
|
||||
// 已执行卡片(done/failed)参数可编辑(会话中最后一张执行过的工作流):改参后经发送按钮重新执行
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', payload: { formValues: Record<string, any>; formFileNames: Record<string, string | string[]>; templates: any[] }): void;
|
||||
// 失败卡片「重新编辑并执行」:由父组件把 formStatus 切回 editing,保留已填值
|
||||
(e: 're-edit'): void;
|
||||
// 失败卡片「重新执行」:复用当前已填值直接重跑(不进入编辑态)
|
||||
(e: 'retry', payload: { formValues: Record<string, any>; formFileNames: Record<string, string | string[]>; templates: any[] }): void;
|
||||
// 执行中取消:工作流取消入口(InputBar 停止按钮对工作流禁用)
|
||||
(e: 'cancel'): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -255,8 +249,9 @@ watch(
|
||||
if (st === 'editing') {
|
||||
collapsed.value = false; // 回显/重新编辑:默认展开
|
||||
} else if (prev === undefined) {
|
||||
// 初始即非编辑态(执行中/完成/失败):可编辑的最后一张已执行卡片展开供改参,其余收起
|
||||
collapsed.value = props.editable ? false : true;
|
||||
// 初始即非编辑态(执行中/完成/失败):一律收起(执行后默认收起,不因可编辑展开),
|
||||
// 用户可手动展开查看已填值/改参
|
||||
collapsed.value = true;
|
||||
} else if (st === 'running') {
|
||||
collapsed.value = true; // 提交瞬间自动收起
|
||||
}
|
||||
@@ -551,25 +546,13 @@ const handleSubmit = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// 直接重跑:复用当前已填值(必填此前已校验通过),不进入编辑态原样重新提交。
|
||||
// 由 InputBar 发送按钮触发(未重新选择工作流时默认重跑最后一个),成功/失败定格卡片均可重跑
|
||||
const handleRetry = () => {
|
||||
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();
|
||||
handleSubmit();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
@@ -814,10 +797,6 @@ watch(
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.exec-cancel-btn {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
@keyframes exec-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
|
||||
Reference in New Issue
Block a user