feat: silent wechat login, marketing tab optimization, admin page foundation
- Add silent WeChat login for MP/browser environments - Fix Python 3.6 compatibility (remove typing.Annotated usage) - Marketing page: tab-based content generation with category support - Translate page: add auto-detect language default - Homepage: add TTS playback, announcement ticker, remove redundant quick-actions - Fix FAB button overlap with custom tabbar on customers/quotation pages - Make openai/anthropic imports lazy for Python 3.6 compat
This commit is contained in:
@@ -4,28 +4,28 @@
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === 'copy' }"
|
||||
@click="activeTab = 'copy'; activeTab === 'copy' && loadStats()"
|
||||
@click="switchTab('copy')"
|
||||
>
|
||||
开发信
|
||||
</view>
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === 'whatsapp' }"
|
||||
@click="activeTab = 'whatsapp'; activeTab === 'whatsapp' && loadStats()"
|
||||
@click="switchTab('whatsapp')"
|
||||
>
|
||||
WhatsApp话术
|
||||
</view>
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === 'product' }"
|
||||
@click="activeTab = 'product'; activeTab === 'product' && loadStats()"
|
||||
@click="switchTab('product')"
|
||||
>
|
||||
产品描述
|
||||
</view>
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === 'keywords' }"
|
||||
@click="activeTab = 'keywords'"
|
||||
@click="switchTab('keywords')"
|
||||
>
|
||||
关键词
|
||||
</view>
|
||||
@@ -49,19 +49,19 @@
|
||||
<view class="form-section">
|
||||
<view class="form-item">
|
||||
<text class="form-label">产品名称</text>
|
||||
<input class="form-input" v-model="formData.product_name" placeholder="如: 户外折叠椅" />
|
||||
<input class="form-input" v-model="formData.product_name" :placeholder="tabConfig[activeTab].namePlaceholder" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">产品描述</text>
|
||||
<textarea class="form-textarea" v-model="formData.description" placeholder="描述产品的特点、规格、优势..." />
|
||||
<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">
|
||||
<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">
|
||||
<view class="form-item" v-if="tabConfig[activeTab].showStyle">
|
||||
<text class="form-label">文案风格</text>
|
||||
<view class="style-options">
|
||||
<view
|
||||
@@ -88,34 +88,30 @@
|
||||
</view>
|
||||
</view>
|
||||
<button class="generate-btn" @click="generateContent" :disabled="loading">
|
||||
{{ loading ? '生成中...' : '生成文案' }}
|
||||
{{ loading ? '生成中...' : tabConfig[activeTab].btnText }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="results-section" v-if="results.length > 0 && activeTab !== 'keywords'">
|
||||
<view class="results-section" v-if="resultsMap[activeTab] && resultsMap[activeTab].length > 0">
|
||||
<view class="results-header">
|
||||
<text class="results-title">生成的文案</text>
|
||||
<text class="refresh-btn" @click="generateContent">换一批</text>
|
||||
<text class="export-btn" @click="exportCsv">导出CSV</text>
|
||||
<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="results-list">
|
||||
<view class="result-item" v-for="(item, index) in results" :key="index">
|
||||
<view class="results-list" v-if="activeTab !== 'keywords'">
|
||||
<view class="result-item" v-for="(item, index) in resultsMap[activeTab]" :key="index">
|
||||
<text class="result-text">{{ item }}</text>
|
||||
<view class="result-actions">
|
||||
<text class="copy-btn" @click="copyText(item)">复制</text>
|
||||
<text class="send-btn" @click="sendToWhatsapp(item)">发送</text>
|
||||
<text class="competitor-btn" @click="runCompetitorAnalysis">竞品分析</text>
|
||||
<text class="send-btn" @click="sendToWhatsapp(item)" v-if="activeTab !== 'product'">发送</text>
|
||||
<text class="competitor-btn" @click="runCompetitorAnalysis" v-if="activeTab === 'copy'">竞品分析</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="history-section" v-if="activeTab === 'keywords' && keywords.length > 0">
|
||||
<view class="history-header">
|
||||
<text class="history-title">关键词建议</text>
|
||||
</view>
|
||||
<view class="keywords-list">
|
||||
<view class="keyword-tag" v-for="(kw, idx) in keywords" :key="idx" @click="copyText(kw)">
|
||||
<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>
|
||||
@@ -131,20 +127,70 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="empty" v-if="!loading && results.length === 0 && activeTab !== 'keywords'">
|
||||
<text>输入产品信息,点击生成文案</text>
|
||||
<view class="empty" v-if="!loading && (!resultsMap[activeTab] || resultsMap[activeTab].length === 0)">
|
||||
<text>{{ tabConfig[activeTab].emptyHint }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, reactive } 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 results = ref([])
|
||||
const keywords = ref([])
|
||||
const resultsMap = reactive({ copy: [], whatsapp: [], product: [], keywords: [] })
|
||||
const competitorResult = ref(null)
|
||||
const stats = ref(null)
|
||||
|
||||
@@ -167,6 +213,11 @@ 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()
|
||||
@@ -183,24 +234,26 @@ const generateContent = async () => {
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
const tab = activeTab.value
|
||||
|
||||
try {
|
||||
if (activeTab.value === 'keywords') {
|
||||
if (tab === 'keywords') {
|
||||
const res = await marketingApi.getKeywords(
|
||||
formData.value.product_name,
|
||||
formData.value.description,
|
||||
''
|
||||
)
|
||||
keywords.value = res.keywords || []
|
||||
resultsMap[tab] = res.keywords || []
|
||||
} else {
|
||||
const cfg = tabConfig[tab]
|
||||
const res = await marketingApi.generate(
|
||||
formData.value.product_name,
|
||||
formData.value.description,
|
||||
'',
|
||||
cfg.category,
|
||||
formData.value.target,
|
||||
formData.value.style
|
||||
)
|
||||
results.value = res.results || []
|
||||
resultsMap[tab] = res.results || []
|
||||
loadStats()
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -222,9 +275,10 @@ const copyText = (text) => {
|
||||
}
|
||||
|
||||
const exportCsv = () => {
|
||||
if (results.value.length === 0) return
|
||||
const items = resultsMap[activeTab.value]
|
||||
if (!items || items.length === 0) return
|
||||
let csv = 'Content\n'
|
||||
results.value.forEach(r => { csv += `"${r.replace(/"/g, '""')}"\n` })
|
||||
items.forEach(r => { csv += `"${r.replace(/"/g, '""')}"\n` })
|
||||
const blob = new Blob([csv], { type: 'text/csv' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
uni.downloadFile({
|
||||
@@ -414,6 +468,11 @@ const runCompetitorAnalysis = async () => {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.results-actions {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
font-size: 24rpx;
|
||||
color: #1890ff;
|
||||
@@ -422,7 +481,6 @@ const runCompetitorAnalysis = async () => {
|
||||
.export-btn {
|
||||
font-size: 24rpx;
|
||||
color: #52c41a;
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
|
||||
.results-list {
|
||||
@@ -470,19 +528,6 @@ const runCompetitorAnalysis = async () => {
|
||||
color: #722ed1;
|
||||
}
|
||||
|
||||
.history-section {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.history-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.keywords-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
Reference in New Issue
Block a user