472 lines
16 KiB
JavaScript
472 lines
16 KiB
JavaScript
/**
|
||
* TradeMate Popup - Main UI Logic
|
||
*/
|
||
import {
|
||
login,
|
||
translate,
|
||
generateReply,
|
||
searchLeads,
|
||
generateMarketing,
|
||
getBalance,
|
||
getSubscriptionPlans,
|
||
getCreditPackages,
|
||
ApiError,
|
||
} from '../api/client.js';
|
||
|
||
// ─── State ───────────────────────────────────────────
|
||
let creditsState = {
|
||
balance: 0,
|
||
dailyFreeCharsLeft: 0,
|
||
planName: 'Free',
|
||
};
|
||
|
||
// ─── DOM refs ────────────────────────────────────────
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
const screenSettings = $('screen-settings');
|
||
const screenMain = $('screen-main');
|
||
|
||
const settingsUrl = $('settings-url');
|
||
const settingsUser = $('settings-username');
|
||
const settingsPass = $('settings-password');
|
||
const settingsLogin = $('settings-login');
|
||
const settingsStatus = $('settings-status');
|
||
|
||
const translateInput = $('translate-input');
|
||
const translateTarget = $('translate-target');
|
||
const translateBtn = $('translate-btn');
|
||
const translateResult = $('translate-result');
|
||
|
||
const replyInput = $('reply-input');
|
||
const replyTone = $('reply-tone');
|
||
const replyBtn = $('reply-btn');
|
||
const replyResult = $('reply-result');
|
||
|
||
const discoveryProduct = $('discovery-product');
|
||
const discoveryMarket = $('discovery-market');
|
||
const discoveryBtn = $('discovery-btn');
|
||
const discoveryResult = $('discovery-result');
|
||
|
||
const marketingName = $('marketing-name');
|
||
const marketingDesc = $('marketing-desc');
|
||
const marketingStyle = $('marketing-style');
|
||
const marketingBtn = $('marketing-btn');
|
||
const marketingResult = $('marketing-result');
|
||
|
||
const creditsDisplay = $('credits-display');
|
||
const btnSettings = $('btn-settings');
|
||
const upgradeBanner = $('upgrade-banner');
|
||
const upgradeBannerText = $('upgrade-banner-text');
|
||
const upgradeBannerBtn = $('upgrade-banner-btn');
|
||
const upgradeBannerClose = $('upgrade-banner-close');
|
||
const upgradeModalOverlay = $('upgrade-modal-overlay');
|
||
const modalClose = $('modal-close');
|
||
const packageModalOverlay = $('package-modal-overlay');
|
||
const pkgModalClose = $('pkg-modal-close');
|
||
|
||
// ─── CONSTANTS ───────────────────────────────────────
|
||
const CONSUMPTION_LABELS = {
|
||
lead_search: { name: '客户搜索', cost: 10 },
|
||
marketing_content: { name: '营销生成', cost: 5 },
|
||
reply_suggest: { name: '智能回复', cost: 2 },
|
||
translate_per_1000chars: { name: '翻译', cost: 1 },
|
||
quotation: { name: '报价单', cost: 2 },
|
||
followup_scan: { name: '跟进扫描', cost: 2 },
|
||
};
|
||
|
||
// ─── Init ────────────────────────────────────────────
|
||
async function init() {
|
||
const { apiUrl, token } = await chrome.storage.local.get(['trademate_api_url', 'trademate_token']);
|
||
|
||
if (apiUrl) settingsUrl.value = apiUrl;
|
||
|
||
if (apiUrl && token) {
|
||
showScreen('main');
|
||
await refreshCredits();
|
||
} else {
|
||
showScreen('settings');
|
||
}
|
||
|
||
setupTabs();
|
||
setupActions();
|
||
setupUpgradeUI();
|
||
}
|
||
|
||
function showScreen(screen) {
|
||
screenSettings.style.display = screen === 'settings' ? 'block' : 'none';
|
||
screenMain.style.display = screen === 'main' ? 'block' : 'none';
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
|
||
// ─── Credits ─────────────────────────────────────────
|
||
async function refreshCredits() {
|
||
try {
|
||
const bal = await getBalance();
|
||
creditsState.balance = bal.balance;
|
||
creditsState.dailyFreeCharsLeft = bal.dailyFreeTranslateCharsLeft;
|
||
|
||
// Determine plan name from subscription data
|
||
if (bal.subscription?.plan_id) {
|
||
creditsState.planName = 'Starter';
|
||
// Could fetch plan name from subscription-plans endpoint for accuracy
|
||
} else {
|
||
creditsState.planName = 'Free';
|
||
}
|
||
|
||
updateCreditsDisplay();
|
||
hideUpgradeBanner(); // clear any stale credit error
|
||
return bal;
|
||
} catch (err) {
|
||
console.warn('Failed to fetch credits:', err.message);
|
||
updateCreditsDisplay(); // show fallback
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function updateCreditsDisplay() {
|
||
const { balance, planName, dailyFreeCharsLeft } = creditsState;
|
||
|
||
let parts = [];
|
||
if (planName !== 'Free') {
|
||
parts.push(`⚡ ${planName}`);
|
||
}
|
||
if (balance > 0) {
|
||
parts.push(`${balance} 积分`);
|
||
}
|
||
parts.push(`剩余`);
|
||
|
||
creditsDisplay.textContent = parts.join(' · ');
|
||
|
||
// Make credits clickable to upgrade
|
||
creditsDisplay.style.cursor = 'pointer';
|
||
creditsDisplay.title = '点击查看套餐';
|
||
creditsDisplay.onclick = () => showUpgradeModal();
|
||
}
|
||
|
||
function showCreditErrorBanner(detail, actionLabel = '升级') {
|
||
upgradeBannerText.textContent = detail || '次数不足';
|
||
upgradeBannerBtn.textContent = actionLabel;
|
||
upgradeBanner.style.display = 'flex';
|
||
}
|
||
|
||
function hideUpgradeBanner() {
|
||
upgradeBanner.style.display = 'none';
|
||
}
|
||
|
||
// ─── Low Credit Warning ──────────────────────────────
|
||
function checkLowCredits(consumed) {
|
||
const cost = consumed || 0;
|
||
const remaining = creditsState.balance - cost;
|
||
if (remaining <= 5 && creditsState.planName === 'Free') {
|
||
// Almost out - show subtle hint in footer
|
||
creditsDisplay.textContent = `⚠ 仅剩 ${Math.max(0, remaining)} 积分 · 升级 ↑`;
|
||
creditsDisplay.style.cursor = 'pointer';
|
||
creditsDisplay.onclick = () => showLowCreditsPrompt();
|
||
}
|
||
}
|
||
|
||
function showLowCreditsPrompt() {
|
||
showCreditErrorBanner('体验积分即将用完,升级后继续使用', '查看套餐');
|
||
}
|
||
|
||
// ─── Upgrade Banner ──────────────────────────────────
|
||
upgradeBannerBtn.addEventListener('click', () => {
|
||
showUpgradeModal();
|
||
});
|
||
|
||
upgradeBannerClose.addEventListener('click', () => {
|
||
hideUpgradeBanner();
|
||
});
|
||
|
||
// ─── Upgrade Modal ───────────────────────────────────
|
||
function showUpgradeModal() {
|
||
upgradeModalOverlay.style.display = 'flex';
|
||
}
|
||
|
||
function hideUpgradeModal() {
|
||
upgradeModalOverlay.style.display = 'none';
|
||
}
|
||
|
||
modalClose.addEventListener('click', hideUpgradeModal);
|
||
upgradeModalOverlay.addEventListener('click', (e) => {
|
||
if (e.target === upgradeModalOverlay) hideUpgradeModal();
|
||
});
|
||
|
||
// ─── Package Modal (low-commitment) ──────────────────
|
||
function showPackageModal() {
|
||
packageModalOverlay.style.display = 'flex';
|
||
}
|
||
|
||
function hidePackageModal() {
|
||
packageModalOverlay.style.display = 'none';
|
||
}
|
||
|
||
pkgModalClose.addEventListener('click', hidePackageModal);
|
||
packageModalOverlay.addEventListener('click', (e) => {
|
||
if (e.target === packageModalOverlay) hidePackageModal();
|
||
});
|
||
|
||
// ─── Plan/Pkg button handlers ────────────────────────
|
||
function setupUpgradeUI() {
|
||
document.querySelectorAll('.plan-btn[data-plan]').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const plan = btn.dataset.plan;
|
||
// Open backend payment page or settings page
|
||
// For now, open the workspace credits page
|
||
chrome.tabs.create({
|
||
url: `${settingsUrl.value?.replace(/\/+$/, '') || 'https://trade.yuzhiran.com'}/workspace/credits`,
|
||
});
|
||
hideUpgradeModal();
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('.plan-btn[data-pkg]').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const pkg = btn.dataset.pkg;
|
||
chrome.tabs.create({
|
||
url: `${settingsUrl.value?.replace(/\/+$/, '') || 'https://trade.yuzhiran.com'}/workspace/credits`,
|
||
});
|
||
hidePackageModal();
|
||
});
|
||
});
|
||
|
||
// Package link in upgrade modal footer
|
||
const pkgLink = document.createElement('a');
|
||
pkgLink.href = '#';
|
||
pkgLink.textContent = '购买积分包(低至 ¥2.9)';
|
||
pkgLink.style.cssText = 'font-size:12px;color:#2563eb;cursor:pointer;';
|
||
pkgLink.onclick = (e) => {
|
||
e.preventDefault();
|
||
hideUpgradeModal();
|
||
showPackageModal();
|
||
};
|
||
document.querySelector('.modal-footer .hint')?.after(pkgLink);
|
||
}
|
||
|
||
// ─── Loading / Result helpers ────────────────────────
|
||
function setLoading(btn, loading) {
|
||
btn.disabled = loading;
|
||
btn.textContent = loading ? '处理中...' : btn.dataset.originalText || btn.textContent;
|
||
if (!btn.dataset.originalText) btn.dataset.originalText = btn.textContent;
|
||
}
|
||
|
||
function showResult(el, html) {
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
function showError(el, msg) {
|
||
el.innerHTML = `<div class="error">${escapeHtml(msg)}</div>`;
|
||
}
|
||
|
||
function handleApiError(el, err) {
|
||
if (err instanceof ApiError && err.isCreditError) {
|
||
// 402 - out of credits: show result + upgrade banner
|
||
showResult(el, `<div class="error credit-error">
|
||
⚡ 积分不足<br>
|
||
<span class="credit-error-hint">${escapeHtml(err.message)}</span>
|
||
</div>`);
|
||
showCreditErrorBanner('积分不足,升级后继续使用');
|
||
} else {
|
||
showError(el, err.message);
|
||
}
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
const d = document.createElement('div');
|
||
d.textContent = str;
|
||
return d.innerHTML;
|
||
}
|
||
|
||
// ─── Tabs ────────────────────────────────────────────
|
||
function setupTabs() {
|
||
document.querySelectorAll('.tab').forEach(tab => {
|
||
tab.addEventListener('click', () => {
|
||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||
tab.classList.add('active');
|
||
const target = document.getElementById(`tab-${tab.dataset.tab}`);
|
||
if (target) target.classList.add('active');
|
||
});
|
||
});
|
||
}
|
||
|
||
// ─── Settings ────────────────────────────────────────
|
||
settingsLogin.addEventListener('click', async () => {
|
||
const url = settingsUrl.value.trim();
|
||
const username = settingsUser.value.trim();
|
||
const password = settingsPass.value.trim();
|
||
|
||
if (!url) { settingsStatus.textContent = '请输入 API 地址'; settingsStatus.className = 'status error'; return; }
|
||
if (!username || !password) { settingsStatus.textContent = '请输入用户名和密码'; settingsStatus.className = 'status error'; return; }
|
||
|
||
setLoading(settingsLogin, true);
|
||
settingsStatus.textContent = '';
|
||
|
||
try {
|
||
const token = await login(username, password, url);
|
||
await chrome.storage.local.set({
|
||
trademate_api_url: url,
|
||
trademate_token: token,
|
||
});
|
||
|
||
settingsStatus.textContent = '登录成功!';
|
||
settingsStatus.className = 'status success';
|
||
|
||
setTimeout(() => {
|
||
showScreen('main');
|
||
refreshCredits();
|
||
}, 800);
|
||
} catch (err) {
|
||
settingsStatus.textContent = err.message;
|
||
settingsStatus.className = 'status error';
|
||
} finally {
|
||
setLoading(settingsLogin, false);
|
||
}
|
||
});
|
||
|
||
btnSettings.addEventListener('click', () => {
|
||
settingsPass.value = '';
|
||
showScreen('settings');
|
||
});
|
||
|
||
// ─── Action Handlers ─────────────────────────────────
|
||
function setupActions() {
|
||
// Translate
|
||
translateBtn.addEventListener('click', async () => {
|
||
const text = translateInput.value.trim();
|
||
if (!text) return;
|
||
setLoading(translateBtn, true);
|
||
showResult(translateResult, '<div class="loading"><span class="spinner"></span>翻译中...</div>');
|
||
try {
|
||
const result = await translate(text, translateTarget.value);
|
||
const translated = result.translated_text || '(无结果)';
|
||
showResult(translateResult, `
|
||
<button class="copy-btn" onclick="navigator.clipboard.writeText(this.parentElement.querySelector('.t-text').textContent)">复制</button>
|
||
<div class="t-text">${escapeHtml(translated)}</div>
|
||
${result.provider_used ? `<div style="color:#94a3b8;font-size:11px;margin-top:4px;">provider: ${result.provider_used}</div>` : ''}
|
||
`);
|
||
// Refresh credits (translation may consume daily free or credits)
|
||
refreshCredits();
|
||
} catch (err) {
|
||
handleApiError(translateResult, err);
|
||
} finally {
|
||
setLoading(translateBtn, false);
|
||
}
|
||
});
|
||
|
||
// Reply
|
||
replyBtn.addEventListener('click', async () => {
|
||
const inquiry = replyInput.value.trim();
|
||
if (!inquiry) return;
|
||
setLoading(replyBtn, true);
|
||
showResult(replyResult, '<div class="loading"><span class="spinner"></span>生成中...</div>');
|
||
try {
|
||
const result = await generateReply(inquiry, replyTone.value);
|
||
const suggestions = result.suggestions || result.results || [];
|
||
let html = suggestions.map((s, i) => `
|
||
<div class="item">
|
||
<div class="item-label">回复 ${i + 1}${s.tone ? ` (${s.tone})` : ''}</div>
|
||
<button class="copy-btn" onclick="navigator.clipboard.writeText(this.parentElement.querySelector('.r-text').textContent)">复制</button>
|
||
<div class="r-text">${escapeHtml(s.reply || s.content || s)}</div>
|
||
</div>
|
||
`).join('');
|
||
if (!html) html = '(无回复建议)';
|
||
showResult(replyResult, html);
|
||
refreshCredits();
|
||
} catch (err) {
|
||
handleApiError(replyResult, err);
|
||
} finally {
|
||
setLoading(replyBtn, false);
|
||
}
|
||
});
|
||
|
||
// Discovery
|
||
discoveryBtn.addEventListener('click', async () => {
|
||
const product = discoveryProduct.value.trim();
|
||
if (!product) return;
|
||
setLoading(discoveryBtn, true);
|
||
showResult(discoveryResult, '<div class="loading"><span class="spinner"></span>搜索中...</div>');
|
||
try {
|
||
const result = await searchLeads(product, discoveryMarket.value.trim() || 'US');
|
||
const leads = result.data || result.results || [];
|
||
|
||
// Response may include credits_remaining
|
||
if (result.credits_remaining !== undefined) {
|
||
creditsState.balance = result.credits_remaining;
|
||
updateCreditsDisplay();
|
||
checkLowCredits(0);
|
||
}
|
||
|
||
if (!Array.isArray(leads)) {
|
||
showResult(discoveryResult, escapeHtml(JSON.stringify(leads, null, 2)));
|
||
return;
|
||
}
|
||
let html = leads.map((l, i) => `
|
||
<div class="item">
|
||
<div class="item-label">${escapeHtml(l.title || l.company_name || `结果 ${i+1}`)}</div>
|
||
<div>${escapeHtml(l.description || l.snippet || '')}</div>
|
||
${l.url ? `<div><a href="${escapeHtml(l.url)}" target="_blank" style="color:#2563eb;font-size:12px;">${escapeHtml(l.url)}</a></div>` : ''}
|
||
${l.emails?.length ? `<div style="font-size:12px;color:#64748b;">📧 ${l.emails.join(', ')}</div>` : ''}
|
||
${l.relevance_score ? `<div style="font-size:11px;color:#94a3b8;">评分: ${l.relevance_score}</div>` : ''}
|
||
</div>
|
||
`).join('');
|
||
if (!html) html = '未找到匹配客户';
|
||
showResult(discoveryResult, html);
|
||
refreshCredits();
|
||
} catch (err) {
|
||
handleApiError(discoveryResult, err);
|
||
} finally {
|
||
setLoading(discoveryBtn, false);
|
||
}
|
||
});
|
||
|
||
// Marketing
|
||
marketingBtn.addEventListener('click', async () => {
|
||
const name = marketingName.value.trim();
|
||
const desc = marketingDesc.value.trim();
|
||
if (!name || !desc) return;
|
||
setLoading(marketingBtn, true);
|
||
showResult(marketingResult, '<div class="loading"><span class="spinner"></span>生成中...</div>');
|
||
try {
|
||
const result = await generateMarketing(name, desc, marketingStyle.value);
|
||
const contents = result.results || [];
|
||
|
||
if (result.credits_remaining !== undefined) {
|
||
creditsState.balance = result.credits_remaining;
|
||
updateCreditsDisplay();
|
||
checkLowCredits(5);
|
||
}
|
||
|
||
let html = contents.map((c, i) => `
|
||
<div class="item">
|
||
<div class="item-label">文案 ${i + 1}</div>
|
||
<button class="copy-btn" onclick="navigator.clipboard.writeText(this.parentElement.querySelector('.m-text').textContent)">复制</button>
|
||
<div class="m-text">${escapeHtml(c.content || c)}</div>
|
||
</div>
|
||
`).join('');
|
||
if (!html) html = '(无结果)';
|
||
showResult(marketingResult, html);
|
||
refreshCredits();
|
||
} catch (err) {
|
||
handleApiError(marketingResult, err);
|
||
} finally {
|
||
setLoading(marketingBtn, false);
|
||
}
|
||
});
|
||
|
||
// Enter key support
|
||
[translateInput, replyInput, discoveryProduct, marketingName].forEach(el => {
|
||
el.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
const btn = el.closest('.tab-content')?.querySelector('.btn-primary');
|
||
if (btn) btn.click();
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// Auto-init on load
|
||
init();
|