Commit 6d033a4b authored by 黄奎's avatar 黄奎
parents 68d5c6bf c8d47ffc
......@@ -828,4 +828,7 @@
line-height: 14px;
min-width: 16px;
padding: 0 4px;
}
.el-drawer__header{
margin-bottom: 20px !important;
}
\ No newline at end of file
<template>
<el-drawer
:visible.sync="drawerVisible"
:title="isEdit ? '编辑报价单' : '新建报价单'"
:size="drawerWidth"
:before-close="handleClose"
class="quote-drawer"
>
<div class="drawer-content" v-loading="loading">
<!-- 基本信息 -->
<div class="section" style="padding: 0 20px;">
<!-- <div class="section-header">
<h3>基本信息</h3>
</div> -->
<el-form :model="form" ref="form" label-width="70px" size="small">
<el-row :gutter="20">
<!-- <el-col :span="12">
<el-form-item label="报价单名称" prop="quoteName" required>
<el-input v-model="form.quoteName" placeholder="请输入报价单名称" />
</el-form-item>
</el-col> -->
<el-col :span="8">
<el-form-item label="行程名称">
<span>{{ itineraryInfo.title || '未选择行程' }}</span>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="有效期" prop="validDate">
<el-date-picker
v-model="form.validDate"
type="date"
placeholder="选择有效期"
value-format="yyyy-MM-dd"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<div style="text-align: right;">
<span>报价总金额:</span>
<span class="total-price">¥{{ formatPrice(totalAmount) }}</span>
</div>
</el-col>
</el-row>
</el-form>
</div>
<!-- 报价项目 -->
<div class="section" style="flex: 1;height: 1px; display: flex;flex-direction: column;">
<div class="section-header">
<h3>报价项目</h3>
<el-dropdown @command="handleAddCategory" trigger="click">
<el-button type="primary" size="small">
添加大类 <i class="el-icon-arrow-down el-icon--right"></i>
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item
v-for="category in availableCategories"
:key="category.key"
:command="category.key"
>
{{ category.name }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
<!-- 报价项目列表 -->
<div v-if="form.categories.length > 0" class="quote-categories">
<div
v-for="category in form.categories"
:key="category.key"
class="category-section"
>
<div class="category-header" @click="toggleCategoryCollapse(category.key)">
<div class="category-title">
<i
:class="categoryCollapsed[category.key] ? 'el-icon-arrow-right' : 'el-icon-arrow-down'"
class="collapse-icon"
></i>
<i :class="getCategoryIcon(category.key)"></i>
<span>{{ category.name }}</span>
<span class="category-total">合计: ¥{{ formatPrice(getCategoryTotal(category)) }}</span>
</div>
<div class="category-actions" @click.stop>
<el-button
type="text"
size="mini"
@click="addCategoryItem(category)"
icon="el-icon-plus"
>
添加项目
</el-button>
<el-button
type="text"
size="mini"
@click="removeCategory(category.key)"
icon="el-icon-delete"
class="danger-btn"
>
删除大类
</el-button>
</div>
</div>
<!-- 项目列表 - 聚合布局 -->
<div class="category-items" v-show="!categoryCollapsed[category.key]">
<!-- 按项目名称聚合显示 -->
<div
v-for="(groupedItem, groupIndex) in getGroupedItems(category)"
:key="groupIndex"
class="grouped-item"
>
<!-- 项目组头部 -->
<div class="item-group-header">
<div class="group-info">
<el-input
v-model="groupedItem.period"
placeholder="时间"
size="mini"
style="width: 80px; margin-right: 8px;"
@input="updateGroupPeriod(category, groupedItem.name, $event)"
/>
<el-input
v-model="groupedItem.name"
placeholder="项目名称"
size="mini"
style="width: 200px; margin-right: 8px;"
@input="updateGroupName(category, groupedItem.originalName, $event)"
/>
<span class="group-total">小计: ¥{{ formatPrice(groupedItem.total) }}</span>
</div>
<div class="group-actions">
<el-button
type="text"
size="mini"
@click="addSpecificationToGroup(category, groupedItem.name)"
icon="el-icon-plus"
>
添加规格
</el-button>
<el-button
type="text"
size="mini"
@click="removeItemGroup(category, groupedItem.name)"
icon="el-icon-delete"
class="danger-btn"
>
删除项目
</el-button>
</div>
</div>
<!-- 规格列表 -->
<div class="specifications-list">
<div
v-for="(spec, specIndex) in groupedItem.specifications"
:key="specIndex"
class="specification-row"
>
<div class="spec-info">
<el-input
v-model="spec.specification"
placeholder="规格说明(如:标准间、成人票等)"
size="mini"
style="width: 180px; margin-right: 8px;"
/>
</div>
<div class="spec-price">
<el-input-number
v-model="spec.unitPrice"
:precision="2"
:step="1"
:min="0"
size="mini"
placeholder="单价"
style="width: 100px; margin-right: 8px;"
@change="calculateItemTotal(spec)"
/>
<el-input-number
v-model="spec.quantity"
:precision="0"
:step="1"
:min="1"
size="mini"
placeholder="数量"
style="width: 80px; margin-right: 8px;"
@change="calculateItemTotal(spec)"
/>
<span class="spec-total">¥{{ formatPrice(spec.totalPrice || 0) }}</span>
</div>
<div class="spec-actions">
<el-button
type="text"
size="mini"
@click="removeSpecification(category, groupedItem.name, specIndex)"
icon="el-icon-delete"
class="danger-btn"
/>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="category.items.length === 0" class="empty-items">
<span>暂无项目,点击"添加项目"开始添加</span>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="form.categories.length === 0" class="empty-categories">
<el-empty description="暂无报价项目,请添加大类开始创建报价单" />
</div>
</div>
<div class="drawer-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSave" :loading="saving">
{{ isEdit ? '保存修改' : '创建报价单' }}
</el-button>
</div>
</div>
<!-- 底部操作栏 -->
</el-drawer>
</template>
<script>
import { quoteService } from '@/services/quote'
import { itineraryService } from '@/services/itinerary'
export default {
name: 'QuoteDrawer',
props: {
visible: {
type: Boolean,
default: false
},
quoteId: {
type: String,
default: null
},
itineraryId: {
type: String,
default: null
}
},
data() {
return {
loading: false,
saving: false,
form: {
quoteName: '',
validDate: '',
categories: []
},
itineraryInfo: {},
// 折叠状态管理
categoryCollapsed: {},
// 所有可用的大类
allCategories: [
{ key: 'transport', name: '大交通', icon: 'el-icon-truck' },
{ key: 'hotel', name: '酒店住宿', icon: 'el-icon-house' },
{ key: 'meal', name: '餐饮', icon: 'el-icon-food' },
{ key: 'ticket', name: '票券', icon: 'el-icon-tickets' },
{ key: 'localTransport', name: '交通', icon: 'el-icon-position' },
{ key: 'other', name: '其它', icon: 'el-icon-more' }
],
// 需要过滤的餐厅关键词
excludedMealKeywords: [
'酒店自助', '酒店内享用', '自理', '敬请自理', '客人自理',
'酒店早餐', '含早', '不含', '无'
]
}
},
computed: {
drawerVisible: {
get() {
return this.visible
},
set(val) {
this.$emit('update:visible', val)
}
},
isEdit() {
return !!this.quoteId
},
drawerWidth() {
const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0)
const calculatedWidth = Math.max(vw * 0.8, 1200)
return `${calculatedWidth}px`
},
availableCategories() {
const existingKeys = this.form.categories.map(c => c.key)
return this.allCategories.filter(c => !existingKeys.includes(c.key))
},
totalAmount() {
return this.form.categories.reduce((total, category) => {
return total + this.getCategoryTotal(category)
}, 0)
}
},
watch: {
visible(val) {
if (val) {
this.initDrawer()
} else {
this.resetForm()
}
}
},
methods: {
async initDrawer() {
this.loading = true
try {
if (this.isEdit) {
// 编辑模式:加载现有报价单
await this.loadQuoteDetail()
} else if (this.itineraryId) {
// 新增模式:根据行程自动生成
await this.loadItineraryAndGenerate()
} else {
// 纯新增模式
this.resetForm()
}
} catch (error) {
this.$message.error('加载数据失败')
} finally {
this.loading = false
}
},
async loadQuoteDetail() {
const response = await quoteService.getQuoteDetail(this.quoteId)
if (response && response.data) {
this.form = { ...response.data }
this.itineraryInfo = response.data.itinerary || {}
}
},
async loadItineraryAndGenerate() {
// 加载行程信息
const response = await itineraryService.getItineraryDetail(this.itineraryId)
if (response) {
this.itineraryInfo = response
this.form.quoteName = `${response.title} - 报价单`
// 根据行程自动生成报价项目
this.generateQuoteFromItinerary(response)
}
},
generateQuoteFromItinerary(itinerary) {
const categories = []
// 生成大交通(支持不同票种)
if (itinerary.days && itinerary.days.some(day =>
day.traffics && day.traffics.some(t => t.type === '航班')
)) {
const transportCategory = {
key: 'transport',
name: '大交通',
items: []
}
itinerary.days.forEach((day, dayIndex) => {
if (day.traffics) {
day.traffics.forEach(traffic => {
if (traffic.type === '航班') {
const flightName = `${traffic.departure}-${traffic.destination}`
// 添加成人票
transportCategory.items.push({
period: `D${dayIndex + 1}`,
name: flightName,
specification: '成人票',
unitPrice: 0,
quantity: 2,
totalPrice: 0
})
// 添加儿童票
transportCategory.items.push({
period: `D${dayIndex + 1}`,
name: flightName,
specification: '儿童票',
unitPrice: 0,
quantity: 0,
totalPrice: 0
})
// 添加婴儿票
transportCategory.items.push({
period: `D${dayIndex + 1}`,
name: flightName,
specification: '婴儿票',
unitPrice: 0,
quantity: 0,
totalPrice: 0
})
}
})
}
})
if (transportCategory.items.length > 0) {
categories.push(transportCategory)
}
}
// 生成酒店住宿(支持不同房型)
if (itinerary.days && itinerary.days.some(day => day.hotels && day.hotels.length > 0)) {
const hotelCategory = {
key: 'hotel',
name: '酒店住宿',
items: []
}
itinerary.days.forEach((day, dayIndex) => {
if (day.hotels && day.hotels.length > 0) {
day.hotels.forEach(hotel => {
// 添加标准间
hotelCategory.items.push({
period: `D${dayIndex + 1} - D${dayIndex + 2}`,
name: hotel.name,
specification: '标准间',
unitPrice: 0,
quantity: 1,
totalPrice: 0
})
// 添加豪华间(默认数量为0)
hotelCategory.items.push({
period: `D${dayIndex + 1} - D${dayIndex + 2}`,
name: hotel.name,
specification: '豪华间',
unitPrice: 0,
quantity: 0,
totalPrice: 0
})
})
}
})
if (hotelCategory.items.length > 0) {
categories.push(hotelCategory)
}
}
// 生成餐饮(过滤无效餐厅信息)
if (itinerary.days && itinerary.days.some(day => day.caterings && day.caterings.length > 0)) {
const mealCategory = {
key: 'meal',
name: '餐饮',
items: []
}
itinerary.days.forEach((day, dayIndex) => {
if (day.caterings && day.caterings.length > 0) {
day.caterings.forEach(catering => {
// 过滤无效的餐厅信息
if (this.isValidMeal(catering.name)) {
mealCategory.items.push({
period: `D${dayIndex + 1}`,
name: catering.name,
specification: catering.type,
unitPrice: 0,
quantity: 2,
totalPrice: 0
})
}
})
}
})
if (mealCategory.items.length > 0) {
categories.push(mealCategory)
}
}
// 生成票券(支持不同票种)
if (itinerary.days && itinerary.days.some(day => day.attractions && day.attractions.length > 0)) {
const ticketCategory = {
key: 'ticket',
name: '票券',
items: []
}
itinerary.days.forEach((day, dayIndex) => {
if (day.attractions && day.attractions.length > 0) {
day.attractions.forEach(attraction => {
// 添加成人票
ticketCategory.items.push({
period: `D${dayIndex + 1}`,
name: attraction.name,
specification: '成人票',
unitPrice: 0,
quantity: 2,
totalPrice: 0
})
// 添加儿童票
ticketCategory.items.push({
period: `D${dayIndex + 1}`,
name: attraction.name,
specification: '儿童票',
unitPrice: 0,
quantity: 0,
totalPrice: 0
})
// 添加老人票
ticketCategory.items.push({
period: `D${dayIndex + 1}`,
name: attraction.name,
specification: '老人票',
unitPrice: 0,
quantity: 0,
totalPrice: 0
})
})
}
})
if (ticketCategory.items.length > 0) {
categories.push(ticketCategory)
}
}
// 生成地面交通
if (itinerary.days && itinerary.days.some(day =>
day.traffics && day.traffics.some(t => t.type !== '航班')
)) {
const localTransportCategory = {
key: 'localTransport',
name: '交通',
items: []
}
// 添加接机
localTransportCategory.items.push({
period: 'D1',
name: '接机',
specification: '专车接机',
unitPrice: 0,
quantity: 1,
totalPrice: 0
})
// 添加送机
localTransportCategory.items.push({
period: `D${itinerary.days.length}`,
name: '送机',
specification: '专车送机',
unitPrice: 0,
quantity: 1,
totalPrice: 0
})
// 添加包车
if (itinerary.days.length > 1) {
localTransportCategory.items.push({
period: `D2-D${itinerary.days.length - 1}`,
name: '目的地包车',
specification: '全程包车',
unitPrice: 0,
quantity: itinerary.days.length - 2,
totalPrice: 0
})
}
categories.push(localTransportCategory)
}
this.form.categories = categories
},
// 验证餐厅信息是否有效
isValidMeal(mealName) {
if (!mealName) {
return false
}
const trimmedName = mealName.trim()
if (!trimmedName) {
return false
}
// 检查是否包含需要过滤的关键词
return !this.excludedMealKeywords.some(keyword =>
trimmedName.includes(keyword)
)
},
// 切换分类折叠状态
toggleCategoryCollapse(categoryKey) {
this.$set(this.categoryCollapsed, categoryKey, !this.categoryCollapsed[categoryKey])
},
handleAddCategory(categoryKey) {
const category = this.allCategories.find(c => c.key === categoryKey)
if (category) {
this.form.categories.push({
key: category.key,
name: category.name,
items: []
})
// 新添加的分类默认展开
this.$set(this.categoryCollapsed, category.key, false)
}
},
removeCategory(categoryKey) {
this.form.categories = this.form.categories.filter(c => c.key !== categoryKey)
},
// 获取按项目名称聚合的数据
getGroupedItems(category) {
const grouped = {}
category.items.forEach(item => {
const key = item.name || '未命名项目'
if (!grouped[key]) {
grouped[key] = {
name: key,
originalName: key,
period: item.period,
specifications: [],
total: 0
}
}
grouped[key].specifications.push(item)
grouped[key].total += (item.totalPrice || 0)
})
return Object.values(grouped)
},
addCategoryItem(category) {
category.items.push({
period: '',
name: '',
specification: '',
unitPrice: 0,
quantity: 1,
totalPrice: 0
})
},
// 为项目组添加新规格
addSpecificationToGroup(category, groupName) {
const existingItem = category.items.find(item => item.name === groupName)
const period = existingItem ? existingItem.period : ''
category.items.push({
period: period,
name: groupName,
specification: '',
unitPrice: 0,
quantity: 1,
totalPrice: 0
})
},
// 删除整个项目组
removeItemGroup(category, groupName) {
category.items = category.items.filter(item => item.name !== groupName)
},
// 删除特定规格
removeSpecification(category, groupName, specIndex) {
const groupItems = category.items.filter(item => item.name === groupName)
if (groupItems.length > specIndex) {
const itemIndex = category.items.indexOf(groupItems[specIndex])
if (itemIndex > -1) {
category.items.splice(itemIndex, 1)
}
}
},
// 更新项目组名称
updateGroupName(category, originalName, newName) {
category.items.forEach(item => {
if (item.name === originalName) {
item.name = newName
}
})
},
// 更新项目组时间
updateGroupPeriod(category, groupName, newPeriod) {
category.items.forEach(item => {
if (item.name === groupName) {
item.period = newPeriod
}
})
},
removeCategoryItem(category, index) {
category.items.splice(index, 1)
},
calculateItemTotal(item) {
item.totalPrice = (item.unitPrice || 0) * (item.quantity || 1)
},
getCategoryTotal(category) {
return category.items.reduce((total, item) => {
return total + (item.totalPrice || 0)
}, 0)
},
getCategoryIcon(categoryKey) {
const category = this.allCategories.find(c => c.key === categoryKey)
return category ? category.icon : 'el-icon-more'
},
formatPrice(price) {
if (!price) return '0.00'
return parseFloat(price).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
},
async handleSave() {
this.$refs.form.validate(async (valid) => {
if (!valid) return
this.saving = true
try {
const data = {
...this.form,
itineraryId: this.itineraryId,
totalPrice: this.totalAmount
}
if (this.isEdit) {
await quoteService.updateQuote({ ...data, id: this.quoteId })
this.$message.success('保存成功')
} else {
await quoteService.createQuote(data)
this.$message.success('创建成功')
}
this.$emit('saved')
} catch (error) {
this.$message.error('保存失败')
} finally {
this.saving = false
}
})
},
handleClose() {
this.$confirm('确定要关闭吗?未保存的数据将丢失。', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.drawerVisible = false
this.$emit('closed')
}).catch(() => {
// 用户取消
})
},
resetForm() {
this.form = {
quoteName: '',
validDate: '',
categories: []
}
this.itineraryInfo = {}
this.categoryCollapsed = {}
}
}
}
</script>
<style scoped>
.quote-drawer {
font-size: 14px;
}
.drawer-content {
height: 100%; /*calc(100vh - 80px); */
/* overflow-y: auto; */
display: flex;
flex-direction: column;
overflow: hidden;
}
.section {
/* margin-bottom: 12px; */
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
/* margin-bottom: 20px; */
padding-bottom: 10px;
padding:0 20px 10px 20px;
border-bottom: 1px solid #ebeef5;
}
.section-header h3 {
margin: 0;
color: #303133;
font-size: 16px;
font-weight: 600;
}
.total-price {
font-size: 18px;
font-weight: bold;
color: #e6a23c;
}
.quote-categories {
flex: 1;
height: 100%;
overflow-y: auto;
padding:12px 20px;
}
.category-section {
border: 1px solid #ebeef5;
border-radius: 8px;
margin-bottom: 20px;
overflow: hidden;
}
.category-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background: #f8f9fa;
border-bottom: 1px solid #ebeef5;
cursor: pointer;
transition: background-color 0.2s;
}
.category-header:hover {
background: #f0f2f5;
}
.category-title {
display: flex;
align-items: center;
font-weight: 600;
color: #303133;
}
.category-title i {
margin-right: 8px;
font-size: 16px;
color: #409eff;
}
.collapse-icon {
margin-right: 8px !important;
font-size: 14px !important;
color: #606266 !important;
transition: transform 0.2s;
cursor: pointer;
}
.category-items {
padding: 20px;
transition: all 0.3s ease;
}
/* 聚合项目样式 */
.grouped-item {
margin-bottom: 20px;
border: 1px solid #e4e7ed;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.item-group-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #f8f9fa;
border-bottom: 1px solid #e4e7ed;
}
.group-info {
display: flex;
align-items: center;
flex: 1;
}
.group-total {
font-weight: bold;
color: #e6a23c;
margin-left: 15px;
}
.group-actions {
display: flex;
gap: 8px;
}
.specifications-list {
padding: 0;
}
.specification-row {
display: flex;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
background: #fafbfc;
transition: background-color 0.2s;
}
.specification-row:hover {
background: #f5f7fa;
}
.specification-row:last-child {
border-bottom: none;
}
.spec-info {
flex: 1;
display: flex;
align-items: center;
}
.spec-price {
display: flex;
align-items: center;
gap: 8px;
}
.spec-total {
font-weight: bold;
color: #e6a23c;
min-width: 80px;
text-align: right;
}
.spec-actions {
display: flex;
align-items: center;
margin-left: 10px;
}
.category-total {
margin-left: 20px;
color: #e6a23c;
font-weight: bold;
}
.category-actions {
display: flex;
gap: 10px;
}
.quote-item {
margin-bottom: 15px;
padding: 15px;
background: #fafafa;
border-radius: 6px;
border: 1px solid #f0f0f0;
}
.item-row {
display: flex;
align-items: center;
gap: 10px;
}
.item-info {
flex: 1;
display: flex;
align-items: center;
}
.item-price {
display: flex;
align-items: center;
gap: 8px;
}
.item-total {
font-weight: bold;
color: #e6a23c;
min-width: 80px;
text-align: right;
}
.item-actions {
display: flex;
align-items: center;
}
.empty-items {
text-align: center;
padding: 40px;
color: #909399;
}
.empty-categories {
text-align: center;
padding: 60px 20px;
}
.drawer-footer {
padding: 12px 20px;
background: #fff;
border-top: 1px solid #ebeef5;
text-align: right;
}
.danger-btn {
color: #f56c6c;
}
.danger-btn:hover {
color: #f56c6c;
}
/* 响应式调整 */
@media (max-width: 1200px) {
.item-row {
flex-direction: column;
align-items: stretch;
gap: 15px;
}
.item-info {
flex-direction: column;
gap: 10px;
}
.item-price {
justify-content: space-between;
}
}
</style>
<template>
<div class="quote-create">
<div class="page-header">
<h2>创建报价单</h2>
<el-button @click="$router.go(-1)">返回</el-button>
</div>
<div class="form-container">
<el-form :model="form" ref="form" label-width="120px" class="quote-form">
<el-form-item label="选择行程" prop="itineraryId" required>
<el-select
v-model="form.itineraryId"
placeholder="请选择行程"
filterable
remote
:remote-method="searchItinerary"
:loading="itineraryLoading"
style="width: 100%"
>
<el-option
v-for="item in itineraryOptions"
:key="item.id"
:label="item.title"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="报价名称" prop="quoteName" required>
<el-input v-model="form.quoteName" placeholder="请输入报价名称" />
</el-form-item>
<el-form-item label="报价说明" prop="description">
<el-input
type="textarea"
v-model="form.description"
placeholder="请输入报价说明"
:rows="4"
/>
</el-form-item>
<el-form-item label="有效期" prop="validDate">
<el-date-picker
v-model="form.validDate"
type="date"
placeholder="选择有效期"
value-format="yyyy-MM-dd"
/>
</el-form-item>
</el-form>
<div class="form-actions">
<el-button @click="$router.go(-1)">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
创建报价单
</el-button>
</div>
</div>
</div>
</template>
<script>
import { quoteService } from '@/services/quote'
import { itineraryService } from '@/services/itinerary'
export default {
name: 'QuoteCreate',
data() {
return {
form: {
itineraryId: '',
quoteName: '',
description: '',
validDate: ''
},
itineraryOptions: [],
itineraryLoading: false,
submitting: false
}
},
methods: {
async searchItinerary(query) {
if (!query) return
this.itineraryLoading = true
try {
const response = await itineraryService.getItineraryList({
keyword: query,
pageIndex: 1,
pageSize: 20
})
if (response && response.data) {
this.itineraryOptions = response.data.list || []
}
} catch (error) {
console.error('搜索行程失败:', error)
} finally {
this.itineraryLoading = false
}
},
async handleSubmit() {
this.$refs.form.validate(async (valid) => {
if (!valid) return
this.submitting = true
try {
await quoteService.createQuote(this.form)
this.$message.success('创建成功')
this.$router.push('/quoted-price/price')
} catch (error) {
console.error('创建失败:', error)
this.$message.error('创建失败')
} finally {
this.submitting = false
}
})
}
}
}
</script>
<style scoped>
.quote-create {
padding: 20px;
background: #f5f5f5;
min-height: 100vh;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 20px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.page-header h2 {
margin: 0;
color: #333;
}
.form-container {
background: #fff;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.quote-form {
max-width: 600px;
}
.form-actions {
margin-top: 30px;
text-align: center;
}
.form-actions .el-button {
margin: 0 10px;
min-width: 100px;
}
</style>
<template>
<div class="quote-edit">
<div class="page-header">
<h2>编辑报价单</h2>
<el-button @click="$router.go(-1)">返回</el-button>
</div>
<div class="form-container" v-loading="loading">
<el-form :model="form" ref="form" label-width="120px" class="quote-form">
<el-form-item label="行程名称">
<span>{{ form.itineraryName }}</span>
</el-form-item>
<el-form-item label="报价名称" prop="quoteName" required>
<el-input v-model="form.quoteName" placeholder="请输入报价名称" />
</el-form-item>
<el-form-item label="报价说明" prop="description">
<el-input
type="textarea"
v-model="form.description"
placeholder="请输入报价说明"
:rows="4"
/>
</el-form-item>
<el-form-item label="有效期" prop="validDate">
<el-date-picker
v-model="form.validDate"
type="date"
placeholder="选择有效期"
value-format="yyyy-MM-dd"
/>
</el-form-item>
<el-form-item label="总报价" prop="totalPrice">
<el-input-number
v-model="form.totalPrice"
:precision="2"
:step="100"
:min="0"
placeholder="请输入总报价"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="form.status" placeholder="请选择状态">
<el-option label="草稿" value="draft" />
<el-option label="待审核" value="pending" />
<el-option label="已通过" value="approved" />
<el-option label="已拒绝" value="rejected" />
</el-select>
</el-form-item>
</el-form>
<div class="form-actions">
<el-button @click="$router.go(-1)">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
保存修改
</el-button>
</div>
</div>
</div>
</template>
<script>
import { quoteService } from '@/services/quote'
export default {
name: 'QuoteEdit',
data() {
return {
form: {
id: '',
itineraryName: '',
quoteName: '',
description: '',
validDate: '',
totalPrice: 0,
status: 'draft'
},
loading: false,
submitting: false
}
},
created() {
this.loadQuoteDetail()
},
methods: {
async loadQuoteDetail() {
const id = this.$route.params.id
if (!id) return
this.loading = true
try {
const response = await quoteService.getQuoteDetail(id)
if (response && response.data) {
this.form = { ...response.data }
}
} catch (error) {
console.error('加载报价详情失败:', error)
this.$message.error('加载数据失败')
} finally {
this.loading = false
}
},
async handleSubmit() {
this.$refs.form.validate(async (valid) => {
if (!valid) return
this.submitting = true
try {
await quoteService.updateQuote(this.form)
this.$message.success('保存成功')
this.$router.push('/quoted-price/price')
} catch (error) {
console.error('保存失败:', error)
this.$message.error('保存失败')
} finally {
this.submitting = false
}
})
}
}
}
</script>
<style scoped>
.quote-edit {
padding: 20px;
background: #f5f5f5;
min-height: 100vh;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 20px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.page-header h2 {
margin: 0;
color: #333;
}
.form-container {
background: #fff;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.quote-form {
max-width: 600px;
}
.form-actions {
margin-top: 30px;
text-align: center;
}
.form-actions .el-button {
margin: 0 10px;
min-width: 100px;
}
</style>
<template>
<div class="price-manager">
<BaseListManager
:columns="columns"
:dataList="dataList"
:loading="loading"
:pagination="pagination"
:actionColumn="actionColumn"
:addActionConfig="addActionConfig"
@add-click="handleAddQuote"
@page-change="handlePageChange"
@size-change="handleSizeChange"
@refresh="loadData"
>
<!-- 自定义插槽 -->
<template #totalPrice="{ row }">
<span class="price-text">¥{{ formatPrice(row.totalPrice) }}</span>
</template>
<template #status="{ row }">
<el-tag :type="getStatusType(row.status)" size="mini">
{{ getStatusText(row.status) }}
</el-tag>
</template>
<template #actions="{ row }">
<el-button type="text" size="mini" @click="handleEdit(row)">
<i class="el-icon-edit"></i> 编辑
</el-button>
<el-button type="text" size="mini" @click="handleDownload(row)">
<i class="el-icon-download"></i> 下载
</el-button>
<el-button type="text" size="mini" @click="handleDelete(row)" class="danger-btn">
<i class="el-icon-delete"></i> 删除
</el-button>
</template>
</BaseListManager>
<!-- 报价单编辑抽屉 -->
<QuoteDrawer
:visible.sync="drawerVisible"
:quote-id="currentQuoteId"
:itinerary-id="currentItineraryId"
@saved="handleQuoteSaved"
@closed="handleDrawerClosed"
/>
</div>
</template>
<script>
import BaseListManager from '@/components/common/BaseListManager.vue'
import QuoteDrawer from './components/QuoteDrawer.vue'
import { quoteService } from '@/services/quote'
export default {
name: 'PriceManager',
components: {
BaseListManager,
QuoteDrawer
},
data() {
return {
dataList: [],
loading: false,
pagination: {
currentPage: 1,
pageSize: 20,
total: 0
},
// 抽屉相关
drawerVisible: false,
currentQuoteId: null,
currentItineraryId: null,
columns: [
{
type: 'index',
label: '序号',
width: 60
},
{
prop: 'itineraryName',
label: '行程名称',
showOverflowTooltip: true
},
{
prop: 'days',
label: '天数',
formatter: (row) => `${row.days}天`
},
{
prop: 'hotelCount',
label: '酒店数量',
width: 100,
formatter: (row) => `${row.hotelCount}家`
},
{
prop: 'ticketCount',
label: '门票数量',
formatter: (row) => `${row.ticketCount}个`
},
{
prop: 'mealCount',
label: '餐饮数量',
width: 100,
formatter: (row) => `${row.mealCount}餐`
},
{
prop: 'transportCount',
label: '包车次数',
formatter: (row) => `${row.transportCount}次`
},
{
prop: 'otherItems',
label: '其它项目',
formatter: (row) => row.otherItems || '-'
},
{
prop: 'totalPrice',
label: '总报价',
slotName: 'totalPrice'
},
// {
// prop: 'status',
// label: '状态',
// width: 100,
// slotName: 'status'
// },
{
prop: 'createTime',
label: '创建时间',
formatter: (row) => this.formatDate(row.createTime)
}
],
actionColumn: {
label: '操作',
fixed: 'right'
},
addActionConfig: {
buttonText: '创建报价单'
}
}
},
created() {
this.loadData()
},
methods: {
async loadData() {
this.loading = true
try {
const params = {
pageIndex: this.pagination.currentPage,
pageSize: this.pagination.pageSize
}
const response = await quoteService.getQuoteList(params)
if (response && response.data) {
// 处理数据,计算各项统计
const processedData = (response.data.list || []).map(item => {
return {
...item,
// 基于行程数据计算统计信息
days: this.calculateDays(item.itinerary),
hotelCount: this.calculateHotelCount(item.itinerary),
ticketCount: this.calculateTicketCount(item.itinerary),
mealCount: this.calculateMealCount(item.itinerary),
transportCount: this.calculateTransportCount(item.itinerary),
otherItems: this.calculateOtherItems(item.itinerary)
}
})
this.dataList = processedData
this.pagination.total = response.data.total || 0
}
} catch (error) {
console.error('加载报价列表失败:', error)
this.$message.error('加载数据失败')
// 如果API还未实现,使用模拟数据进行演示
this.loadMockData()
} finally {
this.loading = false
}
},
// 模拟数据用于演示
loadMockData() {
this.dataList = [
{
id: '1',
itineraryName: '九寨沟黄龙3日游',
days: 3,
hotelCount: 2,
ticketCount: 5,
mealCount: 6,
transportCount: 2,
otherItems: '导游服务',
totalPrice: 2580.00,
status: 'approved',
createTime: '2024-01-15 10:30:00'
},
{
id: '2',
itineraryName: '成都熊猫基地一日游',
days: 1,
hotelCount: 0,
ticketCount: 2,
mealCount: 2,
transportCount: 1,
otherItems: '接送服务',
totalPrice: 380.00,
status: 'pending',
createTime: '2024-01-16 14:20:00'
},
{
id: '3',
itineraryName: '稻城亚丁7日深度游',
days: 7,
hotelCount: 6,
ticketCount: 8,
mealCount: 14,
transportCount: 3,
otherItems: '高原氧气',
totalPrice: 4680.00,
status: 'draft',
createTime: '2024-01-17 09:15:00'
}
]
this.pagination.total = 3
},
// 计算天数
calculateDays(itinerary) {
if (!itinerary || !itinerary.days) return 0
return itinerary.days.length
},
// 计算酒店数量
calculateHotelCount(itinerary) {
if (!itinerary || !itinerary.days) return 0
let count = 0
itinerary.days.forEach(day => {
if (day.hotels && day.hotels.length > 0) {
count += day.hotels.length
}
})
return count
},
// 计算门票数量
calculateTicketCount(itinerary) {
if (!itinerary || !itinerary.days) return 0
let count = 0
itinerary.days.forEach(day => {
if (day.attractions && day.attractions.length > 0) {
count += day.attractions.length
}
})
return count
},
// 计算餐饮数量
calculateMealCount(itinerary) {
if (!itinerary || !itinerary.days) return 0
let count = 0
itinerary.days.forEach(day => {
if (day.caterings && day.caterings.length > 0) {
count += day.caterings.length
}
})
return count
},
// 计算包车次数
calculateTransportCount(itinerary) {
if (!itinerary || !itinerary.days) return 0
let count = 0
itinerary.days.forEach(day => {
if (day.traffics && day.traffics.length > 0) {
// 只统计非航班的交通方式
count += day.traffics.filter(t => t.type !== '航班').length
}
})
return count
},
// 计算其他项目
calculateOtherItems(itinerary) {
if (!itinerary) return '-'
const items = []
// 可以根据行程中的其他信息来判断
if (itinerary.include) items.push('包含项目')
if (itinerary.notInclude) items.push('不含项目')
if (itinerary.warning) items.push('注意事项')
return items.length > 0 ? items.join(', ') : '-'
},
handleAddQuote() {
// 打开新增抽屉,可以传入行程ID
this.currentQuoteId = null
this.currentItineraryId = this.$route.query.itineraryId || '2f09262b-c4aa-4e53-b5ff-178254ecbcf4'
this.drawerVisible = true
},
handleEdit(row) {
// 打开编辑抽屉
this.currentQuoteId = row.id
this.currentItineraryId = row.itineraryId
this.drawerVisible = true
},
handleQuoteSaved() {
// 报价单保存后刷新列表
this.loadData()
this.drawerVisible = false
},
handleDrawerClosed() {
// 抽屉关闭后清理状态
this.currentQuoteId = null
this.currentItineraryId = null
},
async handleDownload(row) {
try {
this.$message.info('正在生成报价单...')
const response = await quoteService.downloadQuote(row.id)
// 创建下载链接
const blob = new Blob([response], { type: 'application/pdf' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${row.itineraryName}_报价单.pdf`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
this.$message.success('下载成功')
} catch (error) {
console.error('下载失败:', error)
this.$message.error('下载失败')
}
},
handleDelete(row) {
this.$confirm(`确定要删除报价单"${row.itineraryName}"吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
await quoteService.deleteQuote(row.id)
this.$message.success('删除成功')
this.loadData()
} catch (error) {
console.error('删除失败:', error)
this.$message.error('删除失败')
}
}).catch(() => {
// 用户取消删除
})
},
handlePageChange(page) {
this.pagination.currentPage = page
this.loadData()
},
handleSizeChange(size) {
this.pagination.pageSize = size
this.pagination.currentPage = 1
this.loadData()
},
formatPrice(price) {
if (!price) return '0.00'
return parseFloat(price).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
},
formatDate(dateStr) {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
},
getStatusType(status) {
const statusMap = {
'draft': 'info',
'pending': 'warning',
'approved': 'success',
'rejected': 'danger'
}
return statusMap[status] || 'info'
},
getStatusText(status) {
const statusMap = {
'draft': '草稿',
'pending': '待审核',
'approved': '已通过',
'rejected': '已拒绝'
}
return statusMap[status] || '未知'
}
}
}
</script>
<style scoped>
.price-manager {
padding: 20px;
background: #f5f5f5;
min-height: 100vh;
}
.price-text {
color: #e6a23c;
font-size: 14px;
}
.danger-btn {
color: #f56c6c;
}
.danger-btn:hover {
color: #f56c6c;
}
</style>
\ No newline at end of file
......@@ -123,7 +123,7 @@ export default {
let isOnline = 0; //0-本地测试,1-线上
let ocrUrl = "http://192.168.5.46:8888";
// domainUrl = "http://192.168.5.214";
domainUrl = "http://192.168.5.39:8083"
domainUrl = "http://192.168.5.214"
// domainUrl = "http://192.168.5.204:8030"
// domainUrl = "http://reborn.oytour.com";
let crmLocalFileStreamDownLoadUrl = "";
......@@ -1989,12 +1989,12 @@ export default {
}
Vue.prototype.deepFirstLetterToLower = function (obj) {
if (typeof obj !== 'object' || obj === null) return obj; // 非对象直接返回
// 处理数组:遍历每个元素递归转换
if (Array.isArray(obj)) {
return obj.map(item => deepFirstLetterToLower(item));
}
// 处理普通对象:遍历键值对递归转换
const newObj = {};
for (const key in obj) {
......@@ -2005,7 +2005,7 @@ export default {
}
return newObj;
}
// 基础首字母转小写函数(同上)
Vue.prototype.firstLetterToLower = function (str) {
if (typeof str !== 'string' || str.length === 0) return '';
......
......@@ -6832,5 +6832,29 @@ export default {
title: '编辑行程'
}
},
{
path: '/quoted-price/price',
name: 'quotedPrice',
component: resolve => require(['@/pages/quoted-price/price'], resolve),
meta: {
title: '报价管理'
}
},
{
path: '/quoted-price/create',
name: 'quotedPriceCreate',
component: resolve => require(['@/pages/quoted-price/create'], resolve),
meta: {
title: '创建报价单'
}
},
{
path: '/quoted-price/edit/:id',
name: 'quotedPriceEdit',
component: resolve => require(['@/pages/quoted-price/edit'], resolve),
meta: {
title: '编辑报价单'
}
},
]
}
import rwRequest from '@/plug/rwRequest';
export const quoteService = {
/**
* 获取报价列表
* @param {Object} params - 查询参数
* @returns {Promise} - API 响应
*/
getQuoteList: (params) => {
return rwRequest.get('/quote/list', { params });
},
/**
* 获取报价详情
* @param {string} id - 报价ID
* @returns {Promise} - API 响应
*/
getQuoteDetail: (id) => {
return rwRequest.get(`/quote/${id}`);
},
/**
* 创建报价
* @param {Object} data - 报价数据
* @returns {Promise} - API 响应
*/
createQuote: (data) => {
return rwRequest.post('/quote', data);
},
/**
* 更新报价
* @param {Object} data - 报价数据
* @returns {Promise} - API 响应
*/
updateQuote: (data) => {
return rwRequest.put('/quote', data);
},
/**
* 删除报价
* @param {string} id - 报价ID
* @returns {Promise} - API 响应
*/
deleteQuote: (id) => {
return rwRequest.delete(`/quote/${id}`);
},
/**
* 下载报价单
* @param {string} id - 报价ID
* @returns {Promise} - API 响应
*/
downloadQuote: (id) => {
return rwRequest.get(`/quote/${id}/download`, {
responseType: 'blob'
});
},
/**
* 根据行程ID生成报价
* @param {string} itineraryId - 行程ID
* @returns {Promise} - API 响应
*/
generateQuoteFromItinerary: (itineraryId) => {
return rwRequest.post(`/quote/generate/${itineraryId}`);
},
/**
* 审核报价
* @param {string} id - 报价ID
* @param {string} status - 审核状态 (approved/rejected)
* @param {string} remark - 审核备注
* @returns {Promise} - API 响应
*/
reviewQuote: (id, status, remark) => {
return rwRequest.post(`/quote/${id}/review`, {
status,
remark
});
},
/**
* 根据行程ID获取行程详情(用于报价单生成)
* @param {string} itineraryId - 行程ID
* @returns {Promise} - API 响应
*/
getItineraryForQuote: (itineraryId) => {
return rwRequest.get(`/itinerary/${itineraryId}/for-quote`);
},
/**
* 复制报价单
* @param {string} id - 报价ID
* @returns {Promise} - API 响应
*/
copyQuote: (id) => {
return rwRequest.post(`/quote/${id}/copy`);
}
};
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment