Files
trade-assistant/uni-app/src/pages/quotation/quotation.vue
T
TradeMate Dev c6206787da 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定时任务
2026-05-08 18:17:12 +08:00

635 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>