Files
shanxia2.0/web-admin/src/components/QuoteSelector.vue
T
2026-08-23 09:47:38 +08:00

389 lines
12 KiB
Vue

<template>
<el-dialog
:title="dialogTitle"
v-model="dialogVisible"
width="calc(100vw - 48px)"
align-center
class="dialog-form-layout quote-selector-dialog"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
append-to-body
>
<!-- 提示信息 -->
<el-alert v-if="props.customerId" :title="`已筛选客户的报价单`" type="info" :closable="false" class="mb-3" show-icon />
<!-- 报价单列表 -->
<div class="panel dialog-list-panel">
<div class="list-toolbar">
<div class="list-toolbar-row quote-selector-toolbar-row">
<ListQuickSearchInput
v-model="quickSearchKeyword"
placeholder="报价编号/客户名称/拼音首字母"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
</div>
</div>
<div class="list-body list-wrap dialog-list-body-only">
<el-table
ref="tableRef"
v-loading="loading"
:data="quoteList"
class="list-table quote-selector-table"
stripe
highlight-current-row
row-key="id"
@current-change="handleCurrentChange"
max-height="calc(100vh - 280px)"
>
<el-table-column label="" min-width="48" align="center" header-align="center">
<template #default="{ row }">
<el-radio v-model="selectedId" :label="String(row.id)" @click.stop @change="handleRadioChange(row)">
<span class="sr-only">选择</span>
</el-radio>
</template>
</el-table-column>
<el-table-column type="index" label="序号" width="60" align="center" header-align="center" />
<el-table-column label="报价单编号" prop="quoteNo" min-width="200" align="center" header-align="center" :show-overflow-tooltip="true" />
<el-table-column label="状态" prop="status" min-width="100" align="center" header-align="center">
<template #default="scope">
<DictTag dict-type="crm_quote_status" :value="scope.row.status" />
</template>
</el-table-column>
<el-table-column label="客户名称" prop="customerName" min-width="220" align="center" header-align="center" :show-overflow-tooltip="true" />
<el-table-column label="产品信息" min-width="280" align="left" header-align="center">
<template #default="{ row }">
<div class="product-info-cell">
<div v-for="(line, idx) in formatQuoteProductLines(row)" :key="idx" class="product-info-line">
{{ line }}
</div>
</div>
</template>
</el-table-column>
<el-table-column label="合同关联情况" prop="contractLinkedCount" min-width="120" align="center" header-align="center">
<template #default="scope">
<span>{{ scope.row.contractLinkedCount != null ? `已关联${scope.row.contractLinkedCount}个合同` : '未关联' }}</span>
</template>
</el-table-column>
<el-table-column label="报价金额(元)" prop="finalAmount" min-width="130" align="right" header-align="center">
<template #default="scope">
<span class="money-display">{{
scope.row.finalAmount != null
? `¥${Number(scope.row.finalAmount).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
: '-'
}}</span>
</template>
</el-table-column>
<el-table-column label="报价日期" prop="quoteDate" min-width="120" align="center" header-align="center" />
<el-table-column label="有效期" prop="expireDate" min-width="120" align="center" header-align="center" />
<el-table-column label="负责人" prop="ownerUserName" min-width="100" align="center" header-align="center" :show-overflow-tooltip="true" />
</el-table>
<ListPagination
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</div>
</div>
<!-- 当前选中报价单 -->
<template v-if="currentQuote">
<el-divider content-position="left">当前选中报价单</el-divider>
<div class="selected-quote">
<el-descriptions :column="3" border>
<el-descriptions-item label="报价标题">{{ currentQuote.quoteTitle }}</el-descriptions-item>
<el-descriptions-item label="报价编号">{{ currentQuote.quoteNo }}</el-descriptions-item>
<el-descriptions-item label="客户名称">{{ currentQuote.customerName }}</el-descriptions-item>
<el-descriptions-item label="报价金额(元)">
<span class="money-display">{{
currentQuote.finalAmount != null
? `¥${Number(currentQuote.finalAmount).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
: '-'
}}</span>
</el-descriptions-item>
<el-descriptions-item label="报价日期">{{ currentQuote.quoteDate || '-' }}</el-descriptions-item>
<el-descriptions-item label="有效期至">{{ currentQuote.expireDate || '-' }}</el-descriptions-item>
</el-descriptions>
</div>
</template>
<template #footer>
<div class="dialog-footer">
<el-button class="btn-ghost" @click="handleClose">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-main" type="primary" @click="handleConfirm" :disabled="!currentQuote">
<el-icon><Check /></el-icon>确定
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts" name="QuoteSelector">
import { useDictStore } from '@/store/modules/dict'
import DictTag from '@/components/DictTag/index.vue'
import ListPagination from '@/components/ListPagination/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import { Check, Close } from '@element-plus/icons-vue'
import { ref, watch, computed, nextTick } from 'vue'
import type { TableInstance } from 'element-plus'
import { listQuote, getQuote } from '@/api/crm/quote'
import type { QuoteProductForm, QuoteQuery, QuoteVO } from '@/api/crm/quote/types'
interface Props {
visible: boolean
customerId?: number | string
}
const props = defineProps<Props>()
const dictStore = useDictStore()
const dialogTitle = computed(() => {
return props.customerId ? '选择报价单(已筛选当前客户)' : '选择报价单'
})
const emit = defineEmits(['update:visible', 'confirm'])
const dialogVisible = ref(false)
watch(
() => props.visible,
async (val) => {
dialogVisible.value = val
if (val) {
await dictStore.loadAllDict()
quickSearchKeyword.value = ''
queryParams.value.pageNum = 1
queryParams.value.keyword = undefined
selectedId.value = ''
currentQuote.value = null
getList()
}
}
)
watch(
() => props.customerId,
(val) => {
if (dialogVisible.value) {
queryParams.value.customerId = val
getList()
}
}
)
watch(dialogVisible, (val) => {
emit('update:visible', val)
})
const queryParams = ref<QuoteQuery>({
pageNum: 1,
pageSize: 20,
keyword: undefined,
customerId: props.customerId,
excludeStatus: 'invalid'
})
const quickSearchKeyword = ref('')
const quoteList = ref<QuoteVO[]>([])
const loading = ref(false)
const total = ref(0)
const currentQuote = ref<QuoteVO | null>(null)
const selectedId = ref('')
const tableRef = ref<TableInstance>()
/** 单位字典 code → 展示文案 */
const resolveUnitLabel = (unit?: string) => {
if (unit == null || String(unit).trim() === '') return ''
return dictStore.getLabel('crm_product_unit', unit) || String(unit).trim()
}
/** 单行产品:产品名称 规格型号 数量 单位;空值跳过,不展示占位符 */
const formatProductLine = (product: QuoteProductForm): string => {
const parts = [
product.productName,
product.model,
product.quantity != null ? String(product.quantity) : '',
resolveUnitLabel(product.unit)
]
.map((v) => (v == null ? '' : String(v).trim()))
.filter(Boolean)
return parts.join(' ')
}
/** 报价单产品信息:一行一个产品 */
const formatQuoteProductLines = (row: QuoteVO): string[] => {
const list = row.productList || []
return list.map(formatProductLine).filter(Boolean)
}
const getList = async () => {
loading.value = true
try {
queryParams.value.customerId = props.customerId
const res = await listQuote({
...queryParams.value,
keyword: queryParams.value.keyword || undefined,
customerId: props.customerId != null ? String(props.customerId) : undefined
})
quoteList.value = res.rows || []
total.value = res.total ?? 0
await nextTick()
syncTableSelection()
} catch (error) {
console.error('获取报价单列表失败:', error)
} finally {
loading.value = false
}
}
const syncTableSelection = () => {
const table = tableRef.value
if (!table) return
if (!selectedId.value) {
table.setCurrentRow(undefined)
return
}
const row = quoteList.value.find((item) => String(item.id) === selectedId.value)
if (row) {
table.setCurrentRow(row)
} else {
table.setCurrentRow(undefined)
}
}
/** ListQuickSearchInput 内部已 debounce 300ms,输入即检索 */
const handleQuickSearch = () => {
queryParams.value.pageNum = 1
queryParams.value.keyword = quickSearchKeyword.value?.trim() || undefined
getList()
}
const handleQuickSearchClear = () => {
quickSearchKeyword.value = ''
queryParams.value.pageNum = 1
queryParams.value.keyword = undefined
getList()
}
const selectQuoteRow = async (row?: QuoteVO | null) => {
if (!row?.id) {
selectedId.value = ''
currentQuote.value = null
syncTableSelection()
return
}
const rowId = String(row.id)
if (rowId === selectedId.value && currentQuote.value && String(currentQuote.value.id) === rowId) {
syncTableSelection()
return
}
selectedId.value = rowId
syncTableSelection()
try {
// 加载完整的报价单信息(包括产品明细),确保 productList 可供合同表单使用
const res = await getQuote(row.id)
currentQuote.value = res.data
} catch (error) {
console.error('获取报价单详情失败:', error)
// 降级:使用列表行数据
currentQuote.value = row
}
}
const handleCurrentChange = async (row?: QuoteVO) => {
await selectQuoteRow(row)
}
const handleRadioChange = async (row: QuoteVO) => {
await selectQuoteRow(row)
}
const handleConfirm = () => {
emit('confirm', currentQuote.value)
handleClose()
}
const handleClose = () => {
currentQuote.value = null
selectedId.value = ''
dialogVisible.value = false
}
</script>
<style scoped>
.selected-quote {
padding: 10px;
background-color: #f5f7fa;
border-radius: 4px;
}
.mb-3 {
margin-bottom: 16px;
}
/* 检索框约占工具栏 1/3 宽,避免独占整行 */
.quote-selector-toolbar-row :deep(.quick-search-input) {
width: 33.333%;
max-width: 33.333%;
flex: 0 0 33.333%;
}
.product-info-cell {
padding: 4px 0;
line-height: 1.5;
text-align: left;
white-space: normal;
}
.product-info-line + .product-info-line {
margin-top: 2px;
}
.quote-selector-table {
:deep(.el-radio) {
height: auto;
margin-right: 0;
}
:deep(.el-radio__label) {
display: none;
}
/* 选中行:比默认 current-row 更深的薄荷绿底 */
:deep(.el-table__body tr.current-row > td.el-table__cell) {
background-color: #a7f3d0 !important;
}
:deep(.el-table__body tr.current-row:hover > td.el-table__cell) {
background-color: #6ee7b7 !important;
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
<style>
/* 选择报价单弹窗:宽高最大化(贴合 dialog-form-layout 视口留白) */
.quote-selector-dialog.el-dialog {
width: calc(100vw - 48px) !important;
height: calc(100vh - var(--dialog-viewport-inset, 20px) * 2) !important;
max-width: calc(100vw - 48px) !important;
max-height: calc(100vh - var(--dialog-viewport-inset, 20px) * 2) !important;
margin: 0 !important;
}
</style>