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,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>
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user