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