Files
trade-assistant/uni-app/src/pages/quotation/quotation.vue
T
TradeMate Dev 7b62c2f8b4 feat: 修复 H5 底部导航覆盖 + 更新项目进度文档
## H5 底部导航修复 (Bug #10)
- 精简 App.vue,移除重复 tabbar,仅保留全局样式
- uni-page 设置 height: calc(100% - 50px) + overflow-y: auto
- 内容区域精确停在底部导航上方,独立滚动不再叠加
- 恢复 custom-tab-bar 组件

## 项目进度文档
- PROGRESS.md 更新至 10 个 Bug 修复
- 新增 H5 底部导航修复记录
- 新增历史变更条目
2026-05-12 20:24:42 +08:00

822 lines
20 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" @click.stop="exportPdf(item)">PDF</text>
<text class="action-btn primary" @click.stop="sendQuotation(item)" v-if="item.status === 'draft'">发送</text>
<text class="action-btn purple" @click.stop="showSmartQuote(item)">智能报价</text>
</view>
</view>
</view>
<view class="empty" v-else>
<text>暂无报价单</text>
</view>
<view class="export-csv-btn" @click="exportCsv">
<text class="export-icon">CSV</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 class="smart-quote-modal" v-if="showSmartQuoteModal" @click="showSmartQuoteModal = false">
<view class="smart-quote-content" @click.stop>
<view class="smart-quote-header">
<text class="smart-quote-title">智能报价</text>
<text class="smart-quote-close" @click="showSmartQuoteModal = false">×</text>
</view>
<view class="smart-quote-body">
<view class="form-item">
<text class="form-label">客户询盘内容</text>
<textarea class="form-textarea" v-model="inquiryText" placeholder="粘贴客户询盘内容,AI自动提取关键信息生成报价单" />
</view>
<view class="form-item">
<text class="form-label">关联客户</text>
<picker :range="customerOptions" range-key="name" @change="onSmartQuoteCustomerChange">
<view class="picker-value">{{ selectedCustomerId ? getCustomerName(selectedCustomerId) : '不关联客户' }}</view>
</picker>
</view>
</view>
<view class="smart-quote-footer">
<button class="cancel-btn" @click="showSmartQuoteModal = false">取消</button>
<button class="submit-btn" @click="generateSmartQuote" :disabled="smartQuoteLoading">
{{ smartQuoteLoading ? '生成中...' : '生成报价单' }}
</button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
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 showSmartQuoteModal = ref(false)
const currentQuotation = ref(null)
const inquiryText = ref('')
const selectedCustomerId = ref(null)
const smartQuoteLoading = ref(false)
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' })
}
}
const showSmartQuote = (item) => {
inquiryText.value = item.text || ''
showSmartQuoteModal.value = true
}
const onSmartQuoteCustomerChange = (e) => {
const c = customerOptions.value[e.detail.value]
selectedCustomerId.value = c?.id || null
}
const generateSmartQuote = async () => {
if (!inquiryText.value.trim()) {
uni.showToast({ title: '请输入询盘内容', icon: 'none' })
return
}
smartQuoteLoading.value = true
try {
await quotationApi.generateFromInquiry(inquiryText.value, selectedCustomerId.value)
uni.showToast({ title: '报价单生成成功', icon: 'success' })
showSmartQuoteModal.value = false
inquiryText.value = ''
selectedCustomerId.value = null
loadQuotations()
} catch (err) {
uni.showToast({ title: err.message || '生成失败', icon: 'none' })
} finally {
smartQuoteLoading.value = false
}
}
const exportCsv = () => {
const url = quotationApi.exportCsv()
const token = uni.getStorageSync('token')
uni.downloadFile({
url,
header: { Authorization: `Bearer ${token}` },
success: (res) => {
if (res.statusCode === 200) {
uni.showToast({ title: '导出成功', icon: 'success' })
} else {
uni.showToast({ title: '导出失败', icon: 'none' })
}
},
fail: () => { uni.showToast({ title: '导出失败', icon: 'none' }) },
})
}
const exportPdf = (item) => {
const url = quotationApi.exportPdf(item.id)
uni.downloadFile({
url,
header: { Authorization: `Bearer ${uni.getStorageSync('token')}` },
success: (res) => {
if (res.statusCode === 200) {
uni.openDocument({
filePath: res.tempFilePath,
success: () => {
uni.showToast({ title: '打开成功', icon: 'success' })
},
fail: () => {
uni.showToast({ title: 'PDF预览失败', icon: 'none' })
},
})
} else {
uni.showToast({ title: 'PDF下载失败', icon: 'none' })
}
},
fail: () => {
uni.showToast({ title: 'PDF下载失败', 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;
}
.action-btn.purple {
background: #722ed1;
color: #fff;
}
.empty {
text-align: center;
color: #999;
padding: 100rpx;
}
.export-csv-btn {
position: fixed;
right: 40rpx;
bottom: 160rpx;
width: 100rpx;
height: 100rpx;
background: #722ed1;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(114, 46, 209, 0.4);
}
.export-icon {
font-size: 28rpx;
color: #fff;
font-weight: 600;
}
.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;
}
.smart-quote-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;
}
.smart-quote-content {
width: 90%;
background: #fff;
border-radius: 16rpx;
display: flex;
flex-direction: column;
max-height: 80%;
}
.smart-quote-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 2rpx solid #f5f5f5;
}
.smart-quote-title {
font-size: 32rpx;
font-weight: 600;
}
.smart-quote-close {
font-size: 44rpx;
color: #999;
}
.smart-quote-body {
padding: 30rpx;
overflow-y: auto;
}
.smart-quote-footer {
display: flex;
gap: 20rpx;
padding: 30rpx;
border-top: 2rpx solid #f5f5f5;
}
.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>