Files
sionrui/frontend/app/web-gold/src/views/kling/components/TextGeneratePopup.vue
2026-03-16 23:54:01 +08:00

521 lines
12 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>
<Teleport to="body">
<transition name="popover-fade">
<div
v-if="visible"
class="popover-overlay"
@click.self="handleClose"
@mousedown.self="handleClose"
>
<div
class="popover-card"
:style="popoverStyle"
@click.stop
>
<!-- 头部 -->
<div class="popover-header">
<span class="popover-title">AI 文案生成</span>
<button class="close-btn" @click="handleClose">
<Icon icon="lucide:x" class="w-3.5 h-3.5" />
</button>
</div>
<!-- 内容区 -->
<div class="popover-body">
<!-- 智能体选择 -->
<div class="form-item">
<label class="form-label">选择智能体</label>
<Select v-model="selectedAgentId" :disabled="loadingAgents" class="agent-select">
<SelectTrigger class="agent-select-trigger">
<SelectValue :placeholder="loadingAgents ? '加载中...' : '请选择智能体'" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="agent in agentList"
:key="agent.id"
:value="agent.id"
>
<div class="agent-option">
<img v-if="agent.icon" :src="agent.icon" class="agent-icon" />
<span class="agent-name">{{ agent.agentName }}</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 主题输入 -->
<div class="form-item">
<label class="form-label">文案主题</label>
<input
v-model="theme"
type="text"
class="theme-input"
placeholder="如:产品介绍、活动推广..."
:disabled="isGenerating"
@keydown.enter="handleGenerate"
/>
</div>
<!-- 生成结果预览 -->
<div v-if="generatedText" class="result-preview">
<div class="result-content">
{{ generatedText }}
<span v-if="isGenerating" class="cursor-blink">|</span>
</div>
</div>
</div>
<!-- 底部按钮 -->
<div class="popover-footer">
<button class="btn btn-cancel" @click="handleClose">取消</button>
<button
class="btn btn-primary"
:disabled="!canGenerate || isGenerating"
@click="handleGenerate"
>
<LoadingOutlined v-if="isGenerating" class="spin" />
<span>{{ isGenerating ? '生成中...' : '生成' }}</span>
</button>
</div>
</div>
</div>
</transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted } from 'vue'
import { Icon } from '@iconify/vue'
import { toast } from 'vue-sonner'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { getAgentList, sendChatStream } from '@/api/agent'
// Props
const props = defineProps<{
visible: boolean
theme?: string
}>()
// Emits
const emit = defineEmits<{
'update:visible': [value: boolean]
'success': [text: string]
'error': [msg: string]
}>()
// 状态
const agentList = ref<any[]>([])
const loadingAgents = ref(false)
const selectedAgentId = ref<number | null>(null)
const theme = ref('')
const generatedText = ref('')
const isGenerating = ref(false)
const abortController = ref<AbortController | null>(null)
const popoverStyle = ref<Record<string, string>>({})
// 计算属性
const canGenerate = computed(() => {
return selectedAgentId.value && theme.value.trim().length > 0
})
// 获取智能体列表
const fetchAgents = async () => {
loadingAgents.value = true
try {
const res = await getAgentList()
if (res.code === 0 && res.data) {
agentList.value = res.data
// 默认选中第一个
if (res.data.length > 0 && !selectedAgentId.value) {
selectedAgentId.value = res.data[0].id
}
}
} catch (error) {
console.error('获取智能体列表失败:', error)
} finally {
loadingAgents.value = false
}
}
// 更新气泡位置
const updatePosition = () => {
// 找到触发按钮
const triggerBtn = document.querySelector('.generate-text-btn')
if (triggerBtn) {
const rect = triggerBtn.getBoundingClientRect()
const popoverWidth = 320
const popoverHeight = 280
// 计算位置,确保不超出视口
let left = rect.right - popoverWidth
let top = rect.bottom + 8
// 边界检测
if (left < 16) left = 16
if (left + popoverWidth > window.innerWidth - 16) {
left = window.innerWidth - popoverWidth - 16
}
if (top + popoverHeight > window.innerHeight - 16) {
top = rect.top - popoverHeight - 8
}
popoverStyle.value = {
position: 'fixed',
left: `${left}px`,
top: `${top}px`,
width: `${popoverWidth}px`,
}
}
}
// 生成文案
const handleGenerate = async () => {
if (!canGenerate.value || isGenerating.value) return
const selectedAgent = agentList.value.find(a => a.id === selectedAgentId.value)
if (!selectedAgent) {
toast.warning('请选择智能体')
return
}
isGenerating.value = true
generatedText.value = ''
abortController.value = new AbortController()
const prompt = `请根据以下主题生成一段播报文案,要求:
1. 语言流畅自然,适合口播
2. 内容简洁有吸引力
3. 不要使用markdown格式直接输出纯文本
主题:${theme.value}`
try {
await sendChatStream({
agentId: selectedAgent.id,
content: prompt,
ctrl: abortController.value,
onMessage: (result: { event: string; content?: string; errorMessage?: string }) => {
if (result.event === 'message' && result.content) {
generatedText.value += result.content
} else if (result.event === 'error') {
toast.error(result.errorMessage || '生成出错')
isGenerating.value = false
}
},
onError: () => {
toast.error('生成失败,请重试')
isGenerating.value = false
},
onClose: () => {
isGenerating.value = false
// 生成完成,触发成功回调
if (generatedText.value) {
emit('success', generatedText.value.trim())
}
}
})
} catch (error: any) {
if (error.name !== 'AbortError') {
toast.error('生成失败')
}
isGenerating.value = false
}
}
// 关闭弹窗
const handleClose = () => {
if (isGenerating.value) {
abortController.value?.abort()
isGenerating.value = false
}
generatedText.value = ''
theme.value = ''
emit('update:visible', false)
}
// 监听 visible 变化
watch(() => props.visible, (val) => {
if (val) {
fetchAgents()
updatePosition()
// 监听窗口大小变化
window.addEventListener('resize', updatePosition)
} else {
window.removeEventListener('resize', updatePosition)
}
})
// 清理
onUnmounted(() => {
window.removeEventListener('resize', updatePosition)
if (abortController.value) {
abortController.value.abort()
}
})
</script>
<style scoped lang="less">
// Notion 风格配色
@bg-popover: #ffffff;
@bg-hover: #f7f6f3;
@text-primary: #37352f;
@text-secondary: #787774;
@text-tertiary: #b4b4b4;
@border-color: #e9e9e7;
@primary-color: #2e75cc;
@primary-hover: #1f63cb;
// 过渡动画
.popover-fade-enter-active,
.popover-fade-leave-active {
transition: all 0.2s ease;
}
.popover-fade-enter-from,
.popover-fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
// 遮罩层(透明,仅用于点击外部关闭)
.popover-overlay {
position: fixed;
inset: 0;
z-index: 1000;
}
// 气泡卡片
.popover-card {
background: @bg-popover;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 1px rgba(0, 0, 0, 0.1);
border: 1px solid @border-color;
overflow: hidden;
z-index: 1001;
}
// 头部
.popover-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid @border-color;
}
.popover-title {
font-size: 13px;
font-weight: 600;
color: @text-primary;
}
.close-btn {
width: 22px;
height: 22px;
padding: 0;
border: none;
background: transparent;
border-radius: 4px;
color: @text-tertiary;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
transition: all 0.15s ease;
&:hover {
background: @bg-hover;
color: @text-secondary;
}
}
// 内容区
.popover-body {
padding: 14px;
}
.form-item {
margin-bottom: 14px;
&:last-child {
margin-bottom: 0;
}
}
.form-label {
display: block;
font-size: 12px;
font-weight: 500;
color: @text-secondary;
margin-bottom: 6px;
}
.agent-select {
width: 100%;
:deep(.ant-select-selector) {
background: @bg-hover !important;
border-radius: 6px !important;
padding: 4px 10px !important;
min-height: 32px !important;
display: flex !important;
align-items: center !important;
}
:deep(.ant-select-selection-item) {
font-size: 13px;
color: @text-primary;
line-height: 24px !important;
display: flex !important;
align-items: center !important;
}
}
.agent-option {
display: flex;
align-items: center;
gap: 8px;
}
.agent-icon {
width: 18px;
height: 18px;
border-radius: 4px;
object-fit: cover;
}
.agent-name {
font-size: 13px;
color: @text-primary;
}
.theme-input {
width: 100%;
height: 32px;
padding: 0 10px;
border: 1px solid @border-color;
border-radius: 6px;
font-size: 13px;
color: @text-primary;
background: @bg-hover;
outline: none;
transition: all 0.15s ease;
&::placeholder {
color: @text-tertiary;
}
&:focus {
border-color: @primary-color;
box-shadow: 0 0 0 2px rgba(46, 117, 204, 0.1);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
// 生成结果预览
.result-preview {
margin-top: 12px;
padding: 10px;
background: @bg-hover;
border-radius: 6px;
max-height: 120px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: @text-tertiary;
border-radius: 2px;
}
}
.result-content {
font-size: 13px;
line-height: 1.5;
color: @text-primary;
white-space: pre-wrap;
word-break: break-word;
}
.cursor-blink {
color: @primary-color;
animation: blink 1s infinite;
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
// 底部按钮
.popover-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 14px;
border-top: 1px solid @border-color;
}
.btn {
height: 30px;
padding: 0 14px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
display: inline-flex;
align-items: center;
gap: 4px;
&.btn-cancel {
background: transparent;
border: none;
color: @text-secondary;
&:hover {
background: @bg-hover;
color: @text-primary;
}
}
&.btn-primary {
background: @primary-color;
border: none;
color: #fff;
&:hover:not(:disabled) {
background: @primary-hover;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
.spin {
font-size: 12px;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>