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
+155
View File
@@ -0,0 +1,155 @@
const { quotationApi, customerApi } = require('../../utils/api');
Page({
data: {
quotations: [],
page: 1,
hasMore: true,
loading: false,
showCreateModal: false,
customers: [],
newQuotation: {
customer_id: '',
title: '',
items: [],
currency: 'USD',
payment_terms: 'T/T 30% deposit',
delivery_terms: 'FOB',
lead_time: '',
notes: '',
},
tempItem: {
product_name: '',
quantity: 1,
unit_price: 0,
},
},
onLoad() {
this.loadQuotations();
},
onReachBottom() {
if (this.data.hasMore && !this.data.loading) {
this.setData({ page: this.data.page + 1 });
this.loadQuotations(true);
}
},
async loadQuotations(isAppend = false) {
if (this.data.loading) return;
this.setData({ loading: true });
try {
const result = await quotationApi.list(this.data.page);
this.setData({
quotations: isAppend ? [...this.data.quotations, ...result.items] : result.items,
hasMore: result.items.length >= result.size,
loading: false,
});
} catch (err) {
wx.showToast({ title: err.message || '加载失败', icon: 'none' });
this.setData({ loading: false });
}
},
async showCreate() {
try {
const customers = await customerApi.list(1, 100);
this.setData({
showCreateModal: true,
customers: customers.items,
});
} catch (err) {
wx.showToast({ title: '加载客户失败', icon: 'none' });
}
},
hideCreate() {
this.setData({
showCreateModal: false,
newQuotation: {
customer_id: '',
title: '',
items: [],
currency: 'USD',
payment_terms: 'T/T 30% deposit',
delivery_terms: 'FOB',
lead_time: '',
notes: '',
},
});
},
onInput(e) {
const field = e.currentTarget.dataset.field;
const value = e.detail.value;
this.setData({ [`newQuotation.${field}`]: value });
},
onItemInput(e) {
const field = e.currentTarget.dataset.field;
const value = e.detail.value;
this.setData({ [`tempItem.${field}`]: value });
},
addItem() {
const { tempItem, newQuotation } = this.data;
if (!tempItem.product_name) {
wx.showToast({ title: '请输入产品名称', icon: 'none' });
return;
}
this.setData({
newQuotation: {
...newQuotation,
items: [...newQuotation.items, { ...tempItem }],
},
tempItem: {
product_name: '',
quantity: 1,
unit_price: 0,
},
});
},
removeItem(e) {
const index = e.currentTarget.dataset.index;
const { newQuotation } = this.data;
newQuotation.items.splice(index, 1);
this.setData({ newQuotation });
},
async createQuotation() {
const { newQuotation } = this.data;
if (!newQuotation.customer_id) {
wx.showToast({ title: '请选择客户', icon: 'none' });
return;
}
if (newQuotation.items.length === 0) {
wx.showToast({ title: '请添加产品', icon: 'none' });
return;
}
try {
await quotationApi.create(newQuotation);
wx.showToast({ title: '创建成功', icon: 'success' });
this.hideCreate();
this.setData({ page: 1, quotations: [] });
this.loadQuotations();
} catch (err) {
wx.showToast({ title: err.message || '创建失败', icon: 'none' });
}
},
viewDetail(e) {
const id = e.currentTarget.dataset.id;
wx.navigateTo({ url: `/pages/quotation/detail?id=${id}` });
},
onPullDownRefresh() {
this.setData({ page: 1, quotations: [] });
this.loadQuotations();
wx.stopPullDownRefresh();
},
});
@@ -0,0 +1,5 @@
{
"navigationBarTitleText": "报价单",
"enablePullDownRefresh": true,
"usingComponents": {}
}
@@ -0,0 +1,89 @@
<view class="container">
<view class="header">
<text class="section-title">报价单</text>
<button class="add-btn" bindtap="showCreate">+ 新建报价</button>
</view>
<view class="quotation-list">
<view class="quotation-item" wx:for="{{quotations}}" wx:key="id" bindtap="viewDetail" data-id="{{item.id}}">
<view class="quotation-header">
<text class="quotation-title">{{item.title || '报价单'}}</text>
<view class="quotation-status status-{{item.status}}">
{{item.status === 'draft' ? '草稿' : item.status === 'sent' ? '已发送' : '已接受'}}
</view>
</view>
<view class="quotation-info">
货币: {{item.currency}} ·条款: {{item.delivery_terms}}
</view>
<view class="quotation-total">
合计: ${{item.total || 0}}
</view>
</view>
<view class="empty" wx:if="{{quotations.length === 0 && !loading}}">
<text>暂无报价单,点击上方创建</text>
</view>
</view>
<view class="modal" wx:if="{{showCreateModal}}" bindtap="hideCreate">
<view class="modal-content" catchtap>
<view class="modal-title">新建报价单</view>
<view class="form-group">
<text class="form-label">客户 *</text>
<picker bindchange="onCustomerChange" value="{{customerIndex}}" range="{{customers}}" range-key="name">
<view class="form-input">
{{customers[customerIndex] ? customers[customerIndex].name : '请选择客户'}}
</view>
</picker>
</view>
<view class="form-group">
<text class="form-label">标题</text>
<input class="form-input" placeholder="报价单标题" value="{{newQuotation.title}}" data-field="title" bindinput="onInput" />
</view>
<view class="items-section">
<text class="form-label">产品明细</text>
<view class="item-row" wx:for="{{newQuotation.items}}" wx:key="index">
<text>{{item.product_name}}</text>
<text>x{{item.quantity}}</text>
<text>${{item.unit_price}}</text>
<text class="remove-item" data-index="{{index}}" bindtap="removeItem">×</text>
</view>
<view class="item-row">
<input class="form-input item-input" placeholder="产品名称" value="{{tempItem.product_name}}" data-field="product_name" bindinput="onItemInput" />
<input class="form-input qty-input" type="number" placeholder="数量" value="{{tempItem.quantity}}" data-field="quantity" bindinput="onItemInput" />
<input class="form-input price-input" type="digit" placeholder="单价" value="{{tempItem.unit_price}}" data-field="unit_price" bindinput="onItemInput" />
</view>
<view class="add-item-btn" bindtap="addItem">+ 添加产品</view>
</view>
<view class="form-group">
<text class="form-label">货币</text>
<input class="form-input" placeholder="USD" value="{{newQuotation.currency}}" data-field="currency" bindinput="onInput" />
</view>
<view class="form-group">
<text class="form-label">付款条款</text>
<input class="form-input" placeholder="T/T 30% deposit" value="{{newQuotation.payment_terms}}" data-field="payment_terms" bindinput="onInput" />
</view>
<view class="form-group">
<text class="form-label">交货条款</text>
<input class="form-input" placeholder="FOB" value="{{newQuotation.delivery_terms}}" data-field="delivery_terms" bindinput="onInput" />
</view>
<view class="form-group">
<text class="form-label">交货周期</text>
<input class="form-input" placeholder="如:25 days" value="{{newQuotation.lead_time}}" data-field="lead_time" bindinput="onInput" />
</view>
<view class="modal-btns">
<view class="modal-btn btn-cancel" bindtap="hideCreate">取消</view>
<view class="modal-btn btn-confirm" bindtap="createQuotation">创建</view>
</view>
</view>
</view>
</view>
+195
View File
@@ -0,0 +1,195 @@
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
background: #fff;
}
.add-btn {
background: #1890ff;
color: #fff;
font-size: 28rpx;
padding: 15rpx 30rpx;
border-radius: 8rpx;
}
.quotation-list {
padding: 0 30rpx;
}
.quotation-item {
background: #fff;
border-radius: 12rpx;
padding: 30rpx;
margin-bottom: 20rpx;
}
.quotation-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15rpx;
}
.quotation-title {
font-size: 30rpx;
font-weight: bold;
color: #333;
}
.quotation-status {
padding: 6rpx 16rpx;
border-radius: 20rpx;
font-size: 22rpx;
}
.status-draft {
background: #f5f5f5;
color: #666;
}
.status-sent {
background: #e6f7ff;
color: #1890ff;
}
.status-accepted {
background: #f6ffed;
color: #52c41a;
}
.quotation-info {
font-size: 24rpx;
color: #999;
margin-bottom: 10rpx;
}
.quotation-total {
font-size: 32rpx;
font-weight: bold;
color: #ff4d4f;
}
.empty {
text-align: center;
padding: 100rpx;
color: #999;
}
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-start;
justify-content: center;
z-index: 100;
overflow-y: auto;
padding-top: 50rpx;
}
.modal-content {
background: #fff;
border-radius: 12rpx;
padding: 40rpx;
width: 85%;
max-height: 90vh;
overflow-y: auto;
}
.modal-title {
font-size: 32rpx;
font-weight: bold;
margin-bottom: 30rpx;
text-align: center;
}
.form-group {
margin-bottom: 20rpx;
}
.form-label {
font-size: 26rpx;
color: #666;
margin-bottom: 10rpx;
display: block;
}
.form-input {
border: 1rpx solid #d9d9d9;
border-radius: 8rpx;
padding: 20rpx;
font-size: 28rpx;
width: 100%;
box-sizing: border-box;
}
.items-section {
background: #f9f9f9;
padding: 20rpx;
border-radius: 8rpx;
margin-bottom: 20rpx;
}
.item-row {
display: flex;
gap: 10rpx;
align-items: center;
margin-bottom: 15rpx;
}
.item-input {
flex: 1;
}
.qty-input {
width: 100rpx;
}
.price-input {
width: 150rpx;
}
.remove-item {
color: #ff4d4f;
font-size: 32rpx;
padding: 10rpx;
}
.add-item-btn {
background: #fff;
border: 1rpx dashed #1890ff;
color: #1890ff;
font-size: 26rpx;
padding: 15rpx;
text-align: center;
border-radius: 8rpx;
}
.modal-btns {
display: flex;
gap: 20rpx;
margin-top: 30rpx;
}
.modal-btn {
flex: 1;
padding: 20rpx;
border-radius: 8rpx;
text-align: center;
font-size: 28rpx;
}
.btn-cancel {
background: #f5f5f5;
color: #666;
}
.btn-confirm {
background: #1890ff;
color: #fff;
}