Files
trade-assistant/uni-app/src/pages/index/index.vue
T
TradeMate Dev c397740748 feat: WeChat Pay integration, translation quota management, login UX fixes
- WeChat Pay APIv3 integration (JSAPI + Native) with cert-based auth
- TranslationQuota model + admin management UI (配额 tab)
- Alibaba MT provider now checks quota before translation
- Fix: admin tabs scrollable on mobile, remove header-card
- Fix: profile/login navigation - logout stays on profile, login returns to profile
- Fix: login form now visible by default (no extra click to show)
- Fix: home page translate link uses navigateTo (was switchTab to non-tabBar page)
- Add .coverage and apiclient_key.pem to gitignore
2026-05-20 18:30:12 +08:00

1241 lines
33 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="index-container">
<view class="header">
<view class="user-info" v-if="hasLogin">
<text class="username">{{ userInfo?.username || '用户' }}</text>
<text class="tier">{{ userInfo?.tier === 'pro' ? 'Pro' : '免费版' }}</text>
</view>
<view class="header-left" v-if="!hasLogin" @click="showAnnouncement = true">
<text class="announcement-icon">📢</text>
<text class="announcement-ticker">{{ announcements[currentAnnouncement] }}</text>
</view>
<view class="header-right" v-if="!hasLogin">
<text class="guest-label">👋 游客模式</text>
<button class="login-btn" @click="goToLogin">登录</button>
</view>
<text class="logout" @click="handleLogout" v-if="hasLogin">退出</text>
</view>
<view class="stats-grid" v-if="hasLogin">
<view class="stat-card">
<text class="stat-value">{{ stats.customers }}</text>
<text class="stat-label">客户总数</text>
</view>
<view class="stat-card warning">
<text class="stat-value">{{ stats.silentCustomers }}</text>
<text class="stat-label">沉默客户</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ stats.todayTranslations }}</text>
<text class="stat-label">今日翻译</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ stats.quotations }}</text>
<text class="stat-label">报价单</text>
</view>
</view>
<view class="guest-welcome" v-else>
<view class="welcome-content">
<text class="welcome-title">欢迎使用外贸小助手 🎉</text>
<text class="welcome-desc">您当前处于游客模式可以体验翻译和信息提取功能</text>
<text class="welcome-hint">登录后可解锁客户管理报价单生成数据分析等更多功能</text>
</view>
<view class="quick-try-section">
<view class="section-title">快速体验</view>
<view class="try-area">
<textarea
class="try-input"
v-model="tryText"
placeholder="输入中文或英文,体验智能翻译..."
/>
<view class="try-actions">
<button class="try-btn primary" @click="handleTryTranslate" :disabled="tryLoading">
{{ tryLoading ? '翻译中...' : '翻译' }}
</button>
<button class="try-btn" @click="handleTryExtract" :disabled="tryLoading">
{{ tryLoading ? '提取中...' : '提取信息' }}
</button>
</view>
</view>
<view class="try-result" v-if="tryResult">
<view class="result-header">
<text class="result-label">翻译结果</text>
<view class="result-actions">
<text class="result-play" @click="playTryResult">朗读</text>
<text class="result-copy" @click="copyTryResult">复制</text>
</view>
</view>
<view class="result-content">
<text class="result-text">{{ tryResult }}</text>
</view>
</view>
<view class="try-extracted" v-if="tryExtracted && Object.keys(tryExtracted).length">
<view class="result-header">
<text class="result-label">提取结果</text>
</view>
<view class="extracted-content">
<view class="extract-field" v-for="(val, key) in tryExtracted" :key="key">
<text class="extract-field-label">{{ extractFieldLabels[key] || key }}</text>
<text class="extract-field-value">{{ val || '-' }}</text>
</view>
</view>
</view>
</view>
</view>
<view class="section" v-if="hasLogin">
<view class="section-title">待跟进客户</view>
<view class="silent-list" v-if="silentCustomers.length > 0">
<view class="silent-item" v-for="item in silentCustomers" :key="item.id">
<view class="silent-info">
<text class="silent-name">{{ item.name }}</text>
<text class="silent-country">{{ item.country }}</text>
</view>
<view class="silent-days">
<text class="days">{{ item.silence_days }}</text>
<text class="label">未联系</text>
</view>
</view>
</view>
<view class="empty" v-else>暂无待跟进客户</view>
</view>
<view class="section" v-if="hasLogin && followupStats.pending > 0">
<view class="section-title">
<text>待跟进提醒</text>
<text class="section-more" @click="goToPage(PAGES.FOLLOWUP)">查看全部 ></text>
</view>
<view class="followup-card" @click="goToPage(PAGES.FOLLOWUP)">
<text class="followup-count">{{ followupStats.pending }}</text>
<text class="followup-label">个客户需要跟进</text>
<text class="followup-hint">{{ followupStats.sent }} 已发送 · {{ followupStats.replied }} 已回复</text>
</view>
</view>
<view class="section" v-if="hasLogin">
<view class="section-title">
<text>快捷翻译</text>
<text class="section-more" @click="goToPage(PAGES.TRANSLATE)">去翻译 ></text>
</view>
<view class="translate-mini">
<textarea class="translate-mini-input" v-model="quickTranslateText" placeholder="输入文本,快速翻译..." />
<view class="translate-mini-actions">
<button class="translate-mini-btn" @click="doQuickTranslate" :disabled="!quickTranslateText.trim()">翻译</button>
<button class="translate-mini-btn extract" @click="doQuickExtract" :disabled="!quickTranslateText.trim()">提取信息</button>
</view>
<text class="translate-mini-result" v-if="quickTranslateResult">{{ quickTranslateResult }}</text>
</view>
</view>
<view class="more-section">
<view class="section-title">功能矩阵</view>
<view class="more-grid">
<view class="more-item" @click="goToPage(PAGES.TRANSLATE)">
<text class="more-icon">🔤</text>
<text class="more-text">翻译</text>
</view>
<view class="more-item" @click="hasLogin ? goToPage(PAGES.PRODUCT) : goToLogin()">
<text class="more-icon">📦</text>
<text class="more-text">产品库</text>
</view>
<view class="more-item" @click="hasLogin ? goToPage(PAGES.FOLLOWUP) : goToLogin()">
<text class="more-icon">📋</text>
<text class="more-text">跟进</text>
<text class="notif-badge" v-if="hasLogin && followupStats.pending > 0">{{ followupStats.pending > 99 ? '99+' : followupStats.pending }}</text>
</view>
<view class="more-item" @click="hasLogin ? goToPage(PAGES.NOTIFICATION) : goToLogin()">
<text class="more-icon">🔔</text>
<text class="more-text">通知</text>
<text class="notif-badge" v-if="hasLogin && unreadCount > 0">{{ unreadCount > 99 ? '99+' : unreadCount }}</text>
</view>
<view class="more-item" @click="hasLogin ? goToPage(PAGES.ANALYTICS) : goToLogin()">
<text class="more-icon">📊</text>
<text class="more-text">分析</text>
</view>
<view class="more-item" @click="goToPage(PAGES.UPGRADE)">
<text class="more-icon">💎</text>
<text class="more-text">升级</text>
</view>
<view class="more-item" @click="goToPage(PAGES.FEEDBACK)">
<text class="more-icon">💬</text>
<text class="more-text">反馈</text>
</view>
<view class="more-item" @click="hasLogin ? goToPage(PAGES.TEAM) : goToLogin()">
<text class="more-icon">👨👩👧👦</text>
<text class="more-text">团队</text>
</view>
<view class="more-item" v-if="isAdmin" @click="goToPage(PAGES.ADMIN)">
<text class="more-icon"></text>
<text class="more-text">管理</text>
</view>
<view class="more-item" @click="showWechatModal = true">
<text class="more-icon">💁</text>
<text class="more-text">联系客服</text>
</view>
</view>
</view>
<view class="onboarding-overlay" v-if="showOnboarding">
<view class="onboarding-modal">
<text class="ob-title">欢迎使用外贸小助手</text>
<text class="ob-subtitle">先告诉我你的产品信息我会为你生成营销素材</text>
<view class="ob-input-group" v-if="onboardingStep === 1">
<text class="ob-label">产品名称</text>
<input class="ob-input" v-model="productName" placeholder="例如:户外折叠椅" />
</view>
<view class="ob-input-group" v-if="onboardingStep === 1">
<text class="ob-label">产品描述</text>
<textarea class="ob-input ob-textarea" v-model="productDesc" placeholder="例如:承重150kg,防水面料,带杯架和扶手" />
</view>
<view class="ob-input-group" v-if="onboardingStep === 1">
<text class="ob-label">目标市场</text>
<input class="ob-input" v-model="targetMarket" placeholder="例如:US importers" />
</view>
<view class="ob-generating" v-if="onboardingStep === 2">
<text class="ob-gen-text">正在为你生成营销素材...</text>
</view>
<view class="ob-result" v-if="onboardingStep === 3 && generatedContent.length > 0">
<text class="ob-result-title">已为你生成以下内容</text>
<view class="ob-content-item" v-for="(item, i) in generatedContent" :key="i">
<text class="ob-content-text">{{ item }}</text>
</view>
<text class="ob-result-hint">你可以去"营销素材""产品库"查看更多</text>
</view>
<view class="ob-actions">
<button class="ob-btn ob-btn-primary" @click="onboardingNext" v-if="onboardingStep === 1">
开始生成
</button>
<button class="ob-btn ob-btn-primary" @click="finishOnboarding" v-if="onboardingStep === 3">
开始使用
</button>
</view>
<text class="ob-skip" @click="finishOnboarding" v-if="onboardingStep === 1">跳过以后再说</text>
</view>
</view>
<view class="modal-overlay" v-if="showWechatModal" @click="showWechatModal = false">
<view class="contact-modal" @click.stop>
<text class="contact-title">📞 联系我们</text>
<view class="contact-body">
<view class="contact-item">
<text class="contact-label">客服微信</text>
<text class="contact-value selectable" selectable>TradeMate_Support</text>
</view>
<view class="contact-item">
<text class="contact-label">用户交流群</text>
<text class="contact-value">添加客服微信后拉你入群</text>
</view>
<view class="contact-qr-placeholder">
<text class="qr-icon">📷</text>
<text class="qr-hint">客服微信二维码</text>
</view>
<text class="contact-tip">添加好友时备注"外贸小助手"</text>
</view>
<button class="announcement-btn" @click="showWechatModal = false">知道了</button>
</view>
</view>
<view class="modal-overlay" v-if="showAnnouncement" @click="showAnnouncement = false">
<view class="announcement-modal" @click.stop>
<text class="announcement-title">📢 系统公告</text>
<view class="announcement-body">
<text class="announcement-line">欢迎使用外贸小助手</text>
<text class="announcement-line">登录后可解锁以下功能</text>
<text class="announcement-item"> 客户管理 管理客户信息与跟进记录</text>
<text class="announcement-item"> 报价单 快速生成并导出专业报价</text>
<text class="announcement-item"> 数据分析 查看业务统计与趋势</text>
<text class="announcement-item"> 营销素材 AI 生成营销文案与关键词</text>
<text class="announcement-line" style="margin-top: 20rpx">现在登录体验全部功能 🚀</text>
</view>
<button class="announcement-btn" @click="showAnnouncement = false; goToLogin()">去登录</button>
</view>
</view>
<view class="footer">
<view class="footer-links">
<text class="footer-link" @click="goToPage(PAGES.AGREEMENT_PRIVACY)">隐私政策</text>
<text class="footer-divider">|</text>
<text class="footer-link" @click="goToPage(PAGES.AGREEMENT_TERMS)">用户协议</text>
</view>
<view class="footer-beian">
<a class="footer-beian-link" :href="EXTERNAL_URLS.BEIAN" target="_blank">{{ APP_INFO.ICP }}</a>
<a class="footer-beian-link" :href="EXTERNAL_URLS.BEIAN_PSB" target="_blank">{{ APP_INFO.PSB }}</a>
</view>
<text class="footer-copyright">© {{ APP_INFO.COPYRIGHT }}. 保留所有权利.</text>
</view>
<AiAssistant />
</view>
</template>
<script setup>
import { ref, computed, onUnmounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { authApi, customerApi, analyticsApi, onboardingApi, notificationApi, followupApi, translateApi, BASE_URL } from '@/utils/api.js'
import AiAssistant from '@/components/ai-assistant.vue'
import { STORAGE_KEYS, PAGES, EXTERNAL_URLS, APP_INFO, EXTRACT_FIELD_LABELS } from '@/config.js'
const showAnnouncement = ref(false)
const currentAnnouncement = ref(0)
const announcements = [
'全新 AI 翻译引擎上线,支持多语言商务翻译',
'登录后免费使用客户管理与报价单功能',
'每日数据看板上线,实时掌握业务动态',
]
let announcementTimer = null
const hasLogin = computed(() => {
const token = uni.getStorageSync('token')
const isGuest = uni.getStorageSync('isGuest')
return !!token && !isGuest
})
const isAdmin = computed(() => {
return hasLogin.value && userInfo.value?.role === 'admin'
})
const userInfo = ref(null)
const stats = ref({
customers: 0,
silentCustomers: 0,
todayTranslations: 0,
quotations: 0,
})
const silentCustomers = ref([])
const unreadCount = ref(0)
const followupStats = ref({ pending: 0, sent: 0, replied: 0 })
const showWechatModal = ref(false)
const showOnboarding = ref(false)
const onboardingStep = ref(1)
const productName = ref('')
const productDesc = ref('')
const targetMarket = ref('US importers')
const generatedContent = ref([])
const quickTranslateText = ref('')
const quickTranslateResult = ref('')
const tryText = ref('')
const tryResult = ref('')
const tryExtracted = ref(null)
const extractFieldLabels = EXTRACT_FIELD_LABELS
const tryLoading = ref(false)
onShow(() => {
announcementTimer = setInterval(() => {
currentAnnouncement.value = (currentAnnouncement.value + 1) % announcements.length
}, 4000)
const token = uni.getStorageSync('token')
const isGuest = uni.getStorageSync('isGuest')
if (token && !isGuest) {
uni.setStorageSync(STORAGE_KEYS.IS_GUEST, false)
loadData()
checkOnboarding()
loadUnread()
loadFollowupStats()
} else {
tryResult.value = ''
tryExtracted.value = null
tryText.value = ''
}
})
onUnmounted(() => {
if (announcementTimer) clearInterval(announcementTimer)
})
const checkOnboarding = async () => {
if (uni.getStorageSync('onboarded')) return
try {
const res = await onboardingApi.status()
if (!res.onboarded) {
showOnboarding.value = true
}
} catch (_) {
}
}
const onboardingNext = async () => {
if (!productName.value.trim()) {
uni.showToast({ title: '请输入产品名称', icon: 'none' })
return
}
onboardingStep.value = 2
try {
const res = await onboardingApi.createProduct(
productName.value.trim(),
productDesc.value.trim(),
'',
targetMarket.value.trim() || 'US importers',
)
generatedContent.value = res.generated_content || []
onboardingStep.value = 3
} catch (err) {
uni.showToast({ title: err.message || '生成失败', icon: 'none' })
onboardingStep.value = 1
}
}
const loadUnread = async () => {
try {
const res = await notificationApi.unreadCount()
unreadCount.value = res.count || 0
} catch (_) {}
}
const loadFollowupStats = async () => {
try {
const res = await followupApi.stats()
followupStats.value = res
} catch (_) {}
}
const finishOnboarding = () => {
uni.setStorageSync(STORAGE_KEYS.ONBOARDED, true)
showOnboarding.value = false
onboardingStep.value = 1
productName.value = ''
productDesc.value = ''
generatedContent.value = []
loadData()
}
const loadData = async () => {
try {
const [userRes, silentRes, overviewRes] = await Promise.all([
authApi.getUserInfo(),
customerApi.getSilent(3),
analyticsApi.getOverview(),
])
userInfo.value = userRes
silentCustomers.value = silentRes.customers || []
stats.value = {
customers: overviewRes.customers?.total || 0,
silentCustomers: overviewRes.customers?.silent_customers || 0,
todayTranslations: overviewRes.translations?.today || 0,
quotations: overviewRes.quotations?.total || 0,
}
} catch (err) {
console.error('加载数据失败', err)
}
}
const tabbarPages = [PAGES.INDEX, PAGES.CUSTOMERS, PAGES.MARKETING, PAGES.QUOTATION]
const goToPage = (url) => {
if (tabbarPages.includes(url)) {
uni.switchTab({ url })
} else {
uni.navigateTo({ url })
}
}
const goToLogin = () => {
uni.showModal({
title: '提示',
content: '请先登录',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: PAGES.LOGIN })
}
},
})
}
const doQuickTranslate = async () => {
if (!quickTranslateText.value.trim()) return
try {
const chinesePattern = /[\u4e00-\u9fa5]/
const targetLang = chinesePattern.test(quickTranslateText.value) ? 'en' : 'zh'
const res = await translateApi.translate(quickTranslateText.value, targetLang, 'auto')
quickTranslateResult.value = res.translated_text || res.translated || '翻译成功'
} catch {
quickTranslateResult.value = '翻译失败'
}
}
const doQuickExtract = async () => {
if (!quickTranslateText.value.trim()) return
try {
const res = await translateApi.extract(quickTranslateText.value, 'auto')
const extracted = res.extracted || {}
const obj = typeof extracted === 'string' ? { raw: extracted } : extracted
quickTranslateResult.value = Object.entries(obj).map(([k, v]) => `${k}: ${v}`).join('\n')
} catch {
quickTranslateResult.value = '提取失败'
}
}
const handleTryTranslate = async () => {
if (!tryText.value.trim()) {
uni.showToast({ title: '请输入内容', icon: 'none' })
return
}
tryLoading.value = true
tryResult.value = ''
tryExtracted.value = null
try {
const chinesePattern = /[\u4e00-\u9fa5]/
const targetLang = chinesePattern.test(tryText.value) ? 'en' : 'zh'
const isGuest = uni.getStorageSync('isGuest')
const res = isGuest
? await translateApi.publicTranslate(tryText.value, targetLang, 'auto')
: await translateApi.translate(tryText.value, targetLang, 'auto')
tryResult.value = res.translated_text || res.translated || '翻译成功'
uni.showToast({ title: '翻译成功', icon: 'success' })
} catch (err) {
uni.showToast({ title: err.message || '翻译失败', icon: 'none' })
} finally {
tryLoading.value = false
}
}
const handleTryExtract = async () => {
if (!tryText.value.trim()) {
uni.showToast({ title: '请输入内容', icon: 'none' })
return
}
tryLoading.value = true
tryResult.value = ''
tryExtracted.value = null
try {
const isGuest = uni.getStorageSync('isGuest')
const res = isGuest
? await translateApi.publicExtract(tryText.value, 'auto')
: await translateApi.extract(tryText.value, 'auto')
const extracted = res.extracted || {}
tryExtracted.value = typeof extracted === 'string' ? { raw: extracted } : extracted
uni.showToast({ title: '提取成功', icon: 'success' })
} catch (err) {
uni.showToast({ title: err.message || '提取失败', icon: 'none' })
} finally {
tryLoading.value = false
}
}
const copyTryResult = () => {
if (!tryResult.value) return
uni.setClipboardData({
data: tryResult.value,
success: () => {
uni.showToast({ title: '已复制', icon: 'success' })
},
})
}
const playTryResult = () => {
if (!tryResult.value || !tryText.value) return
const hasChinese = /[\u4e00-\u9fa5]/.test(tryText.value)
const lang = hasChinese ? 'en' : 'zh'
const token = uni.getStorageSync('token')
const text = tryResult.value
uni.showLoading({ title: '语音生成中...' })
if (typeof window !== 'undefined' && window.Audio) {
uni.request({
url: `${BASE_URL}/translate/tts?text=${encodeURIComponent(text)}&lang=${lang}`,
method: 'GET',
header: { Authorization: `Bearer ${token}` },
responseType: 'arraybuffer',
success: (res) => {
uni.hideLoading()
if (res.statusCode === 200 && res.data) {
const blob = new Blob([res.data], { type: 'audio/mpeg' })
const url = URL.createObjectURL(blob)
const audio = new Audio(url)
audio.onended = () => { URL.revokeObjectURL(url) }
audio.play().catch(() => {
uni.showToast({ title: '播放失败,请检查音量', icon: 'none' })
})
} else {
uni.showToast({ title: '语音生成失败', icon: 'none' })
}
},
fail: () => {
uni.hideLoading()
uni.showToast({ title: '语音生成失败', icon: 'none' })
},
})
} else {
uni.downloadFile({
url: `${BASE_URL}/translate/tts?text=${encodeURIComponent(text)}&lang=${lang}`,
header: { Authorization: `Bearer ${token}` },
success: (res) => {
uni.hideLoading()
if (res.statusCode === 200) {
const audioCtx = uni.createInnerAudioContext()
audioCtx.src = res.tempFilePath
audioCtx.play()
audioCtx.onEnded(() => audioCtx.destroy())
} else {
uni.showToast({ title: '语音生成失败', icon: 'none' })
}
},
fail: () => {
uni.hideLoading()
uni.showToast({ title: '语音生成失败', icon: 'none' })
},
})
}
}
</script>
<style lang="scss" scoped>
.index-container {
min-height: 100%;
background: #f5f5f5;
padding: 20rpx;
box-sizing: border-box;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
background: linear-gradient(135deg, #1890ff 0%, #69c0ff 100%);
border-radius: 16rpx;
margin-bottom: 30rpx;
}
.user-info {
display: flex;
align-items: center;
}
.header-left {
display: flex;
align-items: center;
gap: 12rpx;
flex: 1;
cursor: pointer;
overflow: hidden;
}
.announcement-icon {
font-size: 32rpx;
flex-shrink: 0;
}
.announcement-ticker {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.9);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-right {
display: flex;
align-items: center;
gap: 12rpx;
flex-shrink: 0;
}
.guest-label {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
white-space: nowrap;
}
.login-btn {
background: rgba(255, 255, 255, 0.2);
color: #fff;
font-size: 24rpx;
padding: 8rpx 24rpx;
border-radius: 32rpx;
border: none;
}
.username {
font-size: 32rpx;
color: #fff;
font-weight: 600;
margin-right: 16rpx;
}
.tier {
font-size: 22rpx;
color: #fff;
background: rgba(255, 255, 255, 0.2);
padding: 4rpx 12rpx;
border-radius: 8rpx;
}
.logout {
font-size: 26rpx;
color: #fff;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
margin-bottom: 30rpx;
}
.stat-card {
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
text-align: center;
}
.stat-card.warning .stat-value {
color: #ff4d4f;
}
.stat-value {
font-size: 48rpx;
font-weight: bold;
color: #1890ff;
display: block;
}
.stat-label {
font-size: 24rpx;
color: #999;
margin-top: 8rpx;
display: block;
}
.guest-welcome {
background: #fff;
border-radius: 20rpx;
padding: 40rpx 32rpx;
margin-bottom: 30rpx;
}
.welcome-content {
text-align: center;
margin-bottom: 40rpx;
}
.welcome-title {
font-size: 36rpx;
font-weight: 700;
color: #333;
display: block;
margin-bottom: 16rpx;
}
.welcome-desc {
font-size: 26rpx;
color: #666;
display: block;
margin-bottom: 12rpx;
}
.welcome-hint {
font-size: 24rpx;
color: #1890ff;
background: #e6f7ff;
padding: 12rpx 24rpx;
border-radius: 32rpx;
display: inline-block;
}
.quick-try-section {
border-top: 2rpx solid #f5f5f5;
padding-top: 32rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 600;
margin-bottom: 20rpx;
display: flex;
justify-content: space-between;
align-items: center;
}
.section-more {
font-size: 24rpx;
color: #1890ff;
}
.try-area {
background: #fafafa;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
}
.try-input {
width: 100%;
min-height: 180rpx;
background: #fff;
border-radius: 12rpx;
padding: 20rpx;
font-size: 28rpx;
line-height: 1.6;
box-sizing: border-box;
}
.try-actions {
display: flex;
gap: 20rpx;
margin-top: 20rpx;
}
.try-btn {
flex: 1;
height: 80rpx;
background: #fff;
border: 2rpx solid #d9d9d9;
color: #666;
border-radius: 12rpx;
font-size: 28rpx;
display: flex;
align-items: center;
justify-content: center;
}
.try-btn.primary {
background: #1890ff;
color: #fff;
border-color: #1890ff;
}
.try-btn[disabled] {
opacity: 0.6;
}
.try-result {
background: #f6ffed;
border-radius: 12rpx;
padding: 24rpx;
margin-top: 20rpx;
}
.try-extracted {
background: #f9f0ff;
border-radius: 12rpx;
padding: 24rpx;
margin-top: 20rpx;
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
}
.result-label {
font-size: 24rpx;
color: #52c41a;
font-weight: 500;
}
.result-actions {
display: flex;
gap: 16rpx;
}
.result-copy {
font-size: 24rpx;
color: #1890ff;
}
.result-play {
font-size: 24rpx;
color: #52c41a;
}
.result-content, .extracted-content {
background: #fff;
border-radius: 8rpx;
padding: 16rpx;
}
.result-text {
font-size: 28rpx;
color: #333;
line-height: 1.6;
white-space: pre-wrap;
}
.extract-field {
display: flex;
padding: 8rpx 0;
border-bottom: 1rpx solid rgba(114, 46, 209, 0.1);
}
.extract-field:last-child {
border-bottom: none;
}
.extract-field-label {
width: 160rpx;
font-size: 24rpx;
color: #722ed1;
flex-shrink: 0;
}
.extract-field-value {
flex: 1;
font-size: 24rpx;
color: #333;
}
.section {
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
margin-bottom: 30rpx;
}
.silent-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.silent-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx;
background: #f9f9f9;
border-radius: 12rpx;
}
.silent-info {
display: flex;
flex-direction: column;
}
.silent-name {
font-size: 28rpx;
color: #333;
}
.silent-country {
font-size: 24rpx;
color: #999;
margin-top: 4rpx;
}
.silent-days {
text-align: right;
}
.silent-days .days {
font-size: 28rpx;
color: #ff4d4f;
font-weight: 600;
}
.silent-days .label {
font-size: 22rpx;
color: #999;
display: block;
}
.empty {
text-align: center;
color: #999;
padding: 40rpx;
}
.followup-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16rpx;
padding: 32rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.followup-count {
font-size: 64rpx;
font-weight: bold;
color: #fff;
}
.followup-label {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
margin-top: 8rpx;
}
.followup-hint {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.7);
margin-top: 12rpx;
}
.translate-mini {
padding: 24rpx;
}
.translate-mini-input {
width: 100%;
height: 120rpx;
background: #f5f5f5;
border-radius: 12rpx;
padding: 16rpx 20rpx;
font-size: 26rpx;
box-sizing: border-box;
resize: none;
}
.translate-mini-actions {
display: flex;
gap: 16rpx;
margin-top: 16rpx;
}
.translate-mini-btn {
flex: 1;
height: 64rpx;
line-height: 64rpx;
text-align: center;
background: #1890ff;
color: #fff;
border-radius: 10rpx;
font-size: 26rpx;
}
.translate-mini-btn.extract {
background: #f5f5f5;
color: #333;
}
.translate-mini-result {
display: block;
margin-top: 16rpx;
padding: 16rpx 20rpx;
background: #f0f5ff;
border-radius: 10rpx;
font-size: 26rpx;
color: #333;
white-space: pre-wrap;
}
.more-section {
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
margin-top: 20rpx;
}
.more-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20rpx;
margin-top: 20rpx;
}
.more-item {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.more-icon {
width: 72rpx;
height: 72rpx;
background: #f0f5ff;
color: #667eea;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
font-weight: 600;
margin-bottom: 8rpx;
}
.more-text {
font-size: 22rpx;
color: #666;
}
.notif-badge {
position: absolute; top: -8rpx; right: -8rpx;
background: #ff4d4f; color: #fff; font-size: 18rpx;
min-width: 30rpx; height: 30rpx; border-radius: 15rpx;
text-align: center; line-height: 30rpx; padding: 0 6rpx;
}
.onboarding-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center;
z-index: 999; padding: 40rpx;
}
.onboarding-modal {
background: #fff; border-radius: 24rpx; padding: 48rpx 40rpx;
width: 100%; max-width: 600rpx;
}
.ob-title { font-size: 36rpx; font-weight: 700; color: #333; display: block; text-align: center; }
.ob-subtitle { font-size: 26rpx; color: #999; display: block; text-align: center; margin: 16rpx 0 40rpx; }
.ob-input-group { margin-bottom: 24rpx; }
.ob-label { font-size: 26rpx; color: #666; display: block; margin-bottom: 8rpx; }
.ob-input { width: 100%; height: 80rpx; background: #f5f5f5; border-radius: 12rpx; padding: 0 20rpx; font-size: 28rpx; box-sizing: border-box; }
.ob-textarea { height: 160rpx; padding: 20rpx; }
.ob-generating { padding: 60rpx 0; text-align: center; }
.ob-gen-text { font-size: 28rpx; color: #1890ff; }
.ob-result { margin: 24rpx 0; }
.ob-result-title { font-size: 26rpx; color: #333; font-weight: 500; display: block; margin-bottom: 16rpx; }
.ob-content-item {
padding: 16rpx; background: #f9f9f9; border-radius: 12rpx; margin-bottom: 12rpx;
}
.ob-content-text { font-size: 24rpx; color: #555; line-height: 1.6; }
.ob-result-hint { font-size: 22rpx; color: #999; display: block; text-align: center; margin-top: 16rpx; }
.ob-actions { margin-top: 32rpx; }
.ob-btn { width: 100%; height: 88rpx; border-radius: 12rpx; font-size: 30rpx; border: none; display: flex; align-items: center; justify-content: center; }
.ob-btn-primary { background: #1890ff; color: #fff; }
.ob-skip { display: block; text-align: center; margin-top: 24rpx; font-size: 24rpx; color: #999; }
.modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center;
z-index: 999; padding: 40rpx;
}
.contact-modal {
background: #fff;
border-radius: 20rpx;
width: 85%;
max-width: 560rpx;
max-height: 80vh;
overflow-y: auto;
padding: 40rpx;
}
.contact-title {
font-size: 32rpx;
font-weight: 600;
text-align: center;
display: block;
margin-bottom: 30rpx;
}
.contact-body { margin-bottom: 30rpx; }
.contact-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.contact-label { font-size: 26rpx; color: #666; }
.contact-value { font-size: 28rpx; color: #333; font-weight: 500; }
.contact-qr-placeholder {
display: flex;
flex-direction: column;
align-items: center;
padding: 40rpx 0;
background: #f9f9f9;
border-radius: 12rpx;
margin: 20rpx 0;
}
.qr-icon { font-size: 64rpx; margin-bottom: 16rpx; }
.qr-hint { font-size: 24rpx; color: #999; }
.qr-path { font-size: 20rpx; color: #ccc; margin-top: 8rpx; }
.contact-tip { font-size: 22rpx; color: #999; text-align: center; display: block; }
.announcement-modal {
background: #fff;
border-radius: 24rpx;
padding: 48rpx 40rpx;
width: 100%;
max-width: 560rpx;
}
.announcement-title {
font-size: 34rpx;
font-weight: 700;
color: #333;
display: block;
text-align: center;
margin-bottom: 32rpx;
}
.announcement-body {
margin-bottom: 32rpx;
}
.announcement-line {
font-size: 26rpx;
color: #555;
display: block;
line-height: 1.6;
margin-bottom: 8rpx;
}
.announcement-item {
font-size: 24rpx;
color: #666;
display: block;
line-height: 1.8;
padding-left: 20rpx;
}
.announcement-btn {
width: 100%;
height: 88rpx;
background: #1890ff;
color: #fff;
border-radius: 12rpx;
font-size: 30rpx;
display: flex;
align-items: center;
justify-content: center;
}
.footer {
margin-top: 40rpx;
padding: 40rpx 20rpx 30rpx;
text-align: center;
border-top: 2rpx solid #e8e8e8;
}
.footer-links {
display: flex;
justify-content: center;
align-items: center;
gap: 16rpx;
margin-bottom: 20rpx;
}
.footer-link {
font-size: 24rpx;
color: #1890ff;
}
.footer-divider {
font-size: 24rpx;
color: #d9d9d9;
}
.footer-beian {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 12rpx;
margin-bottom: 12rpx;
}
.footer-beian-link {
font-size: 22rpx;
color: #999;
}
.footer-copyright {
font-size: 22rpx;
color: #bbb;
display: block;
}
</style>