This commit is contained in:
2025-11-10 00:59:40 +08:00
parent 78c46aed71
commit bac96fcbe6
76 changed files with 8726 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,905 @@
<script setup>
import { ref, onMounted } from 'vue'
import { usePromptStore } from '@/stores/prompt'
import MarkdownIt from 'markdown-it'
import { message } from 'ant-design-vue'
import { CommonService } from '@/api/common'
import useVoiceText from '@gold/hooks/web/useVoiceText'
import GmIcon from '@/components/icons/Icon.vue'
const promptStore = usePromptStore()
const md = new MarkdownIt()
// 表单数据(合并为单一输入)
const form = ref({
prompt: '',
userInput: '', // 用户输入的文本或视频链接
amplitude: 50 // 幅度默认50%
})
// 生成的文案内容
const generatedContent = ref('')
// 编辑模式相关
const isEditMode = ref(false)
const editableContent = ref('')
const originalContent = ref('')
// 加载状态
const isLoading = ref(false)
const { getVoiceText } = useVoiceText()
// 页面加载时,如果有提示词则自动填充
onMounted(() => {
if (promptStore.currentPrompt) {
form.value.prompt = promptStore.currentPrompt
}
})
// 生成文案(流式)
async function generateCopywriting() {
const inputContent = form.value.userInput || ''
if (!inputContent.trim()) {
message.warning('请输入内容')
return
}
isLoading.value = true
generatedContent.value = '' // 清空之前的内容
try {
// 如果看起来是视频/音频链接,先尝试转写;否则直接作为文本
let userText = inputContent
if (isLikelyUrl(inputContent)) {
try {
message.info('正在获取视频转写...')
const transcriptions = await getVoiceText([{ audio_url: inputContent }])
const transcript = Array.isArray(transcriptions) && transcriptions[0] ? (transcriptions[0].value || '') : ''
if (transcript.trim()) {
userText = transcript
} else {
message.warning('未从链接获取到可用的语音文本,将直接使用原始输入')
}
} catch (e) {
console.warn('获取转写失败,使用原始输入:', e)
}
}
// 调用 callWorkflow 流式 API
const requestData = {
audio_prompt: form.value.prompt || '', // 音频提示词
user_text: userText, // 用户输入内容或由链接转写得到的文本
amplitude: form.value.amplitude // 幅度,范围 0-100
}
const ctrl = new AbortController()
let fullText = ''
let errorOccurred = false
let isResolved = false
await new Promise((resolve, reject) => {
// 设置超时
const timeout = setTimeout(() => {
if (!isResolved) {
ctrl.abort()
reject(new Error('请求超时,请稍后重试'))
}
}, 180000) // 3分钟超时
CommonService.callWorkflowStream({
data: requestData,
ctrl,
onMessage: (event) => {
try {
if (errorOccurred) return
const dataStr = event?.data || ''
if (!dataStr) return
try {
const obj = JSON.parse(dataStr)
// 根据实际返回格式解析
const piece = obj?.text || obj?.content || obj?.data || ''
if (piece) {
fullText += piece
generatedContent.value = fullText
}
} catch (parseErr) {
console.warn('解析流数据异常:', parseErr)
}
} catch (e) {
console.warn('解析流数据异常:', e)
}
},
onError: (err) => {
clearTimeout(timeout)
if (!isResolved) {
errorOccurred = true
ctrl.abort()
const errorMsg = err?.message || '网络请求失败'
console.error('SSE请求错误:', err)
message.error(errorMsg)
reject(new Error(errorMsg))
}
},
onClose: () => {
clearTimeout(timeout)
if (!isResolved) {
isResolved = true
resolve()
}
}
})
})
generatedContent.value = fullText.trim()
message.success('文案生成成功')
} catch (error) {
console.error('生成文案失败:', error)
message.error('生成文案失败,请重试')
} finally {
isLoading.value = false
}
}
// 获取当前输入值
function getCurrentInputValue() {
return (form.value.userInput || '').trim()
}
// 粗略判断是否为 URL含常见平台域名或 http(s) 开头)
function isLikelyUrl(value) {
if (!value) return false
const v = String(value).trim()
if (/^https?:\/\//i.test(v)) return true
return /(douyin\.com|bilibili\.com|youtube\.com|youtu\.be|tiktok\.com|ixigua\.com|v\.qq\.com)/i.test(v)
}
// 切换编辑模式
function toggleEditMode() {
if (!isEditMode.value) {
// 进入编辑模式
originalContent.value = generatedContent.value
editableContent.value = generatedContent.value
}
isEditMode.value = !isEditMode.value
}
// 保存编辑
function saveEdit() {
generatedContent.value = editableContent.value
isEditMode.value = false
message.success('文案已保存')
}
// 取消编辑
function cancelEdit() {
editableContent.value = originalContent.value
isEditMode.value = false
message.info('已取消编辑')
}
// 复制内容(编辑模式复制编辑区,否则复制生成内容),带降级方案
function copyContent() {
const text = isEditMode.value ? (editableContent.value || '') : (generatedContent.value || '')
if (!text.trim()) {
message.warning('没有可复制的内容')
return
}
// 优先使用异步 Clipboard API
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
message.success('文案已复制到剪贴板')
}).catch(() => {
// 降级到选中复制
fallbackCopy(text)
})
return
}
// 直接降级
fallbackCopy(text)
}
function fallbackCopy(text) {
try {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
const ok = document.execCommand('copy')
document.body.removeChild(textarea)
if (ok) {
message.success('文案已复制到剪贴板')
} else {
message.error('复制失败,请手动复制')
}
} catch (e) {
console.warn('fallback copy failed:', e)
message.error('复制失败,请手动复制')
}
}
defineOptions({ name: 'ContentStyleCopywriting' })
</script>
<template>
<div class="copywriting-page">
<!-- 页面标题区域 -->
<!-- 主要内容区域 -->
<div class="main-content">
<a-row :gutter="16">
<a-col :lg="10" :md="24">
<div class="form-section">
<a-card class="form-card" :bordered="false" title="创作设置">
<a-form :model="form" layout="vertical" class="form-container">
<a-form-item class="form-item">
<template #label>
基础提示词
<span class="form-tip-inline">从视频分析提取的提示词将作为文案生成的基础参考</span>
</template>
<a-textarea
v-model:value="form.prompt"
placeholder="这里显示从视频分析中提取的创作提示词,将作为文案生成的基础参考"
:auto-size="{ minRows: 6, maxRows: 12 }"
class="custom-textarea"
/>
</a-form-item>
<!-- 统一输入文本或视频链接 -->
<a-form-item class="form-item">
<template #label>
输入内容/视频链接
<span class="form-tip-inline">输入要生成的主题/段落或粘贴视频链接以自动转写</span>
</template>
<a-textarea
v-model:value="form.userInput"
placeholder="直接输入文字或粘贴视频链接抖音、B站、YouTube等"
:auto-size="{ minRows: 6, maxRows: 12 }"
class="custom-textarea"
/>
</a-form-item>
<!-- 幅度设置 -->
<a-form-item class="form-item">
<template #label>
创作幅度
<span class="form-tip-inline">调整创作幅度数值越大创意性越强</span>
</template>
<div class="amplitude-row">
<a-slider
v-model:value="form.amplitude"
:min="0"
:max="100"
:tooltip-formatter="(value) => `${value}%`"
style="flex: 1"
/>
<a-input-number
v-model:value="form.amplitude"
:min="0"
:max="100"
style="width: 96px; margin-left: 12px;"
/>
</div>
</a-form-item>
<a-form-item class="form-item">
<a-button
type="primary"
@click="generateCopywriting"
:disabled="!getCurrentInputValue() || isLoading"
:loading="isLoading"
block
size="large"
class="generate-btn"
>
<template v-if="!isLoading">
<span class="btn-icon"><GmIcon name="icon-sparkle" :size="16" /></span>
生成文案
</template>
<template v-else>
生成中...
</template>
</a-button>
</a-form-item>
</a-form>
</a-card>
</div>
</a-col>
<a-col :lg="14" :md="24">
<div class="result-section">
<a-card class="result-card" :bordered="false" title="生成结果">
<template #extra>
<a-space>
<a-button v-if="generatedContent" @click="toggleEditMode" size="small" class="action-btn">
<span class="btn-icon"><GmIcon :name="isEditMode ? 'icon-eye' : 'icon-edit'" :size="14" /></span>
{{ isEditMode ? '预览' : '编辑' }}
</a-button>
<a-button v-if="generatedContent" @click="copyContent" size="small" class="action-btn copy-btn">
<span class="btn-icon"><GmIcon name="icon-copy" :size="14" /></span>
复制
</a-button>
</a-space>
</template>
<div v-if="generatedContent" class="result-content">
<!-- 编辑模式 -->
<div v-if="isEditMode" class="edit-mode">
<a-textarea
v-model:value="editableContent"
:auto-size="{ minRows: 15, maxRows: 30 }"
placeholder="在这里编辑生成的文案内容..."
class="edit-textarea"
/>
<div class="edit-actions">
<a-space>
<a-button @click="saveEdit" type="primary" size="small" class="save-btn">
<span class="btn-icon"><GmIcon name="icon-save" :size="14" /></span>
保存
</a-button>
<a-button @click="cancelEdit" size="small" class="cancel-btn">
<span class="btn-icon"><GmIcon name="icon-cancel" :size="14" /></span>
取消
</a-button>
</a-space>
</div>
</div>
<!-- 预览模式 -->
<div v-else class="generated-content" v-html="md.render(generatedContent)"></div>
</div>
<a-empty v-else class="custom-empty">
<template #description>
<div class="empty-description">
<div class="empty-icon"><GmIcon name="icon-empty-doc" :size="36" /></div>
<div class="empty-title">等待生成文案</div>
<div class="empty-desc">请选择内容类型并填写相关信息,然后点击"生成文案"按钮</div>
</div>
</template>
</a-empty>
</a-card>
</div>
</a-col>
</a-row>
</div>
</div>
</template>
<style scoped>
/* 页面整体样式 */
.copywriting-page {
background: var(--color-bg);
min-height: 100vh;
padding: 0;
}
/* 页面标题区域 */
.page-header {
padding: 20px 0 30px 0;
text-align: center;
}
.header-content {
max-width: 600px;
margin: 0 auto;
padding: 0 20px;
}
.page-title {
font-size: 2rem;
font-weight: 600;
color: var(--color-text);
margin: 0 0 8px 0;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.title-icon {
font-size: 2rem;
}
.page-subtitle {
font-size: 1rem;
color: var(--color-text-secondary);
margin: 0;
font-weight: 400;
}
/* 区域标题 */
.section-header {
margin-bottom: 16px;
}
.section-icon {
font-size: 1.2rem;
margin-bottom: 4px;
display: block;
}
.section-title {
font-size: 1.1rem;
font-weight: 600;
color: #6b7280;
margin: 0 0 4px 0;
}
.section-desc {
font-size: 0.9rem;
color: #9ca3af;
margin: 0;
font-weight: 400;
}
/* 卡片样式 */
.form-card,
.result-card {
background: var(--color-surface);
border-radius: 8px;
box-shadow: var(--shadow-inset-card);
border: 1px solid var(--color-border);
transition: all 0.3s ease;
}
.form-card:hover,
.result-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.form-card :deep(.ant-card-head),
.result-card :deep(.ant-card-head) {
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding: 16px 20px;
}
.form-card :deep(.ant-card-head-title),
.result-card :deep(.ant-card-head-title) {
font-size: 1.05rem;
font-weight: 700;
color: var(--color-text);
}
.form-card :deep(.ant-card-body),
.result-card :deep(.ant-card-body) {
padding: 20px;
}
/* 表单容器 */
.form-container {
padding: 0;
}
.form-item {
margin-bottom: 20px;
}
.form-item :deep(.ant-form-item-label) {
padding-bottom: 8px;
}
.form-item :deep(.ant-form-item-label > label) {
font-weight: 600;
color: var(--color-text);
font-size: 14px;
}
/* 表单标签后的内联提示(不使用 emoji */
.form-tip-inline {
margin-left: 8px;
font-size: 12px;
color: var(--color-text-secondary);
font-weight: 400;
}
/* 自定义输入框样式 */
.custom-input,
.custom-textarea {
border-radius: 6px;
border: 1px solid var(--color-border);
transition: all 0.3s ease;
background: var(--color-surface);
color: var(--color-text);
}
.custom-input:focus,
.custom-textarea:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-glow);
background: var(--color-surface);
}
.custom-input:hover,
.custom-textarea:hover {
border-color: var(--color-border);
background: var(--color-surface);
}
.custom-textarea::placeholder {
color: var(--color-text-secondary);
}
/* 已合并输入:移除单选组相关样式 */
/* 输入区域动画 */
.input-section {
animation: slideIn 0.5s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 旧的块级提示兼容(如他处已有复用时可沿用) */
.form-tip {
display: none;
}
/* 生成按钮样式 */
.generate-btn {
margin-top: 16px;
height: 40px;
border-radius: 6px;
background: var(--color-primary);
border: none;
font-size: 14px;
font-weight: 500;
transition: all 0.3s ease;
}
.generate-btn:hover {
background: var(--color-primary);
box-shadow: var(--glow-primary);
}
.generate-btn:active {
background: var(--color-primary);
}
.btn-icon {
display: inline-flex;
align-items: center;
font-size: 16px;
}
/* 按钮禁用态保持主色,不换颜色(仅本页) */
:deep(.ant-btn-primary[disabled]),
:deep(.ant-btn-primary[disabled]:hover),
:deep(.ant-btn-primary[disabled]:active),
:deep(.ant-btn-primary.ant-btn-disabled),
:deep(.ant-btn-primary.ant-btn-disabled:hover),
:deep(.ant-btn-primary.ant-btn-disabled:active) {
background: var(--color-primary) !important;
border-color: var(--color-primary) !important;
color: #fff !important;
opacity: 0.6; /* 仅降低不透明度,不改变颜色 */
cursor: not-allowed;
box-shadow: none !important;
}
/* 操作按钮样式 */
.action-btn {
border-radius: 6px;
transition: all 0.3s ease;
font-weight: 500;
}
.action-btn:hover {
background: rgba(59, 130, 246, 0.08);
}
.copy-btn:hover {
background: rgba(59, 130, 246, 0.12);
border-color: var(--color-primary);
color: #fff;
}
/* 幅度滑块样式 */
.amplitude-row {
display: flex;
align-items: center;
gap: 12px;
}
/* 暗色下滑块可见性增强 */
:deep(.ant-slider) {
padding: 10px 0; /* 增加垂直留白,减少拥挤感 */
}
:deep(.ant-slider-rail) {
background-color: #252525; /* 未选中轨道更深,增强对比 */
height: 4px;
}
:deep(.ant-slider-track) {
background-color: var(--color-primary);
height: 4px;
}
:deep(.ant-slider:hover .ant-slider-track) {
background-color: var(--color-primary);
}
:deep(.ant-slider-handle::after) {
box-shadow: 0 0 0 2px var(--color-primary);
}
:deep(.ant-slider-handle:focus-visible::after),
:deep(.ant-slider-handle:hover::after),
:deep(.ant-slider-handle:active::after) {
box-shadow: 0 0 0 3px var(--color-primary-glow);
}
/* 空状态样式 */
.custom-empty {
padding: 40px 20px;
}
.empty-description {
text-align: center;
padding: 20px;
}
.empty-icon {
font-size: 3rem;
margin-bottom: 16px;
}
.empty-title {
font-size: 16px;
font-weight: 600;
color: #374151;
margin-bottom: 8px;
}
.empty-desc {
font-size: 14px;
color: #6b7280;
line-height: 1.6;
max-width: 300px;
margin: 0 auto;
}
/* 编辑模式样式 */
.edit-mode {
padding: 0;
background: transparent;
border-radius: 8px;
}
.edit-textarea {
margin-bottom: 12px;
border-radius: 6px;
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
font-size: 14px;
line-height: 1.6;
}
.edit-textarea:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-glow);
background: var(--color-surface);
color: var(--color-text);
}
.edit-textarea:hover {
border-color: var(--color-border);
}
.edit-textarea::placeholder {
color: var(--color-text-secondary);
}
.edit-actions {
text-align: right;
padding-top: 12px;
border-top: 1px solid var(--color-border);
margin-top: 12px;
}
.save-btn {
background: var(--color-primary);
border: 1px solid var(--color-primary);
border-radius: 6px;
font-weight: 500;
}
.save-btn:hover {
background: var(--color-primary);
filter: brightness(1.04);
box-shadow: var(--glow-primary);
}
.cancel-btn {
border-radius: 6px;
font-weight: 500;
}
/* 生成内容样式 */
.result-content {
min-height: 400px;
}
.generated-content {
padding: 24px;
background: #111111;
border-radius: 8px;
border: 1px solid var(--color-border);
line-height: 1.9;
color: #f5f5f5;
min-height: 400px;
font-size: 15.5px;
white-space: pre-wrap;
word-break: break-word;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.generated-content :deep(h1) {
font-size: 22px;
font-weight: 600;
margin-bottom: 16px;
color: #ffffff;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.generated-content :deep(h2) {
font-size: 19px;
font-weight: 600;
margin: 22px 0 12px 0;
color: #fff;
padding-bottom: 6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.generated-content :deep(h3) {
font-size: 16px;
font-weight: 600;
margin: 18px 0 10px 0;
color: #efefef;
}
.generated-content :deep(p) {
margin: 12px 0 14px 0;
color: #e3e6ea;
line-height: 1.9;
font-size: 15.5px;
}
.generated-content :deep(ul),
.generated-content :deep(ol) {
margin: 12px 0;
padding-left: 24px;
}
.generated-content :deep(li) {
margin: 6px 0;
color: #e3e6ea;
line-height: 1.9;
font-size: 15.5px;
}
.generated-content :deep(strong) {
font-weight: 600;
color: #ffffff;
}
.generated-content :deep(code) {
background: #0b0b0b;
padding: 3px 8px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 13.5px;
color: #ffb86c;
border: 1px solid rgba(255, 255, 255, 0.12);
}
.generated-content :deep(pre) {
background: #0b0b0b;
padding: 16px 18px;
border-radius: 6px;
overflow-x: auto;
margin: 12px 0;
border: 1px solid rgba(255, 255, 255, 0.12);
}
.generated-content :deep(pre code) {
background: transparent;
padding: 0;
border: none;
color: #ffb86c;
}
.generated-content :deep(blockquote) {
margin: 16px 0;
padding: 12px 16px;
background: rgba(22, 119, 255, 0.12);
border-left: 4px solid var(--color-primary);
border-radius: 0 6px 6px 0;
font-style: italic;
color: #e3e6ea;
}
/* 响应式设计 */
@media (max-width: 768px) {
.page-header {
padding: 16px 0 24px 0;
}
.page-title {
font-size: 1.5rem;
flex-direction: column;
gap: 6px;
}
.title-icon {
font-size: 1.5rem;
}
.main-content {
padding: 0 16px 24px 16px;
}
.form-card,
.result-card {
margin-bottom: 16px;
}
.form-card :deep(.ant-card-body),
.result-card :deep(.ant-card-body) {
padding: 16px;
}
.radio-label {
padding: 10px 12px;
}
.radio-icon {
font-size: 1rem;
width: 28px;
height: 28px;
margin-right: 10px;
}
.generate-btn {
height: 36px;
font-size: 13px;
}
.generated-content {
padding: 14px;
font-size: 15px;
}
}
</style>