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:
Binary file not shown.
|
After Width: | Height: | Size: 818 B |
+3
-1
@@ -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>
|
||||
|
||||
Generated
+8060
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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": "报价"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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(如DeepL、OpenAI、Anthropic),仅传输需要翻译的文本内容。</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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([])
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
@@ -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
@@ -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
@@ -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
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除所有推送消息
|
||||
*/
|
||||
|
||||
@@ -12,4 +12,4 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user