Files
admin-ui/src/views/home/components/InputBar.vue
T
2910410219andClaude 490cf4bb93 首页「更多」入口改为常驻:点击跳转工作流管理页,不再依赖工作流数量超出 9 个
- InputBar 快捷胶囊区、MainContent 占位卡片区:去掉「超过 9 个才显示」条件,「更多」常驻在列表末尾
- 清理 MainContent 不再使用的 hasMorePlaceWorkflows 状态

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

549 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
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>