Files
admin-ui/src/views/home/components/InputBar.vue
T
2910410219andClaude b140bff5f1 首页工作流与历史会话:修复执行后表单清空/默认展开,工作流最多展示9个+更多入口,历史会话分页加载更多
- 修复:执行时把用户填的表单值写回消息对象,会话认领重建后能恢复,不再清空;执行完成/历史回显卡片默认收起,可手动展开改参
- 功能:首页工作流最多展示 9 个(用户优先、模板补足),超出显示「更多」入口跳转工作流管理页
- 功能:历史会话分页「加载更多」,逐页追加去重
- 重构:工作流执行信号统一走提交(移除 retry/re-edit/cancel),失败卡片重跑/重编辑并入提交路径
- 其他:删除消息时终止进行中的轮;Markdown 渲染缓存设上限

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-21 19:17:12 +08:00

550 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="input-shell">
<div class="input-card" :class="{ 'is-focused': isFocused }">
<!-- 已选中状态标签栏 -->
<Transition name="capsule-slide" @enter="onCapsuleEnter" @after-enter="onCapsuleAfterEnter" @leave="onCapsuleLeave" @after-leave="onCapsuleAfterLeave">
<div v-if="selectedWorkflowId !== null" class="selected-tags">
<div class="selected-tag workflow-tag">
<el-icon><Promotion /></el-icon>
<span>{{ commonWorkflows.find((w) => w.id === selectedWorkflowId)?.name }}</span>
<el-icon v-if="!lockAll" class="tag-close" @click="clearWorkflow"><Close /></el-icon>
</div>
</div>
</Transition>
<!-- 文本输入区 -->
<el-input
v-model="message"
type="textarea"
:autosize="{ minRows: 1, maxRows: 6 }"
:disabled="generating || isWorkflowRunning || selectedWorkflowId !== null || isWorkflowSession"
:placeholder="inputPlaceholder"
class="message-input"
@focus="isFocused = true"
@blur="isFocused = false"
@keydown.enter.exact.prevent="handleSend"
@keydown.enter.shift.exact="() => {}"
/>
<!-- 底部工具栏 -->
<div class="input-toolbar">
<div class="toolbar-left">
<!-- 切换会话模型入口 -->
<button class="tool-icon-btn" :class="{ active: !!currentModelName }" title="切换会话模型" @click="emit('select-model')">
<el-icon><Cpu /></el-icon>
<span class="tool-label">{{ currentModelName || '设置模型' }}</span>
</button>
</div>
<div class="toolbar-right">
<span class="hint-text">Shift+Enter 换行</span>
<button
class="send-btn"
:class="{ 'is-stop': isStopping }"
:disabled="sendBtnDisabled"
@click="handleSend"
>
<el-icon v-if="isStopping"><VideoPause /></el-icon>
<el-icon v-else><Top /></el-icon>
</button>
</div>
</div>
</div>
<!-- 快捷工作流胶囊输入框下方 -->
<Transition name="capsule-slide" @enter="onCapsuleEnter" @after-enter="onCapsuleAfterEnter" @leave="onCapsuleLeave" @after-leave="onCapsuleAfterLeave">
<div v-if="visibleWorkflows.length > 0 && !hideShortcuts" class="workflow-shortcuts">
<button
v-for="wf in visibleWorkflows"
:key="wf.id"
class="shortcut-pill"
:class="{ active: selectedWorkflowId === wf.id }"
@click="toggleWorkflow(wf.id)"
>
<el-icon><Promotion /></el-icon>
{{ 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>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
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 {
sendDisabled?: boolean;
hideShortcuts?: boolean;
generating?: boolean;
// 当前会话模型名称,用于输入栏切换入口展示
currentModelName?: string;
// 工作流正在执行中:输入框禁用,发送按钮变停止(与普通对话生成共用同一开关)
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;
}
interface Workflow {
id: string;
name: string;
prefix: string;
isTemplate: boolean;
raw?: any;
}
const props = withDefaults(defineProps<Props>(), {
sendDisabled: false,
hideShortcuts: false,
generating: false,
currentModelName: '',
isWorkflowRunning: false,
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);
const commonWorkflows = ref<Workflow[]>([]);
// 取消工作流前的选择:父组件确认「继续编辑」时回滚,避免 InputBar 先置空、父侧保留草稿造成状态脱钩
const previousSelectedWorkflowId = ref<string | null>(null);
// 工作流胶囊最多展示 MAX_WORKFLOWS 个:commonWorkflows 用户工作流在前、模板在后,
// 截断前 MAX_WORKFLOWS 个即「用户优先、不足模板补足」;完整列表仍保留供父组件匹配
const visibleWorkflows = computed(() => commonWorkflows.value.slice(0, MAX_WORKFLOWS));
// 输入框整体锁定:正处于生成/工作流执行中(执行中禁止切换工作流)
const lockAll = computed(() => props.isWorkflowRunning || props.generating);
// 输入框占位符:工作流执行中 / 选中工作流待填 / 工作流会话永久禁输入时给出明确提示
const inputPlaceholder = computed(() =>
props.isWorkflowRunning
? '工作流执行中,请稍候...'
: selectedWorkflowId.value !== null
? '请填写上方工作流表单,提交后开始执行'
: 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();
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 })),
];
commonWorkflows.value = workflows.map((w) => ({
id: String(w.id),
name: w.flowName || w.flowTemplateName || '未命名',
prefix: '[工作流] ' + (w.flowName || w.flowTemplateName || '') + ':\n',
isTemplate: !!w.isTemplate,
raw: w,
}));
} catch {
commonWorkflows.value = [];
}
};
const handleSend = () => {
// 生成中 / 工作流执行中:发送按钮即「停止」(普通对话与工作流共用同一开关)
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 || props.sendDisabled) return;
emit('send', msg);
message.value = '';
// 不清除工作流选择,保持表单可见
};
const toggleWorkflow = (id: string) => {
if (lockAll.value) return;
const item = commonWorkflows.value.find((w) => w.id === id);
const newId = selectedWorkflowId.value === id ? null : id;
// 取消(newId 为 null)前记录当前选择,供父组件确认「继续编辑」时回滚
if (newId === null) previousSelectedWorkflowId.value = selectedWorkflowId.value;
selectedWorkflowId.value = newId;
emit('workflow-select', newId, item?.isTemplate || false);
};
const resetAll = () => {
message.value = '';
selectedWorkflowId.value = null;
emit('workflow-select', null);
};
const clearWorkflow = () => {
if (lockAll.value) return;
// 清空(取消)前记录当前选择,供父组件确认「继续编辑」时回滚
previousSelectedWorkflowId.value = selectedWorkflowId.value;
selectedWorkflowId.value = null;
emit('workflow-select', null);
};
const selectWorkflow = (id: string | null) => {
if (lockAll.value) return;
selectedWorkflowId.value = id;
emit('workflow-select', id, false);
};
// 父组件确认「放弃未提交表单」失败时回滚选择:恢复取消前的工作流(标签与禁用态归位),
// 不发 emit(父侧草稿与 selectedWorkflowDetail 保留,状态由父组件维持)
const restoreWorkflowSelection = () => {
if (previousSelectedWorkflowId.value !== null) {
selectedWorkflowId.value = previousSelectedWorkflowId.value;
}
};
onMounted(() => {
fetchWorkflows();
});
// —— 胶囊高度动画:JS 测量实际高度,让 max-height 与内容高度精确匹配,
// 进入时从 0 展开、离开时收缩到 0,配合 CSS transition 消除底部高度突变 ——
const onCapsuleEnter = (el: Element) => {
const e = el as HTMLElement;
e.style.maxHeight = '0px';
requestAnimationFrame(() => {
e.style.maxHeight = e.scrollHeight + 'px';
});
};
const onCapsuleAfterEnter = (el: Element) => {
(el as HTMLElement).style.maxHeight = '';
};
const onCapsuleLeave = (el: Element) => {
const e = el as HTMLElement;
e.style.maxHeight = e.scrollHeight + 'px';
requestAnimationFrame(() => {
e.style.maxHeight = '0px';
});
};
const onCapsuleAfterLeave = (el: Element) => {
(el as HTMLElement).style.maxHeight = '';
};
defineExpose({ resetAll, clearWorkflow, selectWorkflow, restoreWorkflowSelection, fetchWorkflows, selectedWorkflowId, commonWorkflows });
</script>
<style scoped lang="scss">
/* ===== 胶囊显示/隐藏过渡:淡入淡出 + 轻位移 + 高度展开/收缩 ===== */
.capsule-slide-enter-active,
.capsule-slide-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease, max-height 0.25s ease;
overflow: hidden;
}
.capsule-slide-enter-from,
.capsule-slide-leave-to {
opacity: 0;
transform: translateY(-6px);
}
.input-shell {
padding: 0 20px 20px;
background: transparent;
}
.input-card {
max-width: 880px;
margin: 0 auto;
background: #fff;
border: 1.5px solid #e5e8ee;
border-radius: 20px;
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.07);
transition:
border-color 0.2s,
box-shadow 0.2s;
overflow: hidden;
&.is-focused {
border-color: #93c5fd;
box-shadow:
0 0 0 3px rgba(59, 130, 246, 0.12),
0 2px 12px rgba(15, 23, 42, 0.07);
}
}
/* 已选标签栏 */
.selected-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 10px 14px 0;
}
.selected-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px 3px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
&.workflow-tag {
background: #eff6ff;
color: #2563eb;
border: 1px solid #bfdbfe;
}
.tag-close {
margin-left: 2px;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.15s;
&:hover {
opacity: 1;
}
}
}
/* 文本域 */
.message-input {
:deep(.el-textarea__inner) {
border: none;
box-shadow: none;
resize: none;
padding: 16px 18px 10px;
font-size: 15px;
line-height: 1.6;
color: #0f172a;
background: transparent;
min-height: 28px !important;
&::placeholder {
color: #94a3b8;
font-weight: 400;
}
}
}
/* 底部工具栏 */
.input-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px 12px 14px;
}
.toolbar-left,
.toolbar-right {
display: flex;
align-items: center;
gap: 4px;
}
.tool-icon-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 34px;
padding: 0 10px;
border: none;
border-radius: 10px;
background: transparent;
color: #64748b;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
outline: none;
.el-icon {
font-size: 15px;
}
&:hover {
background: #f1f5f9;
color: #334155;
}
&.active {
background: #eff6ff;
color: #2563eb;
}
}
.tool-label {
font-size: 12px;
}
.hint-text {
font-size: 11px;
color: #cbd5e1;
margin-right: 6px;
user-select: none;
}
.send-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: 10px;
background: #2563eb;
color: #fff;
cursor: pointer;
transition:
background 0.15s,
transform 0.1s,
opacity 0.15s;
outline: none;
.el-icon {
font-size: 16px;
}
&:hover:not(:disabled) {
background: #1d4ed8;
transform: translateY(-1px);
}
&:disabled {
background: #e2e8f0;
color: #94a3b8;
cursor: not-allowed;
transform: none;
}
/* 生成中:变停止按钮 */
&.is-stop {
background: #f43f5e;
color: #fff;
&:hover {
background: #e11d48;
}
}
}
/* 快捷工作流胶囊 */
.workflow-shortcuts {
max-width: 880px;
margin: 8px auto 0;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.shortcut-pill {
display: inline-flex;
align-items: center;
gap: 5px;
height: 32px;
padding: 0 12px;
border: 1px solid #e5e8ee;
border-radius: 999px;
background: rgba(255, 255, 255, 0.7);
color: #475569;
font-size: 13px;
font-weight: 500;
cursor: pointer;
backdrop-filter: blur(4px);
transition: all 0.15s;
outline: none;
.el-icon {
font-size: 13px;
}
&:hover {
border-color: #93c5fd;
color: #2563eb;
background: rgba(239, 246, 255, 0.9);
}
&.active {
border-color: #3b82f6;
background: #2563eb;
color: #fff;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3);
}
}
/* 「更多」入口:与普通胶囊区分,虚线边框 + 更轻的视觉重量,提示可进入管理页查看全部 */
.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;
padding: 2px 4px;
border-radius: 4px;
background: #fff7e6;
color: #d97706;
border: 1px solid #fde68a;
white-space: nowrap;
.shortcut-pill.active & {
background: rgba(255, 247, 230, 0.2);
color: #fff;
border-color: rgba(255, 255, 255, 0.4);
}
}
</style>