Add user-friendly loading feedback for all AI/long-running operations
- Discovery: show '搜索中约需30-60秒' message, auto-save to history, timeout hint - Discovery extract/outreach: show '正在分析网站/生成文案' loading message - Translate: inline '翻译中...' placeholder while waiting - Marketing: inline 'AI 生成中...' placeholder, success feedback - Quotations AI: inline progress text + ElMessage.info during generation - Analytics: add v-loading skeleton with '加载数据分析中...' - Notifications: add v-loading skeleton with '加载通知中...' - Followup: wire up '扫描跟进提醒' button with AI progress indicator
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-loading="loading" element-loading-text="加载数据分析中...">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="12" :sm="6" v-for="item in cards" :key="item.label">
|
||||
<el-card shadow="hover" style="text-align:center;margin-bottom:20px">
|
||||
@@ -42,12 +42,15 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getAnalyticsOverview } from '@/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const loading = ref(true)
|
||||
const cards = ref([])
|
||||
const statusData = ref([])
|
||||
const countryData = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getAnalyticsOverview()
|
||||
const d = res.data || res
|
||||
@@ -67,7 +70,8 @@ onMounted(async () => {
|
||||
const countries = customers.by_country || d.countries || []
|
||||
const countryTotal = countries.reduce((a, b) => a + (b.count || b.value || 0), 0) || 1
|
||||
countryData.value = (countries.slice ? countries.slice(0, 10) : []).map(c => ({ country: c.country || c.name, count: c.count || c.value || 0, pct: Math.round((c.count || c.value || 0) / countryTotal * 100) }))
|
||||
} catch { /* ignore */ }
|
||||
} catch { ElMessage.error('加载分析数据失败') }
|
||||
finally { loading.value = false }
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -196,16 +196,19 @@ async function search() {
|
||||
loading.value = true
|
||||
searched.value = true
|
||||
lastSearch.value = { product: form.value.product, market: form.value.market }
|
||||
const msg = ElMessage.info('正在搜索潜在客户,约需30-60秒...完成后将自动保存到"搜索历史"', { duration: 0 })
|
||||
try {
|
||||
const res = await discoverySearch({
|
||||
product_description: form.value.product,
|
||||
target_market: form.value.market || 'US',
|
||||
})
|
||||
msg.close()
|
||||
const d = res.data || res
|
||||
const companies = d.companies || d.items || d.results || d || []
|
||||
results.value = companies.map(r => ({ ...r, _contactDetail: null, _analyzing: false }))
|
||||
provider.value = d.provider || ''
|
||||
|
||||
ElMessage.success(`找到 ${companies.length} 条结果,已保存到搜索历史`)
|
||||
saveDiscoveryRecord({
|
||||
product: form.value.product,
|
||||
market: form.value.market || 'US',
|
||||
@@ -215,8 +218,13 @@ async function search() {
|
||||
})),
|
||||
}).catch(() => {})
|
||||
} catch (e) {
|
||||
const msg = e?.detail || e?.message || '挖掘失败(超时或服务错误)'
|
||||
ElMessage.error(msg)
|
||||
msg.close()
|
||||
const detail = e?.detail || ''
|
||||
if (detail.includes('timeout') || e?.message?.includes('timeout')) {
|
||||
ElMessage.warning('搜索超时,Bing搜索较慢,建议:减少搜索词或稍后重试')
|
||||
} else {
|
||||
ElMessage.error(detail || '挖掘失败')
|
||||
}
|
||||
}
|
||||
finally { loading.value = false }
|
||||
}
|
||||
@@ -239,14 +247,21 @@ async function addCustomer(r) {
|
||||
async function extractContact(r) {
|
||||
if (r._contactDetail) return
|
||||
r._analyzing = true
|
||||
const msg = ElMessage.info('正在分析网站,提取联系方式...', { duration: 0 })
|
||||
try {
|
||||
const res = await discoveryAnalyze({
|
||||
company_url: r.contact,
|
||||
product_description: form.value.product || r.name,
|
||||
})
|
||||
msg.close()
|
||||
r._contactDetail = res.data || res
|
||||
const c = r._contactDetail
|
||||
const count = (c.emails?.length || 0) + (c.phones?.length || 0)
|
||||
ElMessage.success(`已提取 ${count} 条联系方式`)
|
||||
} catch {
|
||||
msg.close()
|
||||
r._contactDetail = { emails: [], phones: [], social: [] }
|
||||
ElMessage.warning('未能提取到联系方式,可手动访问网站查看')
|
||||
}
|
||||
r._analyzing = false
|
||||
}
|
||||
@@ -254,13 +269,19 @@ async function extractContact(r) {
|
||||
async function generateOutreach() {
|
||||
if (!outForm.value.company || !outForm.value.product) { ElMessage.warning('请填写完整'); return }
|
||||
outLoading.value = true
|
||||
const msg = ElMessage.info('AI 正在生成开发信文案...', { duration: 0 })
|
||||
try {
|
||||
const res = await discoveryOutreach({
|
||||
company: { name: outForm.value.company, channel: outForm.value.channel },
|
||||
product: { name: outForm.value.product },
|
||||
})
|
||||
msg.close()
|
||||
outreachResult.value = res.data?.content || res.content || res.text || ''
|
||||
} catch { ElMessage.error('生成失败') }
|
||||
ElMessage.success('开发信已生成,可复制使用')
|
||||
} catch {
|
||||
msg.close()
|
||||
ElMessage.error('生成失败')
|
||||
}
|
||||
finally { outLoading.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
<el-tabs v-model="tab">
|
||||
<el-tab-pane label="待跟进" name="pending">
|
||||
<el-card shadow="never">
|
||||
<div style="margin-bottom:12px;display:flex;gap:8px">
|
||||
<el-button type="primary" :loading="scanning" @click="doScan">扫描跟进提醒</el-button>
|
||||
<span v-if="scanning" style="font-size:12px;color:#999;line-height:32px">AI 正在分析客户沉默情况,生成跟进建议...</span>
|
||||
</div>
|
||||
<el-table :data="pendingList" v-loading="loading" stripe style="width:100%">
|
||||
<el-table-column prop="customer_name" label="客户" min-width="140" />
|
||||
<el-table-column label="沉默天数" width="100">
|
||||
@@ -68,6 +72,7 @@ import { ElMessage } from 'element-plus'
|
||||
const tab = ref('pending')
|
||||
const loading = ref(false)
|
||||
const loadingLogs = ref(false)
|
||||
const scanning = ref(false)
|
||||
const pendingList = ref([])
|
||||
const logList = ref([])
|
||||
const statItems = ref([{ label: '待跟进', value: 0 }, { label: '已发送', value: 0 }, { label: '已回复', value: 0 }, { label: '完成率', value: '0%' }])
|
||||
@@ -91,6 +96,22 @@ async function loadStats() {
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function doScan() {
|
||||
scanning.value = true
|
||||
const msg = ElMessage.info('AI 正在扫描客户沉默情况并生成跟进建议,请稍候...', { duration: 0 })
|
||||
try {
|
||||
await scanFollowups()
|
||||
msg.close()
|
||||
ElMessage.success('扫描完成,请查看待跟进列表')
|
||||
loadPending()
|
||||
loadStats()
|
||||
} catch {
|
||||
msg.close()
|
||||
ElMessage.error('扫描失败')
|
||||
}
|
||||
finally { scanning.value = false }
|
||||
}
|
||||
|
||||
async function loadPending() {
|
||||
loading.value = true
|
||||
try {
|
||||
|
||||
@@ -61,20 +61,30 @@ const keywords = ref([])
|
||||
async function generate() {
|
||||
if (!form.value.product_name) { ElMessage.warning('请输入产品名称'); return }
|
||||
loading.value = true
|
||||
result.value = 'AI 生成中...'
|
||||
try {
|
||||
const res = await generateMarketing({ product_info: { name: form.value.product_name, description: form.value.product_desc }, target_market: form.value.target_market, style: form.value.style, type: form.value.type })
|
||||
result.value = res.data?.content || res.content || res.data?.text || res.text || ''
|
||||
} catch { ElMessage.error('生成失败') }
|
||||
if (result.value) ElMessage.success('营销文案已生成')
|
||||
} catch {
|
||||
result.value = ''
|
||||
ElMessage.error('生成失败')
|
||||
}
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function fetchKeywords() {
|
||||
if (!kwInput.value.trim()) return
|
||||
kwLoading.value = true
|
||||
keywords.value = ['生成中...']
|
||||
try {
|
||||
const res = await getKeywords({ text: kwInput.value })
|
||||
keywords.value = res.data?.keywords || res.keywords || []
|
||||
} catch { ElMessage.error('生成失败') }
|
||||
if (!keywords.length) ElMessage.info('未生成关键词,请尝试修改输入')
|
||||
} catch {
|
||||
keywords.value = []
|
||||
ElMessage.error('生成失败')
|
||||
}
|
||||
finally { kwLoading.value = false }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-loading="loading" element-loading-text="加载通知中...">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
@@ -7,6 +7,9 @@
|
||||
<el-button v-if="list.length" size="small" @click="markAll">全部已读</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="!loading && !list.length">
|
||||
<el-empty description="暂无通知" :image-size="60" />
|
||||
</div>
|
||||
<div v-for="n in list" :key="n.id" class="notif-item" :class="{ unread: !n.is_read }" @click="markRead(n)">
|
||||
<div style="display:flex;justify-content:space-between">
|
||||
<strong>{{ n.title }}</strong>
|
||||
@@ -15,7 +18,6 @@
|
||||
<p style="margin:4px 0;font-size:13px;color:#666">{{ n.content }}</p>
|
||||
<span style="font-size:11px;color:#999">{{ n.created_at }}</span>
|
||||
</div>
|
||||
<el-empty v-if="!list.length" description="暂无通知" :image-size="60" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -26,15 +28,18 @@ import { listNotifications, markNotificationRead, markAllRead } from '@/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const list = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(load)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listNotifications({ page: 1, size: 50 })
|
||||
const d = res.data || res
|
||||
list.value = d.items || d.rows || d.data || d || []
|
||||
} catch { /* ignore */ }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function markRead(n) {
|
||||
|
||||
@@ -138,10 +138,18 @@ async function markSent(row) {
|
||||
async function generateFromInquiry() {
|
||||
if (!inquiryText.value.trim()) return
|
||||
genLoading.value = true
|
||||
genResult.value = 'AI 正在分析询盘并生成报价,请稍候...'
|
||||
const msg = ElMessage.info('正在生成报价...', { duration: 0 })
|
||||
try {
|
||||
const res = await generateQuoteFromInquiry({ text: inquiryText.value })
|
||||
msg.close()
|
||||
genResult.value = res.data?.quotation || res.quotation || JSON.stringify(res.data || res, null, 2)
|
||||
} catch { ElMessage.error('生成失败') }
|
||||
ElMessage.success('报价已生成')
|
||||
} catch {
|
||||
msg.close()
|
||||
genResult.value = ''
|
||||
ElMessage.error('生成失败')
|
||||
}
|
||||
finally { genLoading.value = false }
|
||||
}
|
||||
|
||||
|
||||
@@ -70,30 +70,43 @@ const extractResult = ref('')
|
||||
async function doTranslate() {
|
||||
if (!form.value.text.trim()) return
|
||||
loading.value = true
|
||||
result.value = '翻译中...'
|
||||
try {
|
||||
const res = await translate(form.value)
|
||||
result.value = res.data?.translated_text || res.translated_text || ''
|
||||
} catch { ElMessage.error('翻译失败') }
|
||||
} catch {
|
||||
result.value = ''
|
||||
ElMessage.error('翻译失败')
|
||||
}
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function getReply() {
|
||||
if (!replyInquiry.value.trim()) return
|
||||
replyLoading.value = true
|
||||
suggestions.value = [{ content: '生成中...', tone: '处理中' }]
|
||||
try {
|
||||
const res = await translateReply({ text: replyInquiry.value })
|
||||
suggestions.value = res.data?.suggestions || res.suggestions || []
|
||||
} catch { ElMessage.error('生成建议失败') }
|
||||
if (!suggestions.length) ElMessage.info('未生成建议,请尝试修改询盘内容')
|
||||
} catch {
|
||||
suggestions.value = []
|
||||
ElMessage.error('生成建议失败')
|
||||
}
|
||||
finally { replyLoading.value = false }
|
||||
}
|
||||
|
||||
async function doExtract() {
|
||||
if (!extractText.value.trim()) return
|
||||
extractLoading.value = true
|
||||
extractResult.value = '提取中...'
|
||||
try {
|
||||
const res = await extractInfo({ text: extractText.value })
|
||||
extractResult.value = JSON.stringify(res.data || res, null, 2)
|
||||
} catch { ElMessage.error('提取失败') }
|
||||
} catch {
|
||||
extractResult.value = ''
|
||||
ElMessage.error('提取失败')
|
||||
}
|
||||
finally { extractLoading.value = false }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user