Add admin-frontend and user-frontend standalone projects, certification/invoice/discovery features, fix auth header and theme consistency
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
<template>
|
||||
<view class="discovery-container">
|
||||
<view class="header-card">
|
||||
<text class="title">挖掘新客</text>
|
||||
<text class="subtitle">AI 帮你找到潜在客户并生成开发信</text>
|
||||
</view>
|
||||
|
||||
<view class="tab-bar">
|
||||
<view class="tab-item" :class="{ active: activeTab === 'search' }" @click="activeTab = 'search'">
|
||||
<text class="tab-text">客户挖掘</text>
|
||||
</view>
|
||||
<view class="tab-item" :class="{ active: activeTab === 'outreach' }" @click="activeTab = 'outreach'">
|
||||
<text class="tab-text">开发信生成</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="activeTab === 'search'" class="section">
|
||||
<view class="input-group">
|
||||
<text class="input-label">你的产品/服务</text>
|
||||
<textarea class="input-area" v-model="searchForm.product" placeholder="例如:太阳能电池板 200W 单晶硅" />
|
||||
</view>
|
||||
<view class="input-group">
|
||||
<text class="input-label">目标市场</text>
|
||||
<input class="input-field" v-model="searchForm.market" placeholder="例如:美国、欧洲、东南亚" />
|
||||
</view>
|
||||
<button class="primary-btn" :disabled="loading.search" @click="doSearch">
|
||||
<text v-if="loading.search">搜索中...</text>
|
||||
<text v-else>开始挖掘</text>
|
||||
</button>
|
||||
|
||||
<view v-if="searchResult" class="result-area">
|
||||
|
||||
<view v-if="searchResult.companies && searchResult.companies.length > 0">
|
||||
<view class="result-card" v-for="(company, i) in searchResult.companies" :key="'c' + i">
|
||||
<text class="company-title">{{ company.title }}</text>
|
||||
<text class="company-url" @click="openUrl(company.url)">{{ company.url }}</text>
|
||||
<text class="company-snippet" v-if="company.snippet">{{ company.snippet }}</text>
|
||||
<view class="company-actions" v-if="analysisLoading !== i">
|
||||
<text class="action-btn-sm" @click="analyzeCompany(company, i)">🔍 分析</text>
|
||||
<text class="action-btn-sm add" @click="showAddCustomer(company)">➕ 加入客户</text>
|
||||
</view>
|
||||
<view class="company-actions" v-else>
|
||||
<text class="loading-text">分析中...</text>
|
||||
</view>
|
||||
|
||||
<view v-if="company.analysis" class="analysis-result">
|
||||
<view class="score-bar">
|
||||
<text class="score-label">匹配度</text>
|
||||
<view class="score-track">
|
||||
<view class="score-fill" :style="{ width: (company.analysis.match_score || 0) + '%' }" />
|
||||
</view>
|
||||
<text class="score-val">{{ company.analysis.match_score || '?' }}/100</text>
|
||||
</view>
|
||||
<text class="analysis-text" v-if="company.analysis.match_reason">{{ company.analysis.match_reason }}</text>
|
||||
<view class="contact-row" v-if="company.analysis.contact_info">
|
||||
<text class="contact-item" v-for="(emails, key) in company.analysis.contact_info" :key="key">
|
||||
{{ key }}: {{ Array.isArray(emails) ? emails.join(', ') : emails }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="searchResult.buyer_personas">
|
||||
<view class="strategy-card">
|
||||
<text class="section-label">由于搜索服务未配置,以下是 AI 推荐的发现策略:</text>
|
||||
</view>
|
||||
<view class="result-card" v-for="(persona, i) in searchResult.buyer_personas" :key="'p' + i">
|
||||
<text class="result-title">{{ persona.type }}</text>
|
||||
<text class="result-desc">{{ persona.description }}</text>
|
||||
<view class="tag-row">
|
||||
<text class="tag" v-for="(ch, ci) in persona.channels" :key="'c' + ci">{{ ch }}</text>
|
||||
</view>
|
||||
<view class="query-box" v-if="persona.search_queries">
|
||||
<text class="query-label">搜索关键词:</text>
|
||||
<text class="query-text" v-for="(q, qi) in persona.search_queries" :key="'q' + qi">{{ q }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="strategy-card" v-if="searchResult.strategy">
|
||||
<text class="strategy-title">策略建议</text>
|
||||
<text class="strategy-text">{{ searchResult.strategy }}</text>
|
||||
</view>
|
||||
|
||||
<view class="tips-card" v-if="searchResult.tips">
|
||||
<text class="tips-title">💡 实用建议</text>
|
||||
<text class="tip-item" v-for="(tip, ti) in searchResult.tips" :key="'t' + ti">{{ ti + 1 }}. {{ tip }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="activeTab === 'outreach'" class="section">
|
||||
<view class="input-group">
|
||||
<text class="input-label">目标公司名称</text>
|
||||
<input class="input-field" v-model="outreachForm.company" placeholder="例如:ABC Trading Co." />
|
||||
</view>
|
||||
<view class="input-group">
|
||||
<text class="input-label">公司简介(可选)</text>
|
||||
<textarea class="input-area" v-model="outreachForm.companyDesc" placeholder="对方的业务范围、主营产品等" />
|
||||
</view>
|
||||
<view class="input-group">
|
||||
<text class="input-label">你的产品名称</text>
|
||||
<input class="input-field" v-model="outreachForm.product" placeholder="例如:太阳能电池板" />
|
||||
</view>
|
||||
<view class="input-group">
|
||||
<text class="input-label">产品优势(可选)</text>
|
||||
<textarea class="input-area" v-model="outreachForm.productAdv" placeholder="价格优势、品质认证、交期快等" />
|
||||
</view>
|
||||
<button class="primary-btn" :disabled="loading.outreach" @click="doOutreach">
|
||||
<text v-if="loading.outreach">生成中...</text>
|
||||
<text v-else>生成开发信</text>
|
||||
</button>
|
||||
|
||||
<view v-if="outreachResult" class="result-area">
|
||||
<view class="outreach-card" v-if="outreachResult.email_body">
|
||||
<text class="outreach-label">📧 邮件正文</text>
|
||||
<text class="outreach-content" style="white-space: pre-line">{{ outreachResult.email_body }}</text>
|
||||
<button class="copy-btn" @click="copyText(outreachResult.email_body)">复制</button>
|
||||
</view>
|
||||
<view class="outreach-card" v-if="outreachResult.linkedin_message">
|
||||
<text class="outreach-label">💼 LinkedIn 私信</text>
|
||||
<text class="outreach-content">{{ outreachResult.linkedin_message }}</text>
|
||||
<button class="copy-btn" @click="copyText(outreachResult.linkedin_message)">复制</button>
|
||||
</view>
|
||||
<view class="outreach-card" v-if="outreachResult.whatsapp_message">
|
||||
<text class="outreach-label">📱 WhatsApp 消息</text>
|
||||
<text class="outreach-content">{{ outreachResult.whatsapp_message }}</text>
|
||||
<button class="copy-btn" @click="copyText(outreachResult.whatsapp_message)">复制</button>
|
||||
</view>
|
||||
<view class="tips-card" v-if="outreachResult.tips">
|
||||
<text class="tips-title">💡 发送建议</text>
|
||||
<text class="tip-item" v-for="(tip, ti) in outreachResult.tips" :key="'ot' + ti">{{ ti + 1 }}. {{ tip }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
|
||||
const activeTab = ref('search')
|
||||
|
||||
const searchForm = reactive({
|
||||
product: '',
|
||||
market: 'US',
|
||||
})
|
||||
|
||||
const outreachForm = reactive({
|
||||
company: '',
|
||||
companyDesc: '',
|
||||
product: '',
|
||||
productAdv: '',
|
||||
})
|
||||
|
||||
const loading = reactive({ search: false, outreach: false })
|
||||
const analysisLoading = ref(-1)
|
||||
const searchResult = ref(null)
|
||||
const outreachResult = ref(null)
|
||||
|
||||
const apiBase = '/api/v1'
|
||||
|
||||
async function doSearch() {
|
||||
if (!searchForm.product.trim()) {
|
||||
uni.showToast({ title: '请填写产品描述', icon: 'none' })
|
||||
return
|
||||
}
|
||||
loading.search = true
|
||||
searchResult.value = null
|
||||
try {
|
||||
const res = await uni.request({
|
||||
url: `${apiBase}/discovery/search`,
|
||||
method: 'POST',
|
||||
data: {
|
||||
product_description: searchForm.product,
|
||||
target_market: searchForm.market,
|
||||
},
|
||||
})
|
||||
const body = res.data
|
||||
if (body.success && body.data) {
|
||||
searchResult.value = body.data
|
||||
} else {
|
||||
uni.showToast({ title: '搜索失败,请重试', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '网络错误', icon: 'none' })
|
||||
} finally {
|
||||
loading.search = false
|
||||
}
|
||||
}
|
||||
|
||||
async function analyzeCompany(company, index) {
|
||||
analysisLoading.value = index
|
||||
try {
|
||||
const res = await uni.request({
|
||||
url: `${apiBase}/discovery/analyze`,
|
||||
method: 'POST',
|
||||
data: {
|
||||
company_url: company.url,
|
||||
product_description: searchForm.product,
|
||||
},
|
||||
})
|
||||
const body = res.data
|
||||
if (body.success && body.data) {
|
||||
company.analysis = body.data
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '分析失败', icon: 'none' })
|
||||
} finally {
|
||||
analysisLoading.value = -1
|
||||
}
|
||||
}
|
||||
|
||||
function showAddCustomer(company) {
|
||||
uni.showModal({
|
||||
title: '加入客户列表',
|
||||
content: `将「${company.title}」添加到客户管理?`,
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
const token = uni.getStorageSync('token')
|
||||
await uni.request({
|
||||
url: `${apiBase}/customers`,
|
||||
method: 'POST',
|
||||
header: { Authorization: `Bearer ${token}` },
|
||||
data: {
|
||||
name: company.title,
|
||||
company: company.title,
|
||||
website: company.url,
|
||||
notes: company.snippet || '',
|
||||
status: 'potential',
|
||||
},
|
||||
})
|
||||
uni.showToast({ title: '已加入客户列表', icon: 'success' })
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '添加失败,请手动添加', icon: 'none' })
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function openUrl(url) {
|
||||
uni.setClipboardData({
|
||||
data: url,
|
||||
success: () => uni.showToast({ title: '网址已复制', icon: 'success' }),
|
||||
})
|
||||
}
|
||||
|
||||
async function doOutreach() {
|
||||
if (!outreachForm.company.trim()) {
|
||||
uni.showToast({ title: '请填写目标公司名称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!outreachForm.product.trim()) {
|
||||
uni.showToast({ title: '请填写你的产品名称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
loading.outreach = true
|
||||
outreachResult.value = null
|
||||
try {
|
||||
const res = await uni.request({
|
||||
url: `${apiBase}/discovery/outreach`,
|
||||
method: 'POST',
|
||||
data: {
|
||||
company: {
|
||||
name: outreachForm.company,
|
||||
description: outreachForm.companyDesc,
|
||||
},
|
||||
product: {
|
||||
name: outreachForm.product,
|
||||
advantages: outreachForm.productAdv,
|
||||
},
|
||||
},
|
||||
})
|
||||
const body = res.data
|
||||
if (body.success && body.data) {
|
||||
outreachResult.value = body.data
|
||||
} else {
|
||||
uni.showToast({ title: '生成失败,请重试', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '网络错误', icon: 'none' })
|
||||
} finally {
|
||||
loading.outreach = false
|
||||
}
|
||||
}
|
||||
|
||||
function copyText(text) {
|
||||
uni.setClipboardData({
|
||||
data: text,
|
||||
success: () => uni.showToast({ title: '已复制', icon: 'success' }),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.discovery-container { padding: 30rpx; }
|
||||
.header-card { margin-bottom: 30rpx; }
|
||||
.header-card .title { font-size: 36rpx; font-weight: 700; color: #333; }
|
||||
.header-card .subtitle { font-size: 24rpx; color: #999; margin-top: 8rpx; display: block; }
|
||||
|
||||
.tab-bar { display: flex; background: #f5f5f5; border-radius: 12rpx; padding: 4rpx; margin-bottom: 30rpx; }
|
||||
.tab-item { flex: 1; text-align: center; padding: 16rpx 0; border-radius: 10rpx; }
|
||||
.tab-item.active { background: #fff; box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06); }
|
||||
.tab-text { font-size: 26rpx; color: #666; }
|
||||
.tab-item.active .tab-text { color: #1890ff; font-weight: 600; }
|
||||
|
||||
.section { margin-bottom: 30rpx; }
|
||||
.input-group { margin-bottom: 24rpx; }
|
||||
.input-label { font-size: 26rpx; color: #333; font-weight: 500; margin-bottom: 10rpx; display: block; }
|
||||
.input-area { width: 100%; min-height: 120rpx; background: #fff; border: 2rpx solid #e8e8e8; border-radius: 12rpx; padding: 20rpx; font-size: 26rpx; color: #333; box-sizing: border-box; }
|
||||
.input-field { width: 100%; height: 72rpx; background: #fff; border: 2rpx solid #e8e8e8; border-radius: 12rpx; padding: 0 20rpx; font-size: 26rpx; color: #333; box-sizing: border-box; }
|
||||
.primary-btn { width: 100%; height: 80rpx; background: #1890ff; color: #fff; font-size: 28rpx; font-weight: 600; border-radius: 12rpx; display: flex; align-items: center; justify-content: center; border: none; margin-top: 10rpx; }
|
||||
.primary-btn[disabled] { opacity: 0.6; }
|
||||
.result-area { margin-top: 30rpx; }
|
||||
|
||||
.company-title { font-size: 28rpx; font-weight: 600; color: #333; display: block; margin-bottom: 6rpx; }
|
||||
.company-url { font-size: 22rpx; color: #1890ff; display: block; margin-bottom: 8rpx; }
|
||||
.company-snippet { font-size: 24rpx; color: #666; line-height: 1.5; display: block; margin-bottom: 14rpx; }
|
||||
.company-actions { display: flex; gap: 14rpx; margin-bottom: 12rpx; }
|
||||
.action-btn-sm { font-size: 24rpx; color: #1890ff; background: #eaf4ff; padding: 8rpx 20rpx; border-radius: 8rpx; }
|
||||
.action-btn-sm.add { color: #52c41a; background: #f0fff0; }
|
||||
.loading-text { font-size: 24rpx; color: #999; }
|
||||
|
||||
.analysis-result { background: #f9f9f9; border-radius: 10rpx; padding: 18rpx; margin-top: 8rpx; }
|
||||
.score-bar { display: flex; align-items: center; gap: 10rpx; margin-bottom: 10rpx; }
|
||||
.score-label { font-size: 22rpx; color: #666; flex-shrink: 0; }
|
||||
.score-track { flex: 1; height: 12rpx; background: #eee; border-radius: 6rpx; overflow: hidden; }
|
||||
.score-fill { height: 100%; background: #1890ff; border-radius: 6rpx; transition: width 0.3s; }
|
||||
.score-val { font-size: 22rpx; color: #1890ff; font-weight: 600; flex-shrink: 0; }
|
||||
.analysis-text { font-size: 22rpx; color: #666; display: block; margin-bottom: 8rpx; }
|
||||
.contact-row { display: flex; flex-wrap: wrap; gap: 6rpx; }
|
||||
.contact-item { font-size: 20rpx; color: #999; background: #fff; padding: 4rpx 10rpx; border-radius: 4rpx; }
|
||||
|
||||
.section-label { font-size: 24rpx; color: #fa8c16; background: #fff7e6; padding: 12rpx 18rpx; border-radius: 8rpx; display: block; margin-bottom: 16rpx; }
|
||||
|
||||
.result-card { background: #fff; border-radius: 16rpx; padding: 28rpx; margin-bottom: 20rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04); }
|
||||
.result-title { font-size: 28rpx; font-weight: 600; color: #1890ff; margin-bottom: 8rpx; display: block; }
|
||||
.result-desc { font-size: 24rpx; color: #666; line-height: 1.6; display: block; margin-bottom: 14rpx; }
|
||||
.tag-row { display: flex; flex-wrap: wrap; gap: 10rpx; margin-bottom: 12rpx; }
|
||||
.tag { font-size: 22rpx; color: #1890ff; background: #eaf4ff; padding: 6rpx 16rpx; border-radius: 6rpx; }
|
||||
.query-box { background: #f9f9f9; border-radius: 8rpx; padding: 14rpx; }
|
||||
.query-label { font-size: 22rpx; color: #999; display: block; margin-bottom: 6rpx; }
|
||||
.query-text { font-size: 22rpx; color: #333; background: #fff; padding: 4rpx 12rpx; border-radius: 4rpx; margin: 4rpx 6rpx 4rpx 0; display: inline-block; border: 1rpx solid #e8e8e8; }
|
||||
|
||||
.strategy-card, .tips-card, .outreach-card { background: #fff; border-radius: 16rpx; padding: 28rpx; margin-bottom: 20rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04); }
|
||||
.strategy-title, .tips-title { font-size: 26rpx; font-weight: 600; color: #333; display: block; margin-bottom: 12rpx; }
|
||||
.strategy-text { font-size: 24rpx; color: #666; line-height: 1.6; display: block; }
|
||||
.tip-item { font-size: 24rpx; color: #666; line-height: 1.8; display: block; }
|
||||
|
||||
.outreach-label { font-size: 26rpx; font-weight: 600; color: #333; display: block; margin-bottom: 12rpx; }
|
||||
.outreach-content { font-size: 24rpx; color: #444; line-height: 1.7; display: block; margin-bottom: 14rpx; }
|
||||
.copy-btn { font-size: 22rpx; color: #1890ff; background: #eaf4ff; border: none; border-radius: 6rpx; padding: 8rpx 24rpx; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user