Files
trade-assistant/uni-app/src/pages/marketing/marketing.vue
T

709 lines
18 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>
<view class="marketing-container">
<view class="tabs">
<view
class="tab-item"
:class="{ active: activeTab === 'copy' }"
@click="switchTab('copy')"
>
开发信
</view>
<view
class="tab-item"
:class="{ active: activeTab === 'whatsapp' }"
@click="switchTab('whatsapp')"
>
WhatsApp话术
</view>
<view
class="tab-item"
:class="{ active: activeTab === 'product' }"
@click="switchTab('product')"
>
产品描述
</view>
<view
class="tab-item"
:class="{ active: activeTab === 'keywords' }"
@click="switchTab('keywords')"
>
关键词
</view>
</view>
<view class="stats-section" v-if="stats">
<view class="stat-card">
<text class="stat-value">{{ stats.today_copy || 0 }}</text>
<text class="stat-label">今日复制</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ stats.today_send || 0 }}</text>
<text class="stat-label">今日发送</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ stats.weekly_total || 0 }}</text>
<text class="stat-label">本周共</text>
</view>
</view>
<view class="form-section">
<view class="form-item">
<text class="form-label">产品名称</text>
<input class="form-input" v-model="formData.product_name" :placeholder="tabConfig[activeTab].namePlaceholder" />
</view>
<view class="form-item">
<text class="form-label">{{ tabConfig[activeTab].descLabel }}</text>
<textarea class="form-textarea" v-model="formData.description" :placeholder="tabConfig[activeTab].descPlaceholder" />
</view>
<view class="form-item" v-if="tabConfig[activeTab].showTarget">
<text class="form-label">目标市场</text>
<picker :range="targetMarkets" @change="onTargetChange">
<view class="picker-value">{{ formData.target || '选择目标市场' }}</view>
</picker>
</view>
<view class="form-item" v-if="tabConfig[activeTab].showStyle">
<text class="form-label">文案风格</text>
<view class="style-options">
<view
class="style-option"
:class="{ active: formData.style === 'professional' }"
@click="formData.style = 'professional'"
>
专业正式
</view>
<view
class="style-option"
:class="{ active: formData.style === 'friendly' }"
@click="formData.style = 'friendly'"
>
亲切友好
</view>
<view
class="style-option"
:class="{ active: formData.style === 'persuasive' }"
@click="formData.style = 'persuasive'"
>
促销风格
</view>
</view>
</view>
<button class="generate-btn" @click="generateContent" :disabled="loading">
{{ loading ? statusMessage || '生成中...' : tabConfig[activeTab].btnText }}
</button>
</view>
<view class="results-section" v-if="resultsMap[activeTab] && resultsMap[activeTab].length > 0">
<view class="results-header">
<text class="results-title">{{ tabConfig[activeTab].resultTitle }}</text>
<view class="results-actions">
<text class="refresh-btn" @click="generateContent">换一批</text>
<text class="export-btn" @click="exportCsv" v-if="activeTab !== 'keywords'">导出CSV</text>
</view>
</view>
<view class="style-tabs" v-if="activeTab !== 'keywords' && availableStyles.length > 1">
<view
class="style-tab"
v-for="s in availableStyles"
:key="s"
:class="{ active: selectedStyle === s }"
@click="selectedStyle = s"
>
{{ styleLabels[s] || s }}
</view>
</view>
<view class="results-list" v-if="activeTab !== 'keywords'">
<view class="result-item" v-for="(item, index) in filteredResults" :key="index">
<text class="result-text">{{ item.content || item }}</text>
<view class="result-actions">
<text class="copy-btn" @click="copyText(item.content || item)">复制</text>
<text class="send-btn" @click="sendToWhatsapp(item.content || item)" v-if="activeTab !== 'product'">发送</text>
<text class="competitor-btn" @click="runCompetitorAnalysis" v-if="activeTab === 'copy'">竞品分析</text>
</view>
</view>
</view>
<view class="keywords-list" v-if="activeTab === 'keywords' && resultsMap.keywords && resultsMap.keywords.length > 0">
<view class="keyword-tag" v-for="(kw, idx) in resultsMap.keywords" :key="idx" @click="copyText(kw)">
{{ kw }}
</view>
</view>
</view>
<view class="competitor-section" v-if="competitorResult">
<view class="competitor-header">
<text class="competitor-title">竞品分析结果</text>
<text class="competitor-close" @click="competitorResult = null">×</text>
</view>
<view class="competitor-content">
<text class="competitor-text">{{ competitorResult }}</text>
</view>
</view>
<view class="empty" v-if="!loading && (!resultsMap[activeTab] || resultsMap[activeTab].length === 0)">
<text>{{ tabConfig[activeTab].emptyHint }}</text>
</view>
</view>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { marketingApi, interactionApi } from '@/utils/api.js'
const tabConfig = {
copy: {
label: '开发信',
category: 'sales_letter',
namePlaceholder: '如: 户外折叠椅',
descLabel: '产品描述',
descPlaceholder: '描述产品的特点、规格、优势...',
btnText: '生成开发信',
resultTitle: '生成的开发信',
emptyHint: '输入产品信息,点击生成开发信',
showTarget: true,
showStyle: true,
},
whatsapp: {
label: 'WhatsApp话术',
category: 'whatsapp',
namePlaceholder: '如: 户外折叠椅',
descLabel: '产品及沟通场景',
descPlaceholder: '描述产品特点,以及和客户沟通的具体场景...',
btnText: '生成话术',
resultTitle: '生成的WhatsApp话术',
emptyHint: '输入产品信息,点击生成话术',
showTarget: true,
showStyle: true,
},
product: {
label: '产品描述',
category: 'product_description',
namePlaceholder: '如: 户外折叠椅',
descLabel: '产品详细规格',
descPlaceholder: '描述产品的材质、尺寸、承重、颜色、包装等规格...',
btnText: '生成描述',
resultTitle: '生成的产品描述',
emptyHint: '输入产品信息,点击生成描述',
showTarget: false,
showStyle: false,
},
keywords: {
label: '关键词',
category: '',
namePlaceholder: '如: 户外折叠椅',
descLabel: '产品卖点',
descPlaceholder: '描述产品的核心卖点和目标客户群体...',
btnText: '生成关键词',
resultTitle: '关键词建议',
emptyHint: '输入产品信息,点击生成关键词',
showTarget: false,
showStyle: false,
},
}
const activeTab = ref('copy')
const loading = ref(false)
const statusMessage = ref('')
const resultsMap = reactive({ copy: [], whatsapp: [], product: [], keywords: [] })
const competitorResult = ref(null)
const stats = ref(null)
const selectedStyle = ref('professional')
const styleLabels = {
professional: '专业正式',
friendly: '亲切友好',
urgent: '紧急催促',
benefit_focused: '利益导向',
storytelling: '故事叙述',
}
const availableStyles = computed(() => {
const items = resultsMap[activeTab.value]
if (!items || items.length === 0 || typeof items[0] === 'string') return []
const styles = [...new Set(items.map(i => i.style).filter(Boolean))]
return styles
})
const filteredResults = computed(() => {
const items = resultsMap[activeTab.value]
if (!items || items.length === 0) return []
if (typeof items[0] === 'string') return items
const style = selectedStyle.value
const filtered = items.filter(i => i.style === style)
return filtered.length > 0 ? filtered : items
})
const formData = ref({
product_name: '',
description: '',
target: 'US importers',
style: 'professional',
})
const targetMarkets = ref([
'US importers',
'European buyers',
'Southeast Asia',
'Latin America',
'Middle East',
])
const onTargetChange = (e) => {
formData.value.target = targetMarkets.value[e.detail.value]
}
const switchTab = (tab) => {
activeTab.value = tab
loadStats()
}
const loadStats = async () => {
try {
const res = await interactionApi.getMarketingEffectStats()
stats.value = res
} catch (err) {
console.error('加载统计失败', err)
}
}
const generateContent = async () => {
if (!formData.value.product_name) {
uni.showToast({ title: '请输入产品名称', icon: 'none' })
return
}
loading.value = true
statusMessage.value = 'AI 准备中...'
const tab = activeTab.value
try {
if (tab === 'keywords') {
statusMessage.value = '正在提取关键词...'
const res = await marketingApi.getKeywords(
formData.value.product_name,
formData.value.description,
''
)
resultsMap[tab] = res.keywords || []
} else {
const cfg = tabConfig[tab]
statusMessage.value = 'AI 正在生成文案(约15-30秒)...'
setTimeout(() => {
if (loading.value) statusMessage.value = 'AI 处理中,请稍候...'
}, 8000)
setTimeout(() => {
if (loading.value) statusMessage.value = '即将完成...'
}, 20000)
const res = await marketingApi.generate(
formData.value.product_name,
formData.value.description,
cfg.category,
formData.value.target,
formData.value.style
)
resultsMap[tab] = res.results || []
if (res.results && res.results.length > 0) {
selectedStyle.value = res.results[0].style || formData.value.style
}
loadStats()
}
} catch (err) {
uni.showToast({ title: err.message || '生成失败', icon: 'none' })
} finally {
loading.value = false
statusMessage.value = ''
}
}
const copyText = (text) => {
if (!text) {
uni.showToast({ title: '内容为空', icon: 'none' })
return
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
interactionApi.trackMarketingEffect({ action: 'copy', content_preview: text.slice(0, 100) }).catch(() => {})
loadStats()
uni.showToast({ title: '已复制', icon: 'success' })
}).catch(() => fallbackCopy(text))
} else {
fallbackCopy(text)
}
}
const fallbackCopy = (text) => {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
try {
document.execCommand('copy')
interactionApi.trackMarketingEffect({ action: 'copy', content_preview: text.slice(0, 100) }).catch(() => {})
loadStats()
uni.showToast({ title: '已复制', icon: 'success' })
} catch {
uni.showToast({ title: '复制失败,请手动选择复制', icon: 'none' })
}
document.body.removeChild(ta)
}
const exportCsv = () => {
const items = resultsMap[activeTab.value]
if (!items || items.length === 0) return
let csv = 'Content\n'
items.forEach(r => {
const text = typeof r === 'string' ? r : (r.content || '')
csv += `"${text.replace(/"/g, '""')}"\n`
})
const blob = new Blob([csv], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
uni.downloadFile({
url,
success: (res) => {
uni.saveFile({ tempFilePath: res.tempFilePath })
uni.showToast({ title: '导出成功', icon: 'success' })
},
fail: () => { uni.showToast({ title: '导出失败', icon: 'none' }) },
})
}
const sendToWhatsapp = (text) => {
interactionApi.trackMarketingEffect({ action: 'send', content_preview: text.slice(0, 100) }).catch(() => {})
loadStats()
uni.showToast({ title: '请先选择客户', icon: 'none' })
}
const runCompetitorAnalysis = async () => {
if (!formData.value.product_name) {
uni.showToast({ title: '请先输入产品名称', icon: 'none' })
return
}
try {
uni.showLoading({ title: '分析中...' })
const res = await marketingApi.competitorAnalysis(
formData.value.product_name,
formData.value.description,
'',
formData.value.target
)
uni.hideLoading()
const analysis = res.analysis
if (!analysis || Object.keys(analysis).length === 0) {
competitorResult.value = '暂无分析结果,请确认产品信息后重试'
return
}
const lines = []
if (analysis.price_range) lines.push(`💰 价格范围:${analysis.price_range}`)
if (analysis.market_trends) lines.push(`📈 市场趋势:${analysis.market_trends}`)
if (analysis.key_selling_points && analysis.key_selling_points.length) {
lines.push(`\n🏆 核心卖点:\n ${analysis.key_selling_points.map(p => `${p}`).join('\n ')}`)
}
if (analysis.common_keywords && analysis.common_keywords.length) {
lines.push(`\n🔑 常见关键词:\n ${analysis.common_keywords.map(k => `${k}`).join('\n ')}`)
}
if (analysis.suggestions && analysis.suggestions.length) {
lines.push(`\n💡 建议:\n ${analysis.suggestions.map(s => `${s}`).join('\n ')}`)
}
competitorResult.value = lines.length ? lines.join('\n') : '暂无分析结果'
} catch (err) {
uni.hideLoading()
uni.showToast({ title: err.message || '分析失败', icon: 'none' })
}
}
</script>
<style lang="scss" scoped>
.marketing-container {
min-height: 100vh;
background: #f5f5f5;
padding: 20rpx;
}
.tabs {
display: flex;
background: #fff;
border-radius: 12rpx;
margin-bottom: 20rpx;
overflow-x: auto;
}
.tab-item {
flex: 1;
min-width: 160rpx;
text-align: center;
padding: 24rpx 16rpx;
font-size: 26rpx;
color: #666;
white-space: nowrap;
}
.tab-item.active {
color: #1890ff;
border-bottom: 4rpx solid #1890ff;
}
.stats-section {
display: flex;
gap: 16rpx;
margin-bottom: 20rpx;
}
.stat-card {
flex: 1;
background: #fff;
border-radius: 12rpx;
padding: 20rpx;
text-align: center;
}
.stat-value {
font-size: 40rpx;
font-weight: 700;
color: #1890ff;
display: block;
}
.stat-label {
font-size: 22rpx;
color: #999;
margin-top: 8rpx;
}
.form-section {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
}
.form-item {
margin-bottom: 24rpx;
}
.form-label {
font-size: 26rpx;
color: #666;
display: block;
margin-bottom: 12rpx;
}
.form-input {
width: 100%;
height: 72rpx;
background: #f5f5f5;
border-radius: 8rpx;
padding: 0 20rpx;
font-size: 28rpx;
}
.form-textarea {
width: 100%;
min-height: 160rpx;
background: #f5f5f5;
border-radius: 8rpx;
padding: 20rpx;
font-size: 28rpx;
}
.picker-value {
height: 72rpx;
background: #f5f5f5;
border-radius: 8rpx;
padding: 0 20rpx;
display: flex;
align-items: center;
font-size: 28rpx;
}
.style-options {
display: flex;
gap: 16rpx;
}
.style-option {
flex: 1;
padding: 16rpx;
background: #f5f5f5;
border-radius: 8rpx;
text-align: center;
font-size: 26rpx;
color: #666;
}
.style-option.active {
background: #e6f7ff;
color: #1890ff;
border: 2rpx solid #1890ff;
}
.style-tabs {
display: flex;
gap: 12rpx;
margin-bottom: 20rpx;
overflow-x: auto;
padding-bottom: 4rpx;
}
.style-tab {
flex-shrink: 0;
padding: 12rpx 24rpx;
background: #f5f5f5;
border-radius: 8rpx;
font-size: 24rpx;
color: #666;
white-space: nowrap;
}
.style-tab.active {
background: #e6f7ff;
color: #1890ff;
border: 2rpx solid #1890ff;
}
.generate-btn {
width: 100%;
height: 88rpx;
background: #1890ff;
color: #fff;
border-radius: 12rpx;
font-size: 30rpx;
margin-top: 20rpx;
}
.results-section {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
}
.results-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.results-title {
font-size: 28rpx;
font-weight: 600;
}
.results-actions {
display: flex;
gap: 16rpx;
}
.refresh-btn {
font-size: 24rpx;
color: #1890ff;
}
.export-btn {
font-size: 24rpx;
color: #52c41a;
}
.results-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.result-item {
padding: 20rpx;
background: #f9f9f9;
border-radius: 12rpx;
}
.result-text {
font-size: 26rpx;
line-height: 1.6;
display: block;
margin-bottom: 16rpx;
}
.result-actions {
display: flex;
gap: 20rpx;
}
.copy-btn, .send-btn, .competitor-btn {
font-size: 24rpx;
padding: 8rpx 20rpx;
border-radius: 6rpx;
}
.copy-btn {
background: #e6f7ff;
color: #1890ff;
}
.send-btn {
background: #07c160;
color: #fff;
}
.competitor-btn {
background: #f9f0ff;
color: #722ed1;
}
.keywords-list {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.keyword-tag {
padding: 12rpx 24rpx;
background: #e6f7ff;
color: #1890ff;
border-radius: 20rpx;
font-size: 26rpx;
}
.competitor-section {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
}
.competitor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
}
.competitor-title {
font-size: 26rpx;
color: #722ed1;
font-weight: 600;
}
.competitor-close {
font-size: 36rpx;
color: #999;
}
.competitor-content {
padding: 16rpx;
background: #f9f0ff;
border-radius: 8rpx;
}
.competitor-text {
font-size: 24rpx;
line-height: 1.5;
white-space: pre-wrap;
}
.empty {
text-align: center;
color: #999;
padding: 100rpx;
}
</style>