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:
TradeMate Dev
2026-05-27 16:22:07 +08:00
parent c1638db6b2
commit bc48c220a0
7 changed files with 96 additions and 14 deletions
+6 -2
View File
@@ -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>
+24 -3
View File
@@ -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>
+21
View File
@@ -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 {
+13 -3
View File
@@ -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() {
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 }
}
+7 -2
View File
@@ -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) {
+9 -1
View File
@@ -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 }
}
+16 -3
View File
@@ -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 }
}