- InputBar 快捷胶囊区、MainContent 占位卡片区:去掉「超过 9 个才显示」条件,「更多」常驻在列表末尾 - 清理 MainContent 不再使用的 hasMorePlaceWorkflows 状态 Co-Authored-By: Claude <noreply@anthropic.com>
549 lines
14 KiB
Vue
549 lines
14 KiB
Vue
<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
|
||
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>
|