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:
TradeMate Dev
2026-05-08 18:17:12 +08:00
commit c6206787da
121 changed files with 11743 additions and 0 deletions
+635
View File
@@ -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>