2026-08-19 16:45:51 +08:00
< script setup lang = "ts" >
2026-10-08 17:08:03 +08:00
import { useExportTask } from '@/modules/downloads'
2026-09-15 09:58:15 +08:00
import '@/styles/adminDensity.css'
2026-08-20 18:02:52 +08:00
import { onMounted , reactive , ref , watch } from 'vue'
2026-09-15 09:58:15 +08:00
import { Download , Plus } from '@lucide/vue'
2026-08-20 18:02:52 +08:00
import { ElMessage , ElMessageBox } from 'element-plus'
2026-08-19 16:45:51 +08:00
import AdminPage from '@/shared/components/AdminPage.vue'
2026-08-20 18:02:52 +08:00
import AppDialog from '@/shared/components/AppDialog.vue'
2026-08-19 16:45:51 +08:00
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar , { type QueryField , type QueryModel } from '@/shared/components/QueryToolbar.vue'
2026-08-20 18:02:52 +08:00
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
2026-10-08 17:08:03 +08:00
import { changeQuoteStatus , createQuote , getQuote , listQuotes , listQuoteWarehouseOptions , updateQuote } from '../api/overseasMasterData'
2026-08-20 18:02:52 +08:00
import { overseasMasterDataPermissions as permission } from '../permissions'
import { masterDataErrorMessage } from '../apiError'
import type { MasterDataStatus , OverseasTransitPrice , OverseasWarehouseFee , OverseasWarehouseQuote , OverseasWarehouseQuoteDetail , PartnerWarehouseOption } from '../types'
2026-08-19 16:45:51 +08:00
2026-10-08 17:08:03 +08:00
const submitExport = useExportTask ()
2026-08-20 18:02:52 +08:00
const access = useAccessPreviewStore ()
const rows = ref < OverseasWarehouseQuote [] >([]), total = ref ( 0 ), page = ref ( 1 ), pageSize = ref ( 20 ), loading = ref ( false ), saving = ref ( false ), exporting = ref ( false )
const error = ref < ApiErrorDetails | null >( null ), options = ref < PartnerWarehouseOption [] >([]), detail = ref < OverseasWarehouseQuoteDetail | null >( null )
2026-10-20 10:11:31 +08:00
const detailOpen = ref ( false ), editorOpen = ref ( false ), editingId = ref ( '' ), editingWarehouse = ref ( '' )
2026-08-20 18:02:52 +08:00
const filters = ref < QueryModel >({ keyword : '' , warehouseId : '' , providerName : '' , currency : '' , status : '' })
const fields = ref < QueryField [] >([])
function syncFields () { fields . value = [
2026-08-19 16:45:51 +08:00
{ key : 'keyword' , type : 'input' , label : '报价名称 / 版本' , placeholder : '报价名称 / 版本' , width : 180 },
2026-08-20 18:02:52 +08:00
{ key : 'warehouseId' , type : 'select' , label : '海外仓' , placeholder : '全部海外仓' , options : options . value . map ( v => ({ label : ` ${ v . warehouseName } · ${ v . warehouseCode } ` , value : v . warehouseId })), width : 220 },
{ key : 'providerName' , type : 'input' , label : '服务商' , placeholder : '服务商' , width : 170 },
{ key : 'currency' , type : 'select' , label : '币种' , placeholder : '全部币种' , options : [ 'USD' , 'CNY' ]. map ( v => ({ label : v , value : v })), width : 120 },
{ key : 'status' , type : 'select' , label : '状态' , placeholder : '全部状态' , options : [{ label : '启用' , value : 'ACTIVE' },{ label : '停用' , value : 'INACTIVE' }], width : 120 },
] }
syncFields ()
2026-08-19 16:45:51 +08:00
const columns : DataTableColumn [] = [
2026-08-20 18:02:52 +08:00
{ key : 'actions' , label : '操作' , width : 270 , fixed : 'left' , slotName : 'actions' }, { key : 'name' , prop : 'name' , label : '报价名称' , minWidth : 190 },
{ key : 'warehouseName' , prop : 'warehouseName' , label : '海外仓' , minWidth : 170 }, { key : 'providerName' , prop : 'providerName' , label : '服务商' , minWidth : 180 },
{ key : 'currency' , prop : 'currency' , label : '币种' , width : 74 }, { key : 'effectiveStart' , prop : 'effectiveStart' , label : '生效日期' , width : 112 },
{ key : 'effectiveEnd' , prop : 'effectiveEnd' , label : '失效日期' , width : 112 }, { key : 'versionCode' , prop : 'versionCode' , label : '报价版本' , width : 92 },
{ key : 'status' , label : '状态' , width : 76 , slotName : 'status' }, { key : 'updatedAt' , prop : 'updatedAt' , label : '更新时间' , minWidth : 150 }, { key : 'remark' , prop : 'remark' , label : '备注' , minWidth : 180 },
2026-08-19 16:45:51 +08:00
]
2026-08-20 18:02:52 +08:00
const form = reactive ({ name : '' , warehouseId : '' , providerName : '' , currency : 'USD' , effectiveStart : '' , effectiveEnd : '' , versionCode : 'V1.0' , remark : '' , recordVersion : 0 , warehouseFees : [] as OverseasWarehouseFee [], transitPrices : [] as OverseasTransitPrice [] })
function resetForm (){ Object . assign ( form ,{ name : '' , warehouseId : '' , providerName : '' , currency : 'USD' , effectiveStart : '' , effectiveEnd : '' , versionCode : 'V1.0' , remark : '' , recordVersion : 0 , warehouseFees : [{ item : '入库卸货' , billingUnit : '托' , unitPrice : 0 , minimumCharge : null , remark : null }], transitPrices : []}) }
function params (){ return { page : page . value , size : pageSize . value , keyword : String ( filters . value . keyword || '' ) || undefined , warehouseId : String ( filters . value . warehouseId || '' ) || undefined , providerName : String ( filters . value . providerName || '' ) || undefined , currency : String ( filters . value . currency || '' ) || undefined , status : ( filters . value . status || undefined ) as MasterDataStatus | undefined } }
async function load (){ loading . value = true ; error . value = null ; try { const r = await listQuotes ( params ()); rows . value = r . records ; total . value = r . total } catch ( e ){ rows . value = []; total . value = 0 ; error . value = getApiErrorDetails ( e , '海外仓报价加载失败' )} finally { loading . value = false } }
async function loadOptions (){ try { options . value = await listQuoteWarehouseOptions (); syncFields ()} catch ( e ){ ElMessage . error ( masterDataErrorMessage ( e , '合作仓候选加载失败' ))} }
function search (){ if ( page . value === 1 ) void load (); else page . value = 1 }
async function openDetail ( row : OverseasWarehouseQuote ){ try { detail . value = await getQuote ( row . id ); detailOpen . value = true } catch ( e ){ ElMessage . error ( masterDataErrorMessage ( e , '报价详情加载失败' ))} }
2026-10-20 10:11:31 +08:00
function fill ( d : OverseasWarehouseQuoteDetail ){ const s = d . summary ; editingWarehouse . value = [ s . warehouseName , s . warehouseCode ]. filter ( Boolean ). join ( ' · ' ) || '—' ; Object . assign ( form ,{ name : s . name , warehouseId : s . warehouseId , providerName : s . providerName , currency : s . currency , effectiveStart : s . effectiveStart , effectiveEnd : s . effectiveEnd , versionCode : s . versionCode , remark : s . remark || '' , recordVersion : s . recordVersion , warehouseFees : d . warehouseFees . map ( v =>({... v })), transitPrices : d . transitPrices . map ( v =>({... v }))})}
2026-08-20 18:02:52 +08:00
async function openCreate (){ editingId . value = '' ; resetForm (); if ( ! options . value . length ) await loadOptions (); editorOpen . value = true }
async function openEdit ( row : OverseasWarehouseQuote ){ try { const d = await getQuote ( row . id ); editingId . value = row . id ; fill ( d ); editorOpen . value = true } catch ( e ){ ElMessage . error ( masterDataErrorMessage ( e , '报价详情加载失败' ))}}
async function save (){ if ( ! form . name ||! form . warehouseId ||! form . providerName ||! form . effectiveStart ||! form . effectiveEnd ||! form . warehouseFees . length ){ ElMessage . warning ( '请完整填写报价必填信息' ); return } if ( form . effectiveEnd < form .effectiveStart ) { ElMessage.warning (' 失效日期不能早于生效日期 '); return } saving.value = true ; try { const common = { name : form . name , providerName : form . providerName , currency : form . currency as 'USD' | 'CNY' , effectiveStart : form . effectiveStart , effectiveEnd : form . effectiveEnd , warehouseFees : form . warehouseFees , transitPrices : form . transitPrices , remark : form . remark || null } ; if ( editingId.value ) await updateQuote ( editingId.value , { ...common , recordVersion : form.recordVersion } ); else await createQuote ( { ...common , warehouseId : form.warehouseId , versionCode : form.versionCode } ); ElMessage.success ( editingId.value ?' 报价已更新 ' : ' 报价已创建 '); editorOpen.value = false ; await load () } catch ( e ) { ElMessage.error ( masterDataErrorMessage ( e ,' 报价保存失败 ')) } finally { saving.value = false }}
2026-10-20 10:11:31 +08:00
async function toggle ( row : OverseasWarehouseQuote ) { const next : MasterDataStatus = row.status ===' ACTIVE '?' INACTIVE ' : ' ACTIVE '; try { await ElMessageBox.confirm (` 确认 $ { next = ==' ACTIVE '?' 启用 ' : ' 停用 ' } “$ { row.name } ” 吗 ? `,`${ next = ==' ACTIVE '?' 启用 ' : ' 停用 ' } 海外仓报价 · $ { row.name.trim () || '—' } `, { customClass : ' admin -density-confirm ', type : ' warning ' } ); await changeQuoteStatus ( row.id , next , row.recordVersion ); ElMessage.success (' 报价状态已更新 '); await load () } catch ( e ) { if ( e !==' cancel '&& e !==' close ') ElMessage.error ( masterDataErrorMessage ( e ,' 报价状态更新失败 ')) }}
2026-10-08 17:08:03 +08:00
async function exportCsv () { exporting.value = true ; try { const { page : _ , size : __ , ...q } = params (); await submitExport (' OVERSEAS_QUOTE_LIST ', ' 海外报价 ', q ) } catch ( e ) { ElMessage.error ( masterDataErrorMessage ( e ,' 报价导出失败 ')) } finally { exporting.value = false }}
2026-08-20 18:02:52 +08:00
watch ([ page , pageSize ], load ); onMounted (()= >{ void Promise . all ([ load (), loadOptions ()])})
2026-08-19 16:45:51 +08:00
</ script >
2026-09-15 09:58:15 +08:00
< template >< AdminPage class = "admin-density" >< ListPageLayout aria -label =" 海外仓报价列表 " >
2026-09-30 14:01:58 +08:00
< section >< DataTable appearance = "integrated" :stripe = "false" column -settings-mode =" show " :results-visible = "true" v -model:page = "page" v -model:page-size = "pageSize" :data = "rows" :columns = "columns" :total = "total" :loading = "loading" preference -key =" overseas -quotes-table " row -key =" id " empty -text =" 暂无海外仓报价 " >
< template # toolbar -start >< h1 > 海外仓报价 </ h1 ></ template >
< template # filters >< QueryToolbar v-model = "filters" :fields="fields" ariaLabel="海外仓报价筛选" preference-key="overseas-quotes-query" :loading="loading" @search="search" /></ template >
< template # notices >< LoadErrorState v-if = "error" title="海外仓报价加载失败" :message="error.message" @retry="load" /></ template >
2026-10-08 17:08:03 +08:00
< template # toolbar -actions >< el-button v-if = "access.can(permission.quoteCreate)" type="primary" @click="openCreate"><Plus :size="16"/>新增报价</el-button><el-button v-if="access.can(permission.quoteExport) && access.can('platform:download:list')" :loading="exporting" @click="exportCsv"><Download :size="16" /> 导出 </ el -button ></ template >
2026-09-30 14:01:58 +08:00
< template # actions = "{row}" >< div class = "density-actions row-actions admin-text-actions" >< el-button data -tone =" neutral " class = "density-action" @click ="openDetail(row as OverseasWarehouseQuote)" > 详情 </ el -button >< el-button data -tone =" neutral " class = "density-action" v-if = "access.can(permission.quoteEdit)" @click="openEdit(row as OverseasWarehouseQuote)">编辑</el-button><el-button class="density-action" v-if="access.can(permission.quoteStatus)" :data-tone="(row as OverseasWarehouseQuote).status==='ACTIVE'?'danger':'success'" @click="toggle(row as OverseasWarehouseQuote)" >{{ ( row as OverseasWarehouseQuote ) .status ===' ACTIVE '?' 停用 ' : ' 启用 ' }} </ el -button ></ div ></ template >
2026-08-20 18:02:52 +08:00
< template # status = "{row}" >< el-tag : type = "(row as OverseasWarehouseQuote).status==='ACTIVE'?'success':'info'" >{{( row as OverseasWarehouseQuote ). status === 'ACTIVE' ? '启用' : '停用' }}</ el-tag ></ template >
</ DataTable ></ section >
2026-10-20 10:11:31 +08:00
< AppDialog class = "admin-density-dialog" v-model = "detailOpen" :title="detail?.summary.name?.trim() ? `海外仓报价详情 · ${detail.summary.name.trim()}` : '海外仓报价详情'" width="min(980px, calc(100vw - 28px))" fullscreenable :show-confirm="false" >
< div v-if = "detail" class="density-dialog-sections" >
< section class = "density-dialog-section" data -tone =" summary " aria -label =" 报价摘要 " >
< h3 class = "density-dialog-section__title" > 报价信息 </ h3 >
< dl class = "detail-grid density-fields" >< div v-for = "item in [['报价名称',detail.summary.name],['海外仓',detail.summary.warehouseName],['服务商',detail.summary.providerName],['币种',detail.summary.currency],['生效日期',detail.summary.effectiveStart],['失效日期',detail.summary.effectiveEnd],['版本',detail.summary.versionCode],['状态',detail.summary.status === 'ACTIVE' ? '启用' : '停用']]" :key="item[0] || ''" >< dt >{{ item [ 0 ]}}</ dt >< dd >{{ item [ 1 ] || '—' }}</ dd ></ div ></ dl >
</ section >
< section class = "density-dialog-section" data -tone =" source " aria -label =" 仓储作业费用 " >
< h3 class = "density-dialog-section__title" > 仓储作业费用 </ h3 >
< el-table :data = "detail.warehouseFees" border empty -text =" 暂无仓储作业费用 " >< el-table-column prop = "item" label = "费用项目" min -width =" 150 " />< el-table-column prop = "billingUnit" label = "计费单位" width = "100" />< el-table-column prop = "unitPrice" label = "单价" min -width =" 100 " />< el-table-column label = "最低收费" min -width =" 100 " >< template # default = "{row}" >{{ row . minimumCharge ?? '—' }}</ template ></ el-table-column >< el-table-column label = "备注" min -width =" 150 " >< template # default = "{row}" >{{ row . remark || '—' }}</ template ></ el-table-column ></ el-table >
</ section >
< section class = "density-dialog-section" data -tone =" source " aria -label =" 中转派送价格 " >
< h3 class = "density-dialog-section__title" > 中转派送价格 </ h3 >
< el-table :data = "detail.transitPrices" border empty -text =" 暂无中转派送价格 " >< el-table-column prop = "fbaWarehouseCode" label = "目的FBA仓" min -width =" 120 " />< el-table-column prop = "deliveryMethod" label = "车型/提货方式" min -width =" 160 " />< el-table-column label = "预估迈数" min -width =" 100 " >< template # default = "{row}" >{{ row . estimatedMiles ?? '—' }}</ template ></ el-table-column >< el-table-column prop = "price" label = "报价" min -width =" 100 " />< el-table-column label = "最低收费" min -width =" 100 " >< template # default = "{row}" >{{ row . minimumCharge ?? '—' }}</ template ></ el-table-column ></ el-table >
</ section >
</ div >
</ AppDialog >
< AppDialog class = "admin-density-dialog" v-model = "editorOpen" :title="editingId ? `编辑海外仓报价 · ${form.name.trim() || '—'}` : '新增海外仓报价'" width="min(1180px, calc(100vw - 28px))" fullscreenable :confirm-loading="saving" confirm-text="保存报价" @confirm="save" >
< el-form class = "density-form density-dialog-sections" label -position =" left " label -width =" 90px " :disabled = "saving" >
< section class = "density-dialog-section" data -tone =" work " aria -label =" 报价基础信息 " >
< h3 class = "density-dialog-section__title" > 基础信息 </ h3 >
< table class = "density-table density-table--editable quote-basics-table" >< tbody >
< tr >< td >< el-form-item label = "报价名称" >< el-input v-model = "form.name" placeholder="请输入报价名称" /></el-form-item></td><td><el-form-item label="海外仓"><span v-if="editingId">{{ editingWarehouse }}</span><el-select v-else v-model="form.warehouseId"><el-option v-for="o in options" :key="o.warehouseId" :label="`${o.warehouseName} · ${o.warehouseCode}`" :value="o.warehouseId" /> </ el -select ></ el-form-item ></ td ></ tr >
< tr >< td >< el-form-item label = "服务商" >< el-input v-model = "form.providerName" placeholder="请输入服务商名称" /></el-form-item></td><td><el-form-item label="币种"><el-select v-model="form.currency"><el-option label="USD" value="USD"/><el-option label="CNY" value="CNY" /> </ el -select ></ el-form-item ></ td ></ tr >
< tr >< td >< el-form-item label = "生效日期" >< el-date-picker v-model = "form.effectiveStart" type="date" value-format="YYYY-MM-DD"/></el-form-item></td><td><el-form-item label="失效日期"><el-date-picker v-model="form.effectiveEnd" type="date" value-format="YYYY-MM-DD" /> </ el -form-item ></ td ></ tr >
< tr >< td >< el-form-item label = "报价版本" >< span v-if = "editingId">{{form.versionCode || '—'}}</span><el-input v-else v-model="form.versionCode" placeholder="请输入版本号" /></el-form-item></td><td><el-form-item label="备注"><el-input v-model="form.remark" placeholder="请输入备注(选填)" /></ el-form-item ></ td ></ tr >
</ tbody ></ table >
</ section >
< section class = "density-dialog-section" data -tone =" work " aria -label =" 编辑仓储作业费用 " >
< h3 class = "density-dialog-section__title quote-section-heading" > 仓储作业费用 < el-button @click ="form.warehouseFees.push({item:'',billingUnit:'',unitPrice:0,minimumCharge:null,remark:null})" > + 添加费用 </ el -button ></ h3 >
< div class = "quote-lines-scroll" >< table class = "density-table density-table--editable quote-lines-table" >< thead >< tr >< th scope = "col" > 操作 </ th >< th scope = "col" > 费用项目 </ th >< th scope = "col" > 计费单位 </ th >< th scope = "col" > 单价 </ th >< th scope = "col" > 最低收费 </ th ></ tr ></ thead >< tbody >
< tr v-for = "(f,i) in form.warehouseFees" :key="i"><td><el-button data-tone="danger" class="density-action" :aria-label="`删除第${i+1}项费用`" @click="form.warehouseFees.splice(i,1)">删除</el-button></td><td><el-input v-model="f.item" :aria-label="`第${i+1}项费用项目`" placeholder="请输入费用项目"/></td><td><el-input v-model="f.billingUnit" :aria-label="`第${i+1}项计费单位`" placeholder="请输入计费单位"/></td><td><el-input-number v-model="f.unitPrice" :aria-label="`第${i+1}项费用单价`" :min="0" placeholder="请输入单价" /></td><td><el-input-number v-model="f.minimumCharge" :aria-label="`第${i+1}项费用最低收费`" :min="0" placeholder="请输入最低收费" /></ td ></ tr >
< tr v-if = "!form.warehouseFees.length"><td colspan="5" > 暂无仓储作业费用 </ td ></ tr >
</ tbody ></ table ></ div >
</ section >
< section class = "density-dialog-section" data -tone =" work " aria -label =" 编辑中转派送价格 " >
< h3 class = "density-dialog-section__title quote-section-heading" > 中转派送价格 < el-button @click ="form.transitPrices.push({fbaWarehouseCode:'',deliveryMethod:'',estimatedMiles:null,price:0,minimumCharge:null,remark:null})" > + 添加派送价格 </ el -button ></ h3 >
< div class = "quote-lines-scroll" >< table class = "density-table density-table--editable quote-lines-table" >< thead >< tr >< th scope = "col" > 操作 </ th >< th scope = "col" > 目的FBA仓 </ th >< th scope = "col" > 车型 / 提货方式 </ th >< th scope = "col" > 报价 </ th >< th scope = "col" > 最低收费 </ th ></ tr ></ thead >< tbody >
< tr v-for = "(t,i) in form.transitPrices" :key="i"><td><el-button data-tone="danger" class="density-action" :aria-label="`删除第${i+1}项派送价格`" @click="form.transitPrices.splice(i,1)">删除</el-button></td><td><el-input v-model="t.fbaWarehouseCode" :aria-label="`第${i+1}项目的FBA仓`" placeholder="请输入目的 FBA 仓"/></td><td><el-input v-model="t.deliveryMethod" :aria-label="`第${i+1}项车型或提货方式`" placeholder="请输入车型或提货方式"/></td><td><el-input-number v-model="t.price" :aria-label="`第${i+1}项派送报价`" :min="0" placeholder="请输入报价" /></td><td><el-input-number v-model="t.minimumCharge" :aria-label="`第${i+1}项派送最低收费`" :min="0" placeholder="请输入最低收费" /></ td ></ tr >
< tr v-if = "!form.transitPrices.length"><td colspan="5" > 暂无中转派送价格 </ td ></ tr >
</ tbody ></ table ></ div >
</ section >
</ el -form >
</ AppDialog >
2026-08-20 18:02:52 +08:00
</ ListPageLayout ></ AdminPage ></ template >
2026-10-20 10:11:31 +08:00
< style scoped >
. detail - grid . density - fields { grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr )); }
. quote - basics - table { table - layout : fixed ; }
. quote - basics - table td { width : 50 % ; }
. quote - section - heading { justify - content : space - between ; }
. quote - lines - scroll { overflow - x : auto ; }
. quote - lines - table { table - layout : fixed ; min - width : 760 px ; }
. quote - lines - table th : first - child { width : 70 px ; }
. quote - lines - table th : nth - child ( 2 ) { width : 25 % ; }
@ media ( max - width : 600 px ) { . quote - basics - table tr { display : grid ; } . quote - basics - table td { width : auto ; } }
</ style >