Initial commit: TradeMate 外贸小助手 MVP
项目结构: - backend/ Python FastAPI 后端 - uni-app/ uni-app跨端前端 - docs/ 设计文档 - docker-compose.yml Docker编排 - nginx/scripts/systemd 运维配置 已完成功能: - 用户认证 (JWT) - 智能翻译 + 回复建议 - 营销素材生成 - 客户管理 + 沉默检测 - 报价单管理 - 产品库管理 - 汇率换算 - 推送通知 (uni-push) - WhatsApp Webhook框架 - Celery定时任务
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "trademate",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev:mp-weixin": "uni -p mp-weixin",
|
||||
"build:mp-weixin": "uni build -p mp-weixin",
|
||||
"dev:h5": "uni",
|
||||
"build:h5": "uni build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-app": "3.0.0-4010520240507001",
|
||||
"@dcloudio/uni-components": "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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import '@/static/common.css';
|
||||
|
||||
#app {
|
||||
padding-bottom: 100rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<view class="custom-tabbar" v-if="showTabbar">
|
||||
<view
|
||||
class="tab-item"
|
||||
v-for="(item, index) in tabList"
|
||||
:key="index"
|
||||
@click="switchTab(index)"
|
||||
>
|
||||
<text class="tab-icon" :class="{ active: current === index }">{{ item.icon }}</text>
|
||||
<text class="tab-text" :class="{ active: current === index }">{{ item.text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
|
||||
const current = 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: '📄' },
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
updateCurrent()
|
||||
})
|
||||
|
||||
const showTabbar = computed(() => {
|
||||
const pages = getCurrentPages()
|
||||
if (pages.length) {
|
||||
const currentPage = pages[pages.length - 1].route || ''
|
||||
return !currentPage.includes('login')
|
||||
}
|
||||
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
|
||||
current.value = index
|
||||
uni.switchTab({ url: tabList.value[index].pagePath })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.custom-tabbar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 100rpx;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
font-size: 44rpx;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
font-size: 22rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.tab-text.active {
|
||||
color: #1890ff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createSSRApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
return {
|
||||
app,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/login/login",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "登录"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/index/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "外贸小助手"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/translate/translate",
|
||||
"style": {
|
||||
"navigationBarTitleText": "智能翻译"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/customers/customers",
|
||||
"style": {
|
||||
"navigationBarTitleText": "客户管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/marketing/marketing",
|
||||
"style": {
|
||||
"navigationBarTitleText": "营销素材"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/quotation/quotation",
|
||||
"style": {
|
||||
"navigationBarTitleText": "报价单"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/product/product",
|
||||
"style": {
|
||||
"navigationBarTitleText": "产品库"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "white",
|
||||
"navigationBarTitleText": "外贸小助手",
|
||||
"navigationBarBackgroundColor": "#1890ff",
|
||||
"backgroundColor": "#f5f5f5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
<template>
|
||||
<view class="customers-container">
|
||||
<view class="filter-tabs">
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="{ active: filter === 'all' }"
|
||||
@click="filter = 'all'; loadCustomers()"
|
||||
>
|
||||
全部
|
||||
</view>
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="{ active: filter === 'lead' }"
|
||||
@click="filter = 'lead'; loadCustomers()"
|
||||
>
|
||||
潜在
|
||||
</view>
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="{ active: filter === 'negotiating' }"
|
||||
@click="filter = 'negotiating'; loadCustomers()"
|
||||
>
|
||||
谈判中
|
||||
</view>
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="{ active: filter === 'customer' }"
|
||||
@click="filter = 'customer'; loadCustomers()"
|
||||
>
|
||||
已成交
|
||||
</view>
|
||||
<view
|
||||
class="tab-item warning"
|
||||
:class="{ active: filter === 'silent' }"
|
||||
@click="filter = 'silent'; loadSilent()"
|
||||
>
|
||||
沉默
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="customer-list" v-if="customers.length > 0">
|
||||
<view class="customer-item" v-for="item in customers" :key="item.id">
|
||||
<view class="customer-info">
|
||||
<view class="customer-header">
|
||||
<text class="customer-name">{{ item.name }}</text>
|
||||
<text class="customer-status" :class="item.status">{{ getStatusText(item.status) }}</text>
|
||||
</view>
|
||||
<view class="customer-detail">
|
||||
<text class="detail-item" v-if="item.company">{{ item.company }}</text>
|
||||
<text class="detail-item" v-if="item.country">{{ item.country }}</text>
|
||||
<text class="detail-item" v-if="item.phone">{{ item.phone }}</text>
|
||||
</view>
|
||||
<view class="customer-contact" v-if="item.last_contact_at">
|
||||
<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>
|
||||
<view class="customer-actions">
|
||||
<view class="action-icon" @click="showCustomerDetail(item)">详</view>
|
||||
<view class="action-icon" @click="editCustomer(item)">编</view>
|
||||
<view class="action-icon delete" @click="deleteCustomer(item.id)">删</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="empty" v-else>
|
||||
<text>暂无客户数据</text>
|
||||
</view>
|
||||
|
||||
<view class="add-btn" @click="showAddModal = true">
|
||||
<text class="add-icon">+</text>
|
||||
</view>
|
||||
|
||||
<view class="modal" v-if="showAddModal || showEditModal" @click="closeModal">
|
||||
<view class="modal-content" @click.stop>
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">{{ showEditModal ? '编辑客户' : '新增客户' }}</text>
|
||||
<text class="modal-close" @click="closeModal">×</text>
|
||||
</view>
|
||||
<view class="modal-body">
|
||||
<view class="form-item">
|
||||
<text class="form-label">姓名 *</text>
|
||||
<input class="form-input" v-model="formData.name" placeholder="客户姓名" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">公司</text>
|
||||
<input class="form-input" v-model="formData.company" placeholder="公司名称" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">国家</text>
|
||||
<input class="form-input" v-model="formData.country" placeholder="国家" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">电话</text>
|
||||
<input class="form-input" v-model="formData.phone" placeholder="电话" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">WhatsApp</text>
|
||||
<input class="form-input" v-model="formData.whatsapp_id" placeholder="WhatsApp ID" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">邮箱</text>
|
||||
<input class="form-input" v-model="formData.email" placeholder="邮箱" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">状态</text>
|
||||
<picker :range="statusOptions" @change="onStatusChange">
|
||||
<view class="picker-value">{{ getStatusText(formData.status) || '选择状态' }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
<view class="modal-footer">
|
||||
<button class="cancel-btn" @click="closeModal">取消</button>
|
||||
<button class="submit-btn" @click="submitCustomer">保存</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="detail-modal" v-if="showDetailModal" @click="showDetailModal = false">
|
||||
<view class="detail-content" @click.stop>
|
||||
<view class="detail-header">
|
||||
<text class="detail-name">{{ currentCustomer.name }}</text>
|
||||
<text class="detail-status" :class="currentCustomer.status">{{ getStatusText(currentCustomer.status) }}</text>
|
||||
</view>
|
||||
<view class="detail-body">
|
||||
<view class="detail-row" v-if="currentCustomer.company">
|
||||
<text class="detail-label">公司:</text>
|
||||
<text class="detail-value">{{ currentCustomer.company }}</text>
|
||||
</view>
|
||||
<view class="detail-row" v-if="currentCustomer.country">
|
||||
<text class="detail-label">国家:</text>
|
||||
<text class="detail-value">{{ currentCustomer.country }}</text>
|
||||
</view>
|
||||
<view class="detail-row" v-if="currentCustomer.phone">
|
||||
<text class="detail-label">电话:</text>
|
||||
<text class="detail-value">{{ currentCustomer.phone }}</text>
|
||||
</view>
|
||||
<view class="detail-row" v-if="currentCustomer.whatsapp_id">
|
||||
<text class="detail-label">WhatsApp:</text>
|
||||
<text class="detail-value">{{ currentCustomer.whatsapp_id }}</text>
|
||||
</view>
|
||||
<view class="detail-row" v-if="currentCustomer.email">
|
||||
<text class="detail-label">邮箱:</text>
|
||||
<text class="detail-value">{{ currentCustomer.email }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="detail-footer">
|
||||
<button class="close-btn" @click="showDetailModal = false">关闭</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onShow } from 'vue'
|
||||
import { customerApi } from '@/utils/api.js'
|
||||
|
||||
const filter = ref('all')
|
||||
const customers = ref([])
|
||||
const showAddModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showDetailModal = ref(false)
|
||||
const currentCustomer = ref(null)
|
||||
const formData = ref({
|
||||
name: '',
|
||||
company: '',
|
||||
country: '',
|
||||
phone: '',
|
||||
whatsapp_id: '',
|
||||
email: '',
|
||||
status: 'lead',
|
||||
})
|
||||
|
||||
const statusOptions = ['lead', 'negotiating', 'customer', 'lost']
|
||||
|
||||
onShow(() => {
|
||||
loadCustomers()
|
||||
})
|
||||
|
||||
const loadCustomers = async () => {
|
||||
try {
|
||||
const res = await customerApi.list(1, 20, filter.value === 'all' ? undefined : filter.value)
|
||||
customers.value = res.items || []
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err.message || '加载失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const loadSilent = async () => {
|
||||
try {
|
||||
const res = await customerApi.getSilent(7)
|
||||
customers.value = res.customers || []
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err.message || '加载失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const map = { lead: '潜在', negotiating: '谈判中', customer: '已成交', lost: '已丢失' }
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
const formatTime = (time) => {
|
||||
if (!time) return ''
|
||||
return time.split('T')[0]
|
||||
}
|
||||
|
||||
const showCustomerDetail = (item) => {
|
||||
currentCustomer.value = item
|
||||
showDetailModal.value = true
|
||||
}
|
||||
|
||||
const editCustomer = (item) => {
|
||||
formData.value = { ...item }
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
showAddModal.value = false
|
||||
showEditModal.value = false
|
||||
formData.value = {
|
||||
name: '',
|
||||
company: '',
|
||||
country: '',
|
||||
phone: '',
|
||||
whatsapp_id: '',
|
||||
email: '',
|
||||
status: 'lead',
|
||||
}
|
||||
}
|
||||
|
||||
const onStatusChange = (e) => {
|
||||
formData.value.status = statusOptions[e.detail.value]
|
||||
}
|
||||
|
||||
const submitCustomer = async () => {
|
||||
if (!formData.value.name) {
|
||||
uni.showToast({ title: '请填写姓名', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (showEditModal.value) {
|
||||
await customerApi.update(formData.value.id, formData.value)
|
||||
uni.showToast({ title: '更新成功', icon: 'success' })
|
||||
} else {
|
||||
await customerApi.create(formData.value)
|
||||
uni.showToast({ title: '创建成功', icon: 'success' })
|
||||
}
|
||||
closeModal()
|
||||
loadCustomers()
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err.message || '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const deleteCustomer = async (id) => {
|
||||
uni.showModal({
|
||||
title: '确认删除',
|
||||
content: '确定要删除该客户吗?',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await customerApi.delete(id)
|
||||
uni.showToast({ title: '删除成功', icon: 'success' })
|
||||
loadCustomers()
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err.message || '删除失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.customers-container {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
background: #fff;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 20rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 24rpx;
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
color: #1890ff;
|
||||
background: #e6f7ff;
|
||||
}
|
||||
|
||||
.tab-item.warning {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.customer-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.customer-item {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.customer-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.customer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.customer-name {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
margin-right: 12rpx;
|
||||
}
|
||||
|
||||
.customer-status {
|
||||
font-size: 22rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 6rpx;
|
||||
}
|
||||
|
||||
.customer-status.lead { background: #fff7e6; color: #fa8c16; }
|
||||
.customer-status.negotiating { background: #e6f7ff; color: #1890ff; }
|
||||
.customer-status.customer { background: #f6ffed; color: #52c41a; }
|
||||
.customer-status.lost { background: #fff1f0; color: #ff4d4f; }
|
||||
|
||||
.customer-detail {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
background: #f5f5f5;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 6rpx;
|
||||
}
|
||||
|
||||
.customer-contact {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.silence-days {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.customer-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.action-icon.delete {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 100rpx;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
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);
|
||||
}
|
||||
|
||||
.add-icon {
|
||||
font-size: 60rpx;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 80%;
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 30rpx;
|
||||
border-bottom: 2rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
font-size: 44rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 30rpx;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
display: block;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
height: 72rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8rpx;
|
||||
padding: 0 20rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.picker-value {
|
||||
height: 72rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8rpx;
|
||||
padding: 0 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
padding: 30rpx;
|
||||
border-top: 2rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.cancel-btn, .submit-btn {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.detail-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;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
width: 80%;
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.detail-status {
|
||||
font-size: 22rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 6rpx;
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 2rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
width: 160rpx;
|
||||
color: #999;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
flex: 1;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 100%;
|
||||
height: 80rpx;
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,299 @@
|
||||
<template>
|
||||
<view class="index-container">
|
||||
<view class="header">
|
||||
<view class="user-info">
|
||||
<text class="username">{{ userInfo?.username || '用户' }}</text>
|
||||
<text class="tier">{{ userInfo?.tier === 'pro' ? 'Pro' : '免费版' }}</text>
|
||||
</view>
|
||||
<text class="logout" @click="handleLogout">退出</text>
|
||||
</view>
|
||||
|
||||
<view class="stats-grid">
|
||||
<view class="stat-card">
|
||||
<text class="stat-value">{{ stats.customers }}</text>
|
||||
<text class="stat-label">客户总数</text>
|
||||
</view>
|
||||
<view class="stat-card warning">
|
||||
<text class="stat-value">{{ stats.silentCustomers }}</text>
|
||||
<text class="stat-label">沉默客户</text>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<text class="stat-value">{{ stats.todayTranslations }}</text>
|
||||
<text class="stat-label">今日翻译</text>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<text class="stat-value">{{ stats.quotations }}</text>
|
||||
<text class="stat-label">报价单</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="section-title">待跟进客户</view>
|
||||
<view class="silent-list" v-if="silentCustomers.length > 0">
|
||||
<view class="silent-item" v-for="item in silentCustomers" :key="item.id">
|
||||
<view class="silent-info">
|
||||
<text class="silent-name">{{ item.name }}</text>
|
||||
<text class="silent-country">{{ item.country }}</text>
|
||||
</view>
|
||||
<view class="silent-days">
|
||||
<text class="days">{{ item.silence_days }}天</text>
|
||||
<text class="label">未联系</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="empty" v-else>暂无待跟进客户</view>
|
||||
</view>
|
||||
|
||||
<view class="quick-actions">
|
||||
<view class="action-item" @click="goToPage('/pages/translate/translate')">
|
||||
<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>
|
||||
<text class="action-text">客户</text>
|
||||
</view>
|
||||
<view class="action-item" @click="goToPage('/pages/marketing/marketing')">
|
||||
<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>
|
||||
<text class="action-text">报价</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onShow } from 'vue'
|
||||
import { authApi, customerApi } from '@/utils/api.js'
|
||||
|
||||
const userInfo = ref(null)
|
||||
const stats = ref({
|
||||
customers: 0,
|
||||
silentCustomers: 0,
|
||||
todayTranslations: 0,
|
||||
quotations: 0,
|
||||
})
|
||||
const silentCustomers = ref([])
|
||||
|
||||
onShow(() => {
|
||||
const token = uni.getStorageSync('token')
|
||||
if (!token) {
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
loadData()
|
||||
})
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const userRes = await authApi.getUserInfo()
|
||||
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),
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载数据失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
const goToPage = (url) => {
|
||||
uni.switchTab({ url })
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
uni.showModal({
|
||||
title: '确认退出',
|
||||
content: '确定要退出登录吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
uni.clearStorageSync()
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.index-container {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 30rpx;
|
||||
background: #1890ff;
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 32rpx;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.tier {
|
||||
font-size: 22rpx;
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.logout {
|
||||
font-size: 26rpx;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20rpx;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card.warning .stat-value {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 48rpx;
|
||||
font-weight: bold;
|
||||
color: #1890ff;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
margin-top: 8rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.silent-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.silent-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20rpx;
|
||||
background: #f9f9f9;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.silent-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.silent-name {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.silent-country {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.silent-days {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.silent-days .days {
|
||||
font-size: 28rpx;
|
||||
color: #ff4d4f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.silent-days .label {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 40rpx;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.action-text {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,287 @@
|
||||
<template>
|
||||
<view class="login-container">
|
||||
<view class="logo-section">
|
||||
<text class="logo">TradeMate</text>
|
||||
<text class="subtitle">外贸小助手</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>
|
||||
|
||||
<view class="input-group" v-if="isRegister">
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder="用户名"
|
||||
v-model="username"
|
||||
@input="onUsernameInput"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="input-group">
|
||||
<input
|
||||
class="input"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
v-model="password"
|
||||
@input="onPasswordInput"
|
||||
/>
|
||||
</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">
|
||||
<view class="line"></view>
|
||||
<text class="text">或</text>
|
||||
<view class="line"></view>
|
||||
</view>
|
||||
|
||||
<button class="wechat-btn" @click="handleWechatLogin">
|
||||
<text class="wechat-icon">W</text>
|
||||
微信一键登录
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="footer">
|
||||
<text class="agreement">登录即表示同意</text>
|
||||
<text class="link">《用户协议》</text>
|
||||
<text class="agreement">和</text>
|
||||
<text class="link">《隐私政策》</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { authApi } from '@/utils/api.js'
|
||||
|
||||
const phone = ref('')
|
||||
const password = ref('')
|
||||
const username = ref('')
|
||||
const isRegister = ref(false)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const toggleMode = () => {
|
||||
isRegister.value = !isRegister.value
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!phone.value || !password.value) {
|
||||
error.value = '请输入手机号和密码'
|
||||
return
|
||||
}
|
||||
|
||||
if (isRegister.value && !username.value) {
|
||||
error.value = '请输入用户名'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
if (isRegister.value) {
|
||||
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)
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message || '操作失败,请重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleWechatLogin = () => {
|
||||
uni.getUserProfile({
|
||||
desc: '用于完善用户资料',
|
||||
success: (res) => {
|
||||
console.log('微信登录', res.userInfo)
|
||||
uni.showToast({ title: '微信登录开发中', icon: 'none' })
|
||||
},
|
||||
fail: (err) => {
|
||||
console.log('微信登录失败', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onPhoneInput = (e) => { phone.value = e.detail.value }
|
||||
const onPasswordInput = (e) => { password.value = e.detail.value }
|
||||
const onUsernameInput = (e) => { username.value = e.detail.value }
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #1890ff 0%, #e6f7ff 100%);
|
||||
padding: 120rpx 60rpx 60rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
text-align: center;
|
||||
margin-bottom: 80rpx;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 60rpx;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: #fff;
|
||||
border-radius: 24rpx;
|
||||
padding: 48rpx 40rpx;
|
||||
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 40rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin-bottom: 48rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 96rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 16rpx;
|
||||
padding: 0 24rpx;
|
||||
font-size: 28rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff4d4f;
|
||||
font-size: 24rpx;
|
||||
margin-bottom: 24rpx;
|
||||
text-align: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
height: 96rpx;
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
border-radius: 16rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.submit-btn[disabled] {
|
||||
background: #a0cfff;
|
||||
}
|
||||
|
||||
.toggle-mode {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-top: 32rpx;
|
||||
color: #666;
|
||||
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;
|
||||
background: #07c160;
|
||||
color: #fff;
|
||||
border-radius: 16rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wechat-icon {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 60rpx;
|
||||
}
|
||||
|
||||
.agreement {
|
||||
color: #999;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #1890ff;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,391 @@
|
||||
<template>
|
||||
<view class="marketing-container">
|
||||
<view class="tabs">
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === 'copy' }"
|
||||
@click="activeTab = 'copy'"
|
||||
>
|
||||
开发信
|
||||
</view>
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === 'whatsapp' }"
|
||||
@click="activeTab = 'whatsapp'"
|
||||
>
|
||||
WhatsApp话术
|
||||
</view>
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === 'product' }"
|
||||
@click="activeTab = 'product'"
|
||||
>
|
||||
产品描述
|
||||
</view>
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === 'keywords' }"
|
||||
@click="activeTab = 'keywords'"
|
||||
>
|
||||
关键词
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-section">
|
||||
<view class="form-item">
|
||||
<text class="form-label">产品名称</text>
|
||||
<input class="form-input" v-model="formData.product_name" placeholder="如: 户外折叠椅" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">产品描述</text>
|
||||
<textarea class="form-textarea" v-model="formData.description" placeholder="描述产品的特点、规格、优势..." />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">目标市场</text>
|
||||
<picker :range="targetMarkets" @change="onTargetChange">
|
||||
<view class="picker-value">{{ formData.target || '选择目标市场' }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">文案风格</text>
|
||||
<view class="style-options">
|
||||
<view
|
||||
class="style-option"
|
||||
:class="{ active: formData.style === 'professional' }"
|
||||
@click="formData.style = 'professional'"
|
||||
>
|
||||
专业正式
|
||||
</view>
|
||||
<view
|
||||
class="style-option"
|
||||
:class="{ active: formData.style === 'friendly' }"
|
||||
@click="formData.style = 'friendly'"
|
||||
>
|
||||
亲切友好
|
||||
</view>
|
||||
<view
|
||||
class="style-option"
|
||||
:class="{ active: formData.style === 'persuasive' }"
|
||||
@click="formData.style = 'persuasive'"
|
||||
>
|
||||
促销风格
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<button class="generate-btn" @click="generateContent" :disabled="loading">
|
||||
{{ loading ? '生成中...' : '生成文案' }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="results-section" v-if="results.length > 0">
|
||||
<view class="results-header">
|
||||
<text class="results-title">生成的文案</text>
|
||||
<text class="refresh-btn" @click="generateContent">换一批</text>
|
||||
</view>
|
||||
<view class="results-list">
|
||||
<view class="result-item" v-for="(item, index) in results" :key="index">
|
||||
<text class="result-text">{{ item }}</text>
|
||||
<view class="result-actions">
|
||||
<text class="copy-btn" @click="copyText(item)">复制</text>
|
||||
<text class="send-btn" @click="sendToWhatsapp(item)">发送</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="history-section" v-if="activeTab === 'keywords' && keywords.length > 0">
|
||||
<view class="history-header">
|
||||
<text class="history-title">关键词建议</text>
|
||||
</view>
|
||||
<view class="keywords-list">
|
||||
<view class="keyword-tag" v-for="(kw, idx) in keywords" :key="idx" @click="copyText(kw)">
|
||||
{{ kw }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="empty" v-if="!loading && results.length === 0 && activeTab !== 'keywords'">
|
||||
<text>输入产品信息,点击生成文案</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { marketingApi } from '@/utils/api.js'
|
||||
|
||||
const activeTab = ref('copy')
|
||||
const loading = ref(false)
|
||||
const results = ref([])
|
||||
const keywords = ref([])
|
||||
|
||||
const formData = ref({
|
||||
product_name: '',
|
||||
description: '',
|
||||
target: 'US importers',
|
||||
style: 'professional',
|
||||
})
|
||||
|
||||
const targetMarkets = ref([
|
||||
'US importers',
|
||||
'European buyers',
|
||||
'Southeast Asia',
|
||||
'Latin America',
|
||||
'Middle East',
|
||||
])
|
||||
|
||||
const onTargetChange = (e) => {
|
||||
formData.value.target = targetMarkets.value[e.detail.value]
|
||||
}
|
||||
|
||||
const generateContent = async () => {
|
||||
if (!formData.value.product_name) {
|
||||
uni.showToast({ title: '请输入产品名称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
if (activeTab.value === 'keywords') {
|
||||
const res = await marketingApi.getKeywords(
|
||||
formData.value.product_name,
|
||||
formData.value.description,
|
||||
''
|
||||
)
|
||||
keywords.value = res.keywords || []
|
||||
} else {
|
||||
const res = await marketingApi.generate(
|
||||
formData.value.product_name,
|
||||
formData.value.description,
|
||||
'',
|
||||
formData.value.target,
|
||||
formData.value.style
|
||||
)
|
||||
results.value = res.results || []
|
||||
}
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err.message || '生成失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const copyText = (text) => {
|
||||
uni.setClipboardData({
|
||||
data: text,
|
||||
success: () => {
|
||||
uni.showToast({ title: '已复制', icon: 'success' })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const sendToWhatsapp = (text) => {
|
||||
uni.showToast({ title: '请先选择客户', icon: 'none' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.marketing-container {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
background: #fff;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 20rpx;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
min-width: 160rpx;
|
||||
text-align: center;
|
||||
padding: 24rpx 16rpx;
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
color: #1890ff;
|
||||
border-bottom: 4rpx solid #1890ff;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
display: block;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
height: 72rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8rpx;
|
||||
padding: 0 20rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
min-height: 160rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8rpx;
|
||||
padding: 20rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.picker-value {
|
||||
height: 72rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8rpx;
|
||||
padding: 0 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.style-options {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.style-option {
|
||||
flex: 1;
|
||||
padding: 16rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8rpx;
|
||||
text-align: center;
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.style-option.active {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
border: 2rpx solid #1890ff;
|
||||
}
|
||||
|
||||
.generate-btn {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
border-radius: 12rpx;
|
||||
font-size: 30rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.results-section {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.results-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.results-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
font-size: 24rpx;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.results-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
padding: 20rpx;
|
||||
background: #f9f9f9;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.result-text {
|
||||
font-size: 26rpx;
|
||||
line-height: 1.6;
|
||||
display: block;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.result-actions {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.copy-btn, .send-btn {
|
||||
font-size: 24rpx;
|
||||
padding: 8rpx 20rpx;
|
||||
border-radius: 6rpx;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
background: #07c160;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.history-section {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.history-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.keywords-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.keyword-tag {
|
||||
padding: 12rpx 24rpx;
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
border-radius: 20rpx;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 100rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,602 @@
|
||||
<template>
|
||||
<view class="product-container">
|
||||
<view class="product-list" v-if="products.length > 0">
|
||||
<view class="product-item" v-for="item in products" :key="item.id" @click="showDetail(item)">
|
||||
<view class="product-info">
|
||||
<view class="product-header">
|
||||
<text class="product-name">{{ item.name }}</text>
|
||||
<text class="product-category" v-if="item.category">{{ item.category }}</text>
|
||||
</view>
|
||||
<view class="product-desc" v-if="item.description">{{ item.description }}</view>
|
||||
<view class="product-meta">
|
||||
<text class="meta-item" v-if="item.price">{{ item.price }} {{ item.price_unit }}</text>
|
||||
<text class="meta-item" v-if="item.moq">MOQ: {{ item.moq }}</text>
|
||||
</view>
|
||||
<view class="product-keywords" v-if="item.keywords && item.keywords.length > 0">
|
||||
<text class="keyword-tag" v-for="(kw, idx) in item.keywords.slice(0, 3)" :key="idx">{{ kw }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="product-actions">
|
||||
<text class="action-icon" @click.stop="editProduct(item)">编</text>
|
||||
<text class="action-icon delete" @click.stop="deleteProduct(item.id)">删</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="empty" v-else>
|
||||
<text>暂无产品,点击下方添加产品</text>
|
||||
</view>
|
||||
|
||||
<view class="add-btn" @click="showAddModal = true">
|
||||
<text class="add-icon">+</text>
|
||||
</view>
|
||||
|
||||
<view class="modal" v-if="showAddModal || showEditModal" @click="closeModal">
|
||||
<view class="modal-content" @click.stop>
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">{{ showEditModal ? '编辑产品' : '新增产品' }}</text>
|
||||
<text class="modal-close" @click="closeModal">×</text>
|
||||
</view>
|
||||
<scroll-view class="modal-body" scroll-y>
|
||||
<view class="form-item">
|
||||
<text class="form-label">产品名称 *</text>
|
||||
<input class="form-input" v-model="formData.name" placeholder="产品名称(中文)" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">英文名称</text>
|
||||
<input class="form-input" v-model="formData.name_en" placeholder="Product Name (English)" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">产品分类</text>
|
||||
<input class="form-input" v-model="formData.category" placeholder="如: 家具、电子产品" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">产品描述</text>
|
||||
<textarea class="form-textarea" v-model="formData.description" placeholder="产品描述、特点、规格..." />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">价格</text>
|
||||
<view class="price-input">
|
||||
<input class="form-input" v-model="formData.price" placeholder="价格" style="flex: 1" />
|
||||
<picker :range="['USD', 'EUR', 'GBP', 'CNY']" @change="onUnitChange">
|
||||
<view class="unit-picker">{{ formData.price_unit || 'USD' }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">最小起订量 (MOQ)</text>
|
||||
<input class="form-input" v-model="formData.moq" placeholder="如: 100 pcs" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">关键词 (逗号分隔)</text>
|
||||
<input class="form-input" v-model="keywordsInput" placeholder="关键词1, 关键词2, 关键词3" />
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="modal-footer">
|
||||
<button class="cancel-btn" @click="closeModal">取消</button>
|
||||
<button class="submit-btn" @click="submitProduct">保存</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="detail-modal" v-if="showDetailModal" @click="showDetailModal = false">
|
||||
<view class="detail-content" @click.stop>
|
||||
<view class="detail-header">
|
||||
<text class="detail-title">{{ currentProduct.name }}</text>
|
||||
<text class="detail-category" v-if="currentProduct.category">{{ currentProduct.category }}</text>
|
||||
</view>
|
||||
<scroll-view class="detail-body" scroll-y>
|
||||
<view class="detail-row" v-if="currentProduct.name_en">
|
||||
<text class="detail-label">英文名:</text>
|
||||
<text class="detail-value">{{ currentProduct.name_en }}</text>
|
||||
</view>
|
||||
<view class="detail-row" v-if="currentProduct.description">
|
||||
<text class="detail-label">描述:</text>
|
||||
<text class="detail-value">{{ currentProduct.description }}</text>
|
||||
</view>
|
||||
<view class="detail-row" v-if="currentProduct.price">
|
||||
<text class="detail-label">价格:</text>
|
||||
<text class="detail-value">{{ currentProduct.price }} {{ currentProduct.price_unit }}</text>
|
||||
</view>
|
||||
<view class="detail-row" v-if="currentProduct.moq">
|
||||
<text class="detail-label">MOQ:</text>
|
||||
<text class="detail-value">{{ currentProduct.moq }}</text>
|
||||
</view>
|
||||
<view class="detail-keywords" v-if="currentProduct.keywords && currentProduct.keywords.length > 0">
|
||||
<text class="detail-label">关键词:</text>
|
||||
<view class="keywords-wrap">
|
||||
<text class="keyword-tag" v-for="(kw, idx) in currentProduct.keywords" :key="idx">{{ kw }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="detail-footer">
|
||||
<button class="use-btn" @click="useProduct(currentProduct)">使用此产品</button>
|
||||
<button class="close-btn" @click="showDetailModal = false">关闭</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onShow } from 'vue'
|
||||
import { productApi } from '@/utils/api.js'
|
||||
|
||||
const products = ref([])
|
||||
const showAddModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showDetailModal = ref(false)
|
||||
const currentProduct = ref(null)
|
||||
const keywordsInput = ref('')
|
||||
|
||||
const formData = ref({
|
||||
name: '',
|
||||
name_en: '',
|
||||
category: '',
|
||||
description: '',
|
||||
price: '',
|
||||
price_unit: 'USD',
|
||||
moq: '',
|
||||
keywords: [],
|
||||
})
|
||||
|
||||
const units = ['USD', 'EUR', 'GBP', 'CNY']
|
||||
|
||||
onShow(() => {
|
||||
loadProducts()
|
||||
})
|
||||
|
||||
const loadProducts = async () => {
|
||||
try {
|
||||
const res = await productApi.list()
|
||||
products.value = res.items || []
|
||||
} catch (err) {
|
||||
console.error('加载产品失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
const onUnitChange = (e) => {
|
||||
formData.value.price_unit = units[e.detail.value]
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
showAddModal.value = false
|
||||
showEditModal.value = false
|
||||
formData.value = {
|
||||
name: '',
|
||||
name_en: '',
|
||||
category: '',
|
||||
description: '',
|
||||
price: '',
|
||||
price_unit: 'USD',
|
||||
moq: '',
|
||||
keywords: [],
|
||||
}
|
||||
keywordsInput.value = ''
|
||||
}
|
||||
|
||||
const editProduct = (item) => {
|
||||
formData.value = { ...item }
|
||||
keywordsInput.value = item.keywords ? item.keywords.join(', ') : ''
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
const submitProduct = async () => {
|
||||
if (!formData.value.name) {
|
||||
uni.showToast({ title: '请填写产品名称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const keywords = keywordsInput.value ? keywordsInput.value.split(',').map(k => k.trim()).filter(k => k) : []
|
||||
|
||||
try {
|
||||
if (showEditModal.value) {
|
||||
await productApi.update(formData.value.id, { ...formData.value, keywords })
|
||||
uni.showToast({ title: '更新成功', icon: 'success' })
|
||||
} else {
|
||||
await productApi.create({ ...formData.value, keywords })
|
||||
uni.showToast({ title: '创建成功', icon: 'success' })
|
||||
}
|
||||
closeModal()
|
||||
loadProducts()
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err.message || '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const deleteProduct = async (id) => {
|
||||
uni.showModal({
|
||||
title: '确认删除',
|
||||
content: '确定要删除该产品吗?',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await productApi.delete(id)
|
||||
uni.showToast({ title: '删除成功', icon: 'success' })
|
||||
loadProducts()
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err.message || '删除失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const showDetail = (item) => {
|
||||
currentProduct.value = item
|
||||
showDetailModal.value = true
|
||||
}
|
||||
|
||||
const useProduct = (item) => {
|
||||
uni.showToast({ title: '已选择产品', icon: 'success' })
|
||||
showDetailModal.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
onShow() {
|
||||
const token = uni.getStorageSync('token')
|
||||
if (!token) {
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.product-container {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
.product-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.product-item {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.product-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
margin-right: 12rpx;
|
||||
}
|
||||
|
||||
.product-category {
|
||||
font-size: 22rpx;
|
||||
color: #1890ff;
|
||||
background: #e6f7ff;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 6rpx;
|
||||
}
|
||||
|
||||
.product-desc {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
margin-bottom: 12rpx;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-meta {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
font-size: 24rpx;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.product-keywords {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.keyword-tag {
|
||||
font-size: 22rpx;
|
||||
color: #666;
|
||||
background: #f5f5f5;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 6rpx;
|
||||
}
|
||||
|
||||
.product-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.action-icon.delete {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 100rpx;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
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);
|
||||
}
|
||||
|
||||
.add-icon {
|
||||
font-size: 60rpx;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 90%;
|
||||
max-height: 80%;
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 30rpx;
|
||||
border-bottom: 2rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
font-size: 44rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
padding: 30rpx;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
display: block;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
height: 72rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8rpx;
|
||||
padding: 0 20rpx;
|
||||
font-size: 28rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
min-height: 120rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8rpx;
|
||||
padding: 20rpx;
|
||||
font-size: 28rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.price-input {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.unit-picker {
|
||||
width: 120rpx;
|
||||
height: 72rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
padding: 30rpx;
|
||||
border-top: 2rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.cancel-btn, .submit-btn {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.detail-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;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
width: 90%;
|
||||
max-height: 70%;
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 30rpx;
|
||||
border-bottom: 2rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.detail-category {
|
||||
font-size: 22rpx;
|
||||
color: #1890ff;
|
||||
background: #e6f7ff;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 6rpx;
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
flex: 1;
|
||||
padding: 30rpx;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 2rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
width: 160rpx;
|
||||
color: #999;
|
||||
font-size: 26rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
flex: 1;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.detail-keywords {
|
||||
padding: 16rpx 0;
|
||||
}
|
||||
|
||||
.keywords-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8rpx;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.detail-footer {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
padding: 30rpx;
|
||||
border-top: 2rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.use-btn {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
background: #07c160;
|
||||
color: #fff;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,635 @@
|
||||
<template>
|
||||
<view class="quotation-container">
|
||||
<view class="filter-tabs">
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="{ active: filter === 'all' }"
|
||||
@click="filter = 'all'; loadQuotations()"
|
||||
>
|
||||
全部
|
||||
</view>
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="{ active: filter === 'draft' }"
|
||||
@click="filter = 'draft'; loadQuotations()"
|
||||
>
|
||||
草稿
|
||||
</view>
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="{ active: filter === 'sent' }"
|
||||
@click="filter = 'sent'; loadQuotations()"
|
||||
>
|
||||
已发送
|
||||
</view>
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="{ active: filter === 'accepted' }"
|
||||
@click="filter = 'accepted'; loadQuotations()"
|
||||
>
|
||||
已成交
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="quotation-list" v-if="quotations.length > 0">
|
||||
<view class="quotation-item" v-for="item in quotations" :key="item.id" @click="showDetail(item)">
|
||||
<view class="quotation-header">
|
||||
<text class="quotation-title">{{ item.title || '报价单' }}</text>
|
||||
<text class="quotation-status" :class="item.status">{{ getStatusText(item.status) }}</text>
|
||||
</view>
|
||||
<view class="quotation-info">
|
||||
<text class="info-item">{{ item.currency }} {{ item.total || 0 }}</text>
|
||||
<text class="info-item">{{ formatTime(item.created_at) }}</text>
|
||||
</view>
|
||||
<view class="quotation-actions">
|
||||
<text class="action-btn" @click.stop="copyQuotation(item)">复制</text>
|
||||
<text class="action-btn primary" @click.stop="sendQuotation(item)" v-if="item.status === 'draft'">发送</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="empty" v-else>
|
||||
<text>暂无报价单</text>
|
||||
</view>
|
||||
|
||||
<view class="add-btn" @click="showCreateModal = true">
|
||||
<text class="add-icon">+</text>
|
||||
</view>
|
||||
|
||||
<view class="modal" v-if="showCreateModal" @click="closeModal">
|
||||
<view class="modal-content" @click.stop>
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">创建报价单</text>
|
||||
<text class="modal-close" @click="closeModal">×</text>
|
||||
</view>
|
||||
<scroll-view class="modal-body" scroll-y>
|
||||
<view class="form-item">
|
||||
<text class="form-label">报价标题 *</text>
|
||||
<input class="form-input" v-model="formData.title" placeholder="报价单标题" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">客户</text>
|
||||
<picker :range="customerOptions" range-key="name" @change="onCustomerChange">
|
||||
<view class="picker-value">{{ formData.customer_id ? getCustomerName(formData.customer_id) : '选择客户' }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">货币</text>
|
||||
<picker :range="['USD', 'EUR', 'GBP', 'CNY']" @change="onCurrencyChange">
|
||||
<view class="picker-value">{{ formData.currency }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">付款条款</text>
|
||||
<input class="form-input" v-model="formData.payment_terms" placeholder="T/T 30% deposit" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">交货条款</text>
|
||||
<input class="form-input" v-model="formData.delivery_terms" placeholder="FOB Shanghai" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">交货周期</text>
|
||||
<input class="form-input" v-model="formData.lead_time" placeholder="25 days" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">有效期</text>
|
||||
<input class="form-input" v-model="formData.valid_until" placeholder="2026-06-08" />
|
||||
</view>
|
||||
|
||||
<view class="items-section">
|
||||
<view class="items-header">
|
||||
<text class="items-title">产品明细</text>
|
||||
<text class="add-item-btn" @click="addItem">+ 添加产品</text>
|
||||
</view>
|
||||
<view class="item-row" v-for="(item, idx) in formData.items" :key="idx">
|
||||
<input class="item-input name" v-model="item.product_name" placeholder="产品名称" />
|
||||
<input class="item-input qty" v-model.number="item.quantity" type="number" placeholder="数量" />
|
||||
<input class="item-input price" v-model.number="item.unit_price" type="digit" placeholder="单价" />
|
||||
<text class="item-total">{{ (item.quantity || 0) * (item.unit_price || 0) }}</text>
|
||||
<text class="item-delete" @click="removeItem(idx)">×</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="form-label">折扣</text>
|
||||
<input class="form-input" v-model.number="formData.discount" type="digit" placeholder="0" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">运费</text>
|
||||
<input class="form-input" v-model.number="formData.shipping" type="digit" placeholder="0" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">备注</text>
|
||||
<textarea class="form-textarea" v-model="formData.notes" placeholder="备注信息" />
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="modal-footer">
|
||||
<button class="cancel-btn" @click="closeModal">取消</button>
|
||||
<button class="submit-btn" @click="createQuotation">创建报价单</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="detail-modal" v-if="showDetailModal" @click="showDetailModal = false">
|
||||
<view class="detail-content" @click.stop>
|
||||
<view class="detail-header">
|
||||
<text class="detail-title">{{ currentQuotation.title }}</text>
|
||||
<text class="detail-status" :class="currentQuotation.status">{{ getStatusText(currentQuotation.status) }}</text>
|
||||
</view>
|
||||
<scroll-view class="detail-body" scroll-y>
|
||||
<view class="detail-text">{{ currentQuotation.text }}</view>
|
||||
</scroll-view>
|
||||
<view class="detail-footer">
|
||||
<button class="close-btn" @click="showDetailModal = false">关闭</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onShow } from 'vue'
|
||||
import { quotationApi, customerApi } from '@/utils/api.js'
|
||||
|
||||
const filter = ref('all')
|
||||
const quotations = ref([])
|
||||
const customers = ref([])
|
||||
const showCreateModal = ref(false)
|
||||
const showDetailModal = ref(false)
|
||||
const currentQuotation = ref(null)
|
||||
|
||||
const formData = ref({
|
||||
title: '',
|
||||
customer_id: '',
|
||||
currency: 'USD',
|
||||
payment_terms: 'T/T 30% deposit',
|
||||
delivery_terms: 'FOB Shanghai',
|
||||
lead_time: '',
|
||||
valid_until: '',
|
||||
items: [{ product_name: '', quantity: 1, unit_price: 0 }],
|
||||
discount: 0,
|
||||
shipping: 0,
|
||||
notes: '',
|
||||
})
|
||||
|
||||
const customerOptions = ref([])
|
||||
|
||||
onShow(() => {
|
||||
loadQuotations()
|
||||
loadCustomers()
|
||||
})
|
||||
|
||||
const loadQuotations = async () => {
|
||||
try {
|
||||
const res = await quotationApi.list()
|
||||
quotations.value = res.items || []
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err.message || '加载失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const loadCustomers = async () => {
|
||||
try {
|
||||
const res = await customerApi.list(1, 100)
|
||||
customers.value = res.items || []
|
||||
customerOptions.value = customers.value
|
||||
} catch (err) {
|
||||
console.error('加载客户失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const map = { draft: '草稿', sent: '已发送', accepted: '已成交', rejected: '已拒绝', expired: '已过期' }
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
const formatTime = (time) => {
|
||||
if (!time) return ''
|
||||
return time.split('T')[0]
|
||||
}
|
||||
|
||||
const getCustomerName = (id) => {
|
||||
const c = customers.value.find(item => item.id === id)
|
||||
return c?.name || ''
|
||||
}
|
||||
|
||||
const onCustomerChange = (e) => {
|
||||
const c = customerOptions.value[e.detail.value]
|
||||
formData.value.customer_id = c?.id || ''
|
||||
}
|
||||
|
||||
const onCurrencyChange = (e) => {
|
||||
const currencies = ['USD', 'EUR', 'GBP', 'CNY']
|
||||
formData.value.currency = currencies[e.detail.value]
|
||||
}
|
||||
|
||||
const addItem = () => {
|
||||
formData.value.items.push({ product_name: '', quantity: 1, unit_price: 0 })
|
||||
}
|
||||
|
||||
const removeItem = (idx) => {
|
||||
formData.value.items.splice(idx, 1)
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
showCreateModal.value = false
|
||||
formData.value = {
|
||||
title: '',
|
||||
customer_id: '',
|
||||
currency: 'USD',
|
||||
payment_terms: 'T/T 30% deposit',
|
||||
delivery_terms: 'FOB Shanghai',
|
||||
lead_time: '',
|
||||
valid_until: '',
|
||||
items: [{ product_name: '', quantity: 1, unit_price: 0 }],
|
||||
discount: 0,
|
||||
shipping: 0,
|
||||
notes: '',
|
||||
}
|
||||
}
|
||||
|
||||
const createQuotation = async () => {
|
||||
if (!formData.value.title) {
|
||||
uni.showToast({ title: '请填写标题', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await quotationApi.create(formData.value)
|
||||
uni.showToast({ title: '创建成功', icon: 'success' })
|
||||
closeModal()
|
||||
loadQuotations()
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err.message || '创建失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const showDetail = (item) => {
|
||||
currentQuotation.value = item
|
||||
showDetailModal.value = true
|
||||
}
|
||||
|
||||
const copyQuotation = (item) => {
|
||||
uni.setClipboardData({
|
||||
data: item.text,
|
||||
success: () => {
|
||||
uni.showToast({ title: '已复制报价单', icon: 'success' })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const sendQuotation = async (item) => {
|
||||
try {
|
||||
await quotationApi.updateStatus(item.id, 'sent')
|
||||
uni.showToast({ title: '已标记为已发送', icon: 'success' })
|
||||
loadQuotations()
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err.message || '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.quotation-container {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
background: #fff;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 24rpx;
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
color: #1890ff;
|
||||
background: #e6f7ff;
|
||||
}
|
||||
|
||||
.quotation-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.quotation-item {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.quotation-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.quotation-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quotation-status {
|
||||
font-size: 22rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 6rpx;
|
||||
}
|
||||
|
||||
.quotation-status.draft { background: #f5f5f5; color: #666; }
|
||||
.quotation-status.sent { background: #e6f7ff; color: #1890ff; }
|
||||
.quotation-status.accepted { background: #f6ffed; color: #52c41a; }
|
||||
|
||||
.quotation-info {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.quotation-actions {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
font-size: 24rpx;
|
||||
padding: 8rpx 20rpx;
|
||||
border-radius: 6rpx;
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 100rpx;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
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);
|
||||
}
|
||||
|
||||
.add-icon {
|
||||
font-size: 60rpx;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 90%;
|
||||
height: 80%;
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 30rpx;
|
||||
border-bottom: 2rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
font-size: 44rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
padding: 30rpx;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
display: block;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
height: 72rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8rpx;
|
||||
padding: 0 20rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
min-height: 120rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8rpx;
|
||||
padding: 20rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.picker-value {
|
||||
height: 72rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8rpx;
|
||||
padding: 0 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.items-section {
|
||||
margin: 20rpx 0;
|
||||
}
|
||||
|
||||
.items-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.items-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.add-item-btn {
|
||||
font-size: 26rpx;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.item-input {
|
||||
height: 60rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8rpx;
|
||||
padding: 0 12rpx;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.item-input.name { flex: 2; }
|
||||
.item-input.qty { width: 100rpx; }
|
||||
.item-input.price { width: 120rpx; }
|
||||
|
||||
.item-total {
|
||||
font-size: 24rpx;
|
||||
color: #1890ff;
|
||||
min-width: 80rpx;
|
||||
}
|
||||
|
||||
.item-delete {
|
||||
font-size: 36rpx;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
padding: 30rpx;
|
||||
border-top: 2rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.cancel-btn, .submit-btn {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.detail-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;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
width: 90%;
|
||||
height: 70%;
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 30rpx;
|
||||
border-bottom: 2rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-status {
|
||||
font-size: 22rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 6rpx;
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
flex: 1;
|
||||
padding: 30rpx;
|
||||
}
|
||||
|
||||
.detail-text {
|
||||
font-size: 24rpx;
|
||||
line-height: 1.8;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.detail-footer {
|
||||
padding: 30rpx;
|
||||
border-top: 2rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 100%;
|
||||
height: 80rpx;
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,319 @@
|
||||
<template>
|
||||
<view class="translate-container">
|
||||
<view class="mode-switch">
|
||||
<view
|
||||
class="mode-item"
|
||||
:class="{ active: mode === 'translate' }"
|
||||
@click="mode = 'translate'"
|
||||
>
|
||||
文本翻译
|
||||
</view>
|
||||
<view
|
||||
class="mode-item"
|
||||
:class="{ active: mode === 'reply' }"
|
||||
@click="mode = 'reply'"
|
||||
>
|
||||
回复建议
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="input-section">
|
||||
<view class="input-header">
|
||||
<text class="input-label">{{ mode === 'translate' ? '输入原文' : '客户询盘' }}</text>
|
||||
<picker :range="targetLangs" range-key="name" @change="onTargetChange">
|
||||
<text class="target-lang">{{ targetLangs[targetIndex].name }}</text>
|
||||
</picker>
|
||||
</view>
|
||||
<textarea
|
||||
class="input-area"
|
||||
v-model="inputText"
|
||||
:placeholder="mode === 'translate' ? '输入需要翻译的文本...' : '输入客户的询盘内容...'"
|
||||
@input="onInput"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="action-section">
|
||||
<button class="translate-btn" @click="handleTranslate" :disabled="loading">
|
||||
{{ loading ? '翻译中...' : '翻译' }}
|
||||
</button>
|
||||
<button class="clear-btn" @click="clearAll">清空</button>
|
||||
</view>
|
||||
|
||||
<view class="result-section" v-if="result">
|
||||
<view class="result-header">
|
||||
<text class="result-label">翻译结果</text>
|
||||
<text class="copy-btn" @click="copyResult">复制</text>
|
||||
</view>
|
||||
<view class="result-content">
|
||||
<text class="result-text">{{ result }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="suggestions-section" v-if="suggestions.length > 0">
|
||||
<view class="suggestions-header">
|
||||
<text class="suggestions-label">回复建议</text>
|
||||
</view>
|
||||
<view class="suggestions-list">
|
||||
<view
|
||||
class="suggestion-item"
|
||||
v-for="(item, index) in suggestions"
|
||||
:key="index"
|
||||
@click="selectSuggestion(index)"
|
||||
>
|
||||
<text class="suggestion-text">{{ item.text }}</text>
|
||||
<text class="suggestion-tone">{{ item.tone }}</text>
|
||||
</view>
|
||||
</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'
|
||||
|
||||
const mode = ref('translate')
|
||||
const inputText = ref('')
|
||||
const result = ref('')
|
||||
const suggestions = ref([])
|
||||
const loading = ref(false)
|
||||
const targetIndex = ref(1)
|
||||
const keyboardHeight = ref(0)
|
||||
|
||||
const targetLangs = ref([
|
||||
{ code: 'en', name: 'English' },
|
||||
{ code: 'zh', name: '中文' },
|
||||
{ code: 'es', name: 'Español' },
|
||||
])
|
||||
|
||||
const onTargetChange = (e) => {
|
||||
targetIndex.value = e.detail.value
|
||||
}
|
||||
|
||||
const onInput = () => {
|
||||
// Real-time input handling
|
||||
}
|
||||
|
||||
const handleTranslate = async () => {
|
||||
if (!inputText.value.trim()) {
|
||||
uni.showToast({ title: '请输入内容', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
if (mode.value === 'translate') {
|
||||
const res = await translateApi.translate(
|
||||
inputText.value,
|
||||
targetLangs[targetIndex.value].code
|
||||
)
|
||||
result.value = res.translated
|
||||
} else {
|
||||
const res = await translateApi.getReply(inputText.value, 'professional', 3)
|
||||
suggestions.value = res.suggestions || []
|
||||
result.value = ''
|
||||
}
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err.message || '翻译失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
inputText.value = ''
|
||||
result.value = ''
|
||||
suggestions.value = []
|
||||
}
|
||||
|
||||
const copyResult = () => {
|
||||
uni.setClipboardData({
|
||||
data: result.value,
|
||||
success: () => {
|
||||
uni.showToast({ title: '已复制', icon: 'success' })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const selectSuggestion = (index) => {
|
||||
const selected = suggestions.value[index]
|
||||
uni.setClipboardData({
|
||||
data: selected.text,
|
||||
success: () => {
|
||||
uni.showToast({ title: '已复制建议内容', icon: 'success' })
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.translate-container {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
.mode-switch {
|
||||
display: flex;
|
||||
background: #fff;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.mode-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 24rpx;
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.mode-item.active {
|
||||
color: #1890ff;
|
||||
border-bottom: 4rpx solid #1890ff;
|
||||
}
|
||||
|
||||
.input-section {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.input-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
font-size: 26rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.target-lang {
|
||||
font-size: 26rpx;
|
||||
color: #1890ff;
|
||||
padding: 8rpx 16rpx;
|
||||
background: #e6f7ff;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.input-area {
|
||||
width: 100%;
|
||||
min-height: 200rpx;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.action-section {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.translate-btn {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
border-radius: 12rpx;
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
width: 160rpx;
|
||||
height: 88rpx;
|
||||
background: #fff;
|
||||
border: 2rpx solid #d9d9d9;
|
||||
border-radius: 12rpx;
|
||||
font-size: 30rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.result-section {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.result-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.result-label {
|
||||
font-size: 26rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
font-size: 24rpx;
|
||||
color: #1890ff;
|
||||
padding: 8rpx 16rpx;
|
||||
}
|
||||
|
||||
.result-content {
|
||||
padding: 20rpx;
|
||||
background: #f9f9f9;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.result-text {
|
||||
font-size: 28rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.suggestions-section {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.suggestions-header {
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.suggestions-label {
|
||||
font-size: 26rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.suggestions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.suggestion-item {
|
||||
padding: 20rpx;
|
||||
background: #f9f9f9;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.suggestion-text {
|
||||
font-size: 26rpx;
|
||||
line-height: 1.5;
|
||||
display: block;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.suggestion-tone {
|
||||
font-size: 22rpx;
|
||||
color: #1890ff;
|
||||
background: #e6f7ff;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 6rpx;
|
||||
}
|
||||
|
||||
.keyboard-height {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
page {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
border-radius: 8rpx;
|
||||
padding: 20rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
const BASE_URL = 'http://localhost:8000/api/v1'
|
||||
|
||||
const getAuthHeader = () => {
|
||||
const token = uni.getStorageSync('token')
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
}
|
||||
|
||||
const request = (url, method = 'GET', data = {}) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
url: `${BASE_URL}${url}`,
|
||||
method,
|
||||
data,
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeader(),
|
||||
},
|
||||
success: (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
resolve(res.data)
|
||||
} else if (res.statusCode === 401) {
|
||||
uni.removeStorageSync('token')
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
reject(new Error('Unauthorized'))
|
||||
} 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'),
|
||||
}
|
||||
|
||||
export const translateApi = {
|
||||
translate: (text, targetLang, sourceLang = 'auto') =>
|
||||
request('/translate', 'POST', { text, target_lang: targetLang, source_lang: sourceLang }),
|
||||
getReply: (inquiry, tone = 'professional', count = 3) =>
|
||||
request('/translate/reply', 'POST', { inquiry, tone, count }),
|
||||
}
|
||||
|
||||
export const customerApi = {
|
||||
list: (page = 1, size = 20, status) => {
|
||||
let params = `page=${page}&size=${size}`
|
||||
if (status) params += `&status=${status}`
|
||||
return request(`/customers?${params}`)
|
||||
},
|
||||
get: (id) => request(`/customers/${id}`),
|
||||
create: (data) => request('/customers', 'POST', data),
|
||||
update: (id, data) => request(`/customers/${id}`, 'PATCH', data),
|
||||
delete: (id) => request(`/customers/${id}`, 'DELETE'),
|
||||
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'),
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
let pushClientId = ''
|
||||
let isInitialized = ref(false)
|
||||
|
||||
export const pushService = {
|
||||
/**
|
||||
* 初始化推送服务
|
||||
*/
|
||||
init() {
|
||||
return new Promise((resolve, reject) => {
|
||||
// #ifdef APP-PLUS
|
||||
const plus = window.plus
|
||||
plus.push.init({}, {
|
||||
cover: true,
|
||||
sound: 'system'
|
||||
}, () => {
|
||||
console.log('Push init success')
|
||||
this.getClientId()
|
||||
resolve(true)
|
||||
}, (err) => {
|
||||
console.error('Push init failed:', err)
|
||||
reject(err)
|
||||
})
|
||||
// #endif
|
||||
|
||||
// #ifndef APP-PLUS
|
||||
console.log('非App环境下跳过推送初始化')
|
||||
resolve(false)
|
||||
// #endif
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取客户端推送ID
|
||||
*/
|
||||
getClientId() {
|
||||
// #ifdef APP-PLUS
|
||||
const push = window.plus.push
|
||||
push.getClientInfo((info) => {
|
||||
pushClientId = info.clientid
|
||||
console.log('Push ClientID:', pushClientId)
|
||||
this.registerDevice(pushClientId)
|
||||
}, (err) => {
|
||||
console.error('Get client ID failed:', err)
|
||||
})
|
||||
// #endif
|
||||
},
|
||||
|
||||
/**
|
||||
* 注册设备到服务器
|
||||
*/
|
||||
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(),
|
||||
})
|
||||
console.log('Device registered successfully')
|
||||
} catch (err) {
|
||||
console.error('Register device failed:', err)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 监听接收推送消息
|
||||
*/
|
||||
onMessage(callback) {
|
||||
// #ifdef APP-PLUS
|
||||
const push = window.plus.push
|
||||
push.addEventListener('receive', (msg) => {
|
||||
console.log('Push received:', msg)
|
||||
if (msg.payload) {
|
||||
let payload
|
||||
try {
|
||||
payload = JSON.parse(msg.payload)
|
||||
} catch (e) {
|
||||
payload = { content: msg.payload }
|
||||
}
|
||||
callback({
|
||||
title: msg.title || '外贸小助手',
|
||||
content: msg.content,
|
||||
payload,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
},
|
||||
|
||||
/**
|
||||
* 监听点击推送消息
|
||||
*/
|
||||
onClick(callback) {
|
||||
// #ifdef APP-PLUS
|
||||
const push = window.plus.push
|
||||
push.addEventListener('click', (msg) => {
|
||||
console.log('Push clicked:', msg)
|
||||
if (msg.payload) {
|
||||
let payload
|
||||
try {
|
||||
payload = JSON.parse(msg.payload)
|
||||
} catch (e) {
|
||||
payload = { content: msg.payload }
|
||||
}
|
||||
callback(payload)
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建本地推送通知
|
||||
*/
|
||||
createLocalNotification(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// #ifdef APP-PLUS
|
||||
const push = window.plus.push
|
||||
const msg = {
|
||||
title: options.title || '外贸小助手',
|
||||
content: options.content,
|
||||
payload: options.payload ? JSON.stringify(options.payload) : '',
|
||||
delay: options.delay || 0,
|
||||
icon: 'static/icons/logo.png'
|
||||
}
|
||||
|
||||
push.createMessage(msg, (res) => {
|
||||
console.log('Local notification created:', res)
|
||||
resolve(res)
|
||||
}, (err) => {
|
||||
console.error('Create notification failed:', err)
|
||||
reject(err)
|
||||
})
|
||||
// #endif
|
||||
|
||||
// #ifndef APP-PLUS
|
||||
resolve(false)
|
||||
// #endif
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除所有推送消息
|
||||
*/
|
||||
clearNotifications() {
|
||||
// #ifdef APP-PLUS
|
||||
const push = window.plus.push
|
||||
push.clear()
|
||||
// #endif
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取未读消息数量
|
||||
*/
|
||||
getBadgeCount() {
|
||||
// #ifdef APP-PLUS
|
||||
const main = window.plus.android.runtimeMainActivity()
|
||||
const count = plus.android.invoke(main, 'getIntent', 'getIntExtra', '/badge', 0)
|
||||
return count
|
||||
// #endif
|
||||
return 0
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置角标
|
||||
*/
|
||||
setBadge(count) {
|
||||
// #ifdef APP-PLUS
|
||||
if (uni.setStorageSync) {
|
||||
// 小程序设置角标
|
||||
// #ifdef MP-WEIXIN
|
||||
uni.setStorageSync('badgeCount', count)
|
||||
// #endif
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
|
||||
export default pushService
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import uni from '@dcloudio/vite-plugin-uni'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [uni()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user