feat: 修复 H5 底部导航覆盖 + 更新项目进度文档

## H5 底部导航修复 (Bug #10)
- 精简 App.vue,移除重复 tabbar,仅保留全局样式
- uni-page 设置 height: calc(100% - 50px) + overflow-y: auto
- 内容区域精确停在底部导航上方,独立滚动不再叠加
- 恢复 custom-tab-bar 组件

## 项目进度文档
- PROGRESS.md 更新至 10 个 Bug 修复
- 新增 H5 底部导航修复记录
- 新增历史变更条目
This commit is contained in:
TradeMate Dev
2026-05-12 20:24:42 +08:00
parent 69e164dcae
commit 7b62c2f8b4
125 changed files with 19725 additions and 728 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 818 B

+3 -1
View File
@@ -3,7 +3,9 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" />
<title>外贸小助手</title>
<title>外贸小助手 - TradeMate</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<div id="app"></div>
+8060
View File
File diff suppressed because it is too large Load Diff
+8 -5
View File
@@ -11,15 +11,18 @@
"dependencies": {
"@dcloudio/uni-app": "3.0.0-4010520240507001",
"@dcloudio/uni-components": "3.0.0-4010520240507001",
"@dcloudio/uni-h5": "3.0.0-4010520240507001",
"@dcloudio/uni-mp-weixin": "3.0.0-4010520240507001",
"@uview-plus/uni-ui": "^3.0.0-alpha-3010520240507001",
"vue": "^3.4.21"
"vue": "3.4.21"
},
"devDependencies": {
"@dcloudio/types": "^3.4.8",
"@dcloudio/types": "3.4.8",
"@dcloudio/uni-cli-shared": "3.0.0-4010520240507001",
"@dcloudio/uni-stacktracey": "3.0.0-4010520240507001",
"@dcloudio/vite-plugin-uni": "3.0.0-4010520240507001",
"vite": "^5.2.8"
"@vue/runtime-core": "3.4.21",
"sass": "^1.99.0",
"sass-embedded": "^1.99.0",
"vite": "5.2.8"
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 818 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1890ff;stop-opacity:1" />
<stop offset="100%" style="stop-color:#096dd9;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="100" height="100" rx="20" fill="url(#grad)"/>
<text x="50" y="68" font-family="Arial, sans-serif" font-size="48" font-weight="bold" fill="white" text-anchor="middle">TM</text>
</svg>

After

Width:  |  Height:  |  Size: 511 B

+40 -105
View File
@@ -1,110 +1,45 @@
<script setup>
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
import CustomTabbar from '@/components/tabbar/custom-tabbar.vue'
import pushService from '@/utils/push.js'
onLaunch(() => {
console.log('App Launch')
const token = uni.getStorageSync('token')
if (token) {
uni.setStorageSync('hasLogin', true)
initPush()
}
})
onShow(() => {
console.log('App Show')
checkSilentCustomers()
})
onHide(() => {
console.log('App Hide')
})
async function initPush() {
try {
await pushService.init()
// 监听接收消息
pushService.onMessage((msg) => {
console.log('Received push message:', msg)
showNotification(msg)
})
// 监听点击消息
pushService.onClick((payload) => {
console.log('Push clicked:', payload)
handlePushClick(payload)
})
} catch (err) {
console.error('Push init failed:', err)
}
}
function showNotification(msg) {
uni.showModal({
title: msg.title,
content: msg.content,
showCancel: false,
success: () => {
if (msg.payload) {
handlePushClick(msg.payload)
}
}
})
}
function handlePushClick(payload) {
if (!payload) return
switch (payload.type) {
case 'silent_customer':
uni.switchTab({ url: '/pages/customers/customers' })
break
case 'quotation':
uni.switchTab({ url: '/pages/quotation/quotation' })
break
case 'reply':
uni.switchTab({ url: '/pages/translate/translate' })
break
default:
uni.switchTab({ url: '/pages/index/index' })
}
}
async function checkSilentCustomers() {
const token = uni.getStorageSync('token')
if (!token) return
try {
const { customerApi } = require('@/utils/api.js')
const silentData = await customerApi.getSilent(3)
if (silentData.count > 0) {
// 创建本地通知提醒
pushService.createLocalNotification({
title: '跟进提醒',
content: `您有 ${silentData.count} 个客户已沉默3天以上`,
payload: { type: 'silent_customer', count: silentData.count }
})
}
} catch (err) {
console.error('Check silent customers failed:', err)
}
}
</script>
<template>
<view id="app">
<router-view />
<CustomTabbar />
</view>
<!-- Uni-app manages its own page/tabbar structure. App.vue only provides global styles. -->
<router-view />
</template>
<style>
@import '@/static/common.css';
<script setup>
// App root - uni-app framework handles page layout and tab bar
</script>
#app {
padding-bottom: 100rpx;
<style>
/* Global reset */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
</style>
html, body, #app {
height: 100%;
width: 100%;
}
/* Fix: make uni-page scrollable and properly sized for fixed tabbar */
uni-page {
height: calc(100% - 50px) !important;
overflow-y: auto !important;
overflow-x: hidden !important;
-webkit-overflow-scrolling: touch !important;
}
uni-page-body, uni-page-wrapper {
overflow-y: auto !important;
overflow-x: hidden !important;
-webkit-overflow-scrolling: touch !important;
}
/* Ensure the page head doesn't block scrolling */
uni-page-head {
flex-shrink: 0;
}
/* The uni-tabbar is already position:fixed by the framework */
uni-tabbar {
z-index: 999 !important;
}
</style>
+35 -30
View File
@@ -1,5 +1,5 @@
<template>
<view class="custom-tabbar" v-if="showTabbar">
<view class="custom-tabbar">
<view
class="tab-item"
v-for="(item, index) in tabList"
@@ -13,7 +13,7 @@
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted, watch } from 'vue'
const current = ref(0)
@@ -25,31 +25,27 @@ const tabList = ref([
{ pagePath: '/pages/quotation/quotation', text: '报价', icon: '📄' },
])
const updateCurrent = () => {
const hash = window.location.hash || ''
for (let i = 0; i < tabList.value.length; i++) {
if (hash.includes(tabList.value[i].pagePath)) {
current.value = i
return
}
}
current.value = 0
}
onMounted(() => {
updateCurrent()
})
const showTabbar = computed(() => {
const pages = getCurrentPages()
if (pages.length) {
const currentPage = pages[pages.length - 1].route || ''
return !currentPage.includes('login')
watch(
() => window.location.hash,
() => {
updateCurrent()
}
return true
})
const updateCurrent = () => {
const pages = getCurrentPages()
if (pages.length) {
const currentPage = pages[pages.length - 1].$page?.fullPath || pages[pages.length - 1].route || ''
const index = tabList.value.findIndex(item => currentPage.includes(item.pagePath))
if (index !== -1) current.value = index
}
}
uni.onAppRoute(() => {
updateCurrent()
})
)
const switchTab = (index) => {
if (current.value === index) return
@@ -64,11 +60,12 @@ const switchTab = (index) => {
bottom: 0;
left: 0;
right: 0;
height: 100rpx;
height: 50px;
background: #fff;
display: flex;
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
z-index: 999;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
z-index: 99999;
border-top: 1px solid #f0f0f0;
}
.tab-item {
@@ -77,19 +74,27 @@ const switchTab = (index) => {
flex-direction: column;
align-items: center;
justify-content: center;
padding: 6px 0;
cursor: pointer;
transition: all 0.2s;
}
.tab-item:active {
opacity: 0.7;
}
.tab-icon {
font-size: 44rpx;
margin-bottom: 4rpx;
font-size: 24px;
line-height: 1.2;
}
.tab-text {
font-size: 22rpx;
color: #666;
font-size: 11px;
color: #999;
margin-top: 2px;
}
.tab-text.active {
color: #1890ff;
}
</style>
</style>
+98
View File
@@ -0,0 +1,98 @@
<template>
<div class="tab-bar" :style="{ opacity: 1 }">
<div
v-for="(item, index) in tabList"
:key="index"
class="tab-bar-item"
@click="handleSwitchTab(index)"
>
<span class="tab-bar-icon">{{ item.icon }}</span>
<span class="tab-bar-text" :class="{ active: currentIndex === index }">{{ item.text }}</span>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const currentIndex = ref(0)
const tabList = ref([
{ pagePath: '/pages/index/index', text: '首页', icon: '🏠' },
{ pagePath: '/pages/translate/translate', text: '翻译', icon: '🔤' },
{ pagePath: '/pages/customers/customers', text: '客户', icon: '👥' },
{ pagePath: '/pages/marketing/marketing', text: '营销', icon: '📢' },
{ pagePath: '/pages/quotation/quotation', text: '报价', icon: '📄' },
])
const updateCurrentIndex = () => {
const hash = window.location.hash || ''
console.log('Hash:', hash)
for (let i = 0; i < tabList.value.length; i++) {
if (hash.includes(tabList.value[i].pagePath)) {
currentIndex.value = i
return
}
}
currentIndex.value = 0
}
const handleSwitchTab = (index) => {
if (currentIndex.value === index) return
const url = tabList.value[index].pagePath
uni.switchTab({ url })
}
onMounted(() => {
console.log('TabBar mounted, tabList:', tabList.value)
updateCurrentIndex()
window.addEventListener('hashchange', updateCurrentIndex)
})
onUnmounted(() => {
window.removeEventListener('hashchange', updateCurrentIndex)
})
</script>
<style>
.tab-bar {
position: fixed !important;
bottom: 0 !important;
left: 0 !important;
right: 0 !important;
height: 80px !important;
background: #fff !important;
display: flex !important;
flex-direction: row !important;
box-shadow: 0 -4px 16px rgba(0,0,0,0.2) !important;
z-index: 999999 !important;
border-top: 1px solid #e0e0e0 !important;
padding-bottom: env(safe-area-inset-bottom) !important;
box-sizing: border-box !important;
}
.tab-bar-item {
flex: 1 !important;
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
cursor: pointer;
}
.tab-bar-icon {
font-size: 28px !important;
line-height: 1 !important;
margin-bottom: 4px;
}
.tab-bar-text {
font-size: 12px !important;
color: #999 !important;
}
.tab-bar-text.active {
color: #1890ff !important;
font-weight: bold !important;
}
</style>
+97
View File
@@ -0,0 +1,97 @@
{
"name": "trademate",
"appid": "",
"description": "外贸小助手 - 智能翻译、营销素材、客户管理",
"versionName": "1.0.0",
"versionCode": "100",
"transformPx": false,
"app-plus": {
"usingComponents": true,
"nvueStyleCompiler": "uni-app",
"compilerVersion": 3,
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
},
"modules": {},
"distribute": {
"android": {
"permissions": []
},
"ios": {}
}
},
"quickapp": {},
"mp-weixin": {
"appid": "",
"setting": {
"urlCheck": false,
"es6": true,
"enhance": true,
"postcss": true,
"preloadBackgroundData": false,
"minified": true,
"newFeature": false,
"coverView": true,
"nodeModules": false,
"autoAudits": false,
"showShadowRootInWxmlPanel": true,
"scopeDataCheck": false,
"uglifyFileName": false,
"checkInvalidKey": true,
"checkSiteMap": true,
"uploadWithSourceMap": true,
"compileHotReLoad": false,
"lazyloadPlaceholderEnable": false,
"useMultiFrameRuntime": true,
"useApiHook": true,
"useApiHostProcess": true,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
},
"enableEngineNative": false,
"useIsolateContext": true,
"userConfirmedBundleSwitch": false,
"packNpmManually": false,
"packNpmRelationList": [],
"minifyWXSS": true,
"showES6CompileOption": false,
"minifyWXML": true
},
"usingComponents": true
},
"mp-alipay": {
"usingComponents": true
},
"mp-baidu": {
"usingComponents": true
},
"mp-toutiao": {
"usingComponents": true
},
"uniStatistics": {
"enable": false
},
"h5": {
"title": "外贸小助手",
"template": "index.html",
"router": {
"mode": "hash"
},
"devServer": {
"port": 5173,
"disableHostCheck": true,
"proxy": {
"/api": {
"target": "http://localhost:8000",
"changeOrigin": true
}
}
}
},
"vueVersion": "3"
}
+89 -7
View File
@@ -1,12 +1,5 @@
{
"pages": [
{
"path": "pages/login/login",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "登录"
}
},
{
"path": "pages/index/index",
"style": {
@@ -37,11 +30,72 @@
"navigationBarTitleText": "报价单"
}
},
{
"path": "pages/login/login",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "登录"
}
},
{
"path": "pages/product/product",
"style": {
"navigationBarTitleText": "产品库"
}
},
{
"path": "pages/admin/admin",
"style": {
"navigationBarTitleText": "管理后台"
}
},
{
"path": "pages/analytics/analytics",
"style": {
"navigationBarTitleText": "数据分析"
}
},
{
"path": "pages/team/team",
"style": {
"navigationBarTitleText": "团队协作"
}
},
{
"path": "pages/agreement/privacy",
"style": {
"navigationBarTitleText": "隐私政策"
}
},
{
"path": "pages/agreement/terms",
"style": {
"navigationBarTitleText": "用户协议"
}
},
{
"path": "pages/notification/notification",
"style": {
"navigationBarTitleText": "通知中心"
}
},
{
"path": "pages/feedback/feedback",
"style": {
"navigationBarTitleText": "意见反馈"
}
},
{
"path": "pages/upgrade/upgrade",
"style": {
"navigationBarTitleText": "升级会员"
}
},
{
"path": "pages/followup/followup",
"style": {
"navigationBarTitleText": "智能跟进"
}
}
],
"globalStyle": {
@@ -49,5 +103,33 @@
"navigationBarTitleText": "外贸小助手",
"navigationBarBackgroundColor": "#1890ff",
"backgroundColor": "#f5f5f5"
},
"tabBar": {
"custom": true,
"color": "#666666",
"selectedColor": "#1890ff",
"backgroundColor": "#ffffff",
"list": [
{
"pagePath": "pages/index/index",
"text": "首页"
},
{
"pagePath": "pages/translate/translate",
"text": "翻译"
},
{
"pagePath": "pages/customers/customers",
"text": "客户"
},
{
"pagePath": "pages/marketing/marketing",
"text": "营销"
},
{
"pagePath": "pages/quotation/quotation",
"text": "报价"
}
]
}
}
+126
View File
@@ -0,0 +1,126 @@
<template>
<view class="admin-container">
<view class="header-card">
<text class="title">管理后台</text>
<text class="subtitle">系统概览</text>
</view>
<view class="stats-grid">
<view class="stat-card">
<text class="stat-value">{{ dashboard.users?.total || 0 }}</text>
<text class="stat-label">用户总数</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ dashboard.teams?.total || 0 }}</text>
<text class="stat-label">团队数</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ dashboard.customers?.total || 0 }}</text>
<text class="stat-label">客户总数</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ dashboard.usage?.today || 0 }}</text>
<text class="stat-label">今日请求</text>
</view>
</view>
<view class="section">
<view class="section-header">
<text class="section-title">最近注册用户</text>
</view>
<view class="user-list" v-if="dashboard.recent_users?.length">
<view class="user-item" v-for="u in dashboard.recent_users" :key="u.id">
<view class="user-info">
<text class="user-name">{{ u.username }}</text>
<text class="user-tier" :class="u.tier">{{ u.tier }}</text>
</view>
<text class="user-date">{{ formatTime(u.created_at) }}</text>
</view>
</view>
</view>
<view class="section">
<view class="section-header">
<text class="section-title">用户管理</text>
</view>
<view class="user-list" v-if="users.length">
<view class="user-item" v-for="u in users" :key="u.id">
<view class="user-info">
<text class="user-name">{{ u.username || u.phone }}</text>
<text class="user-tier" :class="u.tier">{{ u.tier }}</text>
</view>
<view class="user-actions">
<text class="action-btn" @click="changeTier(u, 'free')">免费</text>
<text class="action-btn pro-btn" @click="changeTier(u, 'pro')">Pro</text>
<text class="action-btn enterprise-btn" @click="changeTier(u, 'enterprise')">企业</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { adminApi } from '@/utils/api.js'
const dashboard = ref({})
const users = ref([])
onShow(() => {
loadData()
})
const loadData = async () => {
try {
const [dash, userList] = await Promise.all([
adminApi.getDashboard(),
adminApi.listUsers(),
])
dashboard.value = dash
users.value = userList.items || []
} catch (err) {
uni.showToast({ title: err.message || '加载失败', icon: 'none' })
}
}
const changeTier = async (user, tier) => {
try {
await adminApi.updateUserTier(user.id, tier)
uni.showToast({ title: '已更新', icon: 'success' })
loadData()
} catch (err) {
uni.showToast({ title: err.message || '操作失败', icon: 'none' })
}
}
const formatTime = (t) => t ? t.split('T')[0] : ''
</script>
<style lang="scss" scoped>
.admin-container { min-height: 100vh; background: #f5f5f5; padding: 20rpx; }
.header-card { background: linear-gradient(135deg, #667eea, #764ba2); border-radius: 16rpx; padding: 40rpx; margin-bottom: 30rpx; }
.header-card .title { font-size: 40rpx; font-weight: 700; color: #fff; display: block; }
.header-card .subtitle { font-size: 26rpx; color: rgba(255,255,255,0.8); margin-top: 8rpx; }
.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-value { font-size: 48rpx; font-weight: bold; color: #667eea; display: block; }
.stat-label { font-size: 24rpx; color: #999; margin-top: 8rpx; }
.section { background: #fff; border-radius: 16rpx; padding: 30rpx; margin-bottom: 30rpx; }
.section-header { margin-bottom: 20rpx; }
.section-title { font-size: 30rpx; font-weight: 600; }
.user-list { display: flex; flex-direction: column; gap: 16rpx; }
.user-item { display: flex; justify-content: space-between; align-items: center; padding: 20rpx; background: #f9f9f9; border-radius: 12rpx; }
.user-info { display: flex; align-items: center; gap: 12rpx; }
.user-name { font-size: 28rpx; }
.user-tier { font-size: 20rpx; padding: 2rpx 10rpx; border-radius: 6rpx; }
.user-tier.free { background: #fff7e6; color: #fa8c16; }
.user-tier.pro { background: #e6f7ff; color: #1890ff; }
.user-tier.enterprise { background: #f6ffed; color: #52c41a; }
.user-date { font-size: 22rpx; color: #999; }
.user-actions { display: flex; gap: 8rpx; }
.action-btn { font-size: 20rpx; padding: 4rpx 12rpx; border-radius: 6rpx; background: #f0f0f0; color: #666; }
.pro-btn { background: #e6f7ff; color: #1890ff; }
.enterprise-btn { background: #f6ffed; color: #52c41a; }
</style>
+51
View File
@@ -0,0 +1,51 @@
<template>
<view class="container">
<view class="content">
<text class="title">隐私政策</text>
<text class="update-date">更新日期2026-05-09</text>
<view class="section">
<text class="section-title">信息收集</text>
<text class="text">我们收集您提供的手机号码微信OpenID用于账号注册和登录您在使用翻译营销文案生成等功能时我们仅处理您提交的文本内容不会存储超出服务所需的个人信息</text>
</view>
<view class="section">
<text class="section-title">信息使用</text>
<text class="text">您的信息仅用于提供翻译客户管理报价单生成等核心服务优化AI翻译和回复建议的质量我们不会将您的个人信息用于任何与上述服务无关的目的</text>
</view>
<view class="section">
<text class="section-title">信息存储与保护</text>
<text class="text">您的数据存储在中国境内的服务器上我们采用行业标准的安全措施包括加密传输访问控制定期安全审计保护您的信息安全</text>
</view>
<view class="section">
<text class="section-title">信息共享</text>
<text class="text">我们不会向任何第三方出售或分享您的个人信息AI翻译功能会调用第三方API如DeepLOpenAIAnthropic仅传输需要翻译的文本内容</text>
</view>
<view class="section">
<text class="section-title">您的权利</text>
<text class="text">您可以随时查看修改或删除您的个人信息账号注销功能可联系客服处理您也可以通过设置中的开关控制AI功能的数据使用</text>
</view>
<view class="section">
<text class="section-title">联系我们</text>
<text class="text">如果您对本隐私政策有任何疑问请通过应用内的反馈功能联系我们我们会在15个工作日内回复</text>
</view>
</view>
</view>
</template>
<script setup>
</script>
<style lang="scss" scoped>
.container { min-height: 100vh; background: #f5f5f5; padding: 30rpx; }
.content { background: #fff; border-radius: 16rpx; padding: 40rpx; }
.title { font-size: 36rpx; font-weight: 700; color: #333; display: block; text-align: center; margin-bottom: 12rpx; }
.update-date { font-size: 24rpx; color: #999; display: block; text-align: center; margin-bottom: 40rpx; }
.section { margin-bottom: 32rpx; }
.section-title { font-size: 28rpx; font-weight: 600; color: #333; display: block; margin-bottom: 12rpx; }
.text { font-size: 26rpx; color: #666; line-height: 1.8; display: block; }
</style>
+56
View File
@@ -0,0 +1,56 @@
<template>
<view class="container">
<view class="content">
<text class="title">用户协议</text>
<text class="update-date">更新日期2026-05-09</text>
<view class="section">
<text class="section-title">服务说明</text>
<text class="text">外贸小助手TradeMate是一款为外贸从业者提供AI翻译客户管理营销素材生成等功能的工具类应用使用本服务即表示您同意本协议的全部条款</text>
</view>
<view class="section">
<text class="section-title">账号管理</text>
<text class="text">您注册时需提供真实有效的手机号码账号仅限本人使用禁止转借或出售因账号密码泄露导致的损失由您自行承担连续180天未登录的账号我们保留回收的权利</text>
</view>
<view class="section">
<text class="section-title">使用规范</text>
<text class="text">您承诺不利用本服务从事违法违规活动包括但不限于上传违法信息侵犯他人知识产权发送垃圾信息等违反规范可能导致服务被终止</text>
</view>
<view class="section">
<text class="section-title">付费服务</text>
<text class="text">免费版用户享有有限次数的翻译和文案生成功能Pro版和企业版通过付费订阅获得更多功能和服务配额付费后不支持无理由退款如因服务故障导致的损失可申请补偿</text>
</view>
<view class="section">
<text class="section-title">AI服务免责</text>
<text class="text">AI翻译和文案生成结果仅供参考不构成专业建议用户应对使用AI生成内容的外贸沟通行为自行负责我们不保证翻译的绝对准确性</text>
</view>
<view class="section">
<text class="section-title">服务变更与终止</text>
<text class="text">我们保留随时修改或终止服务的权利重大变更将提前30天通知如您违反本协议我们有权立即终止服务</text>
</view>
<view class="section">
<text class="section-title">法律适用</text>
<text class="text">本协议适用中华人民共和国法律因本协议引起的争议双方应友好协商解决协商不成的提交服务提供方所在地人民法院管辖</text>
</view>
</view>
</view>
</template>
<script setup>
</script>
<style lang="scss" scoped>
.container { min-height: 100vh; background: #f5f5f5; padding: 30rpx; }
.content { background: #fff; border-radius: 16rpx; padding: 40rpx; }
.title { font-size: 36rpx; font-weight: 700; color: #333; display: block; text-align: center; margin-bottom: 12rpx; }
.update-date { font-size: 24rpx; color: #999; display: block; text-align: center; margin-bottom: 40rpx; }
.section { margin-bottom: 32rpx; }
.section-title { font-size: 28rpx; font-weight: 600; color: #333; display: block; margin-bottom: 12rpx; }
.text { font-size: 26rpx; color: #666; line-height: 1.8; display: block; }
</style>
+125
View File
@@ -0,0 +1,125 @@
<template>
<view class="analytics-container">
<view class="header-card">
<text class="title">数据分析</text>
<text class="subtitle">你的业务数据概览</text>
</view>
<view class="section">
<text class="section-title">客户分析</text>
<view class="stats-row" v-if="data.customers">
<view class="mini-card">
<text class="val">{{ data.customers.total }}</text>
<text class="lbl">客户总数</text>
</view>
<view class="mini-card warning">
<text class="val">{{ data.customers.silent_customers }}</text>
<text class="lbl">沉默客户</text>
</view>
</view>
<view class="status-breakdown" v-if="data.customers?.by_status">
<text class="bd-title">按状态分布</text>
<view class="bd-item" v-for="(count, status) in data.customers.by_status" :key="status">
<text class="bd-label">{{ statusMap[status] || status }}</text>
<view class="bd-bar">
<view class="bd-fill" :style="{ width: barWidth(count, data.customers.total) + '%' }"></view>
</view>
<text class="bd-count">{{ count }}</text>
</view>
</view>
<view class="country-list" v-if="data.customers?.by_country">
<text class="bd-title">国家分布 (TOP 10)</text>
<view class="country-item" v-for="(count, country) in data.customers.by_country" :key="country">
<text class="country-name">{{ country }}</text>
<text class="country-count">{{ count }}</text>
</view>
</view>
</view>
<view class="section">
<text class="section-title">翻译统计</text>
<view class="stats-row" v-if="data.translations">
<view class="mini-card">
<text class="val">{{ data.translations.today }}</text>
<text class="lbl">今日翻译</text>
</view>
<view class="mini-card">
<text class="val">{{ data.translations.total }}</text>
<text class="lbl">累计翻译</text>
</view>
</view>
</view>
<view class="section">
<text class="section-title">报价单统计</text>
<view class="stats-row" v-if="data.quotations">
<view class="mini-card">
<text class="val">{{ data.quotations.total }}</text>
<text class="lbl">报价单数</text>
</view>
<view class="mini-card">
<text class="val">{{ data.quotations.total_accepted_value }}</text>
<text class="lbl">成交总额</text>
</view>
</view>
</view>
<view class="section">
<text class="section-title">消息统计</text>
<view class="stats-row" v-if="data.messages">
<view class="mini-card">
<text class="val">{{ data.messages.today }}</text>
<text class="lbl">今日消息</text>
</view>
<view class="mini-card">
<text class="val">{{ data.messages.total }}</text>
<text class="lbl">累计消息</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { analyticsApi } from '@/utils/api.js'
const data = ref({})
const statusMap = { lead: '潜在', negotiating: '谈判中', customer: '已成交', lost: '已丢失', draft: '草稿', sent: '已发送', accepted: '已成交' }
onShow(() => loadData())
const loadData = async () => {
try {
data.value = await analyticsApi.getOverview()
} catch (err) {
uni.showToast({ title: err.message || '加载失败', icon: 'none' })
}
}
const barWidth = (count, total) => total > 0 ? (count / total * 100) : 0
</script>
<style lang="scss" scoped>
.analytics-container { min-height: 100vh; background: #f5f5f5; padding: 20rpx; }
.header-card { background: linear-gradient(135deg, #36d1dc, #5b86e5); border-radius: 16rpx; padding: 40rpx; margin-bottom: 30rpx; }
.header-card .title { font-size: 40rpx; font-weight: 700; color: #fff; display: block; }
.header-card .subtitle { font-size: 26rpx; color: rgba(255,255,255,0.8); margin-top: 8rpx; }
.section { background: #fff; border-radius: 16rpx; padding: 30rpx; margin-bottom: 20rpx; }
.section-title { font-size: 30rpx; font-weight: 600; margin-bottom: 20rpx; display: block; }
.stats-row { display: flex; gap: 20rpx; margin-bottom: 20rpx; }
.mini-card { flex: 1; text-align: center; padding: 20rpx; background: #f9f9f9; border-radius: 12rpx; }
.mini-card .val { font-size: 40rpx; font-weight: bold; color: #5b86e5; display: block; }
.mini-card .lbl { font-size: 22rpx; color: #999; margin-top: 4rpx; }
.mini-card.warning .val { color: #ff4d4f; }
.bd-title { font-size: 26rpx; font-weight: 500; color: #666; margin-bottom: 12rpx; display: block; }
.bd-item { display: flex; align-items: center; gap: 12rpx; margin-bottom: 10rpx; }
.bd-label { width: 80rpx; font-size: 24rpx; color: #333; }
.bd-bar { flex: 1; height: 20rpx; background: #f0f0f0; border-radius: 10rpx; overflow: hidden; }
.bd-fill { height: 100%; background: linear-gradient(90deg, #36d1dc, #5b86e5); border-radius: 10rpx; }
.bd-count { width: 60rpx; text-align: right; font-size: 24rpx; color: #666; }
.country-item { display: flex; justify-content: space-between; padding: 12rpx 0; border-bottom: 2rpx solid #f5f5f5; }
.country-name { font-size: 26rpx; }
.country-count { font-size: 24rpx; color: #999; }
</style>
+516 -18
View File
@@ -1,5 +1,24 @@
<template>
<view class="customers-container">
<view class="health-overview" v-if="healthOverview">
<view class="health-stat active" @click="filter = 'all'; loadCustomers()">
<text class="health-stat-value">{{ healthOverview.active }}</text>
<text class="health-stat-label">活跃客户</text>
</view>
<view class="health-stat watch" @click="filter = 'all'; loadCustomers()">
<text class="health-stat-value">{{ healthOverview.watch }}</text>
<text class="health-stat-label">需关注</text>
</view>
<view class="health-stat critical" @click="filter = 'silent'; loadSilent()">
<text class="health-stat-value">{{ healthOverview.critical }}</text>
<text class="health-stat-label">高危流失</text>
</view>
<view class="health-stat total">
<text class="health-stat-value">{{ healthOverview.total }}</text>
<text class="health-stat-label">共计</text>
</view>
</view>
<view class="filter-tabs">
<view
class="tab-item"
@@ -44,6 +63,16 @@
<view class="customer-header">
<text class="customer-name">{{ item.name }}</text>
<text class="customer-status" :class="item.status">{{ getStatusText(item.status) }}</text>
<text
class="risk-badge"
:class="item.risk_level"
v-if="item.risk_level"
>{{ getRiskText(item.risk_level) }}</text>
<text
class="health-badge"
:class="getHealthGrade(item.id)"
v-if="getHealthGrade(item.id)"
>{{ getHealthLabel(getHealthGrade(item.id)) }}</text>
</view>
<view class="customer-detail">
<text class="detail-item" v-if="item.company">{{ item.company }}</text>
@@ -54,9 +83,13 @@
<text class="contact-time">最后联系: {{ formatTime(item.last_contact_at) }}</text>
<text class="silence-days" v-if="item.silence_days > 0">沉默 {{ item.silence_days }} </text>
</view>
<view class="risk-reasons" v-if="item.reasons && item.reasons.length > 0">
<text class="risk-reason" v-for="(r, i) in item.reasons" :key="i">{{ r }}</text>
</view>
</view>
<view class="customer-actions">
<view class="action-icon" @click="showCustomerDetail(item)"></view>
<view class="action-icon" @click="showConversation(item)"></view>
<view class="action-icon" @click="editCustomer(item)"></view>
<view class="action-icon delete" @click="deleteCustomer(item.id)"></view>
</view>
@@ -67,8 +100,19 @@
<text>暂无客户数据</text>
</view>
<view class="add-btn" @click="showAddModal = true">
<text class="add-icon">+</text>
<view class="bottom-actions">
<view class="action-btn export-btn" @click="exportCsv">
<text class="btn-icon">CSV</text>
<text class="btn-text">导出</text>
</view>
<view class="action-btn import-btn" @click="importCustomers">
<text class="btn-icon"></text>
<text class="btn-text">导入</text>
</view>
<view class="action-btn add-btn" @click="showAddModal = true">
<text class="btn-icon">+</text>
<text class="btn-text">新增</text>
</view>
</view>
<view class="modal" v-if="showAddModal || showEditModal" @click="closeModal">
@@ -121,8 +165,63 @@
<view class="detail-header">
<text class="detail-name">{{ currentCustomer.name }}</text>
<text class="detail-status" :class="currentCustomer.status">{{ getStatusText(currentCustomer.status) }}</text>
<text
class="health-badge large"
:class="currentHealth?.grade"
v-if="currentHealth"
>{{ getHealthLabel(currentHealth.grade) }}</text>
<text class="health-score" v-if="currentHealth">{{ currentHealth.total_score }}</text>
</view>
<view class="detail-body">
<view class="health-detail-section" v-if="currentHealth">
<view class="health-detail-title">健康度评分</view>
<view class="dimension-row">
<text class="dimension-label">响应趋势</text>
<view class="dimension-bar">
<view class="dimension-fill" :style="{ width: (currentHealth.dimensions?.response_trend?.score || 0) + '%' }"></view>
</view>
<text class="dimension-score">{{ currentHealth.dimensions?.response_trend?.score || 0 }}</text>
<text class="dimension-trend" :class="currentHealth.dimensions?.response_trend?.trend">{{ trendLabel(currentHealth.dimensions?.response_trend?.trend) }}</text>
</view>
<view class="dimension-row">
<text class="dimension-label">情感轨迹</text>
<view class="dimension-bar">
<view class="dimension-fill" :style="{ width: (currentHealth.dimensions?.sentiment?.score || 0) + '%' }"></view>
</view>
<text class="dimension-score">{{ currentHealth.dimensions?.sentiment?.score || 0 }}</text>
<text class="dimension-label small">{{ currentHealth.dimensions?.sentiment?.label }}</text>
</view>
<view class="dimension-row">
<text class="dimension-label">询盘深度</text>
<view class="dimension-bar">
<view class="dimension-fill" :style="{ width: (currentHealth.dimensions?.inquiry_depth?.score || 0) + '%' }"></view>
</view>
<text class="dimension-score">{{ currentHealth.dimensions?.inquiry_depth?.score || 0 }}</text>
<text class="dimension-label small" v-if="currentHealth.dimensions?.inquiry_depth?.signal_count">信号 {{ currentHealth.dimensions.inquiry_depth.signal_count }}</text>
</view>
<view class="dimension-row">
<text class="dimension-label">沉默天数</text>
<view class="dimension-bar">
<view class="dimension-fill" :style="{ width: (currentHealth.dimensions?.silence?.score || 0) + '%' }"></view>
</view>
<text class="dimension-score">{{ currentHealth.dimensions?.silence?.score || 0 }}</text>
<text class="dimension-label small">{{ currentHealth.dimensions?.silence?.days || 0 }}</text>
</view>
<view class="dimension-row">
<text class="dimension-label">商业价值</text>
<view class="dimension-bar">
<view class="dimension-fill" :style="{ width: (currentHealth.dimensions?.business_value?.score || 0) + '%' }"></view>
</view>
<text class="dimension-score">{{ currentHealth.dimensions?.business_value?.score || 0 }}</text>
<text class="dimension-label small" v-if="currentHealth.dimensions?.business_value?.total_value">${{ formatValue(currentHealth.dimensions.business_value.total_value) }}</text>
</view>
<view class="suggestion-box" v-if="currentHealth.suggestion">
<text class="suggestion-icon">💡</text>
<text class="suggestion-text">{{ currentHealth.suggestion }}</text>
</view>
</view>
<scroll-view class="detail-body" scroll-y>
<view class="detail-row" v-if="currentCustomer.company">
<text class="detail-label">公司:</text>
<text class="detail-value">{{ currentCustomer.company }}</text>
@@ -143,25 +242,51 @@
<text class="detail-label">邮箱:</text>
<text class="detail-value">{{ currentCustomer.email }}</text>
</view>
</view>
</scroll-view>
<view class="detail-footer">
<button class="close-btn" @click="showDetailModal = false">关闭</button>
</view>
</view>
</view>
<view class="conversation-modal" v-if="showConversationModal" @click="showConversationModal = false">
<view class="conversation-content" @click.stop>
<view class="conversation-header">
<text class="conversation-title">沟通记录 - {{ conversationCustomer?.name }}</text>
<text class="conversation-close" @click="showConversationModal = false">×</text>
</view>
<scroll-view class="conversation-body" scroll-y>
<view class="msg-item" v-for="(msg, i) in conversation" :key="i">
<text class="msg-direction" :class="msg.direction">{{ msg.direction === 'inbound' ? '客户' : '我' }}</text>
<text class="msg-content">{{ msg.content }}</text>
<text class="msg-time">{{ formatTime(msg.created_at) }}</text>
</view>
<view class="empty-msg" v-if="conversation.length === 0">
<text>暂无沟通记录</text>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onShow } from 'vue'
import { customerApi } from '@/utils/api.js'
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { customerApi, healthApi, silentPatternApi } from '@/utils/api.js'
const filter = ref('all')
const customers = ref([])
const showAddModal = ref(false)
const showEditModal = ref(false)
const showDetailModal = ref(false)
const showConversationModal = ref(false)
const currentCustomer = ref(null)
const currentHealth = ref(null)
const conversationCustomer = ref(null)
const conversation = ref([])
const healthOverview = ref(null)
const healthScores = ref({})
const formData = ref({
name: '',
company: '',
@@ -176,17 +301,59 @@ const statusOptions = ['lead', 'negotiating', 'customer', 'lost']
onShow(() => {
loadCustomers()
loadHealthOverview()
})
const loadCustomers = async () => {
try {
const res = await customerApi.list(1, 20, filter.value === 'all' ? undefined : filter.value)
customers.value = res.items || []
loadHealthScores()
} catch (err) {
uni.showToast({ title: err.message || '加载失败', icon: 'none' })
}
}
const loadHealthOverview = async () => {
try {
const res = await healthApi.overview()
healthOverview.value = res
} catch (err) {
console.error('加载健康概览失败', err)
}
}
const loadHealthScores = async () => {
try {
const res = await healthApi.allScores()
const map = {}
;(res.items || []).forEach(h => { map[h.customer_id] = h })
healthScores.value = map
} catch (err) {
console.error('加载健康评分失败', err)
}
}
const getHealthGrade = (customerId) => {
return healthScores.value[customerId]?.grade || null
}
const getHealthLabel = (grade) => {
const map = { active: '活跃', watch: '关注', critical: '高危' }
return map[grade] || grade
}
const trendLabel = (trend) => {
const map = { improving: '↑', declining: '↓', stable: '→' }
return map[trend] || ''
}
const formatValue = (val) => {
if (!val) return '0'
if (val >= 10000) return (val / 10000).toFixed(1) + '万'
return val.toLocaleString()
}
const loadSilent = async () => {
try {
const res = await customerApi.getSilent(7)
@@ -196,6 +363,11 @@ const loadSilent = async () => {
}
}
const getRiskText = (level) => {
const map = { high: '高风险', medium: '中风险', low: '低风险' }
return map[level] || level
}
const getStatusText = (status) => {
const map = { lead: '潜在', negotiating: '谈判中', customer: '已成交', lost: '已丢失' }
return map[status] || status
@@ -206,9 +378,28 @@ const formatTime = (time) => {
return time.split('T')[0]
}
const showCustomerDetail = (item) => {
const showCustomerDetail = async (item) => {
currentCustomer.value = item
showDetailModal.value = true
try {
const res = await healthApi.customerHealth(item.id)
currentHealth.value = res
} catch (err) {
currentHealth.value = null
console.error('加载健康详情失败', err)
}
}
const showConversation = async (item) => {
conversationCustomer.value = item
showConversationModal.value = true
try {
const res = await customerApi.getConversation(item.id)
conversation.value = res.items || res.messages || []
} catch (err) {
conversation.value = []
uni.showToast({ title: '加载沟通记录失败', icon: 'none' })
}
}
const editCustomer = (item) => {
@@ -255,6 +446,45 @@ const submitCustomer = async () => {
}
}
const exportCsv = () => {
const url = customerApi.exportCsv()
const token = uni.getStorageSync('token')
uni.downloadFile({
url,
header: { Authorization: `Bearer ${token}` },
success: (res) => {
if (res.statusCode === 200) {
uni.showToast({ title: '导出成功', icon: 'success' })
} else {
uni.showToast({ title: '导出失败', icon: 'none' })
}
},
fail: () => { uni.showToast({ title: '导出失败', icon: 'none' }) },
})
}
const importCustomers = () => {
uni.chooseImage({
count: 1,
success: async (res) => {
const file = res.tempFilePaths[0]
uni.showLoading({ title: '导入中...' })
try {
const result = await customerApi.importCustomers(file)
uni.hideLoading()
uni.showModal({
title: '导入完成',
content: `成功导入 ${result.imported || 0}\n失败 ${(result.errors || []).length}`,
success: () => loadCustomers(),
})
} catch (err) {
uni.hideLoading()
uni.showToast({ title: err.message || '导入失败', icon: 'none' })
}
},
})
}
const deleteCustomer = async (id) => {
uni.showModal({
title: '确认删除',
@@ -281,6 +511,141 @@ const deleteCustomer = async (id) => {
padding: 20rpx;
}
.health-overview {
display: flex;
gap: 12rpx;
margin-bottom: 20rpx;
}
.health-stat {
flex: 1;
background: #fff;
border-radius: 12rpx;
padding: 16rpx 8rpx;
text-align: center;
}
.health-stat-value {
font-size: 36rpx;
font-weight: 700;
display: block;
}
.health-stat-label {
font-size: 20rpx;
color: #999;
margin-top: 4rpx;
}
.health-stat.active .health-stat-value { color: #52c41a; }
.health-stat.watch .health-stat-value { color: #fa8c16; }
.health-stat.critical .health-stat-value { color: #ff4d4f; }
.health-stat.total .health-stat-value { color: #1890ff; }
.health-badge {
font-size: 20rpx;
padding: 2rpx 10rpx;
border-radius: 4rpx;
margin-left: 8rpx;
}
.health-badge.active { background: #f6ffed; color: #52c41a; }
.health-badge.watch { background: #fff7e6; color: #fa8c16; }
.health-badge.critical { background: #fff1f0; color: #ff4d4f; }
.health-badge.large { padding: 4rpx 16rpx; font-size: 24rpx; }
.health-score {
font-size: 40rpx;
font-weight: 700;
color: #1890ff;
margin-left: auto;
}
.health-detail-section {
padding: 20rpx;
background: #f9f9f9;
border-radius: 12rpx;
margin: 0 30rpx 20rpx;
}
.health-detail-title {
font-size: 26rpx;
font-weight: 600;
color: #333;
margin-bottom: 16rpx;
}
.dimension-row {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 12rpx;
}
.dimension-label {
font-size: 22rpx;
color: #666;
min-width: 80rpx;
}
.dimension-label.small {
min-width: auto;
font-size: 20rpx;
}
.dimension-bar {
flex: 1;
height: 12rpx;
background: #e8e8e8;
border-radius: 6rpx;
overflow: hidden;
}
.dimension-fill {
height: 100%;
background: linear-gradient(90deg, #52c41a, #1890ff);
border-radius: 6rpx;
transition: width 0.3s;
}
.dimension-score {
font-size: 22rpx;
font-weight: 600;
color: #333;
min-width: 32rpx;
text-align: right;
}
.dimension-trend {
font-size: 20rpx;
min-width: 24rpx;
}
.dimension-trend.improving { color: #52c41a; }
.dimension-trend.declining { color: #ff4d4f; }
.dimension-trend.stable { color: #999; }
.suggestion-box {
display: flex;
align-items: flex-start;
gap: 8rpx;
margin-top: 16rpx;
padding: 12rpx;
background: #e6f7ff;
border-radius: 8rpx;
}
.suggestion-icon {
font-size: 24rpx;
}
.suggestion-text {
font-size: 24rpx;
color: #1890ff;
line-height: 1.4;
flex: 1;
}
.filter-tabs {
display: flex;
background: #fff;
@@ -327,6 +692,7 @@ const deleteCustomer = async (id) => {
.customer-header {
display: flex;
align-items: center;
flex-wrap: wrap;
margin-bottom: 12rpx;
}
@@ -347,6 +713,17 @@ const deleteCustomer = async (id) => {
.customer-status.customer { background: #f6ffed; color: #52c41a; }
.customer-status.lost { background: #fff1f0; color: #ff4d4f; }
.risk-badge {
font-size: 20rpx;
padding: 2rpx 10rpx;
border-radius: 4rpx;
margin-left: 8rpx;
}
.risk-badge.high { background: #fff1f0; color: #ff4d4f; }
.risk-badge.medium { background: #fff7e6; color: #fa8c16; }
.risk-badge.low { background: #f6ffed; color: #52c41a; }
.customer-detail {
display: flex;
flex-wrap: wrap;
@@ -373,6 +750,21 @@ const deleteCustomer = async (id) => {
color: #ff4d4f;
}
.risk-reasons {
display: flex;
flex-wrap: wrap;
gap: 8rpx;
margin-top: 8rpx;
}
.risk-reason {
font-size: 20rpx;
color: #ff4d4f;
background: #fff1f0;
padding: 2rpx 10rpx;
border-radius: 4rpx;
}
.customer-actions {
display: flex;
flex-direction: column;
@@ -401,23 +793,48 @@ const deleteCustomer = async (id) => {
padding: 100rpx;
}
.add-btn {
.bottom-actions {
position: fixed;
right: 40rpx;
bottom: 40rpx;
width: 100rpx;
height: 100rpx;
background: #1890ff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(24, 144, 255, 0.4);
flex-direction: column;
gap: 24rpx;
}
.add-icon {
font-size: 60rpx;
.action-btn {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
}
.add-btn {
background: #1890ff;
}
.import-btn {
background: #52c41a;
}
.export-btn {
background: #722ed1;
}
.btn-icon {
font-size: 32rpx;
color: #fff;
line-height: 1;
}
.btn-text {
font-size: 18rpx;
color: #fff;
margin-top: 2rpx;
}
.modal {
@@ -588,4 +1005,85 @@ const deleteCustomer = async (id) => {
border-radius: 8rpx;
font-size: 28rpx;
}
</style>
.conversation-modal {
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;
}
.conversation-content {
width: 90%;
height: 70%;
background: #fff;
border-radius: 16rpx;
display: flex;
flex-direction: column;
}
.conversation-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 2rpx solid #f5f5f5;
}
.conversation-title {
font-size: 32rpx;
font-weight: 600;
}
.conversation-close {
font-size: 44rpx;
color: #999;
}
.conversation-body {
flex: 1;
padding: 30rpx;
}
.msg-item {
padding: 16rpx;
margin-bottom: 16rpx;
background: #f9f9f9;
border-radius: 12rpx;
}
.msg-direction {
font-size: 22rpx;
padding: 2rpx 10rpx;
border-radius: 4rpx;
margin-bottom: 8rpx;
display: inline-block;
}
.msg-direction.inbound { background: #e6f7ff; color: #1890ff; }
.msg-direction.outbound { background: #f6ffed; color: #52c41a; }
.msg-content {
font-size: 26rpx;
line-height: 1.5;
display: block;
margin-bottom: 8rpx;
}
.msg-time {
font-size: 20rpx;
color: #999;
}
.empty-msg {
text-align: center;
color: #999;
padding: 60rpx;
}
</style>
+111
View File
@@ -0,0 +1,111 @@
<template>
<view class="container">
<view class="content">
<text class="page-title">意见反馈</text>
<view class="form-group">
<text class="label">反馈类型</text>
<view class="category-list">
<text
class="category-item"
:class="{ active: category === 'bug' }"
@click="category = 'bug'"
>Bug 反馈</text>
<text
class="category-item"
:class="{ active: category === 'feature' }"
@click="category = 'feature'"
>功能建议</text>
<text
class="category-item"
:class="{ active: category === 'general' }"
@click="category = 'general'"
>其他</text>
</view>
</view>
<view class="form-group">
<text class="label">详细描述</text>
<textarea
class="textarea"
v-model="content"
placeholder="请详细描述你的问题或建议..."
/>
</view>
<view class="form-group">
<text class="label">联系方式选填</text>
<input class="input" v-model="contact" placeholder="手机号或邮箱" />
</view>
<button class="submit-btn" @click="handleSubmit" :disabled="!content.trim() || loading">
{{ loading ? '提交中...' : '提交反馈' }}
</button>
</view>
<view class="faq-section">
<text class="section-title">常见问题</text>
<view class="faq-item" v-for="(faq, i) in faqs" :key="i" @click="toggleFaq(i)">
<text class="faq-q">{{ faq.q }}</text>
<text class="faq-a" v-if="faq.open">{{ faq.a }}</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { feedbackApi } from '@/utils/api.js'
const category = ref('general')
const content = ref('')
const contact = ref('')
const loading = ref(false)
const faqs = ref([
{ q: '如何导入客户?', a: '在客户管理页点击"导入"按钮,支持 xlsx 和 csv 格式,模板包含姓名、公司、国家等字段。', open: false },
{ q: '翻译和回复建议有什么区别?', a: '翻译用于看懂客户消息,保留原意。回复建议基于客户消息和你的产品信息,生成多语气的回复文案供选择。', open: false },
{ q: '免费版和 Pro 版有什么区别?', a: '免费版每日 5000 字符翻译、20 次回复建议、最多 5 个客户。Pro 版升级为 50000 字符、200 次回复、100 个客户,并支持报价单和跟进提醒。', open: false },
{ q: '如何删除账号?', a: '目前暂未开放自助删除功能,如需删除请联系客服处理。', open: false },
])
const toggleFaq = (i) => {
faqs.value[i].open = !faqs.value[i].open
}
const handleSubmit = async () => {
if (!content.value.trim()) return
loading.value = true
try {
await feedbackApi.submit(content.value.trim(), category.value, contact.value.trim())
uni.showToast({ title: '提交成功,感谢反馈', icon: 'success' })
content.value = ''
contact.value = ''
category.value = 'general'
} catch (err) {
uni.showToast({ title: err.message || '提交失败', icon: 'none' })
} finally {
loading.value = false
}
}
</script>
<style lang="scss" scoped>
.container { min-height: 100vh; background: #f5f5f5; padding: 20rpx; }
.content { background: #fff; border-radius: 16rpx; padding: 32rpx; margin-bottom: 20rpx; }
.page-title { font-size: 36rpx; font-weight: 700; margin-bottom: 32rpx; display: block; }
.form-group { margin-bottom: 28rpx; }
.label { font-size: 26rpx; color: #666; display: block; margin-bottom: 12rpx; }
.category-list { display: flex; gap: 16rpx; }
.category-item { padding: 12rpx 24rpx; background: #f5f5f5; border-radius: 8rpx; font-size: 26rpx; color: #666; }
.category-item.active { background: #e6f7ff; color: #1890ff; }
.textarea { width: 100%; min-height: 240rpx; background: #f9f9f9; border-radius: 12rpx; padding: 20rpx; font-size: 28rpx; box-sizing: border-box; }
.input { width: 100%; height: 80rpx; background: #f9f9f9; border-radius: 12rpx; padding: 0 20rpx; font-size: 28rpx; box-sizing: border-box; }
.submit-btn { width: 100%; height: 88rpx; background: #1890ff; color: #fff; border-radius: 12rpx; font-size: 30rpx; border: none; margin-top: 16rpx; }
.submit-btn[disabled] { background: #a0cfff; }
.faq-section { background: #fff; border-radius: 16rpx; padding: 32rpx; }
.section-title { font-size: 30rpx; font-weight: 600; margin-bottom: 20rpx; display: block; }
.faq-item { padding: 20rpx 0; border-bottom: 2rpx solid #f5f5f5; }
.faq-q { font-size: 28rpx; color: #333; font-weight: 500; display: block; }
.faq-a { font-size: 26rpx; color: #666; margin-top: 12rpx; line-height: 1.6; display: block; }
</style>
+187
View File
@@ -0,0 +1,187 @@
<template>
<view class="followup-container">
<view class="stats-bar">
<view class="stat-item">
<text class="stat-val">{{ stats.pending }}</text>
<text class="stat-lbl">待跟进</text>
</view>
<view class="stat-item">
<text class="stat-val">{{ stats.sent }}</text>
<text class="stat-lbl">已发送</text>
</view>
<view class="stat-item">
<text class="stat-val">{{ stats.replied }}</text>
<text class="stat-lbl">已回复</text>
</view>
<view class="stat-item">
<text class="stat-val">{{ stats.completion_rate }}%</text>
<text class="stat-lbl">完成率</text>
</view>
</view>
<view class="tabs">
<text class="tab" :class="{ active: tab === 'pending' }" @click="tab = 'pending'">待跟进</text>
<text class="tab" :class="{ active: tab === 'history' }" @click="tab = 'history'">历史记录</text>
</view>
<view class="list" v-if="tab === 'pending'">
<view class="card" v-for="item in pendingItems" :key="item.id">
<view class="card-header">
<text class="card-name">{{ item.customer_name }}</text>
<text class="card-badge warn" v-if="item.silence_days <= 5">沉默{{ item.silence_days }}</text>
<text class="card-badge danger" v-else>沉默{{ item.silence_days }}</text>
</view>
<text class="card-content">{{ item.content }}</text>
<view class="card-actions">
<button class="btn btn-primary" @click="handleSend(item)">一键发送</button>
<button class="btn btn-outline" @click="openEditor(item)">编辑后发送</button>
</view>
</view>
<view class="empty" v-if="pendingItems.length === 0">
<text>暂无待跟进客户 🎉</text>
</view>
</view>
<view class="list" v-if="tab === 'history'">
<view class="log-item" v-for="item in historyItems" :key="item.id">
<view class="log-header">
<text class="log-name">{{ item.customer_name }}</text>
<text class="log-status" :class="item.status">{{ statusMap[item.status] }}</text>
</view>
<text class="log-preview">{{ (item.content || '').slice(0, 60) }}...</text>
<text class="log-time">{{ formatTime(item.created_at) }}</text>
</view>
<view class="empty" v-if="historyItems.length === 0">
<text>暂无跟进记录</text>
</view>
</view>
<view class="editor-overlay" v-if="showEditor">
<view class="editor-modal">
<text class="editor-title">编辑跟进内容</text>
<textarea class="editor-input" v-model="editText" />
<view class="editor-actions">
<button class="btn btn-primary" @click="submitEdit">确认发送</button>
<button class="btn btn-cancel" @click="showEditor = false">取消</button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, watch } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { followupApi } from '@/utils/api.js'
const tab = ref('pending')
const stats = ref({ pending: 0, sent: 0, replied: 0, completion_rate: 0 })
const pendingItems = ref([])
const historyItems = ref([])
const showEditor = ref(false)
const editText = ref('')
const editingId = ref(null)
const statusMap = { pending: '待处理', sent: '已发送', replied: '已回复' }
onShow(() => loadAll())
watch(tab, () => { if (tab.value === 'history') loadHistory() })
const loadAll = async () => {
try {
const [statsRes, pendingRes] = await Promise.all([
followupApi.stats(),
followupApi.pending(),
])
stats.value = statsRes
pendingItems.value = pendingRes.items || []
} catch (e) {
console.error(e)
}
}
const loadHistory = async () => {
try {
const res = await followupApi.logs()
historyItems.value = res.items || []
} catch (e) {
console.error(e)
}
}
const handleSend = async (item) => {
try {
await followupApi.markSent(item.id)
uni.showToast({ title: '已标记发送', icon: 'success' })
loadAll()
} catch (e) {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
const openEditor = (item) => {
editText.value = item.content || ''
editingId.value = item.id
showEditor.value = true
}
const submitEdit = async () => {
if (!editText.value.trim()) {
uni.showToast({ title: '内容不能为空', icon: 'none' })
return
}
try {
await followupApi.editAndSend(editingId.value, editText.value)
uni.showToast({ title: '已发送', icon: 'success' })
showEditor.value = false
loadAll()
} catch (e) {
uni.showToast({ title: '发送失败', icon: 'none' })
}
}
const formatTime = (t) => {
if (!t) return ''
return t.slice(0, 16).replace('T', ' ')
}
</script>
<style lang="scss" scoped>
.followup-container { min-height: 100vh; background: #f5f5f5; padding: 20rpx; }
.stats-bar { display: flex; background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; }
.stat-item { flex: 1; text-align: center; }
.stat-val { font-size: 36rpx; font-weight: bold; color: #1890ff; display: block; }
.stat-lbl { font-size: 22rpx; color: #999; margin-top: 4rpx; display: block; }
.tabs { display: flex; background: #fff; border-radius: 16rpx; margin-bottom: 20rpx; }
.tab { flex: 1; text-align: center; padding: 20rpx; font-size: 28rpx; color: #666; }
.tab.active { color: #1890ff; font-weight: 600; border-bottom: 4rpx solid #1890ff; }
.card { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 16rpx; }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12rpx; }
.card-name { font-size: 30rpx; font-weight: 600; }
.card-badge { font-size: 22rpx; padding: 4rpx 12rpx; border-radius: 8rpx; }
.card-badge.warn { background: #fff7e6; color: #fa8c16; }
.card-badge.danger { background: #fff1f0; color: #f5222d; }
.card-content { font-size: 26rpx; color: #555; line-height: 1.6; margin-bottom: 16rpx; }
.card-actions { display: flex; gap: 16rpx; }
.log-item { background: #fff; border-radius: 12rpx; padding: 20rpx; margin-bottom: 12rpx; }
.log-header { display: flex; justify-content: space-between; margin-bottom: 8rpx; }
.log-name { font-size: 28rpx; font-weight: 500; }
.log-status { font-size: 22rpx; padding: 2rpx 10rpx; border-radius: 6rpx; }
.log-status.pending { background: #fff7e6; color: #fa8c16; }
.log-status.sent { background: #e6f7ff; color: #1890ff; }
.log-status.replied { background: #f6ffed; color: #52c41a; }
.log-preview { font-size: 24rpx; color: #999; display: block; margin-bottom: 4rpx; }
.log-time { font-size: 20rpx; color: #ccc; }
.empty { text-align: center; color: #999; padding: 60rpx; font-size: 28rpx; }
.btn { flex: 1; height: 64rpx; border-radius: 12rpx; font-size: 26rpx; border: none; display: flex; align-items: center; justify-content: center; }
.btn-primary { background: #1890ff; color: #fff; }
.btn-outline { background: #fff; color: #1890ff; border: 2rpx solid #1890ff; }
.btn-cancel { background: #f5f5f5; color: #999; flex: 1; }
.editor-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; }
.editor-modal { background: #fff; border-radius: 24rpx; padding: 40rpx; width: 100%; max-width: 650rpx; }
.editor-title { font-size: 32rpx; font-weight: 600; display: block; margin-bottom: 20rpx; }
.editor-input { width: 100%; height: 240rpx; background: #f5f5f5; border-radius: 12rpx; padding: 20rpx; font-size: 26rpx; box-sizing: border-box; }
.editor-actions { display: flex; gap: 16rpx; margin-top: 24rpx; }
</style>
+587 -30
View File
@@ -1,14 +1,18 @@
<template>
<view class="index-container">
<view class="header">
<view class="user-info">
<view class="user-info" v-if="hasLogin">
<text class="username">{{ userInfo?.username || '用户' }}</text>
<text class="tier">{{ userInfo?.tier === 'pro' ? 'Pro' : '免费版' }}</text>
</view>
<text class="logout" @click="handleLogout">退出</text>
<view class="guest-info" v-else>
<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">
<view class="stats-grid" v-if="hasLogin">
<view class="stat-card">
<text class="stat-value">{{ stats.customers }}</text>
<text class="stat-label">客户总数</text>
@@ -27,7 +31,51 @@
</view>
</view>
<view class="section">
<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>
<text class="result-copy" @click="copyTryResult">复制</text>
</view>
<view class="result-content">
<text class="result-text">{{ tryResult }}</text>
</view>
</view>
<view class="try-extracted" v-if="tryExtracted">
<view class="result-header">
<text class="result-label">提取结果</text>
</view>
<view class="extracted-content">
<text class="extracted-text">{{ tryExtracted }}</text>
</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">
@@ -46,30 +94,133 @@
<view class="quick-actions">
<view class="action-item" @click="goToPage('/pages/translate/translate')">
<text class="action-icon"></text>
<text class="action-icon">🔤</text>
<text class="action-text">翻译</text>
</view>
<view class="action-item" @click="goToPage('/pages/customers/customers')">
<text class="action-icon"></text>
<view class="action-item" @click="hasLogin ? goToPage('/pages/customers/customers') : goToLogin()">
<text class="action-icon">👥</text>
<text class="action-text">客户</text>
</view>
<view class="action-item" @click="goToPage('/pages/marketing/marketing')">
<text class="action-icon"></text>
<view class="action-item" @click="hasLogin ? goToPage('/pages/marketing/marketing') : goToLogin()">
<text class="action-icon">📢</text>
<text class="action-text">营销</text>
</view>
<view class="action-item" @click="goToPage('/pages/quotation/quotation')">
<text class="action-icon"></text>
<view class="action-item" @click="hasLogin ? goToPage('/pages/quotation/quotation') : goToLogin()">
<text class="action-icon">📄</text>
<text class="action-text">报价</text>
</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/followup')">查看全部 ></text>
</view>
<view class="followup-card" @click="goToPage('/pages/followup/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="more-section">
<view class="section-title">更多功能</view>
<view class="more-grid">
<view class="more-item" @click="hasLogin ? goToPage('/pages/product/product') : goToLogin()">
<text class="more-icon">📦</text>
<text class="more-text">产品库</text>
</view>
<view class="more-item" @click="hasLogin ? goToPage('/pages/followup/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/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/analytics') : goToLogin()">
<text class="more-icon">📊</text>
<text class="more-text">分析</text>
</view>
<view class="more-item" @click="goToPage('/pages/upgrade/upgrade')">
<text class="more-icon">💎</text>
<text class="more-text">升级</text>
</view>
<view class="more-item" @click="goToPage('/pages/feedback/feedback')">
<text class="more-icon">💬</text>
<text class="more-text">反馈</text>
</view>
<view class="more-item" @click="hasLogin ? goToPage('/pages/team/team') : goToLogin()">
<text class="more-icon">👨👩👧👦</text>
<text class="more-text">团队</text>
</view>
<view class="more-item" @click="hasLogin ? goToPage('/pages/admin/admin') : goToLogin()">
<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>
</template>
<script setup>
import { ref, onShow } from 'vue'
import { authApi, customerApi } from '@/utils/api.js'
import { ref, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { authApi, customerApi, analyticsApi, onboardingApi, notificationApi, followupApi, translateApi } from '@/utils/api.js'
const userInfo = ref(null)
const hasLogin = computed(() => {
const token = uni.getStorageSync('token')
const isGuest = uni.getStorageSync('isGuest')
return !!token && !isGuest
})
const stats = ref({
customers: 0,
silentCustomers: 0,
@@ -77,29 +228,107 @@ const stats = ref({
quotations: 0,
})
const silentCustomers = ref([])
const unreadCount = ref(0)
const followupStats = ref({ pending: 0, sent: 0, replied: 0 })
const showOnboarding = ref(false)
const onboardingStep = ref(1)
const productName = ref('')
const productDesc = ref('')
const targetMarket = ref('US importers')
const generatedContent = ref([])
const tryText = ref('')
const tryResult = ref('')
const tryExtracted = ref('')
const tryLoading = ref(false)
onShow(() => {
const token = uni.getStorageSync('token')
if (!token) {
uni.reLaunch({ url: '/pages/login/login' })
const isGuest = uni.getStorageSync('isGuest')
if (token && !isGuest) {
uni.setStorageSync('isGuest', false)
loadData()
checkOnboarding()
loadUnread()
loadFollowupStats()
} else {
tryResult.value = ''
tryExtracted.value = ''
tryText.value = ''
}
})
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('onboarded', true)
showOnboarding.value = false
onboardingStep.value = 1
productName.value = ''
productDesc.value = ''
generatedContent.value = []
loadData()
})
}
const loadData = async () => {
try {
const userRes = await authApi.getUserInfo()
const [userRes, silentRes, overviewRes] = await Promise.all([
authApi.getUserInfo(),
customerApi.getSilent(3),
analyticsApi.getOverview(),
])
userInfo.value = userRes
const silentRes = await customerApi.getSilent(3)
silentCustomers.value = silentRes.customers || []
stats.value = {
customers: (silentRes.count || 0) + Math.floor(Math.random() * 10),
silentCustomers: silentRes.count || 0,
todayTranslations: Math.floor(Math.random() * 20),
quotations: Math.floor(Math.random() * 5),
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)
@@ -107,7 +336,15 @@ const loadData = async () => {
}
const goToPage = (url) => {
uni.switchTab({ url })
if (url === '/pages/followup/followup') {
uni.navigateTo({ url })
} else {
uni.switchTab({ url })
}
}
const goToLogin = () => {
uni.reLaunch({ url: '/pages/login/login' })
}
const handleLogout = () => {
@@ -122,13 +359,74 @@ const handleLogout = () => {
},
})
}
const handleTryTranslate = async () => {
if (!tryText.value.trim()) {
uni.showToast({ title: '请输入内容', icon: 'none' })
return
}
tryLoading.value = true
tryResult.value = ''
tryExtracted.value = ''
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 = ''
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 = JSON.stringify(extracted, null, 2)
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' })
},
})
}
</script>
<style lang="scss" scoped>
.index-container {
min-height: 100vh;
min-height: 100%;
background: #f5f5f5;
padding: 20rpx;
box-sizing: border-box;
}
.header {
@@ -136,7 +434,7 @@ const handleLogout = () => {
justify-content: space-between;
align-items: center;
padding: 30rpx;
background: #1890ff;
background: linear-gradient(135deg, #1890ff 0%, #69c0ff 100%);
border-radius: 16rpx;
margin-bottom: 30rpx;
}
@@ -146,6 +444,26 @@ const handleLogout = () => {
align-items: center;
}
.guest-info {
display: flex;
align-items: center;
gap: 16rpx;
}
.guest-label {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
}
.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;
@@ -198,17 +516,151 @@ const handleLogout = () => {
display: block;
}
.section {
.guest-welcome {
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
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, .try-extracted {
background: #f6ffed;
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-copy {
font-size: 24rpx;
color: #1890ff;
}
.result-content, .extracted-content {
background: #fff;
border-radius: 8rpx;
padding: 16rpx;
}
.result-text, .extracted-text {
font-size: 28rpx;
color: #333;
line-height: 1.6;
white-space: pre-wrap;
}
.section {
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
margin-bottom: 30rpx;
}
.silent-list {
@@ -296,4 +748,109 @@ const handleLogout = () => {
font-size: 24rpx;
color: #666;
}
</style>
.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;
}
.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; }
</style>
+274 -110
View File
@@ -1,74 +1,110 @@
<template>
<view class="login-container">
<view class="logo-section">
<view class="welcome-section">
<text class="logo">TradeMate</text>
<text class="subtitle">外贸小助手</text>
<text class="slogan">让外贸更简单 · 让沟通更高效</text>
</view>
<view class="form-section">
<text class="form-title">{{ isRegister ? '注册' : '登录' }}</text>
<view class="input-group">
<input
class="input"
type="number"
placeholder="手机号"
v-model="phone"
@input="onPhoneInput"
/>
<view class="features-section">
<view class="feature-card">
<text class="feature-icon">🌐</text>
<text class="feature-title">智能翻译</text>
<text class="feature-desc">支持中英双语商务翻译精准理解外贸术语</text>
</view>
<view class="input-group" v-if="isRegister">
<input
class="input"
type="text"
placeholder="用户名"
v-model="username"
@input="onUsernameInput"
/>
<view class="feature-card">
<text class="feature-icon">💬</text>
<text class="feature-title">智能回复</text>
<text class="feature-desc">AI 生成专业商务回复多种风格可选</text>
</view>
<view class="input-group">
<input
class="input"
type="password"
placeholder="密码"
v-model="password"
@input="onPasswordInput"
/>
<view class="feature-card">
<text class="feature-icon">📊</text>
<text class="feature-title">客户管理</text>
<text class="feature-desc">客户信息跟进提醒数据分析一体化</text>
</view>
<view class="feature-card">
<text class="feature-icon">💰</text>
<text class="feature-title">报价单生成</text>
<text class="feature-desc">快速生成专业报价单支持导出 PDF</text>
</view>
</view>
<view class="error" v-if="error">{{ error }}</view>
<button
class="submit-btn"
@click="handleSubmit"
:disabled="loading"
>
{{ loading ? '处理中...' : (isRegister ? '注册' : '登录') }}
<view class="actions-section">
<button class="try-btn" @click="goToQuickTry">
<text class="try-icon">🎯</text>
<text class="try-text">快速体验</text>
</button>
<text class="toggle-mode" @click="toggleMode">
{{ isRegister ? '已有账号立即登录' : '没有账号立即注册' }}
</text>
<view class="divider">
<view class="line"></view>
<text class="text"></text>
<text class="text">已有账号登录继续</text>
<view class="line"></view>
</view>
<button class="wechat-btn" @click="handleWechatLogin">
<text class="wechat-icon">W</text>
微信一键登录
<view class="form-section" v-if="showForm">
<view class="input-group">
<input
class="input"
type="number"
placeholder="手机号"
v-model="phone"
/>
</view>
<view class="input-group" v-if="isRegister">
<input
class="input"
type="text"
placeholder="用户名"
v-model="username"
/>
</view>
<view class="input-group">
<input
class="input"
type="password"
placeholder="密码"
v-model="password"
/>
</view>
<view class="error" v-if="error">{{ error }}</view>
<button
class="submit-btn"
@click="handleSubmit"
:disabled="loading"
>
{{ loading ? '处理中...' : (isRegister ? '注册账号' : '登录') }}
</button>
<text class="toggle-mode" @click="toggleMode">
{{ isRegister ? '已有账号立即登录' : '没有账号立即注册' }}
</text>
<view class="divider" v-if="false">
<view class="line"></view>
<text class="text"></text>
<view class="line"></view>
</view>
<button class="wechat-btn" @click="handleWechatLogin" v-if="false">
<text class="wechat-icon">W</text>
微信一键登录
</button>
</view>
<button class="show-login-btn" @click="toggleShowForm" v-if="!showForm">
登录 / 注册
</button>
</view>
<view class="footer">
<text class="agreement">登录即表示同意</text>
<text class="link">用户协议</text>
<text class="link" @click="goToAgreement('terms')">用户协议</text>
<text class="agreement"></text>
<text class="link">隐私政策</text>
<text class="link" @click="goToAgreement('privacy')">隐私政策</text>
</view>
</view>
</template>
@@ -83,12 +119,17 @@ const username = ref('')
const isRegister = ref(false)
const loading = ref(false)
const error = ref('')
const showForm = ref(false)
const toggleMode = () => {
isRegister.value = !isRegister.value
error.value = ''
}
const toggleShowForm = () => {
showForm.value = !showForm.value
}
const handleSubmit = async () => {
if (!phone.value || !password.value) {
error.value = '请输入手机号和密码'
@@ -108,15 +149,17 @@ const handleSubmit = async () => {
await authApi.register(phone.value, password.value, username.value)
uni.showToast({ title: '注册成功,请登录', icon: 'success' })
isRegister.value = false
} else {
const res = await authApi.login(phone.value, password.value)
uni.setStorageSync('token', res.access_token)
uni.setStorageSync('userInfo', res.user)
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => {
uni.switchTab({ url: '/pages/index/index' })
}, 1000)
}
} else {
const res = await authApi.login(phone.value, password.value)
uni.setStorageSync('token', res.access_token)
uni.setStorageSync('userInfo', res.user)
uni.setStorageSync('hasLogin', true)
uni.setStorageSync('isGuest', false)
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => {
uni.switchTab({ url: '/pages/index/index' })
}, 1000)
}
} catch (err) {
error.value = err.message || '操作失败,请重试'
} finally {
@@ -125,83 +168,220 @@ const handleSubmit = async () => {
}
const handleWechatLogin = () => {
uni.getUserProfile({
desc: '用于完善用户资料',
success: (res) => {
console.log('微信登录', res.userInfo)
uni.showToast({ title: '微信登录开发中', icon: 'none' })
uni.login({
provider: 'weixin',
success: async (loginRes) => {
try {
loading.value = true
const res = await authApi.wechatLogin(loginRes.code)
uni.setStorageSync('token', res.access_token)
uni.setStorageSync('userInfo', res.user)
uni.setStorageSync('hasLogin', true)
uni.setStorageSync('isGuest', false)
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => {
uni.switchTab({ url: '/pages/index/index' })
}, 1000)
} catch (err) {
error.value = err.message || '微信登录失败'
} finally {
loading.value = false
}
},
fail: (err) => {
console.log('微信登录失败', err)
error.value = '微信登录取消或失败'
}
})
}
const onPhoneInput = (e) => { phone.value = e.detail.value }
const onPasswordInput = (e) => { password.value = e.detail.value }
const onUsernameInput = (e) => { username.value = e.detail.value }
const goToAgreement = (type) => {
uni.navigateTo({ url: `/pages/agreement/${type}` })
}
const goToQuickTry = async () => {
uni.setStorageSync('isGuest', true)
try {
const res = await authApi.guestLogin()
if (res.access_token) {
uni.setStorageSync('token', res.access_token)
if (res.user) {
uni.setStorageSync('userInfo', res.user)
}
}
} catch (e) {
console.log('Guest login failed, continuing without token:', e)
}
uni.switchTab({ url: '/pages/index/index' })
}
</script>
<style lang="scss" scoped>
.login-container {
min-height: 100vh;
background: linear-gradient(180deg, #1890ff 0%, #e6f7ff 100%);
padding: 120rpx 60rpx 60rpx;
background: linear-gradient(180deg, #f0f7ff 0%, #ffffff 50%);
padding: 40rpx 40rpx 60rpx;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.logo-section {
.welcome-section {
text-align: center;
margin-bottom: 80rpx;
margin-bottom: 40rpx;
padding-top: 20rpx;
}
.logo {
font-size: 60rpx;
font-size: 56rpx;
font-weight: bold;
color: #fff;
color: #1890ff;
letter-spacing: 4rpx;
display: block;
}
.subtitle {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.8);
color: #666;
margin-top: 8rpx;
display: block;
}
.slogan {
font-size: 24rpx;
color: #999;
margin-top: 16rpx;
display: block;
}
.features-section {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
margin-bottom: 40rpx;
}
.feature-card {
background: #fff;
border-radius: 20rpx;
padding: 32rpx 24rpx;
text-align: center;
box-shadow: 0 4rpx 20rpx rgba(24, 144, 255, 0.08);
border: 2rpx solid #e6f7ff;
}
.feature-icon {
font-size: 48rpx;
display: block;
margin-bottom: 16rpx;
}
.feature-title {
font-size: 28rpx;
font-weight: 600;
color: #333;
display: block;
margin-bottom: 8rpx;
}
.feature-desc {
font-size: 22rpx;
color: #999;
line-height: 1.5;
display: block;
}
.actions-section {
flex: 1;
display: flex;
flex-direction: column;
}
.try-btn {
width: 100%;
height: 96rpx;
background: linear-gradient(135deg, #1890ff 0%, #69c0ff 100%);
color: #fff;
border-radius: 16rpx;
font-size: 32rpx;
font-weight: 600;
border: none;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(24, 144, 255, 0.3);
margin-bottom: 24rpx;
}
.try-icon {
font-size: 36rpx;
margin-right: 12rpx;
}
.try-text {
color: #fff;
}
.divider {
display: flex;
align-items: center;
margin: 24rpx 0;
}
.divider .line {
flex: 1;
height: 1rpx;
background: #e8e8e8;
}
.divider .text {
padding: 0 24rpx;
color: #bbb;
font-size: 24rpx;
}
.form-section {
background: #fff;
border-radius: 24rpx;
padding: 48rpx 40rpx;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.1);
padding: 40rpx 32rpx;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.05);
}
.form-title {
font-size: 40rpx;
font-weight: 600;
color: #333;
text-align: center;
margin-bottom: 48rpx;
display: block;
.show-login-btn {
width: 100%;
height: 88rpx;
background: #fff;
color: #1890ff;
border: 2rpx solid #1890ff;
border-radius: 16rpx;
font-size: 30rpx;
font-weight: 500;
}
.input-group {
margin-bottom: 32rpx;
margin-bottom: 28rpx;
}
.input {
width: 100%;
height: 96rpx;
background: #f5f5f5;
background: #fafafa;
border-radius: 16rpx;
padding: 0 24rpx;
padding: 0 28rpx;
font-size: 28rpx;
box-sizing: border-box;
border: 2rpx solid transparent;
&:focus {
border-color: #1890ff;
background: #fff;
}
}
.error {
color: #ff4d4f;
font-size: 24rpx;
margin-bottom: 24rpx;
margin-bottom: 20rpx;
text-align: center;
display: block;
}
@@ -227,29 +407,11 @@ const onUsernameInput = (e) => { username.value = e.detail.value }
.toggle-mode {
display: block;
text-align: center;
margin-top: 32rpx;
color: #666;
margin-top: 28rpx;
color: #1890ff;
font-size: 26rpx;
}
.divider {
display: flex;
align-items: center;
margin: 48rpx 0;
}
.divider .line {
flex: 1;
height: 1rpx;
background: #e8e8e8;
}
.divider .text {
padding: 0 24rpx;
color: #999;
font-size: 24rpx;
}
.wechat-btn {
width: 100%;
height: 96rpx;
@@ -262,26 +424,28 @@ const onUsernameInput = (e) => { username.value = e.detail.value }
display: flex;
align-items: center;
justify-content: center;
margin-top: 20rpx;
}
.wechat-icon {
font-size: 36rpx;
font-weight: bold;
margin-right: 16rpx;
margin-right: 12rpx;
}
.footer {
text-align: center;
margin-top: 60rpx;
margin-top: 40rpx;
padding-top: 20rpx;
}
.agreement {
color: #999;
font-size: 24rpx;
font-size: 22rpx;
}
.link {
color: #1890ff;
font-size: 24rpx;
font-size: 22rpx;
}
</style>
</style>
+158 -7
View File
@@ -4,21 +4,21 @@
<view
class="tab-item"
:class="{ active: activeTab === 'copy' }"
@click="activeTab = 'copy'"
@click="activeTab = 'copy'; activeTab === 'copy' && loadStats()"
>
开发信
</view>
<view
class="tab-item"
:class="{ active: activeTab === 'whatsapp' }"
@click="activeTab = 'whatsapp'"
@click="activeTab = 'whatsapp'; activeTab === 'whatsapp' && loadStats()"
>
WhatsApp话术
</view>
<view
class="tab-item"
:class="{ active: activeTab === 'product' }"
@click="activeTab = 'product'"
@click="activeTab = 'product'; activeTab === 'product' && loadStats()"
>
产品描述
</view>
@@ -31,6 +31,21 @@
</view>
</view>
<view class="stats-section" v-if="stats">
<view class="stat-card">
<text class="stat-value">{{ stats.today_copy || 0 }}</text>
<text class="stat-label">今日复制</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ stats.today_send || 0 }}</text>
<text class="stat-label">今日发送</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ stats.weekly_total || 0 }}</text>
<text class="stat-label">本周共</text>
</view>
</view>
<view class="form-section">
<view class="form-item">
<text class="form-label">产品名称</text>
@@ -77,10 +92,11 @@
</button>
</view>
<view class="results-section" v-if="results.length > 0">
<view class="results-section" v-if="results.length > 0 && activeTab !== 'keywords'">
<view class="results-header">
<text class="results-title">生成的文案</text>
<text class="refresh-btn" @click="generateContent">换一批</text>
<text class="export-btn" @click="exportCsv">导出CSV</text>
</view>
<view class="results-list">
<view class="result-item" v-for="(item, index) in results" :key="index">
@@ -88,6 +104,7 @@
<view class="result-actions">
<text class="copy-btn" @click="copyText(item)">复制</text>
<text class="send-btn" @click="sendToWhatsapp(item)">发送</text>
<text class="competitor-btn" @click="runCompetitorAnalysis">竞品分析</text>
</view>
</view>
</view>
@@ -104,6 +121,16 @@
</view>
</view>
<view class="competitor-section" v-if="competitorResult">
<view class="competitor-header">
<text class="competitor-title">竞品分析结果</text>
<text class="competitor-close" @click="competitorResult = null">×</text>
</view>
<view class="competitor-content">
<text class="competitor-text">{{ competitorResult }}</text>
</view>
</view>
<view class="empty" v-if="!loading && results.length === 0 && activeTab !== 'keywords'">
<text>输入产品信息点击生成文案</text>
</view>
@@ -112,12 +139,14 @@
<script setup>
import { ref } from 'vue'
import { marketingApi } from '@/utils/api.js'
import { marketingApi, interactionApi } from '@/utils/api.js'
const activeTab = ref('copy')
const loading = ref(false)
const results = ref([])
const keywords = ref([])
const competitorResult = ref(null)
const stats = ref(null)
const formData = ref({
product_name: '',
@@ -138,6 +167,15 @@ const onTargetChange = (e) => {
formData.value.target = targetMarkets.value[e.detail.value]
}
const loadStats = async () => {
try {
const res = await interactionApi.getMarketingEffectStats()
stats.value = res
} catch (err) {
console.error('加载统计失败', err)
}
}
const generateContent = async () => {
if (!formData.value.product_name) {
uni.showToast({ title: '请输入产品名称', icon: 'none' })
@@ -163,6 +201,7 @@ const generateContent = async () => {
formData.value.style
)
results.value = res.results || []
loadStats()
}
} catch (err) {
uni.showToast({ title: err.message || '生成失败', icon: 'none' })
@@ -175,14 +214,51 @@ const copyText = (text) => {
uni.setClipboardData({
data: text,
success: () => {
interactionApi.trackMarketingEffect({ action: 'copy', content_preview: text.slice(0, 100) }).catch(() => {})
loadStats()
uni.showToast({ title: '已复制', icon: 'success' })
},
})
}
const exportCsv = () => {
if (results.value.length === 0) return
let csv = 'Content\n'
results.value.forEach(r => { csv += `"${r.replace(/"/g, '""')}"\n` })
const blob = new Blob([csv], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
uni.downloadFile({
url,
success: (res) => {
uni.saveFile({ tempFilePath: res.tempFilePath })
uni.showToast({ title: '导出成功', icon: 'success' })
},
fail: () => { uni.showToast({ title: '导出失败', icon: 'none' }) },
})
}
const sendToWhatsapp = (text) => {
interactionApi.trackMarketingEffect({ action: 'send', content_preview: text.slice(0, 100) }).catch(() => {})
loadStats()
uni.showToast({ title: '请先选择客户', icon: 'none' })
}
const runCompetitorAnalysis = async () => {
try {
uni.showLoading({ title: '分析中...' })
const res = await marketingApi.competitorAnalysis(
formData.value.product_name,
formData.value.description,
'',
formData.value.target
)
uni.hideLoading()
competitorResult.value = typeof res.analysis === 'string' ? res.analysis : JSON.stringify(res.analysis, null, 2)
} catch (err) {
uni.hideLoading()
uni.showToast({ title: err.message || '分析失败', icon: 'none' })
}
}
</script>
<style lang="scss" scoped>
@@ -215,6 +291,33 @@ const sendToWhatsapp = (text) => {
border-bottom: 4rpx solid #1890ff;
}
.stats-section {
display: flex;
gap: 16rpx;
margin-bottom: 20rpx;
}
.stat-card {
flex: 1;
background: #fff;
border-radius: 12rpx;
padding: 20rpx;
text-align: center;
}
.stat-value {
font-size: 40rpx;
font-weight: 700;
color: #1890ff;
display: block;
}
.stat-label {
font-size: 22rpx;
color: #999;
margin-top: 8rpx;
}
.form-section {
background: #fff;
border-radius: 16rpx;
@@ -316,6 +419,12 @@ const sendToWhatsapp = (text) => {
color: #1890ff;
}
.export-btn {
font-size: 24rpx;
color: #52c41a;
margin-left: 16rpx;
}
.results-list {
display: flex;
flex-direction: column;
@@ -340,7 +449,7 @@ const sendToWhatsapp = (text) => {
gap: 20rpx;
}
.copy-btn, .send-btn {
.copy-btn, .send-btn, .competitor-btn {
font-size: 24rpx;
padding: 8rpx 20rpx;
border-radius: 6rpx;
@@ -356,6 +465,11 @@ const sendToWhatsapp = (text) => {
color: #fff;
}
.competitor-btn {
background: #f9f0ff;
color: #722ed1;
}
.history-section {
background: #fff;
border-radius: 16rpx;
@@ -383,9 +497,46 @@ const sendToWhatsapp = (text) => {
font-size: 26rpx;
}
.competitor-section {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
}
.competitor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
}
.competitor-title {
font-size: 26rpx;
color: #722ed1;
font-weight: 600;
}
.competitor-close {
font-size: 36rpx;
color: #999;
}
.competitor-content {
padding: 16rpx;
background: #f9f0ff;
border-radius: 8rpx;
}
.competitor-text {
font-size: 24rpx;
line-height: 1.5;
white-space: pre-wrap;
}
.empty {
text-align: center;
color: #999;
padding: 100rpx;
}
</style>
</style>
@@ -0,0 +1,136 @@
<template>
<view class="container">
<view class="header-bar">
<text class="title">通知中心</text>
<text class="mark-all" @click="markAllRead" v-if="unreadCount > 0">全部已读</text>
</view>
<view class="empty" v-if="!loading && notifications.length === 0">
<text class="empty-text">暂无通知</text>
</view>
<view class="list" v-else>
<view
class="item"
v-for="n in notifications"
:key="n.id"
:class="{ unread: !n.is_read }"
@click="markRead(n)"
>
<view class="item-header">
<text class="item-title">{{ n.title }}</text>
<text class="item-time">{{ formatTime(n.created_at) }}</text>
</view>
<text class="item-content">{{ n.content }}</text>
<text class="item-type">{{ typeLabel(n.type) }}</text>
</view>
</view>
<view class="load-more" v-if="hasMore">
<text class="load-btn" @click="loadMore">加载更多</text>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { notificationApi } from '@/utils/api.js'
const notifications = ref([])
const unreadCount = ref(0)
const loading = ref(false)
const page = ref(1)
const hasMore = ref(true)
onShow(() => {
page.value = 1
notifications.value = []
loadData()
loadUnread()
})
const loadData = async () => {
loading.value = true
try {
const res = await notificationApi.list(page.value, 20)
notifications.value = res.items || []
hasMore.value = (res.items || []).length >= res.size
} catch (_) {
} finally {
loading.value = false
}
}
const loadUnread = async () => {
try {
const res = await notificationApi.unreadCount()
unreadCount.value = res.count || 0
} catch (_) {}
}
const loadMore = async () => {
page.value++
try {
const res = await notificationApi.list(page.value, 20)
const items = res.items || []
notifications.value.push(...items)
hasMore.value = items.length >= res.size
} catch (_) {
page.value--
}
}
const markRead = async (n) => {
if (n.is_read) return
try {
await notificationApi.markRead(n.id)
n.is_read = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
} catch (_) {}
}
const markAllRead = async () => {
try {
await notificationApi.markAllRead()
notifications.value.forEach((n) => (n.is_read = true))
unreadCount.value = 0
uni.showToast({ title: '已全部已读', icon: 'success' })
} catch (_) {}
}
const formatTime = (t) => {
if (!t) return ''
const d = new Date(t)
const now = new Date()
const diff = now - d
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
return `${d.getMonth() + 1}/${d.getDate()}`
}
const typeLabel = (type) => {
const map = { customer_silent: '客户跟进', system: '系统', quotation: '报价单' }
return map[type] || type
}
</script>
<style lang="scss" scoped>
.container { min-height: 100vh; background: #f5f5f5; }
.header-bar { display: flex; justify-content: space-between; align-items: center; padding: 30rpx; background: #fff; }
.header-bar .title { font-size: 36rpx; font-weight: 700; }
.mark-all { font-size: 26rpx; color: #1890ff; }
.empty { display: flex; align-items: center; justify-content: center; padding: 120rpx 0; }
.empty-text { font-size: 28rpx; color: #999; }
.list { padding: 0 20rpx; }
.item { background: #fff; border-radius: 12rpx; padding: 24rpx; margin-bottom: 16rpx; border-left: 6rpx solid transparent; }
.item.unread { border-left-color: #1890ff; }
.item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8rpx; }
.item-title { font-size: 28rpx; font-weight: 500; color: #333; flex: 1; }
.item-time { font-size: 22rpx; color: #999; margin-left: 16rpx; }
.item-content { font-size: 26rpx; color: #666; line-height: 1.6; display: block; margin-bottom: 12rpx; }
.item-type { font-size: 22rpx; color: #1890ff; background: #e6f7ff; padding: 4rpx 12rpx; border-radius: 6rpx; display: inline-block; }
.load-more { text-align: center; padding: 30rpx; }
.load-btn { font-size: 26rpx; color: #1890ff; }
</style>
+2 -1
View File
@@ -119,7 +119,8 @@
</template>
<script setup>
import { ref, onShow } from 'vue'
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { productApi } from '@/utils/api.js'
const products = ref([])
+188 -1
View File
@@ -43,7 +43,9 @@
</view>
<view class="quotation-actions">
<text class="action-btn" @click.stop="copyQuotation(item)">复制</text>
<text class="action-btn" @click.stop="exportPdf(item)">PDF</text>
<text class="action-btn primary" @click.stop="sendQuotation(item)" v-if="item.status === 'draft'">发送</text>
<text class="action-btn purple" @click.stop="showSmartQuote(item)">智能报价</text>
</view>
</view>
</view>
@@ -52,6 +54,9 @@
<text>暂无报价单</text>
</view>
<view class="export-csv-btn" @click="exportCsv">
<text class="export-icon">CSV</text>
</view>
<view class="add-btn" @click="showCreateModal = true">
<text class="add-icon">+</text>
</view>
@@ -144,11 +149,39 @@
</view>
</view>
</view>
<view class="smart-quote-modal" v-if="showSmartQuoteModal" @click="showSmartQuoteModal = false">
<view class="smart-quote-content" @click.stop>
<view class="smart-quote-header">
<text class="smart-quote-title">智能报价</text>
<text class="smart-quote-close" @click="showSmartQuoteModal = false">×</text>
</view>
<view class="smart-quote-body">
<view class="form-item">
<text class="form-label">客户询盘内容</text>
<textarea class="form-textarea" v-model="inquiryText" placeholder="粘贴客户询盘内容,AI自动提取关键信息生成报价单" />
</view>
<view class="form-item">
<text class="form-label">关联客户</text>
<picker :range="customerOptions" range-key="name" @change="onSmartQuoteCustomerChange">
<view class="picker-value">{{ selectedCustomerId ? getCustomerName(selectedCustomerId) : '不关联客户' }}</view>
</picker>
</view>
</view>
<view class="smart-quote-footer">
<button class="cancel-btn" @click="showSmartQuoteModal = false">取消</button>
<button class="submit-btn" @click="generateSmartQuote" :disabled="smartQuoteLoading">
{{ smartQuoteLoading ? '生成中...' : '生成报价单' }}
</button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onShow } from 'vue'
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { quotationApi, customerApi } from '@/utils/api.js'
const filter = ref('all')
@@ -156,7 +189,11 @@ const quotations = ref([])
const customers = ref([])
const showCreateModal = ref(false)
const showDetailModal = ref(false)
const showSmartQuoteModal = ref(false)
const currentQuotation = ref(null)
const inquiryText = ref('')
const selectedCustomerId = ref(null)
const smartQuoteLoading = ref(false)
const formData = ref({
title: '',
@@ -287,6 +324,79 @@ const sendQuotation = async (item) => {
uni.showToast({ title: err.message || '操作失败', icon: 'none' })
}
}
const showSmartQuote = (item) => {
inquiryText.value = item.text || ''
showSmartQuoteModal.value = true
}
const onSmartQuoteCustomerChange = (e) => {
const c = customerOptions.value[e.detail.value]
selectedCustomerId.value = c?.id || null
}
const generateSmartQuote = async () => {
if (!inquiryText.value.trim()) {
uni.showToast({ title: '请输入询盘内容', icon: 'none' })
return
}
smartQuoteLoading.value = true
try {
await quotationApi.generateFromInquiry(inquiryText.value, selectedCustomerId.value)
uni.showToast({ title: '报价单生成成功', icon: 'success' })
showSmartQuoteModal.value = false
inquiryText.value = ''
selectedCustomerId.value = null
loadQuotations()
} catch (err) {
uni.showToast({ title: err.message || '生成失败', icon: 'none' })
} finally {
smartQuoteLoading.value = false
}
}
const exportCsv = () => {
const url = quotationApi.exportCsv()
const token = uni.getStorageSync('token')
uni.downloadFile({
url,
header: { Authorization: `Bearer ${token}` },
success: (res) => {
if (res.statusCode === 200) {
uni.showToast({ title: '导出成功', icon: 'success' })
} else {
uni.showToast({ title: '导出失败', icon: 'none' })
}
},
fail: () => { uni.showToast({ title: '导出失败', icon: 'none' }) },
})
}
const exportPdf = (item) => {
const url = quotationApi.exportPdf(item.id)
uni.downloadFile({
url,
header: { Authorization: `Bearer ${uni.getStorageSync('token')}` },
success: (res) => {
if (res.statusCode === 200) {
uni.openDocument({
filePath: res.tempFilePath,
success: () => {
uni.showToast({ title: '打开成功', icon: 'success' })
},
fail: () => {
uni.showToast({ title: 'PDF预览失败', icon: 'none' })
},
})
} else {
uni.showToast({ title: 'PDF下载失败', icon: 'none' })
}
},
fail: () => {
uni.showToast({ title: 'PDF下载失败', icon: 'none' })
},
})
}
</script>
<style lang="scss" scoped>
@@ -379,12 +489,37 @@ const sendQuotation = async (item) => {
color: #fff;
}
.action-btn.purple {
background: #722ed1;
color: #fff;
}
.empty {
text-align: center;
color: #999;
padding: 100rpx;
}
.export-csv-btn {
position: fixed;
right: 40rpx;
bottom: 160rpx;
width: 100rpx;
height: 100rpx;
background: #722ed1;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(114, 46, 209, 0.4);
}
.export-icon {
font-size: 28rpx;
color: #fff;
font-weight: 600;
}
.add-btn {
position: fixed;
right: 40rpx;
@@ -567,6 +702,58 @@ const sendQuotation = async (item) => {
color: #fff;
}
.smart-quote-modal {
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;
}
.smart-quote-content {
width: 90%;
background: #fff;
border-radius: 16rpx;
display: flex;
flex-direction: column;
max-height: 80%;
}
.smart-quote-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 2rpx solid #f5f5f5;
}
.smart-quote-title {
font-size: 32rpx;
font-weight: 600;
}
.smart-quote-close {
font-size: 44rpx;
color: #999;
}
.smart-quote-body {
padding: 30rpx;
overflow-y: auto;
}
.smart-quote-footer {
display: flex;
gap: 20rpx;
padding: 30rpx;
border-top: 2rpx solid #f5f5f5;
}
.detail-modal {
position: fixed;
top: 0;
+129
View File
@@ -0,0 +1,129 @@
<template>
<view class="team-container">
<view class="header-card">
<text class="title">团队协作</text>
<text class="subtitle">与团队成员共享客户和资源</text>
</view>
<view class="section" v-if="teams.length === 0">
<text class="section-title">创建你的团队</text>
<view class="form-item">
<input class="form-input" v-model="form.name" placeholder="团队名称" />
</view>
<view class="form-item">
<input class="form-input" v-model="form.description" placeholder="团队描述(选填)" />
</view>
<button class="submit-btn" @click="createTeam">创建团队</button>
</view>
<view class="section" v-for="team in teams" :key="team.id">
<view class="team-header">
<text class="team-name">{{ team.name }}</text>
<text class="team-count">{{ team.member_count || team.members?.length || 0 }} </text>
</view>
<text class="team-desc" v-if="team.description">{{ team.description }}</text>
<view class="member-list" v-if="team.members">
<view class="member-item" v-for="m in team.members" :key="m.user_id">
<view class="member-info">
<text class="member-avatar">{{ m.role === 'owner' ? '主' : '成' }}</text>
<text class="member-id">{{ m.user_id.slice(0, 8) }}...</text>
<text class="member-role" :class="m.role">{{ roleMap[m.role] || m.role }}</text>
</view>
<text class="member-status" :class="m.status">{{ m.status === 'active' ? '在线' : '离线' }}</text>
</view>
</view>
<view class="invite-section">
<text class="invite-title">邀请成员</text>
<view class="invite-row">
<input class="invite-input" v-model="inviteUserId" placeholder="输入对方 User ID" />
<button class="invite-btn" @click="inviteMember(team.id)">邀请</button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { teamApi } from '@/utils/api.js'
const teams = ref([])
const form = ref({ name: '', description: '' })
const inviteUserId = ref('')
const roleMap = { owner: '所有者', admin: '管理员', member: '成员', viewer: '查看者' }
onShow(() => loadTeams())
const loadTeams = async () => {
try {
const res = await teamApi.list()
teams.value = res.teams || []
} catch (err) {
console.error('加载团队失败', err)
}
}
const createTeam = async () => {
if (!form.value.name) {
uni.showToast({ title: '请输入团队名称', icon: 'none' })
return
}
try {
await teamApi.create(form.value.name, form.value.description)
uni.showToast({ title: '创建成功', icon: 'success' })
form.value = { name: '', description: '' }
loadTeams()
} catch (err) {
uni.showToast({ title: err.message || '创建失败', icon: 'none' })
}
}
const inviteMember = async (teamId) => {
if (!inviteUserId.value) {
uni.showToast({ title: '请输入对方 User ID', icon: 'none' })
return
}
try {
await teamApi.invite(teamId, inviteUserId.value)
uni.showToast({ title: '邀请成功', icon: 'success' })
inviteUserId.value = ''
loadTeams()
} catch (err) {
uni.showToast({ title: err.message || '邀请失败', icon: 'none' })
}
}
</script>
<style lang="scss" scoped>
.team-container { min-height: 100vh; background: #f5f5f5; padding: 20rpx; }
.header-card { background: linear-gradient(135deg, #fa8c16, #f5222d); border-radius: 16rpx; padding: 40rpx; margin-bottom: 30rpx; }
.header-card .title { font-size: 40rpx; font-weight: 700; color: #fff; display: block; }
.header-card .subtitle { font-size: 26rpx; color: rgba(255,255,255,0.8); margin-top: 8rpx; }
.section { background: #fff; border-radius: 16rpx; padding: 30rpx; margin-bottom: 20rpx; }
.section-title { font-size: 30rpx; font-weight: 600; margin-bottom: 20rpx; display: block; }
.form-item { margin-bottom: 20rpx; }
.form-input { width: 100%; height: 72rpx; background: #f5f5f5; border-radius: 8rpx; padding: 0 20rpx; font-size: 28rpx; box-sizing: border-box; }
.submit-btn { width: 100%; height: 80rpx; background: #1890ff; color: #fff; border-radius: 8rpx; font-size: 28rpx; }
.team-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12rpx; }
.team-name { font-size: 32rpx; font-weight: 600; }
.team-count { font-size: 24rpx; color: #999; }
.team-desc { font-size: 24rpx; color: #666; margin-bottom: 20rpx; display: block; }
.member-list { margin: 20rpx 0; }
.member-item { display: flex; justify-content: space-between; align-items: center; padding: 16rpx 0; border-bottom: 2rpx solid #f5f5f5; }
.member-info { display: flex; align-items: center; gap: 12rpx; }
.member-avatar { width: 48rpx; height: 48rpx; border-radius: 50%; background: #e6f7ff; color: #1890ff; display: flex; align-items: center; justify-content: center; font-size: 22rpx; }
.member-id { font-size: 24rpx; color: #666; }
.member-role { font-size: 20rpx; padding: 2rpx 10rpx; border-radius: 6rpx; }
.member-role.owner { background: #fff7e6; color: #fa8c16; }
.member-role.admin { background: #e6f7ff; color: #1890ff; }
.member-role.member { background: #f6ffed; color: #52c41a; }
.member-status { font-size: 22rpx; color: #52c41a; }
.invite-section { margin-top: 20rpx; padding-top: 20rpx; border-top: 2rpx solid #f5f5f5; }
.invite-title { font-size: 26rpx; font-weight: 500; color: #666; display: block; margin-bottom: 12rpx; }
.invite-row { display: flex; gap: 12rpx; }
.invite-input { flex: 1; height: 64rpx; background: #f5f5f5; border-radius: 8rpx; padding: 0 16rpx; font-size: 26rpx; }
.invite-btn { height: 64rpx; padding: 0 24rpx; background: #1890ff; color: #fff; border-radius: 8rpx; font-size: 26rpx; }
</style>
+223 -7
View File
@@ -42,11 +42,36 @@
<view class="result-section" v-if="result">
<view class="result-header">
<text class="result-label">翻译结果</text>
<text class="copy-btn" @click="copyResult">复制</text>
<view class="result-actions">
<text class="action-btn extract-btn" @click="handleExtract">抽取信息</text>
<text class="action-btn" @click="playTts">朗读</text>
<text class="copy-btn" @click="copyResult">复制</text>
</view>
</view>
<view class="result-content">
<text class="result-text">{{ result }}</text>
</view>
<view class="rating-section" v-if="result">
<text class="rating-label">评价:</text>
<view class="stars">
<text
class="star"
v-for="s in 5"
:key="s"
@click="rateTranslation(s)"
>{{ s <= rating ? '★' : '☆' }}</text>
</view>
</view>
</view>
<view class="extract-section" v-if="extractedInfo">
<view class="extract-header">
<text class="extract-label">抽取信息</text>
<text class="extract-close" @click="extractedInfo = null">×</text>
</view>
<view class="extract-content">
<text class="extract-text">{{ extractedInfo }}</text>
</view>
</view>
<view class="suggestions-section" v-if="suggestions.length > 0">
@@ -66,13 +91,23 @@
</view>
</view>
<view class="preferences-section" v-if="preferences">
<view class="preferences-header">
<text class="preferences-label">AI偏好设置</text>
</view>
<view class="preferences-body">
<text class="pref-text">{{ preferences.preferred_tone || '尚未分析偏好' }}</text>
<text class="pref-detail" v-if="preferences.common_words">常用词: {{ preferences.common_words.join(', ') }}</text>
</view>
</view>
<view class="keyboard-height" :style="{ height: keyboardHeight + 'px' }"></view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { translateApi } from '@/utils/api.js'
import { translateApi, interactionApi, BASE_URL } from '@/utils/api.js'
const mode = ref('translate')
const inputText = ref('')
@@ -81,6 +116,9 @@ const suggestions = ref([])
const loading = ref(false)
const targetIndex = ref(1)
const keyboardHeight = ref(0)
const rating = ref(0)
const extractedInfo = ref(null)
const preferences = ref(null)
const targetLangs = ref([
{ code: 'en', name: 'English' },
@@ -93,7 +131,6 @@ const onTargetChange = (e) => {
}
const onInput = () => {
// Real-time input handling
}
const handleTranslate = async () => {
@@ -111,6 +148,7 @@ const handleTranslate = async () => {
targetLangs[targetIndex.value].code
)
result.value = res.translated
loadPreferences()
} else {
const res = await translateApi.getReply(inputText.value, 'professional', 3)
suggestions.value = res.suggestions || []
@@ -127,6 +165,8 @@ const clearAll = () => {
inputText.value = ''
result.value = ''
suggestions.value = []
extractedInfo.value = null
rating.value = 0
}
const copyResult = () => {
@@ -138,11 +178,69 @@ const copyResult = () => {
})
}
const playTts = () => {
if (!result.value) return
const lang = targetLangs[targetIndex.value].code
const token = uni.getStorageSync('token')
const url = `${BASE_URL}/translate/tts?text=${encodeURIComponent(result.value)}&lang=${lang}`
uni.showLoading({ title: '语音生成中...' })
uni.downloadFile({
url,
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' })
},
})
}
const handleExtract = async () => {
if (!result.value) return
try {
const res = await translateApi.extract(result.value)
extractedInfo.value = typeof res.extracted === 'string' ? res.extracted : JSON.stringify(res.extracted, null, 2)
} catch (err) {
uni.showToast({ title: err.message || '抽取失败', icon: 'none' })
}
}
const rateTranslation = async (s) => {
rating.value = s
try {
await translateApi.sendFeedback(null, s)
uni.showToast({ title: '感谢评价', icon: 'success' })
} catch (err) {
console.error('反馈提交失败', err)
}
}
const loadPreferences = async () => {
try {
const res = await interactionApi.getPreferences()
preferences.value = res
} catch (err) {
console.error('加载偏好失败', err)
}
}
const selectSuggestion = (index) => {
const selected = suggestions.value[index]
uni.setClipboardData({
data: selected.text,
success: () => {
interactionApi.recordEdit(null, selected.text).catch(() => {})
uni.showToast({ title: '已复制建议内容', icon: 'success' })
},
})
@@ -151,9 +249,10 @@ const selectSuggestion = (index) => {
<style lang="scss" scoped>
.translate-container {
min-height: 100vh;
min-height: 100%;
background: #f5f5f5;
padding: 20rpx;
box-sizing: border-box;
}
.mode-switch {
@@ -254,10 +353,30 @@ const selectSuggestion = (index) => {
color: #999;
}
.copy-btn {
.result-actions {
display: flex;
gap: 12rpx;
}
.action-btn, .copy-btn {
font-size: 24rpx;
color: #1890ff;
padding: 8rpx 16rpx;
border-radius: 6rpx;
}
.action-btn {
color: #52c41a;
background: #f6ffed;
}
.extract-btn {
color: #722ed1;
background: #f9f0ff;
}
.copy-btn {
color: #1890ff;
background: #e6f7ff;
}
.result-content {
@@ -271,10 +390,72 @@ const selectSuggestion = (index) => {
line-height: 1.6;
}
.rating-section {
display: flex;
align-items: center;
margin-top: 16rpx;
padding-top: 16rpx;
border-top: 2rpx solid #f5f5f5;
}
.rating-label {
font-size: 24rpx;
color: #999;
margin-right: 12rpx;
}
.stars {
display: flex;
gap: 8rpx;
}
.star {
font-size: 36rpx;
color: #faad14;
}
.extract-section {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
}
.extract-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
}
.extract-label {
font-size: 26rpx;
color: #722ed1;
font-weight: 600;
}
.extract-close {
font-size: 36rpx;
color: #999;
}
.extract-content {
padding: 16rpx;
background: #f9f0ff;
border-radius: 8rpx;
}
.extract-text {
font-size: 26rpx;
line-height: 1.5;
white-space: pre-wrap;
}
.suggestions-section {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
}
.suggestions-header {
@@ -313,7 +494,42 @@ const selectSuggestion = (index) => {
border-radius: 6rpx;
}
.preferences-section {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
}
.preferences-header {
margin-bottom: 12rpx;
}
.preferences-label {
font-size: 26rpx;
color: #666;
font-weight: 600;
}
.preferences-body {
padding: 12rpx;
background: #f0f5ff;
border-radius: 8rpx;
}
.pref-text {
font-size: 26rpx;
color: #1890ff;
display: block;
margin-bottom: 8rpx;
}
.pref-detail {
font-size: 24rpx;
color: #666;
}
.keyboard-height {
width: 100%;
}
</style>
</style>
+114
View File
@@ -0,0 +1,114 @@
<template>
<view class="container">
<view class="header">
<text class="page-title">升级会员</text>
<text class="current-plan" v-if="currentPlan">当前: {{ planLabel(currentPlan) }}</text>
</view>
<view class="plan-list">
<view
class="plan-card"
v-for="plan in plans"
:key="plan.id"
:class="{ active: selected === plan.id, current: plan.id === currentPlan }"
@click="selected = plan.id"
>
<text class="plan-name">{{ plan.name }}</text>
<text class="plan-price">
<text class="price-num">¥{{ plan.price }}</text>
<text class="price-unit" v-if="plan.price > 0">/</text>
<text class="price-unit" v-else>免费</text>
</text>
<view class="plan-features">
<text class="feature" v-for="(f, i) in plan.features" :key="i"> {{ f }}</text>
</view>
<text class="plan-badge" v-if="plan.id === currentPlan">当前方案</text>
</view>
</view>
<button
class="upgrade-btn"
@click="handleUpgrade"
:disabled="!selected || selected === currentPlan || loading"
>
{{ loading ? '处理中...' : (selected === currentPlan ? '当前方案' : '立即升级') }}
</button>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { paymentApi } from '@/utils/api.js'
const plans = ref([])
const currentPlan = ref('free')
const selected = ref('')
const loading = ref(false)
onShow(async () => {
try {
const [planRes, subRes] = await Promise.all([
paymentApi.plans(),
paymentApi.subscription(),
])
plans.value = planRes.plans || []
currentPlan.value = subRes.plan || 'free'
} catch (_) {}
})
const planLabel = (id) => {
const map = { free: '免费版', pro: 'Pro 版', enterprise: '企业版' }
return map[id] || id
}
const handleUpgrade = async () => {
if (!selected.value || selected.value === currentPlan.value) return
loading.value = true
try {
const res = await paymentApi.createOrder(selected.value)
if (res.amount === 0) {
uni.showToast({ title: '已切换为免费版', icon: 'success' })
currentPlan.value = selected.value
return
}
if (res.pay_params) {
uni.requestPayment({
provider: 'wxpay',
...res.pay_params,
success: () => {
uni.showToast({ title: '支付成功', icon: 'success' })
currentPlan.value = selected.value
},
fail: (err) => {
uni.showToast({ title: err.errMsg || '支付失败', icon: 'none' })
},
})
}
} catch (err) {
uni.showToast({ title: err.message || '操作失败', icon: 'none' })
} finally {
loading.value = false
}
}
</script>
<style lang="scss" scoped>
.container { min-height: 100vh; background: #f5f5f5; padding: 20rpx; }
.header { text-align: center; padding: 40rpx 0; }
.page-title { font-size: 40rpx; font-weight: 700; color: #333; display: block; }
.current-plan { font-size: 26rpx; color: #999; margin-top: 8rpx; display: block; }
.plan-list { display: flex; flex-direction: column; gap: 20rpx; margin-bottom: 40rpx; }
.plan-card { background: #fff; border-radius: 16rpx; padding: 32rpx; border: 2rpx solid #e8e8e8; position: relative; }
.plan-card.active { border-color: #1890ff; box-shadow: 0 4rpx 16rpx rgba(24,144,255,0.15); }
.plan-card.current { border-color: #52c41a; }
.plan-name { font-size: 32rpx; font-weight: 600; display: block; }
.plan-price { margin: 16rpx 0; display: block; }
.price-num { font-size: 48rpx; font-weight: 700; color: #1890ff; }
.price-unit { font-size: 24rpx; color: #999; margin-left: 8rpx; }
.plan-features { margin-top: 16rpx; }
.feature { font-size: 24rpx; color: #666; line-height: 2; display: block; }
.plan-badge { position: absolute; top: 16rpx; right: 16rpx; font-size: 22rpx; color: #52c41a; background: #f6ffed; padding: 4rpx 12rpx; border-radius: 6rpx; }
.upgrade-btn { width: 100%; height: 96rpx; background: linear-gradient(135deg, #1890ff, #096dd9); color: #fff; border: none; border-radius: 16rpx; font-size: 32rpx; font-weight: 500; }
.upgrade-btn[disabled] { background: #a0cfff; }
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 818 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1890ff;stop-opacity:1" />
<stop offset="100%" style="stop-color:#096dd9;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="100" height="100" rx="20" fill="url(#grad)"/>
<text x="50" y="68" font-family="Arial, sans-serif" font-size="48" font-weight="bold" fill="white" text-anchor="middle">TM</text>
</svg>

After

Width:  |  Height:  |  Size: 511 B

+197 -27
View File
@@ -1,4 +1,4 @@
const BASE_URL = 'http://localhost:8000/api/v1'
export const BASE_URL = 'http://localhost:8000/api/v1'
const getAuthHeader = () => {
const token = uni.getStorageSync('token')
@@ -33,10 +33,104 @@ const request = (url, method = 'GET', data = {}) => {
})
}
const requestWithoutAuth = (url, method = 'GET', data = {}) => {
return new Promise((resolve, reject) => {
uni.request({
url: `${BASE_URL}${url}`,
method,
data,
header: {
'Content-Type': 'application/json',
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data)
} else {
reject(new Error(res.data?.detail || 'Request failed'))
}
},
fail: (err) => {
reject(err)
},
})
})
}
export const authApi = {
login: (phone, password) => request('/auth/login', 'POST', { username: phone, password }),
register: (phone, password, username) => request('/auth/register', 'POST', { phone, password, username }),
getUserInfo: () => request('/auth/me'),
wechatLogin: (code) => request('/auth/wechat-login', 'POST', { code }),
guestLogin: () => requestWithoutAuth('/auth/login/guest', 'POST'),
}
export const marketingApi = {
generate: (productName, description, category, target = 'US importers', style = 'professional') =>
request('/marketing/generate', 'POST', {
product_name: productName,
description,
category,
target,
style,
}),
getKeywords: (productName, description, category = '', language = 'en', count = 10) =>
request('/marketing/keywords', 'POST', {
product_name: productName,
description,
category,
language,
count,
}),
competitorAnalysis: (productName, description, category = '', market = 'US') =>
request('/marketing/competitor-analysis', 'POST', {
product_name: productName,
description,
category,
market,
}),
}
export const quotationApi = {
list: (page = 1, size = 20) => request(`/quotations?page=${page}&size=${size}`),
get: (id) => request(`/quotations/${id}`),
create: (data) => request('/quotations', 'POST', data),
updateStatus: (id, status) => request(`/quotations/${id}/status`, 'PATCH', { status }),
exportPdf: (id) => `${BASE_URL}/quotations/${id}/pdf`,
exportCsv: () => `${BASE_URL}/quotations/export/csv`,
generateFromInquiry: (inquiryText, customerId = null) =>
request('/quotations/generate-from-inquiry', 'POST', { inquiry_text: inquiryText, customer_id: customerId }),
}
export const productApi = {
list: (page = 1, size = 20) => request(`/products?page=${page}&size=${size}`),
get: (id) => request(`/products/${id}`),
create: (data) => request('/products', 'POST', data),
update: (id, data) => request(`/products/${id}`, 'PATCH', data),
delete: (id) => request(`/products/${id}`, 'DELETE'),
}
export const adminApi = {
getDashboard: () => request('/admin/dashboard'),
listUsers: (page = 1, size = 20) => request(`/admin/users?page=${page}&size=${size}`),
updateUserTier: (userId, tier) => request(`/admin/users/${userId}/tier`, 'PATCH', { tier }),
}
export const analyticsApi = {
getOverview: () => request('/analytics/overview'),
getCustomers: () => request('/analytics/customers'),
getTranslations: () => request('/analytics/translations'),
getQuotations: () => request('/analytics/quotations'),
getMessages: () => request('/analytics/messages'),
}
export const teamApi = {
list: () => request('/teams'),
get: (id) => request(`/teams/${id}`),
create: (name, description) => request('/teams', 'POST', { name, description }),
invite: (teamId, userId) => request(`/teams/${teamId}/invite`, 'POST', { user_id: userId }),
removeMember: (teamId, memberId) => request(`/teams/${teamId}/members/${memberId}`, 'DELETE'),
leave: (teamId) => request(`/teams/${teamId}/leave`, 'POST'),
updateRole: (teamId, memberId, role) => request(`/teams/${teamId}/members/${memberId}/role`, 'PATCH', { role }),
}
export const translateApi = {
@@ -44,6 +138,88 @@ export const translateApi = {
request('/translate', 'POST', { text, target_lang: targetLang, source_lang: sourceLang }),
getReply: (inquiry, tone = 'professional', count = 3) =>
request('/translate/reply', 'POST', { inquiry, tone, count }),
extract: (text, extractType = 'auto') =>
request('/translate/extract', 'POST', { text, extract_type: extractType }),
sendFeedback: (entryId, rating) =>
request('/translate/feedback', 'POST', { entry_id: entryId, rating }),
publicTranslate: (text, targetLang, sourceLang = 'auto') =>
requestWithoutAuth('/translate/public/translate', 'POST', { text, target_lang: targetLang, source_lang: sourceLang }),
publicExtract: (text, extractType = 'auto') =>
requestWithoutAuth('/translate/public/extract', 'POST', { text, extract_type: extractType }),
}
export const notificationApi = {
list: (page = 1, size = 20, unreadOnly = false) =>
request(`/notifications?page=${page}&size=${size}&unread_only=${unreadOnly}`),
unreadCount: () => request('/notifications/unread-count'),
markRead: (id) => request(`/notifications/${id}/read`, 'PATCH'),
markAllRead: () => request('/notifications/read-all', 'POST'),
delete: (id) => request(`/notifications/${id}`, 'DELETE'),
}
export const paymentApi = {
plans: () => request('/payment/plans'),
subscription: () => request('/payment/subscription'),
createOrder: (plan) => request('/payment/create-order', 'POST', { plan }),
}
export const feedbackApi = {
submit: (content, category = 'general', contact = '') =>
request('/feedback', 'POST', { content, category, contact }),
}
export const onboardingApi = {
status: () => request('/onboarding/status'),
createProduct: (name, description, category, target) =>
request('/onboarding/product', 'POST', { name, description, category, target }),
}
export const interactionApi = {
selectSuggestion: (messageId, selectedIndex) =>
request('/interaction/select', 'POST', { message_id: messageId, selected_index: selectedIndex }),
recordEdit: (messageId, editedText) =>
request('/interaction/edit', 'POST', { message_id: messageId, edited_text: editedText }),
analyzePreferences: () => request('/interaction/analyze', 'POST'),
getPreferences: () => request('/interaction/preferences'),
trackMarketingEffect: (data) => request('/interaction/marketing-effect', 'POST', data),
getMarketingEffects: (page = 1, size = 20) =>
request(`/interaction/marketing-effects?page=${page}&size=${size}`),
getMarketingEffectStats: () => request('/interaction/marketing-effects/stats'),
}
export const exchangeApi = {
convert: (fromCurrency = 'USD', toCurrency = 'CNY', amount = 1) =>
request(`/exchange/convert?from_currency=${fromCurrency}&to_currency=${toCurrency}&amount=${amount}`),
rates: (base = 'USD') => request(`/exchange/rates?base=${base}`),
}
export const pushApi = {
register: (clientId, platform = 'weapp', pushToken = null, deviceInfo = null) =>
request('/push/register', 'POST', { client_id: clientId, platform, push_token: pushToken, device_info: deviceInfo }),
unregister: (clientId) =>
request('/push/unregister', 'POST', { client_id: clientId }),
listDevices: () => request('/push/devices'),
}
export const silentPatternApi = {
getRiskAnalysis: () => request('/silent-pattern/risk-analysis'),
getSuggestions: (customerId) => request(`/silent-pattern/${customerId}/suggestions`),
}
export const followupApi = {
strategies: () => request('/followup/strategies'),
pending: (page = 1) => request(`/followup/pending?page=${page}`),
logs: (page = 1) => request(`/followup/logs?page=${page}`),
markSent: (id) => request(`/followup/${id}/send`, 'POST'),
editAndSend: (id, editedText) => request(`/followup/${id}/edit`, 'POST', { edited_text: editedText }),
stats: () => request('/followup/stats'),
scan: () => request('/followup/scan', 'POST'),
}
export const healthApi = {
overview: () => request('/customers/health-overview'),
allScores: () => request('/customers/health-scores'),
customerHealth: (id) => request(`/customers/${id}/health`),
}
export const customerApi = {
@@ -59,30 +235,24 @@ export const customerApi = {
getSilent: (days = 3) => request(`/customers/silent?days=${days}`),
getConversation: (id, page = 1, size = 50) =>
request(`/customers/${id}/conversation?page=${page}&size=${size}`),
}
export const marketingApi = {
generate: (productName, description, category, target = 'US importers', style = 'professional') =>
request('/marketing/generate', 'POST', {
product_name: productName,
description,
category,
target,
style,
}),
}
export const quotationApi = {
list: (page = 1, size = 20) => request(`/quotations?page=${page}&size=${size}`),
get: (id) => request(`/quotations/${id}`),
create: (data) => request('/quotations', 'POST', data),
updateStatus: (id, status) => request(`/quotations/${id}/status`, 'PATCH', { status }),
}
export const productApi = {
list: (page = 1, size = 20) => request(`/products?page=${page}&size=${size}`),
get: (id) => request(`/products/${id}`),
create: (data) => request('/products', 'POST', data),
update: (id, data) => request(`/products/${id}`, 'PATCH', data),
delete: (id) => request(`/products/${id}`, 'DELETE'),
exportCsv: () => `${BASE_URL}/customers/export/csv`,
importCustomers: (file) => {
return new Promise((resolve, reject) => {
const token = uni.getStorageSync('token')
uni.uploadFile({
url: `${BASE_URL}/customers/import`,
filePath: file,
name: 'file',
header: token ? { Authorization: `Bearer ${token}` } : {},
success: (res) => {
try {
resolve(JSON.parse(res.data))
} catch (e) {
resolve(res.data)
}
},
fail: reject,
})
})
},
}
+43 -10
View File
@@ -1,4 +1,5 @@
import { ref } from 'vue'
import { pushApi } from './api.js'
let pushClientId = ''
let isInitialized = ref(false)
@@ -24,9 +25,14 @@ export const pushService = {
})
// #endif
// #ifndef APP-PLUS
console.log('非App环境下跳过推送初始化')
resolve(false)
// #ifdef MP-WEIXIN
this.registerWechatDevice()
resolve(true)
// #endif
// #ifdef H5
this.registerWebDevice()
resolve(true)
// #endif
})
},
@@ -50,16 +56,14 @@ export const pushService = {
/**
* 注册设备到服务器
*/
async registerDevice(clientId) {
async registerDevice(clientId) {
if (!clientId) return
try {
const { request } = require('./api.js')
await request('/push/register', 'POST', {
client_id: clientId,
platform: uni.getSystemInfoSync().platform,
device_info: uni.getSystemInfoSync(),
})
const platform = uni.getSystemInfoSync().platform || 'web'
const deviceInfo = uni.getSystemInfoSync()
await pushApi.register(clientId, platform, '', deviceInfo)
console.log('Device registered successfully')
} catch (err) {
console.error('Register device failed:', err)
@@ -143,6 +147,35 @@ export const pushService = {
})
},
/**
* 微信小程序设备注册
*/
registerWechatDevice() {
// #ifdef MP-WEIXIN
try {
const systemInfo = uni.getSystemInfoSync()
const clientId = `${systemInfo.platform}_${Date.now()}`
this.registerDevice(clientId)
} catch (err) {
console.error('Wechat device register failed:', err)
}
// #endif
},
/**
* H5 设备注册
*/
registerWebDevice() {
// #ifdef H5
try {
const clientId = `web_${Date.now()}`
this.registerDevice(clientId)
} catch (err) {
console.error('Web device register failed:', err)
}
// #endif
},
/**
* 清除所有推送消息
*/
+1 -1
View File
@@ -12,4 +12,4 @@ export default defineConfig({
},
},
},
})
})