Files
admin-ui/src/views/home/components/InputBar.vue
T
2026-08-14 19:57:46 +08:00

469 lines
10 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="!workflowLocked" class="tag-close" @click="clearWorkflow"><Close /></el-icon>
</div>
</div>
</Transition>
<!-- 文本输入区 -->
<el-input
v-model="message"
type="textarea"
:autosize="{ minRows: 1, maxRows: 6 }"
placeholder="有什么想聊的?按 Enter 发送,Shift+Enter 换行"
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" 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>
</div>
<div class="toolbar-right">
<span class="hint-text">Shift+Enter 换行</span>
<button
class="send-btn"
:class="{ 'is-stop': generating }"
:disabled="!generating && sendDisabled"
@click="generating ? emit('stop') : handleSend()"
>
<el-icon v-if="generating"><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>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { Top, MagicStick, Promotion, Close, Paperclip, VideoPause } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { getWorkflowList } from '/@/api/settings/creation';
interface Props {
sendDisabled?: boolean;
workflowLocked?: boolean;
hideShortcuts?: boolean;
generating?: boolean;
}
interface Emits {
(e: 'send', message: string): void;
(e: 'workflow-select', workflowId: string | null, isTemplate?: boolean): void;
(e: 'stop'): void;
}
interface Workflow {
id: string;
name: string;
prefix: string;
isTemplate: boolean;
raw?: any;
}
const props = withDefaults(defineProps<Props>(), {
sendDisabled: false,
workflowLocked: false,
hideShortcuts: false,
generating: false,
});
const emit = defineEmits<Emits>();
const message = ref('');
const isFocused = ref(false);
const selectedWorkflowId = ref<string | null>(null);
const commonWorkflows = ref<Workflow[]>([]);
const visibleWorkflows = computed(() => {
if (props.workflowLocked && selectedWorkflowId.value) {
return commonWorkflows.value.filter((w) => w.id === selectedWorkflowId.value);
}
return commonWorkflows.value;
});
const fetchWorkflows = async () => {
try {
const res = await getWorkflowList();
const userList = res.data?.listFlowUserRes?.list || [];
const tplList = res.data?.listFlowTemplateRes?.list || [];
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.sendDisabled) return;
const msg = message.value.trim();
emit('send', msg || '');
message.value = '';
// 不清除工作流选择,保持表单可见
};
const handleAttachment = () => {
ElMessage.info('附件上传功能开发中...');
};
const toggleWorkflow = (id: string) => {
if (props.workflowLocked) return;
const item = commonWorkflows.value.find((w) => w.id === id);
const newId = selectedWorkflowId.value === id ? null : id;
selectedWorkflowId.value = newId;
emit('workflow-select', newId, item?.isTemplate || false);
};
const resetAll = () => {
message.value = '';
selectedWorkflowId.value = null;
emit('workflow-select', null);
};
const clearWorkflow = () => {
if (props.workflowLocked) return;
selectedWorkflowId.value = null;
emit('workflow-select', null);
};
const selectWorkflow = (id: string | null) => {
if (props.workflowLocked) return;
selectedWorkflowId.value = id;
emit('workflow-select', id, false);
};
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, 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);
}
}
.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>