Fix API errors and improve customer discovery with real web results

- Fix usage/stats 500: use Date() not datetime.date() for SQL cast
- Fix customers 422: raise size limit to 1000
- Replace unreliable MCP client with direct Bing batch search for discovery
- Batch all search queries in one browser session (faster)
- Show real company names/URLs from Bing, not generic templates
- Smart filter for non-business results (news, blogs, forums)
- Fallback suggestions when search results are insufficient
- Frontend: clickable contact URLs, provider indicator, better layout
This commit is contained in:
TradeMate Dev
2026-05-27 10:29:23 +08:00
parent bed5c7abef
commit ab06990e73
7 changed files with 223 additions and 163 deletions
+2 -1
View File
@@ -28,10 +28,11 @@ class NvidiaProvider(OpenAIProvider):
messages.append({"role": "user", "content": message})
t1 = time.time()
max_tokens = 800 if "JSON" in (system or "").upper() else 300
kwargs = {
"model": self.model,
"messages": messages,
"max_tokens": 300,
"max_tokens": max_tokens,
"temperature": 0.3,
}
resp = await self.client.chat.completions.create(**kwargs)
+1 -1
View File
@@ -17,7 +17,7 @@ router = APIRouter()
async def list_customers(
status: Optional[str] = None,
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
size: int = Query(20, ge=1, le=1000),
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
+1
View File
@@ -0,0 +1 @@
_batch_search.js
+131 -107
View File
@@ -1,11 +1,10 @@
import asyncio
import json
import logging
from typing import Dict, Any, Optional
from typing import Dict, Any, Optional, Union
from app.ai.router import get_ai_router
from app.services.search_web import search_companies, fetch_page_text
from app.services.mcp_search_client import mcp_search
from app.services.mcp_search_server import search_bing_batch
logger = logging.getLogger(__name__)
@@ -35,26 +34,31 @@ class DiscoveryService:
async def search(self, product_description: str, target_market: str) -> Dict[str, Any]:
queries = self._build_queries(product_description, target_market)
all_results = await self._mcp_search_all(queries)
if all_results:
return {
"companies": all_results[:15],
"query": product_description,
"market": target_market,
"provider": "mcp_search",
}
all_results = await self._google_search_all(queries)
all_results = await self._web_search_all(queries)
companies = []
provider = "template"
if all_results:
return {
"companies": all_results[:15],
"query": product_description,
"market": target_market,
"provider": "web_search",
}
raw = all_results.get("results", [])
companies = [self._to_company(r) for r in raw[:12]]
provider = all_results.get("provider", "web_search")
logger.info("No real search results, using AI strategy")
return await self._ai_strategy(product_description, target_market)
good_enough = [c for c in companies if self._looks_like_business(c)]
if len(good_enough) < 3:
logger.info(f"Web search returned only {len(good_enough)} good results, supplementing with suggestions")
extras = self._suggest_companies(product_description, target_market)
seen_names = set(c.get("name", "") for c in good_enough)
for c in extras:
if c.get("name") and c["name"] not in seen_names:
seen_names.add(c["name"])
good_enough.append(c)
return {
"companies": good_enough[:15],
"query": product_description,
"market": target_market,
"provider": provider,
}
async def analyze(self, company_url: str, product_description: str) -> Dict[str, Any]:
page_text = await fetch_page_text(company_url)
@@ -117,83 +121,115 @@ URL: {company_url}
logger.warning(f"Outreach AI parse failed: {e}")
return self._template_outreach(company_info, product_info)
async def _mcp_search_all(self, queries: list) -> list:
seen_urls = set()
tasks = [asyncio.create_task(mcp_search(q, max_results=6)) for q in queries[:2]]
all_results = []
async def _web_search_all(self, queries: list) -> dict:
try:
for coro in asyncio.as_completed(tasks, timeout=8):
try:
results = await coro
for r in results:
url = r.get("url", "").rstrip("/")
if url and url not in seen_urls:
seen_urls.add(url)
all_results.append(r)
except (asyncio.TimeoutError, Exception) as e:
logger.debug(f"MCP search query failed: {e}")
except asyncio.TimeoutError:
logger.warning("MCP search overall timeout")
finally:
for t in tasks:
if not t.done():
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
if all_results:
return self._dedup_and_filter(all_results)[:15]
return []
results = await search_bing_batch(queries[:4], max_per_query=5)
if results:
return {"results": self._dedup_and_filter(results)[:15], "provider": "bing"}
except Exception as e:
logger.warning(f"Bing batch search failed: {e}")
results = await search_companies(queries[0], max_results=10)
if results:
return {"results": results[:15], "provider": "google_cse"}
return {}
def _dedup_and_filter(self, results: list) -> list:
seen = set()
filtered = []
junk = ["sciencedirect", "mdpi", "springer", "wiley", "acm.org",
"ieee.org", "researchgate", "nature.com", "oup.com",
"sagepub", "tandfonline", "ncbi", "semanticscholar",
"britannica", "dictionary", "cambridge", "iciba", "wikipedia",
"w3.org", "whatsapp.com", "wechat.com", "qq.com",
"zhihu.com", "sogou.com", "163.com", "sohu.com", "sina.com",
"taobao.com", "tmall.com", "alipay.com", "alibaba.com",
"csdn.net", "blog.csdn", "jianshu.com", "36kr.com",
"huxiu.com", "geekpark.net", "leiphone.com",
"medium.com", "wordpress.com", "blogspot.com",
"youtube.com", "facebook.com", "twitter.com", "instagram.com",
"reddit.com", "quora.com"]
for r in results:
url = r.get("url", "").rstrip("/")
title = r.get("title", "")
if not url or url in seen:
continue
seen.add(url)
s = url.split("/")[2] if "://" in url else url
hostname = s.split(":")[0].lower() if ":" in s else s.lower()
if any(tld in hostname for tld in [".cn", ".com.cn", ".edu", ".ac.", ".gov"]):
if any(tld in hostname for tld in [".edu", ".ac.", ".gov", ".edu.cn"]):
continue
if any(domain in hostname for domain in
["sciencedirect", "mdpi", "springer", "wiley", "acm.org",
"ieee.org", "researchgate", "nature.com", "oup.com",
"sagepub", "tandfonline", "ncbi", "semanticscholar",
"britannica", "dictionary", "cambridge", "iciba", "wikipedia"]):
if any(domain in hostname for domain in junk):
continue
filtered.append(r)
return filtered
async def _google_search_all(self, queries: list) -> list:
all_results = []
seen_urls = set()
for q in queries[:3]:
results = await search_companies(q, max_results=8)
for r in results:
url = r["url"].rstrip("/")
if url not in seen_urls:
seen_urls.add(url)
all_results.append(r)
if len(all_results) >= 15:
break
return self._dedup_and_filter(all_results)[:15]
def _to_company(self, r: dict) -> dict:
url = r.get("url", "")
title = r.get("title", url)[:60]
snippet = r.get("snippet", "")[:200]
return {
"name": title,
"description": snippet,
"country": "",
"match_score": 60,
"contact": url[:100] if url else "暂无",
"source": "web",
}
def _looks_like_business(self, c: dict) -> bool:
name = c.get("name", "")
snippet = c.get("description", "")
junk_words = ["news", "review", "blog", "dictionary", "translate",
"wikipedia", "百科", "词典", "新闻", "评测",
"price", "shop", "buy online", "forum", "subscribe",
"专业媒体", "行业媒体", "新媒体", "门户"]
name_lower = name.lower()
if any(w in name_lower for w in junk_words):
return False
snippet_lower = snippet.lower()
newsy = ["news", "review", "blog post", "article", "dictionary",
"专业媒体", "行业媒体", "新媒体"]
if any(w in snippet_lower for w in newsy):
biz_words = ["company", "inc", "ltd", "corp", "gmbh", "llc",
"manufacturer", "supplier", "exporter",
"wholesale", "distributor", "trading",
"import", "enterprise", "co.", "factory",
"industry", "electric", "solar", "energy",
"automotive", "vehicle", "官网", "有限公司",
"集团", "股份", "实业"]
if not any(w in snippet_lower for w in biz_words):
return False
return True
def _build_queries(self, product: str, market: str) -> list:
return [
import re
has_cjk = bool(re.search(r'[\u4e00-\u9fff]', product))
queries = [
f"{product} importer {market}",
f"{product} distributor {market}",
f"{product} wholesale buyer {market}",
f"{product} procurement {market}",
f"{product} company {market}",
f"{product} trading company {market}",
f"buy {product} from {market}",
f"{product} supply chain {market}",
f"top {product} manufacturers {market}",
f"{product} import export {market}",
f"{product} trading company {market}",
]
if has_cjk:
queries += [
f"{product} {market} 进口商",
f"{product} {market} 经销商",
f"{product} {market} 采购",
f"{product} {market} 批发",
]
b2b_queries = [
f"{product} buyer {market} alibaba",
f"{product} supplier {market}",
f"{product} wholesale price {market}",
]
return queries + b2b_queries
def _extract_json(self, text: str) -> Optional[dict]:
def _extract_json(self, text: str) -> Optional[Union[dict, list]]:
text = text.strip()
for prefix in ["```json", "```", "```JSON"]:
if text.startswith(prefix):
@@ -215,49 +251,37 @@ URL: {company_url}
pass
return None
async def _ai_strategy(self, product: str, market: str) -> Dict[str, Any]:
if not self._ai_available:
return self._template_strategy(product, market)
system = """你是外贸客户发现专家。根据用户的产品和目标市场,列出15家有可能采购该产品的潜在公司。
请以 JSON 格式返回(不要用 markdown 代码块标记):
{
"companies": [
{"name": "公司名称", "description": "公司业务简介", "country": "所在国家", "match_score": 匹配度0-100, "contact": "联系方式(有就写,没有写'需进一步查找'", "source": "推荐来源说明"}
],
"strategy": "整体获取策略建议",
"tips": ["搜索建议1", "搜索建议2"]
}
要求:
- 公司名称要真实感,不要编造知名大公司
- 公司业务要与产品相关
- 匹配度要有区分度,60-95之间
- 至少返回10家
- 只返回 JSON,不要其他内容"""
prompt = f"产品:{product}\n目标市场:{market}\n请列出在该市场可能采购该产品的公司。"
try:
result = await self.ai.chat(prompt, system_prompt=system)
content = result.get("reply", "")
parsed = self._extract_json(content)
if parsed and "companies" in parsed:
parsed["provider"] = result.get("provider_used", "unknown")
parsed["ai_generated"] = True
return parsed
return self._template_strategy(product, market)
except Exception as e:
logger.warning(f"AI strategy failed: {e}")
return self._template_strategy(product, market)
def _suggest_companies(self, product: str, market: str) -> list:
return [
{"name": f"{product} Importers in {market}", "description": f"{market} 从事 {product} 进口和批发的贸易商和专业进口商", "country": market, "match_score": 80, "contact": f"在 LinkedIn 搜索 '{product} importer {market}'", "source": "建议"},
{"name": f"{product} Distributors in {market}", "description": f"{market} 分销 {product} 的分销渠道商和批发商", "country": market, "match_score": 75, "contact": f"在 Google/Bing 搜索 '{product} distributor {market}'", "source": "建议"},
{"name": f"{market} Trade Association", "description": f"联系 {market} 的相关行业协会获取会员企业名录", "country": market, "match_score": 70, "contact": f"搜索 '{market} {product} association'", "source": "建议"},
{"name": f"Alibaba {market} Buyers", "description": f"在 Alibaba.com 搜索 '{product}' 并筛选 {market} 买家", "country": market, "match_score": 75, "contact": "https://www.alibaba.com", "source": "建议"},
{"name": f"LinkedIn {market} Decision Makers", "description": f"在 LinkedIn 搜索 '{market} {product} procurement/sourcing manager' 找决策人", "country": market, "match_score": 65, "contact": "LinkedIn Premium", "source": "建议"},
{"name": f"{market} Import-Export Records", "description": f"在 importgenius.com 搜索 {product}{market} 的进口记录,找到真实买家", "country": market, "match_score": 70, "contact": "https://www.importgenius.com", "source": "建议"},
]
def _template_strategy(self, product: str, market: str) -> Dict[str, Any]:
search_terms = [
f"{product} importer {market}",
f"{product} distributor {market}",
f"{product} wholesale {market}",
f"{product} buyers {market}",
]
b2b_sites = ["Alibaba.com", "TradeIndia.com", "GlobalSources.com", "Made-in-China.com"]
return {
"companies": [
{"name": f"{product} Importers in {market} (示例)", "description": f"{market}从事{product}进口和批发的贸易商,建议在LinkedIn上搜索相关关键词", "country": market, "match_score": 75, "contact": "需进一步查找", "source": "AI推荐"},
{"name": f"{product} Distributors in {market} (示例)", "description": f"{market}分销{product}的渠道商,建议通过Google搜索关键词", "country": market, "match_score": 70, "contact": "需进一步查找", "source": "AI推荐"},
{"name": f"{product} Importers in {market}", "description": f"使用Google/Bing搜索 '{product} importer {market}' 可找到正在采购该产品的进口商", "country": market, "match_score": 80, "contact": "通过搜索结果获取", "source": "搜索建议"},
{"name": f"{product} Distributors in {market}", "description": f"使用Google/Bing搜索 '{product} distributor {market}' 可找到分销渠道商", "country": market, "match_score": 75, "contact": "通过搜索结果获取", "source": "搜索建议"},
{"name": f"{product} Wholesale Buyers in {market}", "description": f"使用Google/Bing搜索 '{product} wholesale {market}' 可找到批发采购商", "country": market, "match_score": 70, "contact": "通过搜索结果获取", "source": "搜索建议"},
{"name": f"{market} {product} Trade Partners", "description": f"在B2B平台({', '.join(b2b_sites[:3])})搜索 {market} 买家发布的采购需求", "country": market, "match_score": 65, "contact": "B2B平台站内信", "source": "B2B平台"},
],
"strategy": f"建议在 LinkedIn 和 Google 搜索 {market} {product} 相关公司,使用导入商、批发商、经销商等关键词组合",
"tips": ["使用多个搜索词组合", "找到公司后在 LinkedIn 找决策人", "查看公司网站了解其业务范围"],
"strategy": f"推荐搜索计划:\n1. 搜索 '{' '.join(search_terms[:2])}' 直接找客户\n2. 在 LinkedIn 搜索 '{market} {product} manager' 找决策人\n3. 在 {b2b_sites[0]}{b2b_sites[1]} 查找 {market} 买家询盘\n4. 参加 {market} 相关行业展会获取名录",
"tips": [
f"把搜索词 '{product} importer {market}' 改成当地语言效果更好",
"找到公司后访问官网,在 About/Team 页面找决策人LinkedIn",
"用 SimilarWeb 查看目标公司网站流量和来源",
"在行业协会网站查找会员名录"],
"provider": "template",
"ai_generated": True,
}
+64 -47
View File
@@ -4,6 +4,7 @@ import logging
import os
import subprocess
from typing import List, Dict
import functools
from mcp.server.fastmcp import FastMCP
@@ -11,79 +12,95 @@ logger = logging.getLogger(__name__)
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
NODE_BIN = "/usr/bin/node"
BING_SCRIPT = r"""
BATCH_SCRIPT = r"""
const p = require('puppeteer');
(async () => {
const b = await p.launch({headless:true,args:['--no-sandbox','--disable-setuid-sandbox','--disable-blink-features=AutomationControlled']});
const page = await b.newPage();
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
await page.setExtraHTTPHeaders({'Accept-Language':'en-US,en;q=0.9'});
await page.evaluateOnNewDocument(() => { Object.defineProperty(navigator, 'webdriver', {get:()=>undefined}); });
const q = process.argv[process.argv.length - 2];
const max = parseInt(process.argv[process.argv.length - 1] || '10', 10);
const sk = ['bing.com','google.com','facebook.com','twitter.com','instagram.com','youtube.com','reddit.com','amazon.com','wikipedia.org','baidu.com','linkedin.com','pinterest.com','ebay.com','walmart.com','w3.org','whatsapp.com','wechat.com','qq.com','taobao.com','tmall.com','alibaba.com','alipay.com','dict','dictionary','translate','zhihu.com','baike.baidu.com','sogou.com','163.com','sohu.com','sina.com','iciba.com','cambridge','britannica','sciencedirect','mdpi.com','springer','wiley.com','acm.org','ieee.org','researchgate','semanticscholar','ncbi.nlm.nih','nature.com','oup.com','sagepub','tandfonline'];
const queries = JSON.parse(process.argv[process.argv.length - 2]);
const max = parseInt(process.argv[process.argv.length - 1] || '6', 10);
const sk = ['bing.com','google.com','facebook.com','twitter.com','instagram.com','youtube.com','reddit.com','amazon.com','walmart.com','w3.org','whatsapp.com','wechat.com','qq.com','taobao.com','tmall.com','alipay.com','zhihu.com','baike.baidu.com','sogou.com','163.com','sohu.com','sina.com','iciba.com','cambridge','britannica','sciencedirect','mdpi.com','springer','wiley.com','acm.org','ieee.org','researchgate','semanticscholar','ncbi.nlm.nih','nature.com','oup.com','sagepub','tandfonline','pinterest','ebay','dictionary','translate'];
try {
await page.goto('https://cn.bing.com/search?q=' + encodeURIComponent(q) + '&setlang=en-US&cc=US', {waitUntil:'domcontentloaded',timeout:10000});
await page.waitForSelector('.b_algo', {timeout:5000}).catch(()=>{});
const results = await page.evaluate((m, sk) => {
const reCJK = /[\u4e00-\u9fff\u3400-\u4dbf]/;
const found = []; const seen = new Set();
document.querySelectorAll('li.b_algo').forEach(li => {
const a = li.querySelector('h2 a'); if (!a) return;
let url = (a.href || '').replace(/\/$/,'');
if (!url.startsWith('http') || seen.has(url)) return;
seen.add(url);
if (sk.some(d => url.includes(d))) return;
const hostname = url.replace(/^https?:\/\//,'').split('/')[0];
if (hostname.endsWith('.cn') || hostname.endsWith('.com.cn') || hostname.endsWith('.edu') || hostname.endsWith('.ac')) return;
const title = (a.textContent||'').trim().substring(0,100);
if (reCJK.test(title)) return;
const s = li.querySelector('.b_caption p, .b_lineclamp2');
found.push({title, url, snippet:s?s.textContent.trim().substring(0,200):''});
});
return found.slice(0,m);
}, max, sk);
console.log(JSON.stringify(results));
const b = await p.launch({headless:true,args:['--no-sandbox','--disable-setuid-sandbox','--disable-blink-features=AutomationControlled'],timeout:10000});
const allResults = [];
const seenUrls = new Set();
for (const q of queries) {
try {
const page = await b.newPage();
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
await page.setExtraHTTPHeaders({'Accept-Language':'en-US,en;q=0.9'});
const url = 'https://www.bing.com/search?q=' + encodeURIComponent(q) + '&setlang=en-US&cc=US';
await page.goto(url, {waitUntil:'domcontentloaded',timeout:8000});
await page.waitForSelector('.b_algo', {timeout:4000}).catch(()=>{});
const results = await page.evaluate((m, sk) => {
const found = []; const seen = new Set();
document.querySelectorAll('li.b_algo').forEach(li => {
const a = li.querySelector('h2 a'); if (!a) return;
let url = (a.href || '').replace(/\/$/,'');
if (!url.startsWith('http') || seen.has(url)) return;
seen.add(url);
if (sk.some(d => url.includes(d))) return;
const hostname = url.replace(/^https?:\/\//,'').split('/')[0];
if (hostname.endsWith('.edu') || hostname.endsWith('.ac') || hostname.endsWith('.gov')) return;
const title = (a.textContent||'').trim().substring(0,100);
const s = li.querySelector('.b_caption p, .b_lineclamp2');
found.push({title, url, snippet:s?s.textContent.trim().substring(0,200):''});
});
return found.slice(0,m);
}, max, sk);
for (const r of results) {
if (!seenUrls.has(r.url)) {
seenUrls.add(r.url);
allResults.push(r);
}
}
await page.close();
} catch(e) { /* skip failed query */ }
}
console.log(JSON.stringify(allResults.slice(0, max * queries.length)));
await b.close();
} catch(e) { console.log('[]'); }
await b.close();
})();
"""
BING_SCRIPT_FILE = os.path.join(os.path.dirname(__file__), "_bing_search.js")
BATCH_SCRIPT_FILE = os.path.join(os.path.dirname(__file__), "_batch_search.js")
NODE_MODULES = os.path.join(PROJECT_ROOT, "node_modules")
async def search_bing(query: str, max_results: int = 10) -> List[Dict[str, str]]:
async def search_bing_batch(queries: List[str], max_per_query: int = 6) -> List[Dict[str, str]]:
loop = asyncio.get_running_loop()
try:
with open(BING_SCRIPT_FILE, "w") as f:
f.write(BING_SCRIPT)
with open(BATCH_SCRIPT_FILE, "w") as f:
f.write(BATCH_SCRIPT)
env = os.environ.copy()
env["NODE_PATH"] = NODE_MODULES
result = subprocess.run(
[NODE_BIN, BING_SCRIPT_FILE, query, str(max_results)],
capture_output=True,
text=True,
timeout=15,
cwd=PROJECT_ROOT,
env=env,
fn = functools.partial(
subprocess.run,
[NODE_BIN, BATCH_SCRIPT_FILE, json.dumps(queries), str(max_per_query)],
capture_output=True, text=True, timeout=120, cwd=PROJECT_ROOT, env=env,
)
if result.returncode != 0:
logger.warning(f"Bing search failed: {result.stderr[:300]}")
return []
result = await loop.run_in_executor(None, fn)
for line in result.stdout.strip().split("\n"):
line = line.strip()
if line.startswith("["):
return json.loads(line)
return []
except subprocess.TimeoutExpired:
logger.warning("Bing search timed out")
logger.warning("Bing batch search timed out")
return []
except (json.JSONDecodeError, Exception) as e:
logger.warning(f"Bing search error: {e}")
logger.warning(f"Bing batch search error: {e}")
return []
async def search_bing(query: str, max_results: int = 10) -> List[Dict[str, str]]:
return await search_bing_batch([query], max_per_query=max_results)
mcp = FastMCP("trade-search", log_level="WARNING")
+3 -2
View File
@@ -2,6 +2,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from fastapi import HTTPException, Depends
from datetime import datetime, date
from sqlalchemy import Date
import logging
from app.models import UsageLog, SystemConfig, User, Customer, Product
@@ -52,7 +53,7 @@ class UsageService:
stmt = select(func.count()).where(
UsageLog.user_id == user_id,
UsageLog.action == action,
func.cast(UsageLog.created_at, date) == today,
func.cast(UsageLog.created_at, Date) == today,
)
result = await self.db.execute(stmt)
return result.scalar() or 0
@@ -64,7 +65,7 @@ class UsageService:
), 0)).where(
UsageLog.user_id == user_id,
UsageLog.action == "translate",
func.cast(UsageLog.created_at, date) == today,
func.cast(UsageLog.created_at, Date) == today,
)
result = await self.db.execute(stmt)
return result.scalar() or 0
+21 -5
View File
@@ -11,12 +11,24 @@
</el-form-item>
</el-form>
<div v-if="results.length">
<el-card v-for="r in results" :key="r.id || r.name" shadow="hover" style="margin-top:12px">
<h4>{{ r.name }} <el-tag v-if="r.match_score" size="small" :type="scoreType(r.match_score)">{{ r.match_score }}%</el-tag></h4>
<p v-if="r.description" style="color:#666;font-size:13px">{{ r.description }}</p>
<p v-if="r.contact" style="font-size:12px;color:#999">联系方式{{ r.contact }}</p>
<div style="margin-top:8px">
<el-alert v-if="provider === 'template' || provider === '建议'" title="以下为搜索策略建议,可用于手动开发客户" type="warning" show-icon :closable="false" style="margin-bottom:12px" />
<el-alert v-if="provider === 'bing'" title="以下为搜索到的相关公司,点击'访问网站'了解更多,也可添加为客户" type="info" show-icon :closable="false" style="margin-bottom:12px" />
<el-card v-for="(r, idx) in results" :key="r.id || r.name || idx" shadow="hover" style="margin-top:12px">
<div style="display:flex;justify-content:space-between;align-items:center">
<h4 style="margin:0">{{ r.name }} <el-tag v-if="r.match_score" size="small" :type="scoreType(r.match_score)">{{ r.match_score }}%</el-tag></h4>
<el-tag v-if="r.source && provider !== 'template'" size="small" type="info">{{ r.source }}</el-tag>
</div>
<p v-if="r.description" style="color:#666;font-size:13px;margin:8px 0">{{ r.description }}</p>
<p v-if="r.contact" style="font-size:12px;color:#999;margin:4px 0">
联系方式
<template v-if="r.contact.startsWith('http')">
<a :href="r.contact" target="_blank" rel="noopener">{{ r.contact.substring(0, 50) }}{{ r.contact.length > 50 ? '…' : '' }}</a>
</template>
<template v-else>{{ r.contact }}</template>
</p>
<div style="margin-top:8px;display:flex;gap:8px">
<el-button size="small" type="primary" @click="addCustomer(r)">添加为客户</el-button>
<el-button v-if="r.contact && r.contact.startsWith('http')" size="small" @click="openUrl(r.contact)">访问网站</el-button>
</div>
</el-card>
</div>
@@ -55,6 +67,9 @@ const tab = ref('search')
const loading = ref(false)
const searched = ref(false)
const results = ref([])
const provider = ref('')
function openUrl(url) { window.open(url, '_blank') }
const form = ref({ product: '', market: '' })
const outForm = ref({ company: '', product: '', channel: 'email' })
const outLoading = ref(false)
@@ -73,6 +88,7 @@ async function search() {
})
const d = res.data || res
results.value = d.companies || d.items || d.results || d || []
provider.value = d.provider || ''
} catch { ElMessage.error('挖掘失败') }
finally { loading.value = false }
}