4608 lines
136 KiB
Vue
4608 lines
136 KiB
Vue
<template>
|
||
<div class="service-channel-layout">
|
||
<!-- 左侧渠道列表:使用公共组件 CustomLeftMenu(参考销售产品样式) -->
|
||
<div class="channel-sidebar">
|
||
<CustomLeftMenu
|
||
:tree-data="channelTreeData"
|
||
:selected-keys="channelSelectedKeys"
|
||
v-model:expanded-keys="channelExpandedKeys"
|
||
v-model:search-keyword="searchKeyword"
|
||
:search-placeholder="t('channel.serviceChannels.search.placeholder')"
|
||
:refresh-title="t('channel.serviceChannels.search.refresh')"
|
||
:add-title="t('channel.serviceChannels.search.addProvider')"
|
||
:show-node-index="true"
|
||
:show-method="false"
|
||
:show-map-count="false"
|
||
:show-node-code="false"
|
||
:show-node-actions="true"
|
||
:show-branch-node-actions="true"
|
||
:delete-label="t('channel.serviceChannels.actions.delete')"
|
||
@search="handleSearch"
|
||
@refresh="handleSearch"
|
||
@add="handleAddProvider"
|
||
@select="handleChannelSelectFromTree"
|
||
>
|
||
<template #branch-node-menu="{ nodeData }">
|
||
<a-doption @click="() => handleLeftMenuBranchEdit(nodeData)">
|
||
<icon-edit style="margin-right: 6px" />
|
||
{{ t('channel.serviceChannels.leftMenu.editProvider') }}
|
||
</a-doption>
|
||
<a-doption @click="() => handleLeftMenuBranchDelete(nodeData)">
|
||
<icon-delete style="margin-right: 6px" />
|
||
{{ t('channel.serviceChannels.leftMenu.deleteProvider') }}
|
||
</a-doption>
|
||
<a-doption @click="() => handleLeftMenuBranchAddChannel(nodeData)">
|
||
<icon-plus style="margin-right: 6px" />
|
||
{{ t('channel.serviceChannels.leftMenu.addChannelHere') }}
|
||
</a-doption>
|
||
</template>
|
||
<template #node-menu="{ nodeData }">
|
||
<a-doption @click="() => handleLeftMenuLeafEdit(nodeData)">
|
||
<icon-edit style="margin-right: 6px" />
|
||
{{ t('channel.serviceChannels.leftMenu.editChannel') }}
|
||
</a-doption>
|
||
<a-doption @click="() => handleLeftMenuLeafDelete(nodeData)">
|
||
<icon-delete style="margin-right: 6px" />
|
||
{{ t('channel.serviceChannels.actions.delete') }}
|
||
</a-doption>
|
||
</template>
|
||
<template #node-second-row-extra="{ nodeData }">
|
||
<table class="channel-second-row-table">
|
||
<tr>
|
||
<td class="channel-row-left">
|
||
<span class="create-time">{{ t('channel.serviceChannels.createTime', { time: nodeData?.createTime }) }}</span>
|
||
</td>
|
||
<td class="channel-row-right">
|
||
<span
|
||
class="switch-swipe-wrapper"
|
||
@click.capture.stop="() => {
|
||
const channel = findChannelById(nodeData?.id)
|
||
if (channel) { channel.isActive = !channel.isActive; handleToggleChannel(channel); }
|
||
}"
|
||
@touchstart.passive="onStatusSwipeStart"
|
||
@touchend="(e) => onChannelSwitchSwipeEnd(nodeData, e)"
|
||
>
|
||
<a-switch
|
||
:model-value="nodeData?.isActive"
|
||
size="small"
|
||
type="circle"
|
||
class="channel-active-switch"
|
||
checked-color="#00b42a"
|
||
unchecked-color="#f53f3f"
|
||
/>
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</template>
|
||
</CustomLeftMenu>
|
||
</div>
|
||
|
||
<!-- 右侧配置区域 -->
|
||
<div class="channel-content content-panel">
|
||
<!-- 标签页 -->
|
||
<div class="tabs-section">
|
||
<div class="tabs-container">
|
||
<div
|
||
v-for="tab in tabs"
|
||
:key="tab.key"
|
||
class="tab-item"
|
||
:class="{ active: activeTab === tab.key }"
|
||
@click="activeTab = tab.key"
|
||
>
|
||
{{ tab.label }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 渠道配置 -->
|
||
<div v-if="activeTab === 'config'" class="config-form">
|
||
<div class="form-container">
|
||
<div class="form-row">
|
||
<div class="form-item">
|
||
<label class="form-label">{{ t('channel.serviceChannels.form.channelCode') }}</label>
|
||
<a-input v-model="formData.channelCode" :placeholder="t('channel.serviceChannels.form.placeholder.input')" class="form-input" />
|
||
</div>
|
||
<div class="form-item">
|
||
<label class="form-label">{{ t('channel.serviceChannels.form.channelName') }}</label>
|
||
<a-input v-model="formData.channelName" :placeholder="t('channel.serviceChannels.form.placeholder.input')" class="form-input" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<div class="form-item">
|
||
<label class="form-label">{{ t('channel.serviceChannels.form.timeLimit') }}</label>
|
||
<div class="time-input-group">
|
||
<a-input v-model="formData.minTime" :placeholder="t('channel.serviceChannels.form.placeholder.min')" class="time-input" />
|
||
<span class="separator">-</span>
|
||
<a-input v-model="formData.maxTime" :placeholder="t('channel.serviceChannels.form.placeholder.max')" class="time-input" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<div class="form-item">
|
||
<label class="form-label">{{ t('channel.serviceChannels.form.forecastType') }}</label>
|
||
<a-select
|
||
v-model="formData.forecastType"
|
||
:placeholder="t('channel.serviceChannels.form.placeholder.selectForecastType')"
|
||
allow-clear
|
||
class="form-input"
|
||
:options="forecastTypeSelectOptions"
|
||
/>
|
||
</div>
|
||
<div class="form-item">
|
||
<label class="form-label">{{ t('channel.serviceChannels.form.forecastCode') }}</label>
|
||
<a-input
|
||
v-model="formData.forecastCode"
|
||
:placeholder="t('channel.serviceChannels.form.placeholder.forecastCode')"
|
||
class="form-input"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<div class="form-item full-width">
|
||
<label class="form-label">{{ t('channel.serviceChannels.form.description') }}</label>
|
||
<a-textarea
|
||
v-model="formData.description"
|
||
:placeholder="t('channel.serviceChannels.form.placeholder.description')"
|
||
:rows="4"
|
||
class="form-textarea"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<div class="form-item full-width">
|
||
<label class="form-label">{{ t('channel.serviceChannels.form.preScript') }}</label>
|
||
<a-textarea
|
||
v-model="formData.preScript"
|
||
:placeholder="t('channel.serviceChannels.form.placeholder.preScript')"
|
||
:rows="4"
|
||
class="form-textarea"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 保存按钮 -->
|
||
<div class="form-row">
|
||
<div class="form-item">
|
||
<a-button
|
||
type="primary"
|
||
size="small"
|
||
style="width: 100px;"
|
||
@click="handleSave"
|
||
>
|
||
{{ t('channel.serviceChannels.actions.save') }}
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 渠道买价tab内容 -->
|
||
<div v-if="activeTab === 'price'" class="price-section">
|
||
<div class="price-header">
|
||
<div class="version-info">
|
||
{{ t('channel.salesProducts.price.effectiveVersion', { version: effectiveVersion }) }}
|
||
<span style="margin-left: 16px; color: #8c8c8c;">生效时间:{{ effectiveTime }}</span>
|
||
</div>
|
||
<div class="price-header-actions">
|
||
<a-button
|
||
type="secondary"
|
||
class="add-price-btn"
|
||
@click="handleAddPrice"
|
||
>
|
||
<icon-plus class="btn-icon" />
|
||
{{ t('channel.salesProducts.actions.add') }}
|
||
</a-button>
|
||
<a-button
|
||
type="primary"
|
||
class="save-btn"
|
||
@click="handleChannelPublishPrice"
|
||
>
|
||
价格发布
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
<div class="price-table-container">
|
||
<a-table
|
||
:data="priceTableData"
|
||
:columns="priceColumns"
|
||
row-key="key"
|
||
:pagination="false"
|
||
:bordered="false"
|
||
class="price-table"
|
||
>
|
||
<template #status="{ record }">
|
||
<div
|
||
class="switch-swipe-wrapper"
|
||
@click.capture.stop="() => { record.status = !record.status; handleTogglePriceStatus(record); }"
|
||
@touchstart.passive="onStatusSwipeStart"
|
||
@touchend="(e) => onStatusSwipeEnd(record, e)"
|
||
>
|
||
<a-switch
|
||
:model-value="record.status"
|
||
type="circle"
|
||
size="small"
|
||
class="product-active-switch"
|
||
checked-color="#00b42a"
|
||
unchecked-color="#f53f3f"
|
||
/>
|
||
</div>
|
||
</template>
|
||
<template #costItemCell="{ record }">
|
||
{{ dictLabelByType('fee_item', record.costItem) }}
|
||
</template>
|
||
<template #costUnitCell="{ record }">
|
||
{{ dictLabelByType('cost_unit', record.costUnit) }}
|
||
</template>
|
||
<template #currencyCell="{ record }">
|
||
{{ dictLabelByType('currency', record.currency) }}
|
||
</template>
|
||
<template #billingFactorsCell="{ record }">
|
||
{{ formatPriceTableBillingFactors(record) }}
|
||
</template>
|
||
<template #operations="{ record }">
|
||
<a-space>
|
||
<a-button
|
||
type="text"
|
||
size="small"
|
||
class="action-btn"
|
||
@click="handleEditPrice(record)"
|
||
:title="t('channel.salesProducts.actions.edit')"
|
||
>
|
||
<icon-edit />
|
||
</a-button>
|
||
<a-button
|
||
type="text"
|
||
size="small"
|
||
class="action-btn delete-btn"
|
||
@click="handleDeletePrice(record)"
|
||
:title="t('channel.salesProducts.actions.delete')"
|
||
>
|
||
<icon-delete />
|
||
</a-button>
|
||
</a-space>
|
||
</template>
|
||
</a-table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 发布记录tab内容 -->
|
||
<div v-if="activeTab === 'records'" class="publish-records-section">
|
||
<div class="publish-records-container">
|
||
<!-- 左侧面板 -->
|
||
<div class="publish-records-left">
|
||
<div class="publish-tabs-container">
|
||
<div class="publish-tabs">
|
||
<div
|
||
:class="['publish-tab-item', { active: publishRecordTab === 'price' }]"
|
||
@click="handleChangePublishRecordTab('price')"
|
||
>
|
||
{{ t('channel.serviceChannels.record.tabs.price') }}
|
||
</div>
|
||
<div
|
||
:class="['publish-tab-item', { active: publishRecordTab === 'rules' }]"
|
||
@click="handleChangePublishRecordTab('rules')"
|
||
>
|
||
{{ t('channel.serviceChannels.record.tabs.rules') }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="user-info">
|
||
</div>
|
||
<div class="version-list">
|
||
<div
|
||
v-for="(item, index) in currentListData"
|
||
:key="item.id"
|
||
:class="['version-item', { active: publishRecordActiveIndex === index }]"
|
||
@click="handleChangePublishRecordIndex(index)"
|
||
>
|
||
<div class="version-item-header">
|
||
<span class="version-item-number">版本号:{{ item.version }}</span>
|
||
<span
|
||
:class="[
|
||
'version-item-type',
|
||
item.status === '生效' ? 'type-effective' : item.status === '回滚' ? 'type-rollback' : 'type-invalid'
|
||
]"
|
||
>
|
||
{{ item.status === '生效' ? '发布' : item.status === '回滚' ? '回滚' : item.status }}
|
||
</span>
|
||
</div>
|
||
<div class="version-item-footer">
|
||
<span>{{ item.publishUser }}</span>
|
||
<span>生效:{{ item.publishTime }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右侧面板 -->
|
||
<div class="publish-records-right">
|
||
<div class="right-header">
|
||
<a-button
|
||
type="text"
|
||
@click="handleRollbackToVersion"
|
||
class="rollback-btn"
|
||
>
|
||
{{ t('channel.serviceChannels.record.actions.rollback') }}
|
||
</a-button>
|
||
<div class="version-notes">
|
||
<span>{{ t('channel.serviceChannels.record.versionRemark') }}</span>
|
||
<a-popover
|
||
:content="currentPublishRecord.description || t('channel.serviceChannels.record.noRemark')"
|
||
placement="top-start"
|
||
>
|
||
<template #content>
|
||
{{ currentPublishRecord.description || t('channel.serviceChannels.record.noRemark') }}
|
||
</template>
|
||
<template #default>
|
||
<icon-question-circle-fill
|
||
class="notes-icon"
|
||
style="color: #faad14; font-size: 18px"
|
||
/>
|
||
</template>
|
||
</a-popover>
|
||
</div>
|
||
</div>
|
||
<div class="code-content">
|
||
<a-textarea
|
||
:model-value="formattedJsonContent"
|
||
:readonly="true"
|
||
:auto-size="{ minRows: 20, maxRows: 30 }"
|
||
class="w-100%"
|
||
style="font-family: Menlo, Consolas, monospace; font-size: 12px;"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 发布日期选择弹窗 -->
|
||
<a-modal
|
||
v-model:visible="publishDateVisible"
|
||
:title="t('channel.serviceChannels.modal.publishDateTitle')"
|
||
:footer="false"
|
||
width="400px"
|
||
>
|
||
<div class="publish-date-modal">
|
||
<a-date-picker
|
||
v-model="publishDate"
|
||
:placeholder="t('channel.serviceChannels.modal.publishDate.placeholder')"
|
||
style="width: 100%"
|
||
show-time
|
||
@ok="handleConfirmPublishDate"
|
||
/>
|
||
<div class="publish-date-actions">
|
||
<a-button @click="publishDateVisible = false">{{ t('channel.serviceChannels.modal.actions.cancel') }}</a-button>
|
||
<a-button type="primary" @click="handleConfirmPublishDate(publishDate)">{{ t('channel.serviceChannels.modal.actions.ok') }}</a-button>
|
||
</div>
|
||
</div>
|
||
</a-modal>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 新增/编辑价格配置弹窗 -->
|
||
<a-modal
|
||
v-model:visible="priceModalVisible"
|
||
:title="isEditMode ? t('channel.serviceChannels.actions.edit') : t('channel.serviceChannels.actions.add')"
|
||
width="1400px"
|
||
:footer="false"
|
||
@cancel="handleCancelPriceDrawer"
|
||
>
|
||
<div class="price-modal-content">
|
||
<!-- Tab切换 -->
|
||
<div class="drawer-title">
|
||
<div class="drawer-tabs">
|
||
<div
|
||
v-for="tab in priceDrawerTabList"
|
||
:key="tab.key"
|
||
:class="['drawer-tab-item', { active: priceDrawerActiveTab === tab.key }]"
|
||
@click="handlePriceDrawerTabSwitch(tab.key)"
|
||
>
|
||
{{ t(tab.labelKey) }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 基础信息tab -->
|
||
<div v-if="priceDrawerActiveTab === 'basic'" class="basic-info-content">
|
||
<!-- 表单部分 -->
|
||
<div class="form-section-drawer">
|
||
<a-form :model="basicInfoForm" layout="vertical" class="price-form">
|
||
<a-row :gutter="24">
|
||
<a-col :span="8">
|
||
<a-form-item :label="t('channel.serviceChannels.drawer.form.costItem')" required>
|
||
<a-select
|
||
v-model="basicInfoForm.costItem"
|
||
:placeholder="t('channel.serviceChannels.form.placeholder.select')"
|
||
:loading="channelPriceDictLoading"
|
||
>
|
||
<a-option
|
||
v-for="option in costItemOptions"
|
||
:key="option.value"
|
||
:value="option.value"
|
||
>
|
||
{{ option.label }}
|
||
</a-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :span="8">
|
||
<a-form-item :label="t('channel.serviceChannels.drawer.form.costUnit')" required>
|
||
<a-select
|
||
v-model="basicInfoForm.costUnit"
|
||
:placeholder="t('channel.serviceChannels.form.placeholder.select')"
|
||
:loading="channelPriceDictLoading"
|
||
>
|
||
<a-option
|
||
v-for="option in costUnitOptions"
|
||
:key="option.value"
|
||
:value="option.value"
|
||
>
|
||
{{ option.label }}
|
||
</a-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :span="8">
|
||
<a-form-item :label="t('channel.serviceChannels.drawer.form.currency')" required>
|
||
<a-select
|
||
v-model="basicInfoForm.currency"
|
||
:placeholder="t('channel.serviceChannels.form.placeholder.select')"
|
||
:loading="channelPriceDictLoading"
|
||
>
|
||
<a-option
|
||
v-for="option in currencyOptions"
|
||
:key="option.value"
|
||
:value="option.value"
|
||
>
|
||
{{ option.label }}
|
||
</a-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
<a-row :gutter="24">
|
||
<a-col :span="8">
|
||
<a-form-item :label="t('channel.serviceChannels.drawer.form.chargingTime')">
|
||
<a-select
|
||
v-model="basicInfoForm.chargingTime"
|
||
:placeholder="t('channel.serviceChannels.form.placeholder.select')"
|
||
:loading="channelPriceDictLoading"
|
||
>
|
||
<a-option
|
||
v-for="option in chargingTimeOptions"
|
||
:key="option.value"
|
||
:value="option.value"
|
||
>
|
||
{{ option.label }}
|
||
</a-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :span="16">
|
||
<a-form-item :label="t('channel.serviceChannels.drawer.form.chargingCondition')">
|
||
<a-input
|
||
v-model="basicInfoForm.chargingCondition"
|
||
:placeholder="t('channel.serviceChannels.form.placeholder.input')"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
</a-form>
|
||
</div>
|
||
|
||
<!-- 计费因素表格 -->
|
||
<div class="billing-factors-section">
|
||
<div class="billing-factors-header">
|
||
<div class="section-title">{{ t('channel.serviceChannels.drawer.section.billingFactors') }}</div>
|
||
<a-space>
|
||
<a-button
|
||
type="secondary"
|
||
size="small"
|
||
@click="handleAddBillingFactor"
|
||
>
|
||
<icon-plus />
|
||
新增
|
||
</a-button>
|
||
<a-button
|
||
type="primary"
|
||
size="small"
|
||
@click="handleGeneratePriceList"
|
||
>
|
||
<icon-plus />
|
||
{{ t('channel.serviceChannels.drawer.footer.generatePriceList') }}
|
||
</a-button>
|
||
<a-button
|
||
type="primary"
|
||
size="small"
|
||
@click="handleSaveBasicInfo"
|
||
>
|
||
{{ t('channel.serviceChannels.actions.save') }}
|
||
</a-button>
|
||
</a-space>
|
||
</div>
|
||
<div class="billing-factors-table">
|
||
<table class="factors-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="width: 80px">{{ t('channel.serviceChannels.drawer.table.billingFactors.serialNo') }}</th>
|
||
<th style="width: 200px">{{ t('channel.serviceChannels.drawer.table.billingFactors.factor') }}</th>
|
||
<th>{{ t('channel.serviceChannels.drawer.table.billingFactors.factorValues') }}</th>
|
||
<th style="width: 100px">{{ t('channel.serviceChannels.drawer.table.billingFactors.actions') }}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="billing-factors-tbody">
|
||
<tr
|
||
v-for="(row, index) in billingFactorsData"
|
||
:key="row.id"
|
||
>
|
||
<td>
|
||
<div class="serial-number-cell">
|
||
<icon-drag-arrow class="drag-icon" />
|
||
<span>{{ row.serialNumber }}</span>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<a-select
|
||
v-model="row.factor"
|
||
:placeholder="t('channel.serviceChannels.form.placeholder.select')"
|
||
:loading="channelPriceDictLoading"
|
||
size="small"
|
||
style="width: 100%"
|
||
>
|
||
<a-option
|
||
v-for="option in factorOptions"
|
||
:key="option.value"
|
||
:value="option.value"
|
||
>
|
||
{{ option.label }}
|
||
</a-option>
|
||
</a-select>
|
||
</td>
|
||
<td>
|
||
<div class="factor-values-cell">
|
||
<a-tag
|
||
v-for="(value, valueIndex) in row.factorValues"
|
||
:key="valueIndex"
|
||
closable
|
||
@close="handleDeleteFactorValue(row, valueIndex)"
|
||
style="margin-right: 8px; margin-bottom: 4px"
|
||
>
|
||
{{ formatFactorValueTagDisplay(row.factor, value) }}
|
||
</a-tag>
|
||
<a-button
|
||
type="text"
|
||
size="small"
|
||
@click="handleAddFactorValue(row)"
|
||
class="add-value-btn"
|
||
>
|
||
<icon-plus />
|
||
</a-button>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<a-button
|
||
type="text"
|
||
size="small"
|
||
status="danger"
|
||
@click="handleDeleteBillingFactor(index)"
|
||
>
|
||
<icon-delete />
|
||
</a-button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 价格表tab -->
|
||
<div
|
||
v-if="priceDrawerActiveTab === 'priceList'"
|
||
:key="`price-list-${priceListData.length}-${priceTableHeaders.length}`"
|
||
class="price-list-content"
|
||
>
|
||
<div class="price-list-header">
|
||
<div class="price-list-title">
|
||
{{
|
||
dictLabelByType('fee_item', basicInfoForm.costItem) ||
|
||
t('channel.serviceChannels.drawer.section.priceListTitle')
|
||
}}
|
||
</div>
|
||
<div class="price-list-actions">
|
||
<a-button
|
||
type="secondary"
|
||
size="small"
|
||
@click="handleExportPriceList"
|
||
>
|
||
导出价格
|
||
</a-button>
|
||
<a-button
|
||
type="secondary"
|
||
size="small"
|
||
@click="handleImportPriceList"
|
||
>
|
||
导入价格
|
||
</a-button>
|
||
<a-button
|
||
type="primary"
|
||
size="small"
|
||
:loading="savingPriceDrawer"
|
||
@click="handleSavePriceList"
|
||
>
|
||
{{ t('channel.serviceChannels.actions.save') }}
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
<div class="price-list-table-container">
|
||
<table class="price-list-table">
|
||
<thead>
|
||
<tr>
|
||
<th
|
||
v-for="header in priceTableHeaders"
|
||
:key="header.key"
|
||
style="width: 120px"
|
||
>
|
||
{{ header.label }}
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr
|
||
v-for="(row, index) in priceListData"
|
||
:key="row.id"
|
||
>
|
||
<template v-for="header in priceTableHeaders" :key="header.key">
|
||
<td
|
||
v-if="header.key.startsWith('factor_')"
|
||
:rowspan="getFactorRowSpan(header.key, index) > 0 ? getFactorRowSpan(header.key, index) : undefined"
|
||
:style="getFactorRowSpan(header.key, index) > 0 ? {} : { display: 'none' }"
|
||
class="factor-cell"
|
||
>
|
||
{{
|
||
formatPriceTableFactorCell(
|
||
header.factorIndex,
|
||
String((row as any)[header.key] ?? '')
|
||
)
|
||
}}
|
||
</td>
|
||
<td
|
||
v-else-if="header.key === 'calculationFormula'"
|
||
>
|
||
<a-select
|
||
v-model="row.calculationFormula"
|
||
placeholder="请选择计算公式"
|
||
size="small"
|
||
style="width: 100%"
|
||
>
|
||
<a-option
|
||
v-for="option in calculationFormulaOptions"
|
||
:key="option.value"
|
||
:value="option.value"
|
||
>
|
||
{{ option.label }}
|
||
</a-option>
|
||
</a-select>
|
||
</td>
|
||
<td
|
||
v-else-if="header.isCustomer"
|
||
>
|
||
<a-input-number
|
||
v-model="(row as any)[header.key]"
|
||
size="small"
|
||
style="width: 100%"
|
||
:min="0"
|
||
:precision="2"
|
||
/>
|
||
</td>
|
||
</template>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- 自定义底部按钮 -->
|
||
<template #footer>
|
||
<div class="price-modal-footer">
|
||
<a-button @click="handleCancelPriceDrawer">
|
||
{{ t('channel.serviceChannels.drawer.footer.cancel') }}
|
||
</a-button>
|
||
<a-button
|
||
v-if="priceDrawerActiveTab === 'basic'"
|
||
type="primary"
|
||
@click="handleGeneratePriceList"
|
||
>
|
||
<icon-plus />
|
||
{{ t('channel.serviceChannels.drawer.footer.generatePriceList') }}
|
||
</a-button>
|
||
<a-button
|
||
v-if="priceDrawerActiveTab === 'priceList'"
|
||
type="primary"
|
||
:loading="savingPriceDrawer"
|
||
@click="handleSavePriceList"
|
||
>
|
||
<icon-plus />
|
||
{{ t('channel.serviceChannels.drawer.footer.save') }}
|
||
</a-button>
|
||
</div>
|
||
</template>
|
||
</a-modal>
|
||
|
||
<!-- 保存确认弹窗 -->
|
||
<a-modal
|
||
v-model:visible="saveConfirmModalVisible"
|
||
title="保存确认"
|
||
width="500px"
|
||
@cancel="handleCancelSave"
|
||
>
|
||
<div class="save-confirm-modal">
|
||
<div class="save-confirm-message">
|
||
<p>当前保存并不会生效,请选择是仅保存还是保存并生效。</p>
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<div class="modal-footer">
|
||
<a-button @click="handleCancelSave">
|
||
取消
|
||
</a-button>
|
||
<a-button @click="handleSaveOnly">
|
||
仅保存
|
||
</a-button>
|
||
<a-button type="primary" @click="handleSaveAndActivate">
|
||
保存并生效
|
||
</a-button>
|
||
</div>
|
||
</template>
|
||
</a-modal>
|
||
|
||
<!-- 发布弹窗 -->
|
||
<ModalPro
|
||
title="发布确认"
|
||
:width="1280"
|
||
:footer="false"
|
||
@register="registerPublishModal"
|
||
>
|
||
<div class="release-content slim">
|
||
<div class="diff-header">
|
||
<div class="diff-title">更新缓存</div>
|
||
<div class="diff-actions">
|
||
<a-button
|
||
type="primary"
|
||
size="small"
|
||
@click="handlePublishConfirm"
|
||
:disabled="diffLength === 0"
|
||
>
|
||
发布
|
||
</a-button>
|
||
<a-checkbox v-model="showOnlyDiff" size="small">
|
||
只看差异
|
||
</a-checkbox>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="diff-boxes">
|
||
<div class="diff-box">
|
||
<div class="diff-box-title">线上缓存</div>
|
||
<pre class="diff-pre">{{ getOriginalValue }}</pre>
|
||
</div>
|
||
<div class="diff-box">
|
||
<div class="diff-box-title">更新缓存 (差异数: {{ diffLength }})</div>
|
||
<pre class="diff-pre">{{ getModifiedModel }}</pre>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="legend">
|
||
<span class="legend-item add">🟢 新增</span>
|
||
<span class="legend-item remove">🔴 删除</span>
|
||
<span class="legend-item change">🟡 修改</span>
|
||
<span class="legend-item same">⚪ 不变</span>
|
||
</div>
|
||
|
||
<div class="merged-diff">
|
||
<div class="diff-box-title">更新缓存</div>
|
||
<div class="merged-diff-body">
|
||
<template v-for="(row, idx) in visibleDiffRows" :key="idx">
|
||
<template v-if="row.type === 'change'">
|
||
<div class="merged-line change">
|
||
<span class="line-mark">🟡</span>
|
||
<span class="line-text">
|
||
{{ row.oldText }} → {{ row.newText }}
|
||
</span>
|
||
</div>
|
||
</template>
|
||
<template v-else>
|
||
<div
|
||
class="merged-line"
|
||
:class="{
|
||
added: row.type === 'add',
|
||
removed: row.type === 'remove',
|
||
same: row.type === 'same'
|
||
}"
|
||
>
|
||
<span class="line-mark">
|
||
{{ row.type === 'add' ? '🟢' : row.type === 'remove' ? '🔴' : '⚪' }}
|
||
</span>
|
||
<span class="line-text">{{ row.text }}</span>
|
||
</div>
|
||
</template>
|
||
</template>
|
||
<div v-if="!visibleDiffRows.length" class="diff-empty">
|
||
没有内容需要发布
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</ModalPro>
|
||
|
||
<!-- 发布确认弹窗 -->
|
||
<a-modal
|
||
v-model:visible="publishConfirmVisible"
|
||
title="发布确认"
|
||
@cancel="handleCancelPublishConfirm"
|
||
@ok="handleConfirmPublish"
|
||
>
|
||
<div class="mb-15px">请选择生效的日期时间,不选默认立即生效</div>
|
||
<div class="mb-15px">
|
||
<a-input
|
||
v-model="publishDescription"
|
||
placeholder="请输入版本备注"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<a-date-picker
|
||
v-model="publishConfirmDate"
|
||
placeholder="请选择发布日期"
|
||
style="width: 100%"
|
||
show-time
|
||
format="YYYY-MM-DD HH:mm:ss"
|
||
:disabled-date="disabledDate"
|
||
:disabled-time="disabledTime"
|
||
/>
|
||
</div>
|
||
</a-modal>
|
||
|
||
<!-- 删除渠道确认弹窗 -->
|
||
<a-modal
|
||
v-model:visible="deleteChannelModalVisible"
|
||
title="确认删除渠道"
|
||
width="500px"
|
||
@cancel="handleCancelDeleteChannel"
|
||
@ok="handleConfirmDeleteChannel"
|
||
>
|
||
<div class="delete-confirm-modal">
|
||
<div class="delete-confirm-message">
|
||
<p>删除渠道后无法恢复,请谨慎操作。</p>
|
||
<p style="margin-top: 12px; font-weight: 600; color: #262626;">
|
||
请输入渠道名称 <span style="color: #f53f3f;">"{{ deletingChannel?.name }}"</span> 以确认删除:
|
||
</p>
|
||
</div>
|
||
<div style="margin-top: 16px;">
|
||
<a-input
|
||
v-model="deleteChannelInput"
|
||
placeholder="请输入渠道名称"
|
||
@press-enter="handleConfirmDeleteChannel"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</a-modal>
|
||
|
||
<!-- 删除价格配置确认弹窗 -->
|
||
<a-modal
|
||
v-model:visible="deletePriceModalVisible"
|
||
title="确认删除价格配置"
|
||
width="500px"
|
||
@cancel="handleCancelDeletePrice"
|
||
@ok="handleConfirmDeletePrice"
|
||
>
|
||
<div class="delete-confirm-modal">
|
||
<div class="delete-confirm-message">
|
||
<p>删除价格配置后无法恢复,请谨慎操作。</p>
|
||
<p style="margin-top: 12px; font-weight: 600; color: #262626;">
|
||
请输入费用项名称 <span style="color: #f53f3f;">"{{ deletingPriceRecord?.priceName || deletingPriceRecord?.costItem || '' }}"</span> 以确认删除:
|
||
</p>
|
||
</div>
|
||
<div style="margin-top: 16px;">
|
||
<a-input
|
||
v-model="deletePriceInput"
|
||
placeholder="请输入费用项名称"
|
||
@press-enter="handleConfirmDeletePrice"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</a-modal>
|
||
|
||
<!-- 新增服务商弹窗 -->
|
||
<a-modal
|
||
v-model:visible="addProviderModalVisible"
|
||
title="新增服务商"
|
||
width="600px"
|
||
@cancel="handleCancelAddProvider"
|
||
@ok="handleConfirmAddProvider"
|
||
>
|
||
<div class="add-provider-modal">
|
||
<a-form :model="addProviderForm" layout="vertical">
|
||
<a-form-item label="应用" required>
|
||
<a-select
|
||
v-model="addProviderForm.appId"
|
||
placeholder="请选择应用"
|
||
:options="appOptions"
|
||
allow-search
|
||
allow-clear
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item label="API服务商" required>
|
||
<a-select
|
||
v-model="addProviderForm.serviceProviderId"
|
||
placeholder="请选择API服务商"
|
||
:options="serviceProviderOptions"
|
||
allow-search
|
||
allow-clear
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item label="服务商名称" required>
|
||
<a-input
|
||
v-model="addProviderForm.providerName"
|
||
:placeholder="t('channel.serviceChannels.form.placeholder.input')"
|
||
/>
|
||
</a-form-item>
|
||
</a-form>
|
||
</div>
|
||
</a-modal>
|
||
|
||
<!-- 新增服务渠道弹窗 -->
|
||
<a-modal
|
||
v-model:visible="addChannelModalVisible"
|
||
title="新增服务渠道"
|
||
width="800px"
|
||
@cancel="handleCancelAddChannel"
|
||
@ok="handleConfirmAddChannel"
|
||
>
|
||
<div class="add-channel-modal">
|
||
<a-form :model="addChannelForm" layout="vertical">
|
||
<a-row :gutter="24">
|
||
<a-col :span="12">
|
||
<a-form-item label="渠道代码" required>
|
||
<a-input
|
||
v-model="addChannelForm.channelCode"
|
||
:placeholder="t('channel.serviceChannels.form.placeholder.input')"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :span="12">
|
||
<a-form-item label="渠道名称" required>
|
||
<a-input
|
||
v-model="addChannelForm.channelName"
|
||
:placeholder="t('channel.serviceChannels.form.placeholder.input')"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
<a-row :gutter="24">
|
||
<a-col :span="24">
|
||
<a-form-item label="时效限制">
|
||
<div class="time-input-group">
|
||
<a-input
|
||
v-model="addChannelForm.minTime"
|
||
:placeholder="t('channel.serviceChannels.form.placeholder.min')"
|
||
class="time-input"
|
||
/>
|
||
<span class="separator">-</span>
|
||
<a-input
|
||
v-model="addChannelForm.maxTime"
|
||
:placeholder="t('channel.serviceChannels.form.placeholder.max')"
|
||
class="time-input"
|
||
/>
|
||
</div>
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
<a-row :gutter="24">
|
||
<a-col :span="12">
|
||
<a-form-item :label="t('channel.serviceChannels.form.forecastType')">
|
||
<a-select
|
||
v-model="addChannelForm.forecastType"
|
||
:placeholder="t('channel.serviceChannels.form.placeholder.selectForecastType')"
|
||
allow-clear
|
||
:options="forecastTypeSelectOptions"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :span="12">
|
||
<a-form-item :label="t('channel.serviceChannels.form.forecastCode')">
|
||
<a-input
|
||
v-model="addChannelForm.forecastCode"
|
||
:placeholder="t('channel.serviceChannels.form.placeholder.forecastCode')"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
<a-row :gutter="24">
|
||
<a-col :span="24">
|
||
<a-form-item label="描述">
|
||
<a-textarea
|
||
v-model="addChannelForm.description"
|
||
:placeholder="t('channel.serviceChannels.form.placeholder.description')"
|
||
:rows="4"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
<a-row :gutter="24">
|
||
<a-col :span="24">
|
||
<a-form-item label="前置脚本">
|
||
<a-textarea
|
||
v-model="addChannelForm.preScript"
|
||
:placeholder="t('channel.serviceChannels.form.placeholder.preScript')"
|
||
:rows="4"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
</a-form>
|
||
</div>
|
||
</a-modal>
|
||
|
||
<!-- 添加因素值弹窗(与销售产品卖价一致) -->
|
||
<a-modal
|
||
v-model:visible="addFactorValueModalVisible"
|
||
title="添加因素值"
|
||
width="400px"
|
||
@cancel="handleCancelAddFactorValue"
|
||
@ok="handleConfirmAddFactorValue"
|
||
>
|
||
<div class="add-factor-value-modal">
|
||
<div class="add-factor-value-actions">
|
||
<a-button
|
||
type="text"
|
||
size="small"
|
||
:disabled="currentFactorValueOptions.length === 0"
|
||
@click="handleSelectAllFactorValues"
|
||
>
|
||
{{ isAllFactorValuesSelected ? '清空' : '全选' }}
|
||
</a-button>
|
||
</div>
|
||
<a-select
|
||
v-model="addFactorValueSelected"
|
||
:options="currentFactorValueOptions"
|
||
:placeholder="currentFactorValueOptions.length ? '请选择因素值' : '该因素暂无可选值'"
|
||
multiple
|
||
allow-clear
|
||
allow-search
|
||
style="width: 100%"
|
||
@press-enter="handleConfirmAddFactorValue"
|
||
/>
|
||
</div>
|
||
</a-modal>
|
||
|
||
<!-- 编辑服务商弹窗 -->
|
||
<a-modal
|
||
v-model:visible="editProviderModalVisible"
|
||
title="编辑服务商"
|
||
width="600px"
|
||
@cancel="handleCancelEditProvider"
|
||
@ok="handleConfirmEditProvider"
|
||
>
|
||
<div class="edit-provider-modal">
|
||
<a-form :model="editProviderForm" layout="vertical">
|
||
<a-form-item label="应用" required>
|
||
<a-select
|
||
v-model="editProviderForm.appId"
|
||
placeholder="请选择应用"
|
||
:options="appOptions"
|
||
allow-search
|
||
allow-clear
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item label="API服务商" required>
|
||
<a-select
|
||
v-model="editProviderForm.serviceProviderId"
|
||
placeholder="请选择API服务商"
|
||
:options="serviceProviderOptions"
|
||
allow-search
|
||
allow-clear
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item label="服务商名称" required>
|
||
<a-input
|
||
v-model="editProviderForm.providerName"
|
||
:placeholder="t('channel.serviceChannels.form.placeholder.input')"
|
||
/>
|
||
</a-form-item>
|
||
</a-form>
|
||
</div>
|
||
</a-modal>
|
||
|
||
<!-- 删除服务商确认弹窗 -->
|
||
<a-modal
|
||
v-model:visible="deleteProviderModalVisible"
|
||
title="确认删除服务商"
|
||
width="500px"
|
||
@cancel="handleCancelDeleteProvider"
|
||
@ok="handleConfirmDeleteProvider"
|
||
>
|
||
<div class="delete-provider-modal">
|
||
<div class="delete-confirm-message">
|
||
<p>删除服务商后无法恢复,请谨慎操作。</p>
|
||
<p style="margin-top: 12px; font-weight: 600; color: #262626;">
|
||
确认要删除服务商 <span style="color: #f53f3f;">"{{ deletingProvider?.name }}"</span> 吗?
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</a-modal>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, computed, watch, nextTick, onMounted } from 'vue'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { IconPlus, IconDelete, IconSearch, IconEdit, IconDragArrow, IconQuestionCircleFill, IconFolder, IconMore } from '@arco-design/web-vue/es/icon'
|
||
import { Message, Modal, Input } from '@arco-design/web-vue'
|
||
import ModalPro from '@/components/ModalPro/index.vue'
|
||
import useModal from '@/components/ModalPro/useModal'
|
||
import { diffJson } from 'diff'
|
||
import dayjs from 'dayjs'
|
||
import * as XLSX from 'xlsx'
|
||
import Sortable from 'sortablejs'
|
||
import { getAppGetList } from '@/api/ApplicationServices/apps'
|
||
import {
|
||
getServiceProviderGetServiceProviderDropdownList,
|
||
postServiceProviderUpdate,
|
||
} from '@/api/interface-serviceProvider/serviceProvider'
|
||
import {
|
||
createServiceChannelGroup,
|
||
createServiceChannel,
|
||
createServiceChannelPrice,
|
||
deleteServiceChannel,
|
||
deleteServiceChannelGroup,
|
||
deleteServiceChannelPrice,
|
||
getServiceChannelDetail,
|
||
getServiceChannelGroupList,
|
||
getServiceChannelList,
|
||
getServiceChannelPriceList,
|
||
getServiceChannelPublishPriceList,
|
||
getServiceChannelPublishRulesList,
|
||
publishServiceChannel,
|
||
rollbackServiceChannelPublish,
|
||
toggleServiceChannelPriceStatus,
|
||
buildServiceChannelUpdatePayload,
|
||
updateServiceChannel,
|
||
updateServiceChannelGroup,
|
||
updateServiceChannelPrice,
|
||
} from '@/api/channel/serviceChannel'
|
||
import { getDictList } from '@/api/system/dict'
|
||
import { postForewarningServiceKpiAreaGetKpiAreas } from '@/api/channel/kpiArea'
|
||
import { getWarehouseList } from '@/api/system/warehouse'
|
||
import CustomLeftMenu from '@/components/CustomLeftMenu/index.vue'
|
||
import type { CustomLeftMenuTreeNode } from '@/components/CustomLeftMenu/types'
|
||
|
||
defineOptions({
|
||
name: 'ServiceChannel'
|
||
})
|
||
|
||
const { t } = useI18n()
|
||
|
||
type NormalizedDictRow = {
|
||
dictType: string
|
||
dictCode: string
|
||
dictName: string
|
||
dictSort: number
|
||
}
|
||
|
||
const channelPriceDictRows = ref<NormalizedDictRow[]>([])
|
||
const channelPriceDictLoading = ref(false)
|
||
const channelPriceDictFetched = ref(false)
|
||
let channelPriceDictLoadPromise: Promise<void> | null = null
|
||
|
||
function normalizeAnyDictRow(raw: any): NormalizedDictRow | null {
|
||
const dictType = String(raw?.dictType ?? raw?.DictType ?? '').trim()
|
||
if (!dictType) return null
|
||
const dictCode = String(raw?.dictCode ?? raw?.DictCode ?? '').trim()
|
||
const dictName = String(raw?.dictName ?? raw?.DictName ?? '').trim()
|
||
if (!dictCode && !dictName) return null
|
||
const dictSort = Number(raw?.dictSort ?? raw?.DictSort ?? 0) || 0
|
||
return {
|
||
dictType,
|
||
dictCode: dictCode || dictName,
|
||
dictName: dictName || dictCode,
|
||
dictSort,
|
||
}
|
||
}
|
||
|
||
function dictRowsOfType(dictType: string): NormalizedDictRow[] {
|
||
const want = dictType.toLowerCase()
|
||
return channelPriceDictRows.value
|
||
.filter((r) => r.dictType.toLowerCase() === want)
|
||
.sort((a, b) => a.dictSort - b.dictSort || String(a.dictCode).localeCompare(String(b.dictCode)))
|
||
}
|
||
|
||
function mapDictToSelectOptions(dictType: string) {
|
||
return dictRowsOfType(dictType).map((r) => ({
|
||
label: r.dictName || r.dictCode,
|
||
value: r.dictCode,
|
||
}))
|
||
}
|
||
|
||
/** 与后端字典类型一致;列表接口带 dictType,客户端再按该类型兜底过滤 */
|
||
const FORECAST_TYPE_DICT_TYPE = 'forecast_type'
|
||
|
||
async function mergeForecastTypeDictFromApi() {
|
||
const want = FORECAST_TYPE_DICT_TYPE.toLowerCase()
|
||
try {
|
||
const { items } = await getDictList({
|
||
dictType: FORECAST_TYPE_DICT_TYPE,
|
||
page: 1,
|
||
pageSize: 10000,
|
||
})
|
||
const normalized = (items || [])
|
||
.map(normalizeAnyDictRow)
|
||
.filter(Boolean) as NormalizedDictRow[]
|
||
const filtered = normalized.filter((r) => r.dictType.toLowerCase() === want)
|
||
const rest = channelPriceDictRows.value.filter((r) => r.dictType.toLowerCase() !== want)
|
||
channelPriceDictRows.value = [...rest, ...filtered]
|
||
} catch (e: any) {
|
||
Message.error(e?.message || '加载预报单号类型失败')
|
||
}
|
||
}
|
||
|
||
const forecastTypeSelectOptions = computed(() => mapDictToSelectOptions(FORECAST_TYPE_DICT_TYPE))
|
||
|
||
function dictLabelByType(dictType: string, code: string | undefined | null): string {
|
||
if (code == null || code === '') return ''
|
||
const c = String(code).trim()
|
||
const want = dictType.toLowerCase()
|
||
const hit = channelPriceDictRows.value.find(
|
||
(r) => r.dictType.toLowerCase() === want && r.dictCode === c
|
||
)
|
||
if (hit?.dictName) return hit.dictName
|
||
const byName = channelPriceDictRows.value.find(
|
||
(r) => r.dictType.toLowerCase() === want && r.dictName === c
|
||
)
|
||
return byName?.dictName || c
|
||
}
|
||
|
||
function formatPriceTableBillingFactors(row: any): string {
|
||
const list = row?.factors
|
||
if (Array.isArray(list) && list.length > 0) {
|
||
return list
|
||
.map((f: any) => pricingFactorColumnLabel(String(f?.factor ?? f?.name ?? '')))
|
||
.filter(Boolean)
|
||
.join('+')
|
||
}
|
||
const legacy = row?.billingFactors
|
||
return typeof legacy === 'string' ? legacy : ''
|
||
}
|
||
|
||
async function ensureChannelPriceDictLoaded() {
|
||
if (channelPriceDictFetched.value) return
|
||
if (channelPriceDictLoadPromise) {
|
||
await channelPriceDictLoadPromise
|
||
return
|
||
}
|
||
channelPriceDictLoading.value = true
|
||
channelPriceDictLoadPromise = (async () => {
|
||
try {
|
||
const { items } = await getDictList({ page: 1, pageSize: 10000 })
|
||
channelPriceDictRows.value = (items || [])
|
||
.map(normalizeAnyDictRow)
|
||
.filter(Boolean) as NormalizedDictRow[]
|
||
await mergeForecastTypeDictFromApi()
|
||
} catch (e: any) {
|
||
Message.error(e?.message || '加载字典失败')
|
||
channelPriceDictRows.value = []
|
||
} finally {
|
||
channelPriceDictFetched.value = true
|
||
channelPriceDictLoading.value = false
|
||
channelPriceDictLoadPromise = null
|
||
}
|
||
})()
|
||
await channelPriceDictLoadPromise
|
||
}
|
||
|
||
function pricingFactorColumnLabel(factorValue: string): string {
|
||
if (!factorValue) return ''
|
||
const hit = dictRowsOfType('pricing_factor').find(
|
||
(r) => r.dictCode === factorValue || r.dictName === factorValue
|
||
)
|
||
return hit?.dictName || factorValue
|
||
}
|
||
|
||
function getPricingFactorMeta(factorKey: string): NormalizedDictRow | undefined {
|
||
if (!factorKey) return undefined
|
||
return dictRowsOfType('pricing_factor').find(
|
||
(r) => r.dictCode === factorKey || r.dictName === factorKey
|
||
)
|
||
}
|
||
|
||
function isCargoTypeValueFactor(factorKey: string): boolean {
|
||
if (factorKey === '货物类型') return true
|
||
const m = getPricingFactorMeta(factorKey)
|
||
return m?.dictCode === 'cargo'
|
||
}
|
||
|
||
function isCustomerGroupValueFactor(factorKey: string): boolean {
|
||
if (!factorKey) return false
|
||
const k = String(factorKey).trim()
|
||
if (k === '客户' || k === '客户组' || k === 'customer') return true
|
||
const m = getPricingFactorMeta(k)
|
||
return m?.dictCode === 'customer' || m?.dictName === '客户' || m?.dictName === '客户组'
|
||
}
|
||
|
||
function isCustomerFactor(factorValue: string): boolean {
|
||
return isCustomerGroupValueFactor(factorValue)
|
||
}
|
||
|
||
function getSelectedCustomerGroupCodesFromBilling(): string[] {
|
||
for (const item of billingFactorsData.value) {
|
||
if (!item.factor || !Array.isArray(item.factorValues) || item.factorValues.length === 0) {
|
||
continue
|
||
}
|
||
if (!isCustomerGroupValueFactor(item.factor)) continue
|
||
return item.factorValues.map((v) => String(v).trim()).filter(Boolean)
|
||
}
|
||
return []
|
||
}
|
||
|
||
function isPartitionValueFactor(factorKey: string): boolean {
|
||
if (factorKey === '分区') return true
|
||
const m = getPricingFactorMeta(factorKey)
|
||
return m?.dictCode === 'zone'
|
||
}
|
||
|
||
function isWarehouseValueFactor(factorKey: string): boolean {
|
||
if (factorKey === '仓库') return true
|
||
const m = getPricingFactorMeta(factorKey)
|
||
return m?.dictCode === 'warehouse'
|
||
}
|
||
|
||
function toCanonicalFactorValues(factor: string, values: string[] | undefined): string[] {
|
||
const vs = Array.isArray(values) ? values : []
|
||
if (isPartitionValueFactor(factor)) {
|
||
return vs.map((v) => {
|
||
const s = String(v).trim()
|
||
if (priceAreaFactorOptions.value.some((o) => o.value === s)) return s
|
||
const byLabel = priceAreaFactorOptions.value.find((o) => o.label === s)
|
||
return byLabel?.value ?? s
|
||
})
|
||
}
|
||
if (isWarehouseValueFactor(factor)) {
|
||
return vs.map((v) => {
|
||
const s = String(v).trim()
|
||
if (warehouseFactorOptions.value.some((o) => o.value === s)) return s
|
||
const byLabel = warehouseFactorOptions.value.find((o) => o.label === s)
|
||
return byLabel?.value ?? s
|
||
})
|
||
}
|
||
if (isCargoTypeValueFactor(factor)) {
|
||
return vs.map((v) => {
|
||
const s = String(v).trim()
|
||
const hit = dictRowsOfType('cargo_attribute').find(
|
||
(r) => r.dictCode === s || r.dictName === s
|
||
)
|
||
return hit?.dictCode ?? s
|
||
})
|
||
}
|
||
if (isCustomerGroupValueFactor(factor)) {
|
||
return vs.map((v) => {
|
||
const s = String(v).trim()
|
||
const hit = dictRowsOfType('customer_group').find(
|
||
(r) => r.dictCode === s || r.dictName === s
|
||
)
|
||
return hit?.dictCode ?? s
|
||
})
|
||
}
|
||
return vs.map((v) => String(v))
|
||
}
|
||
|
||
function customerGroupPriceTableHeaderLabel(code: string, index: number): string {
|
||
const c = String(code ?? '').trim()
|
||
if (c) {
|
||
const hit = dictRowsOfType('customer_group').find(
|
||
(r) => r.dictCode === c || r.dictName === c
|
||
)
|
||
const name = hit?.dictName?.trim()
|
||
if (name) return name
|
||
}
|
||
return `客户组${index + 1}`
|
||
}
|
||
|
||
function dictFactorDisplayNameOnly(dictType: string, stored: string): string {
|
||
const s = String(stored ?? '').trim()
|
||
if (!s) return ''
|
||
const rows = dictRowsOfType(dictType)
|
||
const byCode = rows.find((r) => r.dictCode === s)
|
||
if (byCode?.dictName) return byCode.dictName
|
||
const byName = rows.find((r) => r.dictName === s)
|
||
return byName?.dictName || ''
|
||
}
|
||
|
||
function formatFactorValueTagDisplay(factorKey: string, stored: string): string {
|
||
const s = String(stored ?? '').trim()
|
||
if (!s) return ''
|
||
if (isCargoTypeValueFactor(factorKey)) {
|
||
return dictFactorDisplayNameOnly('cargo_attribute', s) || ''
|
||
}
|
||
if (isCustomerGroupValueFactor(factorKey)) {
|
||
return dictFactorDisplayNameOnly('customer_group', s) || ''
|
||
}
|
||
if (isPartitionValueFactor(factorKey)) {
|
||
const opt = priceAreaFactorOptions.value.find((o) => o.value === s || o.label === s)
|
||
return opt?.label || ''
|
||
}
|
||
if (isWarehouseValueFactor(factorKey)) {
|
||
const opt = warehouseFactorOptions.value.find((o) => o.value === s || o.label === s)
|
||
return opt?.label || ''
|
||
}
|
||
return s
|
||
}
|
||
|
||
function formatPriceTableFactorCell(factorIndex: number | undefined, raw: string): string {
|
||
if (factorIndex == null || factorIndex < 0) return ''
|
||
const fac = billingFactorsData.value[factorIndex]?.factor
|
||
if (!fac) return ''
|
||
return formatFactorValueTagDisplay(fac, raw)
|
||
}
|
||
|
||
type IdSelectOption = { label: string; value: string }
|
||
|
||
const weightFactorOptions: IdSelectOption[] = Array.from({ length: 20 }, (_, index) => {
|
||
const v = ((index + 1) * 0.5).toFixed(1).replace(/\.0$/, '')
|
||
return { label: v, value: v }
|
||
})
|
||
|
||
function filterKpiAreasByType(items: any[], areaType: string) {
|
||
const want = areaType.toLowerCase()
|
||
return (items || []).filter((item) => {
|
||
const t = String(item?.areaType ?? item?.AreaType ?? '').toLowerCase()
|
||
return !t || t === want
|
||
})
|
||
}
|
||
|
||
const priceAreaFactorOptions = ref<IdSelectOption[]>([])
|
||
const warehouseFactorOptions = ref<IdSelectOption[]>([])
|
||
|
||
async function loadBillingFactorSelectOptions() {
|
||
try {
|
||
const [areaRes, warehouseRes] = await Promise.all([
|
||
postForewarningServiceKpiAreaGetKpiAreas({
|
||
page: 1,
|
||
pageSize: 500,
|
||
areaType: 'price',
|
||
}),
|
||
getWarehouseList({
|
||
page: 1,
|
||
pageSize: 500,
|
||
}),
|
||
])
|
||
const areaItems = areaRes?.items ?? (areaRes as any)?.data?.items ?? []
|
||
const warehouseItems = warehouseRes?.items ?? []
|
||
priceAreaFactorOptions.value = filterKpiAreasByType(areaItems, 'price').map((it: any) => {
|
||
const id = it?.id ?? it?.Id
|
||
const name = String(it?.name ?? it?.Name ?? id ?? '')
|
||
return { label: name, value: String(id ?? '') }
|
||
})
|
||
warehouseFactorOptions.value = warehouseItems.map((it: any) => {
|
||
const id = it?.id ?? it?.Id
|
||
const name = String(it?.warehouseName ?? it?.WarehouseName ?? id ?? '')
|
||
return { label: name, value: String(id ?? '') }
|
||
})
|
||
} catch (e: any) {
|
||
Message.error(e?.message || '加载计费因素下拉失败')
|
||
priceAreaFactorOptions.value = []
|
||
warehouseFactorOptions.value = []
|
||
}
|
||
}
|
||
|
||
function defaultCalculationFormulaCode(): string {
|
||
const rows = dictRowsOfType('calculation_formula')
|
||
const hit = rows.find((r) => r.dictName === '总价')
|
||
return hit?.dictCode || rows[0]?.dictCode || '总价'
|
||
}
|
||
|
||
function defaultChargingTimeCode(): string {
|
||
const rows = dictRowsOfType('charging_time')
|
||
const hit = rows.find((r) => r.dictName === '出库')
|
||
return hit?.dictCode || rows[0]?.dictCode || '出库'
|
||
}
|
||
|
||
const cargoAttributeValueOptions = computed<IdSelectOption[]>(() =>
|
||
dictRowsOfType('cargo_attribute').map((r) => ({
|
||
label: r.dictName || '(未命名)',
|
||
value: r.dictCode,
|
||
}))
|
||
)
|
||
|
||
const customerGroupValueOptions = computed<IdSelectOption[]>(() =>
|
||
dictRowsOfType('customer_group').map((r) => ({
|
||
label: r.dictName || '(未命名)',
|
||
value: r.dictCode,
|
||
}))
|
||
)
|
||
|
||
const factorValuesOptionsByFactor = computed<Record<string, IdSelectOption[]>>(() => {
|
||
const cargoOpts = cargoAttributeValueOptions.value
|
||
const cgOpts = customerGroupValueOptions.value
|
||
const base: Record<string, IdSelectOption[]> = {
|
||
货物类型: cargoOpts,
|
||
分区: priceAreaFactorOptions.value,
|
||
重量: weightFactorOptions,
|
||
仓库: warehouseFactorOptions.value,
|
||
客户: cgOpts,
|
||
客户组: cgOpts,
|
||
}
|
||
const out: Record<string, IdSelectOption[]> = { ...base }
|
||
for (const r of dictRowsOfType('pricing_factor')) {
|
||
const code = r.dictCode
|
||
const name = r.dictName
|
||
if (!code) continue
|
||
const src = (name && base[name]) || base[code]
|
||
if (src) {
|
||
out[code] = src
|
||
if (name && base[name]) out[name] = base[name]
|
||
}
|
||
}
|
||
return out
|
||
})
|
||
|
||
// 搜索关键词
|
||
const searchKeyword = ref('')
|
||
|
||
// 选中的渠道
|
||
const selectedChannel = ref(null)
|
||
|
||
// 当前激活的标签页
|
||
const activeTab = ref('config')
|
||
|
||
// 标签页配置
|
||
const tabs = computed(() => [
|
||
{ key: 'config', label: t('channel.serviceChannels.tabs.config') },
|
||
{ key: 'price', label: t('channel.serviceChannels.tabs.price') },
|
||
{ key: 'records', label: t('channel.serviceChannels.tabs.record') }
|
||
])
|
||
|
||
/** 接口无数据时的菜单树分组示例(与 oms表结构与接口设计 服务商分组列表响应一致) */
|
||
const FALLBACK_SERVICE_CHANNEL_GROUP_LIST = [
|
||
{
|
||
id: 1,
|
||
name: '壹号',
|
||
appId: 8,
|
||
apiServiceProviderId: 41,
|
||
createTime: '2026-05-04 14:15:45',
|
||
updateTime: '1900-01-01 00:00:00',
|
||
},
|
||
]
|
||
|
||
// 渠道组数据(父:服务商分组;子:渠道列表)
|
||
const channelGroups = ref<any[]>([])
|
||
|
||
// 左侧菜单:按关键词筛选渠道组(组名或渠道名)
|
||
const filteredChannelGroups = computed(() => {
|
||
const kw = searchKeyword.value?.toLowerCase() || ''
|
||
if (!kw) return channelGroups.value
|
||
return channelGroups.value
|
||
.map((group: any) => {
|
||
const nameMatch = group.name?.toLowerCase().includes(kw)
|
||
const channels = (group.channels || []).filter((ch: any) =>
|
||
ch.name?.toLowerCase().includes(kw)
|
||
)
|
||
if (nameMatch || channels.length > 0) {
|
||
return { ...group, channels: nameMatch ? (group.channels || []) : channels }
|
||
}
|
||
return null
|
||
})
|
||
.filter(Boolean)
|
||
})
|
||
|
||
// 左侧菜单:渠道树数据(组为父节点,渠道为子节点)
|
||
const channelTreeData = computed<CustomLeftMenuTreeNode[]>(() => {
|
||
const groups = filteredChannelGroups.value || []
|
||
return groups.map((group: any) => ({
|
||
key: `group-${group.id}`,
|
||
title: group.name,
|
||
isLeaf: false,
|
||
id: group.id,
|
||
name: group.name,
|
||
appId: group.appId,
|
||
apiServiceProviderId: group.apiServiceProviderId,
|
||
children: (group.channels || []).map((ch: any) => ({
|
||
key: `item-${ch.id}`,
|
||
title: ch.name,
|
||
isLeaf: true,
|
||
id: ch.id,
|
||
name: ch.name,
|
||
...ch
|
||
}))
|
||
}))
|
||
})
|
||
|
||
function findGroupByTreeNodeId(id: number | string | undefined) {
|
||
if (id == null || id === '') return null
|
||
return channelGroups.value.find((g: any) => Number(g.id) === Number(id)) ?? null
|
||
}
|
||
|
||
function handleLeftMenuBranchEdit(nodeData: any) {
|
||
const g = findGroupByTreeNodeId(nodeData?.id)
|
||
if (g) handleEditGroup(g)
|
||
}
|
||
|
||
function handleLeftMenuBranchDelete(nodeData: any) {
|
||
const g = findGroupByTreeNodeId(nodeData?.id)
|
||
if (g) handleDeleteGroup(g)
|
||
}
|
||
|
||
function handleLeftMenuBranchAddChannel(nodeData: any) {
|
||
const g = findGroupByTreeNodeId(nodeData?.id)
|
||
if (g) handleAddChannelToGroup(g)
|
||
}
|
||
|
||
function handleLeftMenuLeafEdit(nodeData: any) {
|
||
const ch = findChannelById(nodeData?.id)
|
||
if (ch) {
|
||
void selectChannel(ch)
|
||
activeTab.value = 'config'
|
||
}
|
||
}
|
||
|
||
function handleLeftMenuLeafDelete(nodeData: any) {
|
||
const ch = findChannelById(nodeData?.id)
|
||
if (ch) handleDeleteChannel(ch)
|
||
}
|
||
|
||
const channelSelectedKeys = computed(() =>
|
||
selectedChannel.value ? [`item-${(selectedChannel.value as any).id}`] : []
|
||
)
|
||
|
||
const channelExpandedKeys = ref<string[]>([])
|
||
watch(
|
||
() => channelGroups.value.map((g: any) => `group-${g.id}`),
|
||
(keys) => {
|
||
channelExpandedKeys.value = keys
|
||
},
|
||
{ deep: true }
|
||
)
|
||
|
||
function findChannelById(id: number | string) {
|
||
const want = Number(id)
|
||
const idOk = Number.isFinite(want)
|
||
for (const group of channelGroups.value) {
|
||
const ch = (group.channels || []).find((c: any) => {
|
||
if (idOk) return Number(c.id) === want
|
||
return c.id === id
|
||
})
|
||
if (ch) return ch
|
||
}
|
||
return null
|
||
}
|
||
|
||
/** 按分组顺序取第一个渠道 */
|
||
function findFirstChannelInTree(): any | null {
|
||
for (const g of channelGroups.value) {
|
||
const list = g.channels || []
|
||
if (list.length > 0) return list[0]
|
||
}
|
||
return null
|
||
}
|
||
|
||
/** 进入页面 / 列表刷新后:无选中或选中已不存在时默认选第一个渠道(await 避免与保存后回填竞态) */
|
||
async function ensureDefaultChannelSelection() {
|
||
const first = findFirstChannelInTree()
|
||
if (!first) {
|
||
selectedChannel.value = null
|
||
return
|
||
}
|
||
const curId = (selectedChannel.value as any)?.id
|
||
if (curId != null) {
|
||
const still = findChannelById(curId)
|
||
if (still) {
|
||
await selectChannel(still)
|
||
return
|
||
}
|
||
}
|
||
await selectChannel(first)
|
||
}
|
||
|
||
function handleChannelSelectFromTree(keys: string[], info: any) {
|
||
const node = info?.node
|
||
if (!node || !node.isLeaf) return
|
||
const key = node.key
|
||
if (!key.startsWith('item-')) return
|
||
const id = Number(key.slice(5))
|
||
const channel = findChannelById(id)
|
||
if (channel) selectChannel(channel)
|
||
}
|
||
|
||
// 表单数据
|
||
const formData = reactive({
|
||
channelCode: '',
|
||
channelName: '',
|
||
minTime: '',
|
||
maxTime: '',
|
||
forecastType: '',
|
||
forecastCode: '',
|
||
description: '',
|
||
preScript: ''
|
||
})
|
||
|
||
// 搜索/刷新处理
|
||
const handleSearch = () => {
|
||
loadChannelGroups()
|
||
}
|
||
|
||
const appOptions = ref<Array<{ label: string; value: number }>>([])
|
||
const serviceProviderOptions = ref<Array<{ label: string; value: number }>>([])
|
||
|
||
// 新增服务渠道弹窗相关(从左侧分组菜单「新增渠道」进入时带 groupId → POST /channel/service-channel/create)
|
||
const addChannelModalVisible = ref(false)
|
||
const addChannelTargetGroupId = ref<number | null>(null)
|
||
const addChannelForm = reactive({
|
||
channelCode: '',
|
||
channelName: '',
|
||
minTime: '',
|
||
maxTime: '',
|
||
forecastType: '',
|
||
forecastCode: '',
|
||
description: '',
|
||
preScript: ''
|
||
})
|
||
|
||
// 加载应用列表
|
||
const loadAppList = async () => {
|
||
try {
|
||
const result: any = await getAppGetList({ PageIndex: 1, PageSize: 1000 })
|
||
const items = result?.data?.items || result?.items || result?.data || []
|
||
if (Array.isArray(items) && items.length > 0) {
|
||
appOptions.value = items.map((item: any) => ({
|
||
label: item.name || '',
|
||
value: item.id || 0
|
||
}))
|
||
}
|
||
} catch (error) {
|
||
console.error('加载应用列表失败:', error)
|
||
// 如果API请求失败,不抛出错误,避免404错误影响页面
|
||
}
|
||
}
|
||
|
||
// 加载服务商列表
|
||
const loadServiceProviderList = async () => {
|
||
try {
|
||
const result: any = await getServiceProviderGetServiceProviderDropdownList({})
|
||
const items = result?.data || result || []
|
||
if (Array.isArray(items) && items.length > 0) {
|
||
serviceProviderOptions.value = items.map((item: any) => ({
|
||
label: item.name || item.label || '',
|
||
value: item.id || item.value || 0
|
||
}))
|
||
}
|
||
} catch (error) {
|
||
console.error('加载服务商列表失败:', error)
|
||
// 如果API请求失败,不抛出错误,避免404错误影响页面
|
||
}
|
||
}
|
||
|
||
// 编辑服务商相关
|
||
const editProviderModalVisible = ref(false)
|
||
const editingProviderGroupId = ref<number | null>(null)
|
||
const editProviderForm = reactive({
|
||
providerName: '',
|
||
appId: undefined as number | undefined,
|
||
serviceProviderId: undefined as number | undefined
|
||
})
|
||
|
||
// 取消编辑服务商
|
||
const handleCancelEditProvider = () => {
|
||
editProviderModalVisible.value = false
|
||
editingProviderGroupId.value = null
|
||
}
|
||
|
||
// 删除服务商确认弹窗
|
||
const deleteProviderModalVisible = ref(false)
|
||
const deletingProvider = ref<any>(null)
|
||
|
||
// 删除服务商
|
||
const handleDeleteGroup = (group: any) => {
|
||
// 校验服务商下是否有渠道
|
||
if (group.channels && group.channels.length > 0) {
|
||
Message.warning(`该服务商下存在 ${group.channels.length} 个渠道,请先删除所有渠道后再删除服务商`)
|
||
return
|
||
}
|
||
|
||
deletingProvider.value = group
|
||
deleteProviderModalVisible.value = true
|
||
}
|
||
|
||
// 取消删除服务商
|
||
const handleCancelDeleteProvider = () => {
|
||
deleteProviderModalVisible.value = false
|
||
deletingProvider.value = null
|
||
}
|
||
|
||
// 确认删除服务商
|
||
const handleConfirmDeleteProvider = async () => {
|
||
if (!deletingProvider.value) return
|
||
|
||
// 再次校验(防止数据变化)
|
||
const group = channelGroups.value.find(g => g.id === deletingProvider.value.id)
|
||
if (group && group.channels && group.channels.length > 0) {
|
||
Message.warning(`该服务商下存在 ${group.channels.length} 个渠道,无法删除`)
|
||
deleteProviderModalVisible.value = false
|
||
deletingProvider.value = null
|
||
return
|
||
}
|
||
|
||
try {
|
||
await deleteServiceChannelGroup(deletingProvider.value.id)
|
||
Message.success('删除服务商成功')
|
||
deleteProviderModalVisible.value = false
|
||
deletingProvider.value = null
|
||
// 刷新列表
|
||
await loadChannelGroups()
|
||
} catch (error) {
|
||
console.error('删除服务商失败:', error)
|
||
Message.error('删除服务商失败')
|
||
}
|
||
}
|
||
|
||
// 加载渠道组列表(防重入:避免刷新按钮短时间触发两次请求)
|
||
let channelGroupsLoadingPromise: Promise<void> | null = null
|
||
const loadChannelGroups = async () => {
|
||
if (channelGroupsLoadingPromise) return channelGroupsLoadingPromise
|
||
channelGroupsLoadingPromise = (async () => {
|
||
try {
|
||
let groupsRaw: any[] = []
|
||
try {
|
||
groupsRaw = await getServiceChannelGroupList({})
|
||
} catch (e) {
|
||
console.error('加载服务商分组列表失败:', e)
|
||
groupsRaw = []
|
||
}
|
||
if (!Array.isArray(groupsRaw) || groupsRaw.length === 0) {
|
||
groupsRaw = [...FALLBACK_SERVICE_CHANNEL_GROUP_LIST]
|
||
}
|
||
|
||
const map = new Map<number, any>()
|
||
const order: number[] = []
|
||
for (const g of groupsRaw) {
|
||
const gid = Number(g.id ?? g.Id ?? 0)
|
||
if (!gid) continue
|
||
if (!map.has(gid)) {
|
||
map.set(gid, {
|
||
id: gid,
|
||
name: String(g.name ?? g.Name ?? `组${gid}`),
|
||
appId: g.appId ?? g.AppId,
|
||
apiServiceProviderId: g.apiServiceProviderId ?? g.ApiServiceProviderId,
|
||
channels: [] as any[],
|
||
})
|
||
order.push(gid)
|
||
}
|
||
}
|
||
|
||
let items: any[] = []
|
||
try {
|
||
const listRes = await getServiceChannelList({ page: 1, pageSize: 500 })
|
||
items = listRes.items || []
|
||
} catch (e) {
|
||
console.error('加载渠道列表失败:', e)
|
||
Message.error('加载渠道列表失败')
|
||
}
|
||
|
||
for (const it of items) {
|
||
const gid = Number(it.groupId ?? 0)
|
||
if (!gid) continue
|
||
if (!map.has(gid)) {
|
||
const gname = it.groupName || `组${gid}`
|
||
map.set(gid, { id: gid, name: gname, channels: [] as any[] })
|
||
order.push(gid)
|
||
}
|
||
map.get(gid).channels.push({
|
||
id: it.id,
|
||
name: it.channelName,
|
||
channelCode: it.channelCode,
|
||
groupId: it.groupId,
|
||
createTime: it.createTime,
|
||
isActive: it.isActive === 1,
|
||
})
|
||
}
|
||
|
||
channelGroups.value = order.map((id) => map.get(id)).filter(Boolean)
|
||
await ensureDefaultChannelSelection()
|
||
} catch (error) {
|
||
console.error('加载渠道组列表失败:', error)
|
||
Message.error('加载服务渠道失败')
|
||
channelGroups.value = []
|
||
selectedChannel.value = null
|
||
} finally {
|
||
channelGroupsLoadingPromise = null
|
||
}
|
||
})()
|
||
return channelGroupsLoadingPromise
|
||
}
|
||
|
||
// 基础下拉(应用/服务商)改为按需加载:进入页面只加载渠道列表
|
||
const referenceOptionsLoaded = ref(false)
|
||
const referenceOptionsLoading = ref(false)
|
||
const ensureReferenceOptionsLoaded = async () => {
|
||
if (referenceOptionsLoaded.value || referenceOptionsLoading.value) return
|
||
referenceOptionsLoading.value = true
|
||
try {
|
||
await Promise.allSettled([loadAppList(), loadServiceProviderList()])
|
||
referenceOptionsLoaded.value = true
|
||
} finally {
|
||
referenceOptionsLoading.value = false
|
||
}
|
||
}
|
||
|
||
// 组件挂载时仅加载渠道列表
|
||
onMounted(() => {
|
||
loadChannelGroups()
|
||
})
|
||
// 计费因素数据
|
||
interface BillingFactor {
|
||
id?: number
|
||
serialNumber?: number
|
||
factor: string
|
||
factorValues: string[]
|
||
}
|
||
|
||
// 默认计费因素数据(与销售产品-产品卖价一致:字典 pricing_factor 解析名称 → 存 dictCode)
|
||
const getDefaultBillingFactorsData = () => {
|
||
const legacyNames = ['分区', '重量', '客户']
|
||
const rows = dictRowsOfType('pricing_factor')
|
||
const resolveFactor = (displayName: string): string => {
|
||
const hit = rows.find((r) => r.dictName === displayName || r.dictCode === displayName)
|
||
return hit?.dictCode || displayName
|
||
}
|
||
return legacyNames.map((name, index) => ({
|
||
id: index + 1,
|
||
serialNumber: index + 1,
|
||
factor: resolveFactor(name),
|
||
factorValues: [] as string[],
|
||
}))
|
||
}
|
||
|
||
const billingFactorsData = ref<BillingFactor[]>(getDefaultBillingFactorsData().map((item) => ({ ...item })))
|
||
|
||
function normalizeBillingFactorsFromApi(factors: any): BillingFactor[] {
|
||
if (!Array.isArray(factors) || factors.length === 0) {
|
||
return getDefaultBillingFactorsData().map((item) => ({ ...item }))
|
||
}
|
||
return factors.map((item: any, index: number) => ({
|
||
id: item?.id ?? index + 1,
|
||
serialNumber: item?.serialNumber ?? index + 1,
|
||
factor: item?.factor ?? item?.name ?? '',
|
||
factorValues: item?.factorValues ?? item?.values ?? [],
|
||
}))
|
||
}
|
||
|
||
function normalizePriceDetailsFromApi(details: any): any[] {
|
||
if (!Array.isArray(details)) return []
|
||
return details.map((item: any, index: number) => ({
|
||
id: item?.id ?? index + 1,
|
||
...item,
|
||
}))
|
||
}
|
||
|
||
function coercePriceListRowsFactorCells() {
|
||
const rows = billingFactorsData.value
|
||
for (const prow of priceListData.value) {
|
||
if (!prow || typeof prow !== 'object') continue
|
||
rows.forEach((bf, i) => {
|
||
const k = `factor_${i}`
|
||
if (!Object.prototype.hasOwnProperty.call(prow, k)) return
|
||
const cur = (prow as any)[k]
|
||
if (cur == null || String(cur).trim() === '') return
|
||
const [next] = toCanonicalFactorValues(bf.factor, [String(cur)])
|
||
if (next != null && String(next).trim() !== '') (prow as any)[k] = next
|
||
})
|
||
}
|
||
}
|
||
|
||
function buildFactorsPayloadForApi(): Array<{ serialNumber: number; factor: string; factorValues: string[] }> {
|
||
return billingFactorsData.value
|
||
.filter((item) => item.factor)
|
||
.map((item, idx) => ({
|
||
serialNumber: idx + 1,
|
||
factor: item.factor,
|
||
factorValues: toCanonicalFactorValues(item.factor, item.factorValues),
|
||
}))
|
||
}
|
||
|
||
/** 将接口返回的 details(含 factorValuesJson)还原为价格表网格行 */
|
||
function mapDetailsToPriceListRows(details: any[]): any[] {
|
||
if (!Array.isArray(details)) return []
|
||
return details.map((item: any, index: number) => {
|
||
if (item?.factorValuesJson && typeof item.factorValuesJson === 'object') {
|
||
const row: any = {
|
||
id: item.id ?? index + 1,
|
||
calculationFormula: item.calculationFormula || defaultCalculationFormulaCode(),
|
||
calculatedValue: item.calculatedValue,
|
||
}
|
||
const fj = item.factorValuesJson as Record<string, string>
|
||
for (let i = 0; i < billingFactorsData.value.length; i++) {
|
||
const bf = billingFactorsData.value[i]
|
||
if (!bf.factor || isCustomerFactor(bf.factor)) continue
|
||
const label = pricingFactorColumnLabel(bf.factor)
|
||
row[`factor_${i}`] = fj[label] ?? fj[bf.factor] ?? ''
|
||
}
|
||
const cgs = item.customerGroupValues
|
||
if (cgs && typeof cgs === 'object') {
|
||
const codes = getSelectedCustomerGroupCodesFromBilling()
|
||
codes.forEach((code, idx) => {
|
||
const lab = customerGroupPriceTableHeaderLabel(code, idx)
|
||
row[`customer_${idx}`] = Number(cgs[lab]) || 0
|
||
})
|
||
}
|
||
return row
|
||
}
|
||
return { id: item?.id ?? index + 1, ...item }
|
||
})
|
||
}
|
||
|
||
function buildServiceChannelDetailsPayload(): any[] {
|
||
if (!priceListData.value.length) return []
|
||
return priceListData.value.map((row: any, index: number) => {
|
||
const factorValuesJson: Record<string, string> = {}
|
||
for (let i = 0; i < billingFactorsData.value.length; i++) {
|
||
const bf = billingFactorsData.value[i]
|
||
if (!bf.factor || isCustomerFactor(bf.factor)) continue
|
||
const colKey = `factor_${i}`
|
||
const v = row[colKey]
|
||
const nameKey = pricingFactorColumnLabel(bf.factor)
|
||
factorValuesJson[nameKey] = v != null && String(v).trim() !== '' ? String(v) : ''
|
||
}
|
||
const cgCodes = getSelectedCustomerGroupCodesFromBilling()
|
||
const customerGroupValues: Record<string, number> = {}
|
||
cgCodes.forEach((code, idx) => {
|
||
const label = customerGroupPriceTableHeaderLabel(code, idx)
|
||
customerGroupValues[label] = Number(row[`customer_${idx}`]) || 0
|
||
})
|
||
return {
|
||
id: row.id ?? index + 1,
|
||
factorValuesJson,
|
||
calculationFormula: row.calculationFormula || defaultCalculationFormulaCode(),
|
||
calculatedValue: Number(row.calculatedValue ?? 0) || 0,
|
||
customerGroupValues,
|
||
}
|
||
})
|
||
}
|
||
|
||
// 价格表数据(详情弹窗内)
|
||
const priceListData = ref<any[]>([])
|
||
|
||
// 价格表数据(用于列表展示)
|
||
const priceTableData = ref<any[]>([])
|
||
|
||
|
||
// 价格弹窗相关
|
||
const priceModalVisible = ref(false)
|
||
const priceDrawerActiveTab = ref('basic')
|
||
const isEditMode = ref(false)
|
||
const editingPriceRecord = ref<any>(null)
|
||
|
||
// 基础信息表单
|
||
const basicInfoForm = reactive({
|
||
costItem: '',
|
||
costUnit: '',
|
||
currency: 'CNY',
|
||
chargingTime: '',
|
||
chargingCondition: ''
|
||
})
|
||
|
||
// 渠道买价表列(与销售产品-产品卖价一致:字典编码列用 slot 展示名称)
|
||
const priceColumns = ref([
|
||
{ title: t('channel.salesProducts.table.providers.serialNo'), dataIndex: 'serialNumber', width: 80, align: 'center' },
|
||
{
|
||
title: t('channel.salesProducts.table.price.status'),
|
||
dataIndex: 'status',
|
||
width: 100,
|
||
align: 'center',
|
||
slotName: 'status',
|
||
},
|
||
{
|
||
title: t('channel.salesProducts.drawer.form.costItem'),
|
||
dataIndex: 'costItem',
|
||
width: 140,
|
||
slotName: 'costItemCell',
|
||
},
|
||
{
|
||
title: t('channel.salesProducts.drawer.form.costUnit'),
|
||
dataIndex: 'costUnit',
|
||
width: 100,
|
||
align: 'center',
|
||
slotName: 'costUnitCell',
|
||
},
|
||
{
|
||
title: t('channel.salesProducts.drawer.form.currency'),
|
||
dataIndex: 'currency',
|
||
width: 100,
|
||
align: 'center',
|
||
slotName: 'currencyCell',
|
||
},
|
||
{
|
||
title: t('channel.salesProducts.drawer.section.billingFactors'),
|
||
dataIndex: 'billingFactors',
|
||
width: 200,
|
||
ellipsis: true,
|
||
tooltip: true,
|
||
slotName: 'billingFactorsCell',
|
||
},
|
||
{ title: '修改时间', dataIndex: 'modifyTime', width: 180 },
|
||
{ title: '修改人', dataIndex: 'modifier', width: 100 },
|
||
{
|
||
title: t('channel.salesProducts.table.providers.actions'),
|
||
width: 150,
|
||
align: 'center',
|
||
fixed: 'right',
|
||
slotName: 'operations',
|
||
},
|
||
])
|
||
|
||
// 发布记录相关
|
||
const publishRecordTab = ref('price')
|
||
const currentListData = ref<any[]>([])
|
||
const publishRecordActiveIndex = ref(0)
|
||
const currentPublishRecord = computed(() => currentListData.value[publishRecordActiveIndex.value] || {})
|
||
const formattedJsonContent = computed(() => {
|
||
if (!currentPublishRecord.value?.content) return ''
|
||
try {
|
||
return JSON.stringify(JSON.parse(currentPublishRecord.value.content), null, 2)
|
||
} catch {
|
||
return currentPublishRecord.value.content || ''
|
||
}
|
||
})
|
||
|
||
async function refreshChannelPriceEffectiveInfo(serviceChannelId?: number | string) {
|
||
const cid = serviceChannelId ?? (selectedChannel.value as any)?.id
|
||
if (cid == null) {
|
||
effectiveVersion.value = '--'
|
||
effectiveTime.value = '--'
|
||
return
|
||
}
|
||
try {
|
||
const res = await getServiceChannelPublishPriceList({ serviceChannelId: cid, page: 1, pageSize: 50 })
|
||
const items = res.items || []
|
||
const eff = items.find(
|
||
(r: any) =>
|
||
r.status === '生效' || r.status === 1 || String(r?.status ?? '').includes('生效')
|
||
)
|
||
const pick = eff || items[0]
|
||
if (pick) {
|
||
effectiveVersion.value = String(pick.version ?? '--')
|
||
effectiveTime.value = String(pick.publishTime ?? pick.createTime ?? '--')
|
||
} else {
|
||
effectiveVersion.value = '--'
|
||
effectiveTime.value = '--'
|
||
}
|
||
} catch {
|
||
effectiveVersion.value = '--'
|
||
effectiveTime.value = '--'
|
||
}
|
||
}
|
||
|
||
function mapServiceChannelPriceRow(p: any, idx: number) {
|
||
const id = p?.id ?? p?.Id
|
||
const serialNumber = p?.serialNumber ?? p?.SerialNumber ?? idx + 1
|
||
const st = p?.status ?? p?.Status
|
||
const status = st === 1 || st === true || st === '1'
|
||
const rawFactors = Array.isArray(p?.factors ?? p?.Factors) ? (p.factors ?? p.Factors) : []
|
||
const factors = rawFactors.map((f: any) => ({
|
||
...f,
|
||
factor: f?.factor ?? f?.Factor,
|
||
factorValues: f?.factorValues ?? f?.FactorValues ?? f?.values ?? f?.Values ?? [],
|
||
}))
|
||
const billingFactors =
|
||
factors.map((f: any) => f.factor).filter(Boolean).join('+') || '-'
|
||
const key =
|
||
id != null && id !== ''
|
||
? id
|
||
: `sc-price-${idx}-${serialNumber}-${String(p?.costItem ?? p?.CostItem ?? '')}`
|
||
return {
|
||
...p,
|
||
key,
|
||
id,
|
||
serialNumber,
|
||
status,
|
||
priceName: p?.priceName ?? p?.PriceName,
|
||
costItem: p?.costItem ?? p?.CostItem,
|
||
costUnit: p?.costUnit ?? p?.CostUnit,
|
||
currency: p?.currency ?? p?.Currency,
|
||
chargingTime: p?.chargingTime ?? p?.ChargingTime,
|
||
chargingCondition: p?.chargingCondition ?? p?.ChargingCondition,
|
||
factors,
|
||
details: Array.isArray(p?.details ?? p?.Details) ? (p.details ?? p.Details) : [],
|
||
billingFactors,
|
||
modifyTime: p?.updateTime ?? p?.UpdateTime ?? p?.createTime ?? p?.CreateTime ?? '',
|
||
modifier: p?.updateBy ?? p?.UpdateBy ?? p?.createBy ?? p?.CreateBy ?? '',
|
||
}
|
||
}
|
||
|
||
async function loadServiceChannelPriceRows(serviceChannelId: number | string) {
|
||
const raw = await getServiceChannelPriceList(serviceChannelId)
|
||
const list = Array.isArray(raw) ? raw : []
|
||
priceTableData.value = list.map((p: any, i: number) => mapServiceChannelPriceRow(p, i))
|
||
await refreshChannelPriceEffectiveInfo(serviceChannelId)
|
||
}
|
||
|
||
function mapPublishRecordRow(r: any) {
|
||
const content =
|
||
r.content == null
|
||
? ''
|
||
: typeof r.content === 'string'
|
||
? r.content
|
||
: JSON.stringify(r.content, null, 2)
|
||
return {
|
||
id: r.id,
|
||
version: r.version,
|
||
publishTime: r.publishTime || r.createTime,
|
||
publisher: r.publishUser || '',
|
||
publishUser: r.publishUser,
|
||
description: r.description,
|
||
content,
|
||
status: r.status,
|
||
}
|
||
}
|
||
|
||
async function loadServiceChannelPublishRecords(tab?: 'price' | 'rules') {
|
||
const cid = (selectedChannel.value as any)?.id
|
||
if (cid == null) {
|
||
currentListData.value = []
|
||
return
|
||
}
|
||
const t = tab ?? (publishRecordTab.value as 'price' | 'rules')
|
||
try {
|
||
const res =
|
||
t === 'price'
|
||
? await getServiceChannelPublishPriceList({ serviceChannelId: cid, page: 1, pageSize: 200 })
|
||
: await getServiceChannelPublishRulesList({ serviceChannelId: cid, page: 1, pageSize: 200 })
|
||
currentListData.value = (res.items || []).map(mapPublishRecordRow)
|
||
publishRecordActiveIndex.value = 0
|
||
} catch {
|
||
currentListData.value = []
|
||
}
|
||
}
|
||
|
||
watch([activeTab, selectedChannel], async () => {
|
||
const cid = (selectedChannel.value as any)?.id
|
||
if (cid == null) return
|
||
if (activeTab.value === 'price') {
|
||
await ensureChannelPriceDictLoaded()
|
||
await loadServiceChannelPriceRows(cid)
|
||
await refreshChannelPriceEffectiveInfo(cid)
|
||
}
|
||
if (activeTab.value === 'records') {
|
||
await loadServiceChannelPublishRecords()
|
||
}
|
||
})
|
||
|
||
// 发布日期相关
|
||
const publishDateVisible = ref(false)
|
||
const publishDate = ref<Date | null>(null)
|
||
|
||
// 版本信息(生效版本来自买价发布记录接口)
|
||
const effectiveVersion = ref('--')
|
||
const effectiveTime = ref('--')
|
||
|
||
// 价格表列顺序(与销售产品卖价一致)
|
||
const priceTableHeaders = computed(() => {
|
||
type H = { key: string; label: string; isCustomer: boolean; factorIndex?: number }
|
||
const rows = priceListData.value
|
||
|
||
const factorHeaders: H[] = []
|
||
for (let i = 0; i < billingFactorsData.value.length; i++) {
|
||
const factor = billingFactorsData.value[i]
|
||
if (!factor.factor || isCustomerFactor(factor.factor)) continue
|
||
const colKey = `factor_${i}`
|
||
const hasMappedValues = factor.factorValues.length > 0
|
||
const hasRowValues = rows.some((row) => {
|
||
const v = (row as any)?.[colKey]
|
||
return v != null && String(v).trim() !== ''
|
||
})
|
||
if (hasMappedValues || hasRowValues) {
|
||
factorHeaders.push({
|
||
key: colKey,
|
||
label: pricingFactorColumnLabel(factor.factor),
|
||
isCustomer: false,
|
||
factorIndex: i,
|
||
})
|
||
}
|
||
}
|
||
|
||
const formulaHeader: H = {
|
||
key: 'calculationFormula',
|
||
label: '计算公式',
|
||
isCustomer: false,
|
||
}
|
||
|
||
const customerGroupHeaders: H[] = []
|
||
const selectedCg = getSelectedCustomerGroupCodesFromBilling()
|
||
if (selectedCg.length > 0) {
|
||
selectedCg.forEach((code, index) => {
|
||
customerGroupHeaders.push({
|
||
key: `customer_${index}`,
|
||
label: customerGroupPriceTableHeaderLabel(code, index),
|
||
isCustomer: true,
|
||
})
|
||
})
|
||
} else {
|
||
let maxIdx = -1
|
||
for (const row of rows) {
|
||
if (!row || typeof row !== 'object') continue
|
||
for (const k of Object.keys(row as object)) {
|
||
const m = /^customer_(\d+)$/.exec(k)
|
||
if (m) maxIdx = Math.max(maxIdx, Number(m[1]))
|
||
}
|
||
}
|
||
for (let index = 0; index <= maxIdx; index++) {
|
||
customerGroupHeaders.push({
|
||
key: `customer_${index}`,
|
||
label: `客户组${index + 1}`,
|
||
isCustomer: true,
|
||
})
|
||
}
|
||
}
|
||
|
||
return [...factorHeaders, formulaHeader, ...customerGroupHeaders]
|
||
})
|
||
|
||
// 生成价格表数据(根据计费因素和因素值,与销售产品卖价一致)
|
||
const generatePriceListData = () => {
|
||
const newData: any[] = []
|
||
|
||
const nonCustomerFactors = billingFactorsData.value.filter(
|
||
(item: BillingFactor) => item.factor && !isCustomerFactor(item.factor) && item.factorValues.length > 0
|
||
)
|
||
|
||
if (nonCustomerFactors.length === 0) {
|
||
Message.warning('请至少配置一个非客户因素')
|
||
return
|
||
}
|
||
|
||
const generateCombinations = (arrays: string[][]): string[][] => {
|
||
if (arrays.length === 0) return [[]]
|
||
if (arrays.length === 1) return arrays[0].map((item) => [item])
|
||
|
||
const [first, ...rest] = arrays
|
||
const restCombinations = generateCombinations(rest)
|
||
const result: string[][] = []
|
||
|
||
for (const firstItem of first) {
|
||
for (const restCombination of restCombinations) {
|
||
result.push([firstItem, ...restCombination])
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
const factorValueArrays = nonCustomerFactors.map((factor) => factor.factorValues)
|
||
const combinations = generateCombinations(factorValueArrays)
|
||
|
||
combinations.forEach((combination, index) => {
|
||
const row: any = {
|
||
id: index + 1,
|
||
calculationFormula: defaultCalculationFormulaCode(),
|
||
}
|
||
|
||
nonCustomerFactors.forEach((factor: BillingFactor, factorIndex: number) => {
|
||
row[`factor_${billingFactorsData.value.findIndex((item: BillingFactor) => item.id === factor.id)}`] =
|
||
combination[factorIndex]
|
||
})
|
||
|
||
const selectedCg = getSelectedCustomerGroupCodesFromBilling()
|
||
selectedCg.forEach((_, customerIndex) => {
|
||
row[`customer_${customerIndex}`] = 0
|
||
})
|
||
|
||
newData.push(row)
|
||
})
|
||
|
||
priceListData.value = newData
|
||
}
|
||
|
||
// 生成价格表
|
||
const handleGeneratePriceList = async () => {
|
||
await ensureChannelPriceDictLoaded()
|
||
await loadBillingFactorSelectOptions()
|
||
generatePriceListData()
|
||
priceDrawerActiveTab.value = 'priceList'
|
||
Message.success('价格表生成成功')
|
||
}
|
||
|
||
// 保存基础信息
|
||
const handleSaveBasicInfo = () => {
|
||
Message.success('基础信息保存成功')
|
||
}
|
||
|
||
const savingPriceDrawer = ref(false)
|
||
|
||
function nextServiceChannelPriceSerialNumber(): number {
|
||
const nums = priceTableData.value
|
||
.map((r: any) => Number(r.serialNumber))
|
||
.filter((n) => Number.isFinite(n) && n > 0)
|
||
return (nums.length ? Math.max(...nums) : 0) + 1
|
||
}
|
||
|
||
/** 组装单条买价请求体(对齐 /channel/service-channel/price/create|update) */
|
||
function buildServiceChannelSinglePriceBody(serviceChannelId: number | string) {
|
||
const cid = Number(serviceChannelId)
|
||
const factors = buildFactorsPayloadForApi()
|
||
const details = buildServiceChannelDetailsPayload()
|
||
const chargingTime = basicInfoForm.chargingTime || defaultChargingTimeCode()
|
||
const priceName =
|
||
dictLabelByType('fee_item', basicInfoForm.costItem) || basicInfoForm.costItem || ''
|
||
const serialNumber =
|
||
isEditMode.value && editingPriceRecord.value != null && editingPriceRecord.value.serialNumber != null
|
||
? Number(editingPriceRecord.value.serialNumber)
|
||
: nextServiceChannelPriceSerialNumber()
|
||
const status =
|
||
isEditMode.value && editingPriceRecord.value != null
|
||
? editingPriceRecord.value.status
|
||
? 1
|
||
: 0
|
||
: 1
|
||
return {
|
||
serviceChannelId: cid,
|
||
serialNumber,
|
||
status,
|
||
priceName,
|
||
costItem: basicInfoForm.costItem,
|
||
costUnit: basicInfoForm.costUnit,
|
||
currency: basicInfoForm.currency,
|
||
chargingTime,
|
||
chargingCondition: basicInfoForm.chargingCondition || '',
|
||
factors,
|
||
details,
|
||
}
|
||
}
|
||
|
||
// 保存价格表:POST price/create 或 price/update,成功后刷新列表
|
||
const handleSavePriceList = async () => {
|
||
if (!basicInfoForm.costItem?.trim()) {
|
||
Message.warning('请先填写费用项')
|
||
return
|
||
}
|
||
const cid = (selectedChannel.value as any)?.id
|
||
if (cid == null) {
|
||
Message.warning('请先选择渠道')
|
||
return
|
||
}
|
||
if (!priceListData.value.length) {
|
||
Message.warning('请先在「基础信息」中生成价格表后再保存')
|
||
return
|
||
}
|
||
savingPriceDrawer.value = true
|
||
try {
|
||
const body = buildServiceChannelSinglePriceBody(cid)
|
||
if (isEditMode.value && editingPriceRecord.value?.id != null) {
|
||
await updateServiceChannelPrice({
|
||
id: editingPriceRecord.value.id,
|
||
...body,
|
||
})
|
||
Message.success('价格配置已更新')
|
||
} else {
|
||
await createServiceChannelPrice(body)
|
||
Message.success('价格配置已添加')
|
||
}
|
||
await loadServiceChannelPriceRows(cid)
|
||
priceModalVisible.value = false
|
||
isEditMode.value = false
|
||
editingPriceRecord.value = null
|
||
} catch (e: any) {
|
||
Message.error(e?.message || '保存失败')
|
||
} finally {
|
||
savingPriceDrawer.value = false
|
||
}
|
||
}
|
||
|
||
// 导出价格表到Excel
|
||
const handleExportPriceList = () => {
|
||
try {
|
||
// 准备表头数据
|
||
const headers = priceTableHeaders.value.map((h) => h.label)
|
||
|
||
// 准备表格数据(所有行都导出,合并的单元格只在第一行显示值)
|
||
const tableData: any[] = []
|
||
const merges: Array<{ s: { r: number; c: number }; e: { r: number; c: number } }> = []
|
||
|
||
priceListData.value.forEach((row: any, rowIndex: number) => {
|
||
const rowData: any = {}
|
||
|
||
priceTableHeaders.value.forEach((header, colIndex) => {
|
||
if (header.key.startsWith('factor_')) {
|
||
// 因素列:与表格展示一致(名称而非编码/id),与销售产品卖价导出一致
|
||
const rowSpan = getFactorRowSpan(header.key, rowIndex)
|
||
if (rowSpan > 0) {
|
||
rowData[header.label] = formatPriceTableFactorCell(
|
||
header.factorIndex,
|
||
String((row as any)[header.key] ?? '')
|
||
)
|
||
if (rowSpan > 1) {
|
||
// 需要合并:从当前行(rowIndex+1,因为第0行是表头)到rowIndex+rowSpan-1
|
||
merges.push({
|
||
s: { r: rowIndex + 1, c: colIndex }, // +1 因为第0行是表头
|
||
e: { r: rowIndex + rowSpan, c: colIndex } // rowIndex + rowSpan 是最后一行(包含)
|
||
})
|
||
}
|
||
} else {
|
||
// 这是合并的其他行,留空(Excel会自动处理合并)
|
||
rowData[header.label] = ''
|
||
}
|
||
} else if (header.key === 'calculationFormula') {
|
||
rowData[header.label] = row.calculationFormula || ''
|
||
} else if (header.isCustomer) {
|
||
rowData[header.label] = (row as any)[header.key] || 0
|
||
}
|
||
})
|
||
|
||
tableData.push(rowData)
|
||
})
|
||
|
||
// 创建工作簿
|
||
const wb = XLSX.utils.book_new()
|
||
const ws = XLSX.utils.json_to_sheet(tableData)
|
||
|
||
// 设置合并单元格
|
||
if (merges.length > 0) {
|
||
ws['!merges'] = merges
|
||
}
|
||
|
||
// 设置列宽
|
||
const colWidths = headers.map(() => ({ wch: 15 }))
|
||
ws['!cols'] = colWidths
|
||
|
||
// 添加工作表到工作簿
|
||
XLSX.utils.book_append_sheet(wb, ws, '价格表')
|
||
|
||
// 导出文件
|
||
const fileName = `${basicInfoForm.costItem || '价格表'}_${dayjs().format('YYYY-MM-DD_HH-mm-ss')}.xlsx`
|
||
XLSX.writeFile(wb, fileName)
|
||
|
||
Message.success('导出成功')
|
||
} catch (error) {
|
||
console.error('导出价格表失败:', error)
|
||
Message.error('导出失败')
|
||
}
|
||
}
|
||
|
||
// 导入价格表(与销售产品卖价一致:sheet_to_json + 列标题映射)
|
||
const handleImportPriceList = () => {
|
||
const input = document.createElement('input')
|
||
input.type = 'file'
|
||
input.accept = '.xlsx,.xls'
|
||
input.onchange = (e: Event) => {
|
||
const file = (e.target as HTMLInputElement).files?.[0]
|
||
if (!file) return
|
||
|
||
const reader = new FileReader()
|
||
reader.onload = (event: ProgressEvent<FileReader>) => {
|
||
try {
|
||
const data = new Uint8Array(event.target?.result as ArrayBuffer)
|
||
const workbook = XLSX.read(data, { type: 'array' })
|
||
const firstSheetName = workbook.SheetNames[0]
|
||
const worksheet = workbook.Sheets[firstSheetName]
|
||
|
||
const jsonData = XLSX.utils.sheet_to_json(worksheet)
|
||
if (jsonData.length === 0) {
|
||
Message.warning('Excel文件为空')
|
||
return
|
||
}
|
||
|
||
const importedData = jsonData.map((row: any, index: number) => {
|
||
const newRow: any = {
|
||
id: index + 1,
|
||
calculationFormula:
|
||
row[priceTableHeaders.value.find((h) => h.key === 'calculationFormula')?.label || ''] ||
|
||
defaultCalculationFormulaCode(),
|
||
}
|
||
|
||
priceTableHeaders.value.forEach((header) => {
|
||
if (header.key !== 'calculationFormula') {
|
||
if (header.isCustomer) {
|
||
newRow[header.key] = row[header.label] || 0
|
||
} else {
|
||
newRow[header.key] = row[header.label] || ''
|
||
}
|
||
}
|
||
})
|
||
|
||
return newRow
|
||
})
|
||
|
||
priceListData.value = importedData
|
||
coercePriceListRowsFactorCells()
|
||
Message.success('导入成功')
|
||
} catch (error) {
|
||
console.error('导入价格表失败:', error)
|
||
Message.error('导入失败,请检查文件格式')
|
||
}
|
||
}
|
||
reader.readAsArrayBuffer(file)
|
||
}
|
||
input.click()
|
||
}
|
||
|
||
// 计算因素行的合并行数(与销售产品卖价一致)
|
||
const getFactorRowSpan = (headerKey: string, rowIndex: number): number => {
|
||
if (!headerKey.startsWith('factor_')) {
|
||
return 0
|
||
}
|
||
|
||
const currentRow = priceListData.value[rowIndex]
|
||
const currentValue = (currentRow as any)[headerKey]
|
||
|
||
if (currentValue === undefined || currentValue === '') {
|
||
return 0
|
||
}
|
||
|
||
const headerIndex = priceTableHeaders.value.findIndex((h) => h.key === headerKey)
|
||
if (headerIndex < 0) return 0
|
||
|
||
let startIndex = rowIndex
|
||
for (let i = rowIndex - 1; i >= 0; i--) {
|
||
const prevRow = priceListData.value[i]
|
||
let allFactorsMatch = true
|
||
for (let j = 0; j < headerIndex; j++) {
|
||
const prevHeader = priceTableHeaders.value[j]
|
||
if (prevHeader.key.startsWith('factor_')) {
|
||
if ((prevRow as any)[prevHeader.key] !== (currentRow as any)[prevHeader.key]) {
|
||
allFactorsMatch = false
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if (!allFactorsMatch) break
|
||
|
||
if ((prevRow as any)[headerKey] === currentValue) {
|
||
startIndex = i
|
||
} else {
|
||
break
|
||
}
|
||
}
|
||
|
||
let endIndex = rowIndex
|
||
for (let i = rowIndex + 1; i < priceListData.value.length; i++) {
|
||
const nextRow = priceListData.value[i]
|
||
let allFactorsMatch = true
|
||
for (let j = 0; j < headerIndex; j++) {
|
||
const prevHeader = priceTableHeaders.value[j]
|
||
if (prevHeader.key.startsWith('factor_')) {
|
||
if ((nextRow as any)[prevHeader.key] !== (currentRow as any)[prevHeader.key]) {
|
||
allFactorsMatch = false
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if (!allFactorsMatch) break
|
||
|
||
if ((nextRow as any)[headerKey] === currentValue) {
|
||
endIndex = i
|
||
} else {
|
||
break
|
||
}
|
||
}
|
||
|
||
if (startIndex === rowIndex) {
|
||
return endIndex - startIndex + 1
|
||
}
|
||
|
||
return 0
|
||
}
|
||
|
||
// 检查是否可以切换到价格表tab(与销售产品卖价一致)
|
||
const canSwitchToPriceList = computed(() => {
|
||
if (priceListData.value.length > 0) return true
|
||
return billingFactorsData.value.some(
|
||
(item: BillingFactor) =>
|
||
item.factor && !isCustomerFactor(item.factor) && item.factorValues.length > 0
|
||
)
|
||
})
|
||
|
||
// 处理tab切换
|
||
const handlePriceDrawerTabSwitch = async (tabKey: string) => {
|
||
if (tabKey === 'priceList' && !canSwitchToPriceList.value) {
|
||
Message.warning('请配置计费因素和映射值,点击生成价格表')
|
||
return
|
||
}
|
||
if (tabKey === 'priceList') {
|
||
await ensureChannelPriceDictLoaded()
|
||
await loadBillingFactorSelectOptions()
|
||
}
|
||
priceDrawerActiveTab.value = tabKey
|
||
}
|
||
|
||
|
||
// 切换价格状态(模板中已切换 record.status,此处只落库)
|
||
const handleTogglePriceStatus = async (record: any) => {
|
||
const cid = (selectedChannel.value as any)?.id
|
||
if (cid == null || record?.id == null) return
|
||
try {
|
||
await toggleServiceChannelPriceStatus(record.id, record.status ? 1 : 0)
|
||
await loadServiceChannelPriceRows(cid)
|
||
} catch (e: any) {
|
||
record.status = !record.status
|
||
Message.error(e?.message || '更新失败')
|
||
}
|
||
}
|
||
|
||
// 状态开关滑动切换
|
||
const statusTouchStartX = ref(0)
|
||
const STATUS_SWIPE_THRESHOLD = 40
|
||
function onStatusSwipeStart(e: TouchEvent) {
|
||
statusTouchStartX.value = e.touches[0].clientX
|
||
}
|
||
function onStatusSwipeEnd(record: any, e: TouchEvent) {
|
||
const endX = e.changedTouches[0].clientX
|
||
if (Math.abs(endX - statusTouchStartX.value) >= STATUS_SWIPE_THRESHOLD) {
|
||
record.status = !record.status
|
||
handleTogglePriceStatus(record)
|
||
}
|
||
}
|
||
function onChannelSwitchSwipeEnd(nodeData: any, e: TouchEvent) {
|
||
const endX = e.changedTouches[0].clientX
|
||
if (Math.abs(endX - statusTouchStartX.value) >= STATUS_SWIPE_THRESHOLD) {
|
||
const channel = findChannelById(nodeData?.id)
|
||
if (channel) {
|
||
channel.isActive = !channel.isActive
|
||
handleToggleChannel(channel)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 新增服务商
|
||
const handleAddProvider = () => {
|
||
ensureReferenceOptionsLoaded()
|
||
addProviderForm.providerName = ''
|
||
addProviderForm.appId = undefined
|
||
addProviderForm.serviceProviderId = undefined
|
||
addProviderModalVisible.value = true
|
||
}
|
||
|
||
// 编辑服务商
|
||
const handleEditGroup = (group: any) => {
|
||
ensureReferenceOptionsLoaded()
|
||
editingProviderGroupId.value = Number(group.id)
|
||
editProviderForm.providerName = group.name || ''
|
||
editProviderForm.appId = (group as any).appId
|
||
editProviderForm.serviceProviderId =
|
||
(group as any).apiServiceProviderId ?? (group as any).serviceProviderId
|
||
editProviderModalVisible.value = true
|
||
}
|
||
|
||
// 新增渠道到服务商
|
||
const handleAddChannelToGroup = async (group: any) => {
|
||
addChannelTargetGroupId.value = group?.id != null ? Number(group.id) : null
|
||
addChannelForm.channelCode = ''
|
||
addChannelForm.channelName = ''
|
||
addChannelForm.minTime = ''
|
||
addChannelForm.maxTime = ''
|
||
addChannelForm.forecastType = ''
|
||
addChannelForm.forecastCode = ''
|
||
addChannelForm.description = ''
|
||
addChannelForm.preScript = ''
|
||
addChannelModalVisible.value = true
|
||
await mergeForecastTypeDictFromApi()
|
||
}
|
||
|
||
function applyServiceChannelDetailToForm(d: Record<string, any>) {
|
||
if (d == null || typeof d !== 'object') return
|
||
formData.channelCode = String(d.channelCode ?? d.ChannelCode ?? '')
|
||
formData.channelName = String(d.channelName ?? d.ChannelName ?? '')
|
||
const minRaw = d.minTime ?? d.MinTime
|
||
const maxRaw = d.maxTime ?? d.MaxTime
|
||
formData.minTime = minRaw != null && minRaw !== '' ? String(minRaw) : ''
|
||
formData.maxTime = maxRaw != null && maxRaw !== '' ? String(maxRaw) : ''
|
||
formData.forecastType = String(d.forecastType ?? d.ForecastType ?? '')
|
||
formData.forecastCode = String(d.forecastCode ?? d.ForecastCode ?? '')
|
||
formData.description = String(d.description ?? d.Description ?? '')
|
||
formData.preScript = String(d.preScript ?? d.PreScript ?? '')
|
||
}
|
||
|
||
// 选择渠道
|
||
const selectChannel = async (channel: any) => {
|
||
selectedChannel.value = channel
|
||
if (!channel?.id) return
|
||
await ensureChannelPriceDictLoaded()
|
||
try {
|
||
const d = await getServiceChannelDetail(channel.id)
|
||
if (d == null || typeof d !== 'object') {
|
||
Message.error('渠道详情为空,请稍后重试')
|
||
formData.channelCode = String(channel.channelCode ?? '')
|
||
formData.channelName = String(channel.name ?? '')
|
||
return
|
||
}
|
||
applyServiceChannelDetailToForm(d)
|
||
} catch (e) {
|
||
console.error(e)
|
||
Message.error((e as any)?.message || '加载渠道详情失败')
|
||
formData.channelCode = String(channel.channelCode ?? '')
|
||
formData.channelName = String(channel.name ?? '')
|
||
return
|
||
}
|
||
try {
|
||
await loadServiceChannelPriceRows(channel.id)
|
||
} catch (e) {
|
||
console.error('加载渠道买价失败:', e)
|
||
}
|
||
if (activeTab.value === 'records') {
|
||
try {
|
||
await loadServiceChannelPublishRecords()
|
||
} catch (e) {
|
||
console.error('加载发布记录失败:', e)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 切换渠道状态
|
||
const handleToggleChannel = async (channel: any) => {
|
||
if (!channel?.id) return
|
||
try {
|
||
const d = await getServiceChannelDetail(channel.id)
|
||
const activeNow = d.isActive === 1 || d.isActive === true ? 1 : 0
|
||
const next = activeNow === 1 ? 0 : 1
|
||
await updateServiceChannel(
|
||
buildServiceChannelUpdatePayload(d, { isActive: next })
|
||
)
|
||
channel.isActive = next === 1
|
||
Message.success(`渠道已${channel.isActive ? '启用' : '禁用'}`)
|
||
await loadChannelGroups()
|
||
} catch (e: any) {
|
||
Message.error(e?.message || '更新失败')
|
||
}
|
||
}
|
||
|
||
// 删除渠道 - 使用弹窗确认
|
||
|
||
/** 保存成功后异步发布(不阻塞保存完成提示;与 POST /channel/service-channel/publish 一致) */
|
||
function runPublishAfterChannelConfigSave(
|
||
serviceChannelId: number | string,
|
||
operatorType: 'price' | 'rules'
|
||
) {
|
||
void (async () => {
|
||
try {
|
||
await publishServiceChannel({
|
||
serviceChannelId,
|
||
operatorType,
|
||
description: '渠道配置保存并发布',
|
||
})
|
||
Message.success('发布成功')
|
||
await loadServiceChannelPublishRecords(operatorType)
|
||
if (operatorType === 'price') {
|
||
await loadServiceChannelPriceRows(serviceChannelId)
|
||
}
|
||
await loadChannelGroups()
|
||
} catch (e: any) {
|
||
Message.error(e?.message || '发布失败')
|
||
}
|
||
})()
|
||
}
|
||
|
||
/** 先 POST update,成功后再拉详情刷新表单(与「先保存再同步服务端数据」一致) */
|
||
async function executeChannelConfigSave(
|
||
successMessage: string,
|
||
options?: { publishAfter?: 'price' | 'rules' }
|
||
) {
|
||
if (!selectedChannel.value || !(selectedChannel.value as any).id) {
|
||
Message.warning('请先选择渠道')
|
||
return
|
||
}
|
||
const ch = selectedChannel.value as any
|
||
const groupId = Number(ch.groupId)
|
||
if (!Number.isFinite(groupId) || groupId <= 0) {
|
||
Message.error('缺少分组信息,无法保存')
|
||
return
|
||
}
|
||
const isActive = ch.isActive === true || ch.isActive === 1 ? 1 : 0
|
||
const payload = buildServiceChannelUpdatePayload(
|
||
{ id: ch.id, groupId, isActive },
|
||
{
|
||
groupId,
|
||
channelCode: formData.channelCode,
|
||
channelName: formData.channelName,
|
||
minTime: formData.minTime ? Number(formData.minTime) : 0,
|
||
maxTime: formData.maxTime ? Number(formData.maxTime) : 0,
|
||
forecastType: formData.forecastType,
|
||
forecastCode: formData.forecastCode,
|
||
description: formData.description,
|
||
preScript: formData.preScript,
|
||
isActive,
|
||
}
|
||
)
|
||
const savedId = ch.id
|
||
try {
|
||
await updateServiceChannel(payload)
|
||
Message.success(successMessage)
|
||
// 刷新树并 await 选中渠道拉详情,避免 void selectChannel 与手动 getDetail 竞态导致表单空白或错乱
|
||
await loadChannelGroups()
|
||
if (options?.publishAfter) {
|
||
runPublishAfterChannelConfigSave(savedId, options.publishAfter)
|
||
}
|
||
} catch (e: any) {
|
||
Message.error(e?.message || '保存失败')
|
||
}
|
||
}
|
||
|
||
// 保存配置:先弹出「保存确认」,再在「仅保存 / 保存并生效」里先 update 再拉详情刷新表单
|
||
const handleSave = async () => {
|
||
if (!selectedChannel.value || !(selectedChannel.value as any).id) {
|
||
Message.warning('请先选择渠道')
|
||
return
|
||
}
|
||
saveConfirmContext.value = 'config'
|
||
await nextTick()
|
||
saveConfirmModalVisible.value = true
|
||
}
|
||
|
||
// 价格发布:确认后 POST /channel/service-channel/publish
|
||
const handleChannelPublishPrice = async () => {
|
||
const cid = (selectedChannel.value as any)?.id
|
||
if (cid == null) {
|
||
Message.warning('请先选择渠道')
|
||
return
|
||
}
|
||
if (priceTableData.value.length === 0) {
|
||
Message.warning('暂无可发布的费用项')
|
||
return
|
||
}
|
||
await ensureChannelPriceDictLoaded()
|
||
modifiedValue.value = JSON.stringify({ priceTableData: priceTableData.value }, null, 2)
|
||
originalValue.value = JSON.stringify(
|
||
{ version: effectiveVersion.value, publishTime: effectiveTime.value },
|
||
null,
|
||
2
|
||
)
|
||
showOnlyDiff.value = false
|
||
await nextTick()
|
||
;(publishModalMethods as any).openModal?.()
|
||
}
|
||
|
||
// 新增价格
|
||
const handleAddPrice = async () => {
|
||
await ensureChannelPriceDictLoaded()
|
||
await loadBillingFactorSelectOptions()
|
||
// 重置编辑模式
|
||
isEditMode.value = false
|
||
editingPriceRecord.value = null
|
||
// 重置表单
|
||
Object.assign(basicInfoForm, {
|
||
costItem: '',
|
||
costUnit: '',
|
||
currency: '',
|
||
chargingTime: '',
|
||
chargingCondition: '无',
|
||
})
|
||
// 重置计费因素数据为默认值
|
||
billingFactorsData.value = getDefaultBillingFactorsData().map(item => ({ ...item }))
|
||
// 重置价格表数据
|
||
priceListData.value = []
|
||
priceDrawerActiveTab.value = 'basic'
|
||
priceModalVisible.value = true
|
||
|
||
// 初始化拖拽排序
|
||
nextTick(() => {
|
||
initSortable()
|
||
})
|
||
}
|
||
|
||
// 编辑价格
|
||
const handleEditPrice = async (record: any) => {
|
||
await ensureChannelPriceDictLoaded()
|
||
await loadBillingFactorSelectOptions()
|
||
isEditMode.value = true
|
||
editingPriceRecord.value = record
|
||
const cid = (selectedChannel.value as any)?.id
|
||
let sourceRecord = record
|
||
if (cid != null && record?.id != null) {
|
||
try {
|
||
const list = await getServiceChannelPriceList(cid)
|
||
const matched = (list || []).find(
|
||
(item: any) => String(item?.id ?? item?.Id) === String(record?.id ?? record?.Id)
|
||
)
|
||
if (matched) sourceRecord = matched
|
||
} catch (e: any) {
|
||
Message.warning(e?.message || '加载最新买价配置失败,已使用列表数据回填')
|
||
}
|
||
}
|
||
Object.assign(basicInfoForm, {
|
||
costItem: sourceRecord.costItem || '',
|
||
costUnit: sourceRecord.costUnit || '',
|
||
currency: sourceRecord.currency || '',
|
||
chargingTime: sourceRecord.chargingTime || '',
|
||
chargingCondition: sourceRecord.chargingCondition || '无',
|
||
})
|
||
billingFactorsData.value = normalizeBillingFactorsFromApi(sourceRecord?.factors)
|
||
for (const row of billingFactorsData.value) {
|
||
row.factorValues = toCanonicalFactorValues(row.factor, row.factorValues)
|
||
}
|
||
priceListData.value = mapDetailsToPriceListRows(sourceRecord?.details || [])
|
||
coercePriceListRowsFactorCells()
|
||
priceDrawerActiveTab.value = 'basic'
|
||
priceModalVisible.value = true
|
||
nextTick(() => {
|
||
initSortable()
|
||
})
|
||
}
|
||
|
||
// 删除价格 - 使用弹窗确认
|
||
|
||
// 切换发布记录tab
|
||
const handleChangePublishRecordTab = async (tab: string) => {
|
||
publishRecordTab.value = tab
|
||
publishRecordActiveIndex.value = 0
|
||
await loadServiceChannelPublishRecords(tab as 'price' | 'rules')
|
||
}
|
||
|
||
// 切换发布记录索引
|
||
const handleChangePublishRecordIndex = (index: number) => {
|
||
publishRecordActiveIndex.value = index
|
||
}
|
||
|
||
// 回滚到指定版本
|
||
const handleRollbackToVersion = () => {
|
||
if (!currentPublishRecord.value) {
|
||
Message.warning('请选择要回滚的版本')
|
||
return
|
||
}
|
||
Modal.confirm({
|
||
title: '确认回滚',
|
||
content: `确定要回滚到版本 "${currentPublishRecord.value.version}" 吗?`,
|
||
onOk: async () => {
|
||
try {
|
||
const cid = (selectedChannel.value as any)?.id
|
||
const rec = currentPublishRecord.value as any
|
||
if (cid == null || rec?.id == null) {
|
||
Message.warning('数据不完整')
|
||
return
|
||
}
|
||
const operatorType = publishRecordTab.value === 'rules' ? 'rules' : 'price'
|
||
await rollbackServiceChannelPublish({
|
||
serviceChannelId: cid,
|
||
recordId: rec.id,
|
||
operatorType,
|
||
})
|
||
Message.success('回滚成功')
|
||
await loadServiceChannelPublishRecords()
|
||
await loadChannelGroups()
|
||
} catch (error: any) {
|
||
console.error('回滚失败:', error)
|
||
Message.error(error?.message || '回滚失败')
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
// 确认发布日期
|
||
const handleConfirmPublishDate = async (date: Date | null) => {
|
||
if (!date) {
|
||
Message.warning('请选择发布日期')
|
||
return
|
||
}
|
||
const cid = (selectedChannel.value as any)?.id
|
||
if (cid == null) {
|
||
Message.warning('请先选择渠道')
|
||
return
|
||
}
|
||
try {
|
||
const operatorType = publishRecordTab.value === 'rules' ? 'rules' : 'price'
|
||
await publishServiceChannel({
|
||
serviceChannelId: cid,
|
||
operatorType,
|
||
description: undefined,
|
||
})
|
||
publishDateVisible.value = false
|
||
Message.success('发布成功')
|
||
await loadServiceChannelPublishRecords()
|
||
await loadServiceChannelPriceRows(cid)
|
||
await loadChannelGroups()
|
||
} catch (e: any) {
|
||
Message.error(e?.message || '发布失败')
|
||
}
|
||
}
|
||
|
||
// 拖拽排序相关
|
||
let sortableInstance: any = null
|
||
|
||
// 初始化拖拽排序
|
||
const initSortable = () => {
|
||
// 销毁旧的实例
|
||
if (sortableInstance) {
|
||
sortableInstance.destroy()
|
||
sortableInstance = null
|
||
}
|
||
|
||
nextTick(() => {
|
||
const tbody = document.querySelector('.billing-factors-table .billing-factors-tbody')
|
||
if (!tbody) return
|
||
|
||
sortableInstance = Sortable.create(tbody as HTMLElement, {
|
||
handle: '.drag-icon',
|
||
animation: 150,
|
||
onEnd: (evt: any) => {
|
||
const { oldIndex, newIndex } = evt
|
||
if (oldIndex === newIndex || oldIndex === undefined || newIndex === undefined) return
|
||
|
||
// 更新数据顺序
|
||
const movedItem = billingFactorsData.value.splice(oldIndex, 1)[0]
|
||
billingFactorsData.value.splice(newIndex, 0, movedItem)
|
||
|
||
// 重新编号
|
||
billingFactorsData.value.forEach((item, idx) => {
|
||
item.serialNumber = idx + 1
|
||
})
|
||
}
|
||
})
|
||
})
|
||
}
|
||
|
||
// 取消价格弹窗
|
||
const handleCancelPriceDrawer = () => {
|
||
priceModalVisible.value = false
|
||
// 重置编辑模式
|
||
isEditMode.value = false
|
||
editingPriceRecord.value = null
|
||
// 清理拖拽实例
|
||
if (sortableInstance) {
|
||
sortableInstance.destroy()
|
||
sortableInstance = null
|
||
}
|
||
}
|
||
|
||
// 保存确认弹窗:渠道配置(与销售产品「仅保存 / 保存并生效」交互一致;买价发布仍用「价格发布」)
|
||
const handleSaveOnly = async () => {
|
||
saveConfirmModalVisible.value = false
|
||
if (saveConfirmContext.value !== 'config') {
|
||
saveConfirmContext.value = null
|
||
return
|
||
}
|
||
saveConfirmContext.value = null
|
||
await executeChannelConfigSave('保存成功(未生效)')
|
||
}
|
||
|
||
const handleSaveAndActivate = async () => {
|
||
saveConfirmModalVisible.value = false
|
||
if (saveConfirmContext.value !== 'config') {
|
||
saveConfirmContext.value = null
|
||
return
|
||
}
|
||
saveConfirmContext.value = null
|
||
// 渠道配置「保存并生效」:POST /channel/service-channel/publish 传 operatorType=rules
|
||
await executeChannelConfigSave('保存成功', { publishAfter: 'rules' })
|
||
}
|
||
|
||
// 价格抽屉tab列表
|
||
const priceDrawerTabList = [
|
||
{ key: 'basic', labelKey: 'channel.serviceChannels.drawer.tabs.basicInfo' },
|
||
{ key: 'priceList', labelKey: 'channel.serviceChannels.drawer.tabs.priceList' }
|
||
]
|
||
|
||
// 选项数据(与销售产品卖价一致:走字典)
|
||
const factorOptions = computed(() => mapDictToSelectOptions('pricing_factor'))
|
||
const costItemOptions = computed(() => mapDictToSelectOptions('fee_item'))
|
||
const costUnitOptions = computed(() => mapDictToSelectOptions('cost_unit'))
|
||
const currencyOptions = computed(() => mapDictToSelectOptions('currency'))
|
||
const chargingTimeOptions = computed(() => mapDictToSelectOptions('charging_time'))
|
||
const calculationFormulaOptions = computed(() => mapDictToSelectOptions('calculation_formula'))
|
||
|
||
// 新增计费因素行
|
||
const handleAddBillingFactor = () => {
|
||
const newId = Math.max(...billingFactorsData.value.map((item) => item.id || 0), 0) + 1
|
||
billingFactorsData.value.push({
|
||
id: newId,
|
||
serialNumber: billingFactorsData.value.length + 1,
|
||
factor: '',
|
||
factorValues: [],
|
||
})
|
||
if (priceModalVisible.value && priceDrawerActiveTab.value === 'basic') {
|
||
nextTick(() => {
|
||
initSortable()
|
||
})
|
||
}
|
||
}
|
||
|
||
// 删除计费因素行
|
||
const handleDeleteBillingFactor = (index: number) => {
|
||
billingFactorsData.value.splice(index, 1)
|
||
// 重新编号
|
||
billingFactorsData.value.forEach((item, idx) => {
|
||
item.serialNumber = idx + 1
|
||
})
|
||
if (priceModalVisible.value && priceDrawerActiveTab.value === 'basic') {
|
||
nextTick(() => {
|
||
initSortable()
|
||
})
|
||
}
|
||
}
|
||
|
||
// 添加因素值弹窗(与销售产品卖价一致:多选字典 / 分区 / 仓库等)
|
||
const addFactorValueModalVisible = ref(false)
|
||
const addFactorValueSelected = ref<string[]>([])
|
||
const currentFactorRow = ref<any>(null)
|
||
|
||
const currentFactorValueOptions = computed(() => {
|
||
const factor = currentFactorRow.value?.factor
|
||
if (!factor) return []
|
||
return factorValuesOptionsByFactor.value[factor] || []
|
||
})
|
||
|
||
const isAllFactorValuesSelected = computed(() => {
|
||
const options = currentFactorValueOptions.value
|
||
if (options.length === 0) return false
|
||
return options.every((option) => addFactorValueSelected.value.includes(option.value))
|
||
})
|
||
|
||
const handleAddFactorValue = async (row: any) => {
|
||
if (!row?.factor) {
|
||
Message.warning('请先选择因素')
|
||
return
|
||
}
|
||
await ensureChannelPriceDictLoaded()
|
||
await loadBillingFactorSelectOptions()
|
||
currentFactorRow.value = row
|
||
addFactorValueSelected.value = []
|
||
addFactorValueModalVisible.value = true
|
||
}
|
||
|
||
const handleConfirmAddFactorValue = () => {
|
||
const selectedValues = addFactorValueSelected.value || []
|
||
if (selectedValues.length === 0) {
|
||
Message.warning('请至少选择一个因素值')
|
||
return
|
||
}
|
||
if (!currentFactorRow.value) return
|
||
const existing = new Set(currentFactorRow.value.factorValues || [])
|
||
const toAdd = selectedValues.filter((value) => !existing.has(value))
|
||
if (toAdd.length === 0) {
|
||
Message.warning('所选因素值均已存在')
|
||
return
|
||
}
|
||
currentFactorRow.value.factorValues.push(...toAdd)
|
||
addFactorValueModalVisible.value = false
|
||
addFactorValueSelected.value = []
|
||
currentFactorRow.value = null
|
||
Message.success('添加成功')
|
||
}
|
||
|
||
const handleCancelAddFactorValue = () => {
|
||
addFactorValueModalVisible.value = false
|
||
addFactorValueSelected.value = []
|
||
currentFactorRow.value = null
|
||
}
|
||
|
||
const handleSelectAllFactorValues = () => {
|
||
if (isAllFactorValuesSelected.value) {
|
||
addFactorValueSelected.value = []
|
||
return
|
||
}
|
||
addFactorValueSelected.value = currentFactorValueOptions.value.map((option) => option.value)
|
||
}
|
||
|
||
// 删除因素值
|
||
const handleDeleteFactorValue = (row: any, valueIndex: number) => {
|
||
row.factorValues.splice(valueIndex, 1)
|
||
}
|
||
|
||
// 新增服务商相关
|
||
const addProviderModalVisible = ref(false)
|
||
const addProviderForm = reactive({
|
||
providerName: '',
|
||
appId: undefined as number | undefined,
|
||
serviceProviderId: undefined as number | undefined
|
||
})
|
||
|
||
const handleCancelAddProvider = () => {
|
||
addProviderModalVisible.value = false
|
||
addProviderForm.providerName = ''
|
||
addProviderForm.appId = undefined
|
||
addProviderForm.serviceProviderId = undefined
|
||
}
|
||
|
||
const handleConfirmAddProvider = async () => {
|
||
if (!addProviderForm.providerName || !addProviderForm.appId || !addProviderForm.serviceProviderId) {
|
||
Message.warning('请填写完整信息')
|
||
return
|
||
}
|
||
try {
|
||
await createServiceChannelGroup({
|
||
name: addProviderForm.providerName,
|
||
appId: Number(addProviderForm.appId),
|
||
apiServiceProviderId: Number(addProviderForm.serviceProviderId),
|
||
})
|
||
Message.success('新增服务商成功')
|
||
addProviderModalVisible.value = false
|
||
await loadServiceProviderList()
|
||
await loadChannelGroups()
|
||
} catch (error) {
|
||
console.error('新增服务商失败:', error)
|
||
Message.error('新增失败')
|
||
}
|
||
}
|
||
|
||
// 编辑服务商确认
|
||
const handleConfirmEditProvider = async () => {
|
||
if (!editProviderForm.providerName || !editProviderForm.appId || !editProviderForm.serviceProviderId) {
|
||
Message.warning('请填写完整信息')
|
||
return
|
||
}
|
||
try {
|
||
await updateServiceChannelGroup({
|
||
id: Number(editingProviderGroupId.value),
|
||
name: editProviderForm.providerName,
|
||
appId: Number(editProviderForm.appId),
|
||
apiServiceProviderId: Number(editProviderForm.serviceProviderId),
|
||
})
|
||
const group = channelGroups.value.find(g => Number((g as any).id) === editingProviderGroupId.value)
|
||
if (group) {
|
||
;(group as any).name = editProviderForm.providerName
|
||
;(group as any).appId = editProviderForm.appId
|
||
;(group as any).apiServiceProviderId = editProviderForm.serviceProviderId
|
||
}
|
||
Message.success('编辑服务商成功')
|
||
editProviderModalVisible.value = false
|
||
editingProviderGroupId.value = null
|
||
await loadServiceProviderList()
|
||
await loadChannelGroups()
|
||
} catch (error) {
|
||
console.error('编辑服务商失败:', error)
|
||
Message.error('编辑失败')
|
||
}
|
||
}
|
||
|
||
// 删除渠道相关
|
||
const deleteChannelModalVisible = ref(false)
|
||
const deletingChannel = ref<any>(null)
|
||
const deleteChannelInput = ref('')
|
||
|
||
const handleCancelDeleteChannel = () => {
|
||
deleteChannelModalVisible.value = false
|
||
deletingChannel.value = null
|
||
deleteChannelInput.value = ''
|
||
}
|
||
|
||
const handleConfirmDeleteChannel = async () => {
|
||
if (deleteChannelInput.value !== deletingChannel.value?.name) {
|
||
Message.warning('渠道名称不匹配,请重新输入')
|
||
return
|
||
}
|
||
try {
|
||
if (deletingChannel.value?.id != null) {
|
||
await deleteServiceChannel(deletingChannel.value.id)
|
||
}
|
||
Message.success('删除成功')
|
||
deleteChannelModalVisible.value = false
|
||
deletingChannel.value = null
|
||
deleteChannelInput.value = ''
|
||
selectedChannel.value = null
|
||
await loadChannelGroups()
|
||
} catch (error: any) {
|
||
console.error('删除渠道失败:', error)
|
||
Message.error(error?.message || '删除失败')
|
||
}
|
||
}
|
||
|
||
// 更新删除渠道函数
|
||
const handleDeleteChannel = (channel: any) => {
|
||
deletingChannel.value = channel
|
||
deleteChannelInput.value = ''
|
||
deleteChannelModalVisible.value = true
|
||
}
|
||
|
||
// 删除价格相关
|
||
const deletePriceModalVisible = ref(false)
|
||
const deletingPriceRecord = ref<any>(null)
|
||
const deletePriceInput = ref('')
|
||
|
||
const handleCancelDeletePrice = () => {
|
||
deletePriceModalVisible.value = false
|
||
deletingPriceRecord.value = null
|
||
deletePriceInput.value = ''
|
||
}
|
||
|
||
const handleConfirmDeletePrice = async () => {
|
||
const priceName = deletingPriceRecord.value?.priceName || deletingPriceRecord.value?.costItem || ''
|
||
if (deletePriceInput.value !== priceName) {
|
||
Message.warning('费用项名称不匹配,请重新输入')
|
||
return
|
||
}
|
||
const cid = (selectedChannel.value as any)?.id
|
||
const pid = deletingPriceRecord.value?.id
|
||
try {
|
||
if (pid != null) {
|
||
await deleteServiceChannelPrice(pid)
|
||
}
|
||
Message.success('删除成功')
|
||
deletePriceModalVisible.value = false
|
||
deletingPriceRecord.value = null
|
||
deletePriceInput.value = ''
|
||
if (cid != null) await loadServiceChannelPriceRows(cid)
|
||
} catch (error: any) {
|
||
console.error('删除价格配置失败:', error)
|
||
Message.error(error?.message || '删除失败')
|
||
}
|
||
}
|
||
|
||
// 更新删除价格函数
|
||
const handleDeletePrice = (record: any) => {
|
||
deletingPriceRecord.value = record
|
||
deletePriceInput.value = ''
|
||
deletePriceModalVisible.value = true
|
||
}
|
||
|
||
// 新增渠道相关
|
||
const handleCancelAddChannel = () => {
|
||
addChannelModalVisible.value = false
|
||
addChannelTargetGroupId.value = null
|
||
addChannelForm.channelCode = ''
|
||
addChannelForm.channelName = ''
|
||
addChannelForm.minTime = ''
|
||
addChannelForm.maxTime = ''
|
||
addChannelForm.forecastType = ''
|
||
addChannelForm.forecastCode = ''
|
||
addChannelForm.description = ''
|
||
addChannelForm.preScript = ''
|
||
}
|
||
|
||
const handleConfirmAddChannel = async () => {
|
||
if (!addChannelForm.channelCode || !addChannelForm.channelName) {
|
||
Message.warning('请填写渠道代码和名称')
|
||
return
|
||
}
|
||
const gid =
|
||
addChannelTargetGroupId.value ??
|
||
(channelGroups.value[0]?.id != null ? Number(channelGroups.value[0].id) : null)
|
||
if (gid == null || !Number.isFinite(gid)) {
|
||
Message.warning('未选择服务商分组,请从左侧分组菜单新增渠道')
|
||
return
|
||
}
|
||
try {
|
||
await createServiceChannel({
|
||
groupId: gid,
|
||
channelCode: addChannelForm.channelCode,
|
||
channelName: addChannelForm.channelName,
|
||
minTime: addChannelForm.minTime ? Number(addChannelForm.minTime) : 0,
|
||
maxTime: addChannelForm.maxTime ? Number(addChannelForm.maxTime) : 0,
|
||
forecastType: addChannelForm.forecastType || 'waybill',
|
||
forecastCode: addChannelForm.forecastCode,
|
||
description: addChannelForm.description,
|
||
preScript: addChannelForm.preScript,
|
||
isActive: 1,
|
||
})
|
||
Message.success('新增渠道成功')
|
||
addChannelModalVisible.value = false
|
||
addChannelTargetGroupId.value = null
|
||
await loadChannelGroups()
|
||
} catch (error: any) {
|
||
console.error('新增渠道失败:', error)
|
||
Message.error(error?.message || '新增失败')
|
||
}
|
||
}
|
||
|
||
// 发布相关
|
||
const publishConfirmVisible = ref(false)
|
||
const publishDescription = ref('')
|
||
const publishConfirmDate = ref<Date | null>(null)
|
||
|
||
const [registerPublishModal, publishModalMethods] = useModal()
|
||
|
||
// 原始值和修改值
|
||
const originalValue = ref('')
|
||
const modifiedValue = ref('')
|
||
|
||
const getOriginalValue = computed(() => {
|
||
if (originalValue.value) {
|
||
try {
|
||
return JSON.stringify(JSON.parse(originalValue.value || '{}'), null, 2)
|
||
} catch {
|
||
return originalValue.value
|
||
}
|
||
}
|
||
return '{}'
|
||
})
|
||
|
||
const getModifiedModel = computed(() => {
|
||
if (modifiedValue.value) {
|
||
try {
|
||
return JSON.stringify(JSON.parse(modifiedValue.value || '{}'), null, 2)
|
||
} catch {
|
||
return modifiedValue.value
|
||
}
|
||
}
|
||
return '{}'
|
||
})
|
||
|
||
// 差异相关
|
||
const showOnlyDiff = ref(false)
|
||
const diffLength = computed(() => {
|
||
if (!originalValue.value || !modifiedValue.value) return 0
|
||
try {
|
||
const diff = diffJson(
|
||
JSON.parse(originalValue.value || '{}'),
|
||
JSON.parse(modifiedValue.value || '{}')
|
||
)
|
||
return diff.filter((d: any) => d.added || d.removed).length
|
||
} catch {
|
||
return 0
|
||
}
|
||
})
|
||
|
||
const visibleDiffRows = computed(() => {
|
||
if (!originalValue.value || !modifiedValue.value) return []
|
||
try {
|
||
const diff = diffJson(
|
||
JSON.parse(originalValue.value || '{}'),
|
||
JSON.parse(modifiedValue.value || '{}')
|
||
)
|
||
const flat: Array<{ text: string; status: 'add' | 'remove' | 'same' }> = []
|
||
diff.forEach((p: any) => {
|
||
const status = p.added ? 'add' : p.removed ? 'remove' : 'same'
|
||
const lines = (p.value || '').split('\n')
|
||
lines.forEach((line: string, idx: number) => {
|
||
if (idx === lines.length - 1 && line === '') return
|
||
flat.push({ text: line, status })
|
||
})
|
||
})
|
||
const merged: Array<
|
||
| { type: 'add' | 'remove' | 'same'; text: string }
|
||
| { type: 'change'; oldText: string; newText: string }
|
||
> = []
|
||
let i = 0
|
||
while (i < flat.length) {
|
||
const curr = flat[i]
|
||
const next = flat[i + 1]
|
||
if (curr?.status === 'remove' && next?.status === 'add') {
|
||
merged.push({ type: 'change', oldText: curr.text, newText: next.text })
|
||
i += 2
|
||
} else {
|
||
merged.push({ type: curr.status, text: curr.text })
|
||
i += 1
|
||
}
|
||
}
|
||
if (showOnlyDiff.value) {
|
||
return merged.filter((row) => row.type !== 'same')
|
||
}
|
||
return merged
|
||
} catch {
|
||
return []
|
||
}
|
||
})
|
||
|
||
// 日期禁用
|
||
const currentHour = ref(new Date().getHours())
|
||
const currentMinute = ref(new Date().getMinutes())
|
||
const currentSecond = ref(new Date().getSeconds())
|
||
|
||
const disabledDate = (date: Date) => {
|
||
const today = new Date()
|
||
today.setHours(0, 0, 0, 0)
|
||
return date < today
|
||
}
|
||
|
||
const disabledTime = () => {
|
||
return {
|
||
disabledHours: () => {
|
||
const hours: number[] = []
|
||
for (let i = 0; i < currentHour.value; i++) {
|
||
hours.push(i)
|
||
}
|
||
return hours
|
||
},
|
||
disabledMinutes: (selectedHour: number) => {
|
||
if (selectedHour === currentHour.value) {
|
||
const minutes: number[] = []
|
||
for (let i = 0; i <= currentMinute.value; i++) {
|
||
minutes.push(i)
|
||
}
|
||
return minutes
|
||
}
|
||
return []
|
||
},
|
||
disabledSeconds: (selectedHour: number, selectedMinute: number) => {
|
||
if (selectedHour === currentHour.value && selectedMinute === currentMinute.value) {
|
||
const seconds: number[] = []
|
||
for (let i = 0; i <= currentSecond.value; i++) {
|
||
seconds.push(i)
|
||
}
|
||
return seconds
|
||
}
|
||
return []
|
||
},
|
||
}
|
||
}
|
||
|
||
// 发布确认
|
||
const handlePublishConfirm = () => {
|
||
if (!diffLength.value) {
|
||
Message.warning('没有内容需要发布')
|
||
return
|
||
}
|
||
publishConfirmVisible.value = true
|
||
}
|
||
|
||
const handleCancelPublishConfirm = () => {
|
||
publishConfirmVisible.value = false
|
||
publishDescription.value = ''
|
||
publishConfirmDate.value = null
|
||
}
|
||
|
||
const handleConfirmPublish = async () => {
|
||
const cid = (selectedChannel.value as any)?.id
|
||
if (cid == null) {
|
||
Message.warning('请先选择渠道')
|
||
return
|
||
}
|
||
const operatorType =
|
||
activeTab.value === 'records' && publishRecordTab.value === 'rules' ? 'rules' : 'price'
|
||
try {
|
||
await publishServiceChannel({
|
||
serviceChannelId: cid,
|
||
operatorType,
|
||
description: publishDescription.value || undefined,
|
||
})
|
||
Message.success('发布成功')
|
||
publishConfirmVisible.value = false
|
||
publishDescription.value = ''
|
||
publishConfirmDate.value = null
|
||
await loadServiceChannelPublishRecords()
|
||
await loadServiceChannelPriceRows(cid)
|
||
await loadChannelGroups()
|
||
;(publishModalMethods as any).closeModal?.()
|
||
} catch (error: any) {
|
||
console.error('发布失败:', error)
|
||
Message.error(error?.message || '发布失败')
|
||
}
|
||
}
|
||
|
||
// 保存确认(点保存仅打开弹窗;确认后先 POST update,再 GET detail 回填表单)
|
||
const saveConfirmModalVisible = ref(false)
|
||
const saveConfirmContext = ref<'config' | null>(null)
|
||
|
||
const handleCancelSave = () => {
|
||
saveConfirmModalVisible.value = false
|
||
saveConfirmContext.value = null
|
||
}
|
||
</script>
|
||
|
||
<style lang="less" scoped>
|
||
.service-channel-layout {
|
||
display: flex;
|
||
height: 100%;
|
||
padding: 16px;
|
||
gap: 16px;
|
||
background-color: var(--color-fill-2);
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* 左侧使用 CustomLeftMenu,仅保留布局(参考销售产品) */
|
||
.channel-sidebar {
|
||
width: 280px;
|
||
flex-shrink: 0;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
|
||
}
|
||
|
||
/* 第二行表格:创建时间对齐 node-title,开关对齐 node-actions-cell */
|
||
.channel-second-row-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
table-layout: fixed;
|
||
}
|
||
|
||
.channel-row-left {
|
||
text-align: left;
|
||
padding: 0;
|
||
padding-left: 24px;
|
||
vertical-align: middle;
|
||
width: auto;
|
||
font-size: 12px;
|
||
color: rgba(255, 255, 255, 0.65);
|
||
|
||
.create-time {
|
||
font-size: 10px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
}
|
||
|
||
.channel-row-right {
|
||
text-align: right;
|
||
padding: 0;
|
||
width: 30px;
|
||
vertical-align: middle;
|
||
}
|
||
|
||
.channel-sidebar :deep(.channel-active-switch.arco-switch-checked) {
|
||
background-color: #00b42a !important;
|
||
}
|
||
.channel-sidebar :deep(.channel-active-switch:not(.arco-switch-checked)) {
|
||
background-color: #f53f3f !important;
|
||
}
|
||
|
||
:deep(.node-second-row-extra-slot) {
|
||
display: block;
|
||
width: 100%;
|
||
}
|
||
|
||
:deep(.node-second-row-extra-cell) {
|
||
text-align: left;
|
||
}
|
||
|
||
/* 选中时创建时间保持深色,不变白 */
|
||
:deep(.tree-node.selected .create-time) {
|
||
font-size: 10px;
|
||
color: #666 !important;
|
||
}
|
||
|
||
.channel-content.content-panel {
|
||
flex: 1;
|
||
background-color: var(--color-bg-1);
|
||
border-radius: 24px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.switch-swipe-wrapper {
|
||
display: inline-block;
|
||
}
|
||
|
||
.tabs-section {
|
||
border-bottom: 1px solid #e8e8e8;
|
||
padding: 0 24px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.tabs-container {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0;
|
||
}
|
||
|
||
.tab-item {
|
||
padding: 16px 20px;
|
||
cursor: pointer;
|
||
border-bottom: 3px solid transparent;
|
||
transition: all 0.3s;
|
||
position: relative;
|
||
|
||
&:hover {
|
||
background-color: #f8f9fa;
|
||
}
|
||
|
||
&.active {
|
||
border-bottom-color: var(--app-primary-color);
|
||
color: var(--app-primary-color);
|
||
font-weight: 600;
|
||
}
|
||
}
|
||
|
||
.save-btn {
|
||
height: 36px;
|
||
padding: 0 24px;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.config-form {
|
||
flex: 1;
|
||
padding: 24px;
|
||
overflow-y: auto;
|
||
|
||
.form-container {
|
||
max-width: 800px;
|
||
}
|
||
|
||
.form-row {
|
||
.form-item {
|
||
min-width: 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
.form-row {
|
||
display: flex;
|
||
gap: 24px;
|
||
margin-bottom: 24px;
|
||
|
||
&:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
}
|
||
|
||
.form-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
flex: 1;
|
||
|
||
&.full-width {
|
||
flex: 1 1 100%;
|
||
}
|
||
}
|
||
|
||
.form-label {
|
||
font-size: 14px;
|
||
color: #262626;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.form-input,
|
||
.form-select {
|
||
width: 100%;
|
||
min-width: 200px;
|
||
height: 32px;
|
||
}
|
||
|
||
.form-textarea {
|
||
width: 100%;
|
||
min-width: 200px;
|
||
resize: vertical;
|
||
}
|
||
|
||
.time-input {
|
||
width: 100%;
|
||
min-width: 0;
|
||
}
|
||
|
||
.time-input-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
|
||
.time-input {
|
||
flex: 1;
|
||
height: 32px;
|
||
}
|
||
|
||
.separator {
|
||
color: #8c8c8c;
|
||
font-weight: 500;
|
||
}
|
||
}
|
||
|
||
.price-section {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 24px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.price-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 16px;
|
||
|
||
.version-info {
|
||
font-size: 14px;
|
||
color: #262626;
|
||
}
|
||
|
||
.price-header-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.add-price-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
|
||
.btn-icon {
|
||
font-size: 14px;
|
||
}
|
||
}
|
||
}
|
||
|
||
.price-table-container {
|
||
flex: 1;
|
||
overflow: auto;
|
||
|
||
.price-table {
|
||
:deep(.arco-table-th) {
|
||
background-color: #fafafa;
|
||
font-weight: 500;
|
||
}
|
||
|
||
:deep(.arco-table-td) {
|
||
padding: 12px 16px;
|
||
}
|
||
|
||
.action-btn {
|
||
color: var(--app-primary-color);
|
||
|
||
&:hover {
|
||
color: #1a6b6b;
|
||
background-color: #f0f9f9;
|
||
}
|
||
|
||
&.delete-btn {
|
||
color: #f53f3f;
|
||
|
||
&:hover {
|
||
color: #d32f2f;
|
||
background-color: #fff1f0;
|
||
}
|
||
}
|
||
}
|
||
|
||
:deep(.product-active-switch.arco-switch-checked) {
|
||
background-color: #00b42a !important;
|
||
}
|
||
:deep(.product-active-switch:not(.arco-switch-checked)) {
|
||
background-color: #f53f3f !important;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 抽屉相关样式
|
||
.drawer-title {
|
||
width: 100%;
|
||
|
||
.drawer-tabs {
|
||
display: flex;
|
||
gap: 0;
|
||
border-bottom: 1px solid #e8e8e8;
|
||
|
||
.drawer-tab-item {
|
||
padding: 8px 24px;
|
||
cursor: pointer;
|
||
border-bottom: 3px solid transparent;
|
||
transition: all 0.3s;
|
||
color: #8c8c8c;
|
||
|
||
&:hover {
|
||
color: var(--app-primary-color);
|
||
}
|
||
|
||
&.active {
|
||
border-bottom-color: var(--app-primary-color);
|
||
color: var(--app-primary-color);
|
||
font-weight: 600;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.basic-info-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 24px;
|
||
padding: 16px 0;
|
||
}
|
||
|
||
.form-section-drawer {
|
||
.price-form {
|
||
:deep(.arco-form-item-label) {
|
||
font-weight: 500;
|
||
}
|
||
}
|
||
}
|
||
|
||
.billing-factors-section {
|
||
.billing-factors-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 16px;
|
||
|
||
.section-title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #262626;
|
||
}
|
||
}
|
||
|
||
.billing-factors-table {
|
||
border: 1px solid #e8e8e8;
|
||
border-radius: 6px;
|
||
overflow-x: scroll;
|
||
overflow-y: scroll;
|
||
max-height: 400px;
|
||
height: 400px;
|
||
|
||
.factors-table {
|
||
width: 100%;
|
||
min-width: 800px;
|
||
border-collapse: collapse;
|
||
|
||
thead {
|
||
background-color: #fafafa;
|
||
|
||
th {
|
||
padding: 12px 16px;
|
||
text-align: left;
|
||
font-weight: 600;
|
||
color: #262626;
|
||
border-bottom: 1px solid #e8e8e8;
|
||
}
|
||
}
|
||
|
||
tbody {
|
||
tr {
|
||
border-bottom: 1px solid #f0f0f0;
|
||
|
||
&:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
&:hover {
|
||
background-color: #fafafa;
|
||
}
|
||
|
||
td {
|
||
padding: 12px 16px;
|
||
vertical-align: middle;
|
||
}
|
||
}
|
||
}
|
||
|
||
.serial-number-cell {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
|
||
.drag-icon {
|
||
font-size: 18px;
|
||
color: #8c8c8c;
|
||
cursor: move;
|
||
user-select: none;
|
||
|
||
&:hover {
|
||
color: var(--app-primary-color);
|
||
}
|
||
}
|
||
}
|
||
|
||
.factor-values-cell {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 4px;
|
||
|
||
.add-value-btn {
|
||
color: var(--app-primary-color);
|
||
|
||
&:hover {
|
||
color: #1a6b6b;
|
||
background-color: #f0f9f9;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.price-list-content {
|
||
padding: 24px 0;
|
||
|
||
.price-list-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 16px;
|
||
|
||
.price-list-title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #262626;
|
||
}
|
||
|
||
.price-list-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
}
|
||
|
||
.price-list-table-container {
|
||
border: 1px solid #e8e8e8;
|
||
border-radius: 6px;
|
||
overflow-x: scroll;
|
||
overflow-y: scroll;
|
||
max-height: 500px;
|
||
height: 500px;
|
||
|
||
.price-list-table {
|
||
width: 100%;
|
||
min-width: 1000px;
|
||
border-collapse: collapse;
|
||
|
||
thead {
|
||
background-color: #fafafa;
|
||
|
||
th {
|
||
padding: 12px 16px;
|
||
text-align: left;
|
||
font-weight: 600;
|
||
color: #262626;
|
||
border-bottom: 1px solid #e8e8e8;
|
||
border-right: 1px solid #e8e8e8;
|
||
|
||
&:last-child {
|
||
border-right: none;
|
||
}
|
||
}
|
||
}
|
||
|
||
tbody {
|
||
tr {
|
||
border-bottom: 1px solid #f0f0f0;
|
||
|
||
&:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
&:hover {
|
||
background-color: #fafafa;
|
||
}
|
||
|
||
td {
|
||
padding: 12px 16px;
|
||
vertical-align: middle;
|
||
border-right: 1px solid #f0f0f0;
|
||
|
||
&:last-child {
|
||
border-right: none;
|
||
}
|
||
|
||
&.factor-cell {
|
||
font-weight: 500;
|
||
background-color: #fafafa;
|
||
vertical-align: middle;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.price-modal-content {
|
||
padding: 8px 0;
|
||
overflow: visible;
|
||
}
|
||
|
||
.price-modal-footer {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
// 发布记录样式
|
||
.publish-records-section {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 24px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.publish-records-container {
|
||
display: flex;
|
||
height: 100%;
|
||
gap: 0;
|
||
border: 1px solid #e8e8e8;
|
||
border-radius: 6px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.publish-records-left {
|
||
width: 370px;
|
||
border-right: 1px solid #e8e8e8;
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 24px;
|
||
overflow-y: auto;
|
||
background-color: #fafafa;
|
||
|
||
.version-header {
|
||
margin-bottom: 16px;
|
||
|
||
.version-number {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #262626;
|
||
}
|
||
}
|
||
|
||
.publish-tabs-container {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
|
||
.publish-tabs {
|
||
display: flex;
|
||
gap: 0;
|
||
flex: 1;
|
||
border: 1px solid #e8e8e8;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
|
||
.publish-tab-item {
|
||
flex: 1;
|
||
padding: 8px 16px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
background-color: #fff;
|
||
border-right: 1px solid #e8e8e8;
|
||
transition: all 0.3s;
|
||
font-size: 14px;
|
||
color: #8c8c8c;
|
||
|
||
&:last-child {
|
||
border-right: none;
|
||
}
|
||
|
||
&:hover {
|
||
background-color: #f6f8f8;
|
||
}
|
||
|
||
&.active {
|
||
background-color: var(--app-primary-color);
|
||
color: #fff;
|
||
font-weight: 600;
|
||
}
|
||
}
|
||
}
|
||
|
||
.publish-link-btn {
|
||
color: #287EDC;
|
||
padding: 0;
|
||
|
||
&:hover {
|
||
color: #1a6b6b;
|
||
}
|
||
}
|
||
}
|
||
|
||
.user-info {
|
||
font-size: 12px;
|
||
color: #8c8c8c;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.version-list {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
|
||
.version-item {
|
||
padding: 12px;
|
||
margin-bottom: 8px;
|
||
background-color: #fff;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
transition: all 0.3s;
|
||
|
||
&:hover {
|
||
background-color: #f6f8f8;
|
||
}
|
||
|
||
&.active {
|
||
background-color: #EDF6FF;
|
||
}
|
||
|
||
.version-item-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
|
||
.version-item-number {
|
||
font-size: 12px;
|
||
color: #202122;
|
||
line-height: 14px;
|
||
}
|
||
|
||
.version-item-type {
|
||
font-size: 14px;
|
||
line-height: 18px;
|
||
|
||
&.type-publish,
|
||
&.type-effective {
|
||
color: #508A24;
|
||
}
|
||
|
||
&.type-rollback {
|
||
color: #f53f3f;
|
||
}
|
||
|
||
&.type-invalid {
|
||
color: #8c8c8c;
|
||
}
|
||
}
|
||
}
|
||
|
||
.version-item-footer {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: 12px;
|
||
color: #737373;
|
||
line-height: 18px;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.publish-records-right {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 24px;
|
||
background-color: #fff;
|
||
overflow: hidden;
|
||
|
||
.right-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 16px;
|
||
|
||
.rollback-btn {
|
||
color: var(--app-primary-color);
|
||
|
||
&:hover {
|
||
color: #1a6b6b;
|
||
}
|
||
}
|
||
|
||
.version-notes {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
color: #262626;
|
||
font-size: 12px;
|
||
|
||
.notes-icon {
|
||
cursor: pointer;
|
||
}
|
||
}
|
||
}
|
||
|
||
.code-content {
|
||
flex: 1;
|
||
overflow: auto;
|
||
}
|
||
}
|
||
|
||
.publish-date-modal {
|
||
padding: 8px 0;
|
||
|
||
.publish-date-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 12px;
|
||
margin-top: 16px;
|
||
}
|
||
}
|
||
|
||
.price-config,
|
||
.release-record {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 300px;
|
||
}
|
||
|
||
.empty-state {
|
||
text-align: center;
|
||
}
|
||
|
||
// 保存确认弹窗样式
|
||
.save-confirm-modal {
|
||
padding: 8px 0;
|
||
|
||
.save-confirm-message {
|
||
font-size: 14px;
|
||
color: #262626;
|
||
line-height: 1.6;
|
||
|
||
p {
|
||
margin: 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
.modal-footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 12px;
|
||
}
|
||
|
||
// 发布弹窗样式
|
||
.release-content.slim {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.diff-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.diff-title {
|
||
font-size: 13px;
|
||
color: #232A33;
|
||
}
|
||
|
||
.diff-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.diff-boxes {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 12px;
|
||
}
|
||
|
||
.diff-box {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.diff-box-title {
|
||
font-size: 12px;
|
||
color: #666;
|
||
}
|
||
|
||
.diff-pre {
|
||
margin: 0;
|
||
padding: 10px 12px;
|
||
background: #f7f8fa;
|
||
border: 1px solid #e8e8e8;
|
||
border-radius: 6px;
|
||
min-height: 160px;
|
||
max-height: 280px;
|
||
overflow: auto;
|
||
font-family: Menlo, Consolas, monospace;
|
||
font-size: 12px;
|
||
line-height: 18px;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.diff-actions :deep(.arco-btn) {
|
||
padding: 0 8px;
|
||
min-width: 32px;
|
||
}
|
||
|
||
.merged-diff {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.merged-diff-body {
|
||
border: 1px solid #e8e8e8;
|
||
border-radius: 6px;
|
||
background: #fff;
|
||
max-height: 600px;
|
||
overflow: auto;
|
||
font-family: Menlo, Consolas, monospace;
|
||
font-size: 12px;
|
||
line-height: 18px;
|
||
padding: 10px 12px;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.merged-line {
|
||
display: flex;
|
||
gap: 8px;
|
||
padding: 4px 0;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.merged-line.added {
|
||
background: #e6ffed;
|
||
}
|
||
|
||
.merged-line.removed {
|
||
background: #ffecec;
|
||
text-decoration: line-through;
|
||
}
|
||
|
||
.merged-line.change {
|
||
background: #fffbe6;
|
||
}
|
||
|
||
.merged-line.same {
|
||
color: #666;
|
||
}
|
||
|
||
.line-mark {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.line-text {
|
||
flex: 1;
|
||
}
|
||
|
||
.legend {
|
||
display: flex;
|
||
gap: 12px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.legend-item.add { color: #16a34a; }
|
||
.legend-item.remove { color: #dc2626; }
|
||
.legend-item.change { color: #ca8a04; }
|
||
.legend-item.same { color: #666; }
|
||
|
||
.diff-empty {
|
||
text-align: center;
|
||
color: #8c8c8c;
|
||
padding: 20px;
|
||
}
|
||
|
||
.mb-15px {
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.delete-confirm-modal {
|
||
padding: 8px 0;
|
||
|
||
.delete-confirm-message {
|
||
font-size: 14px;
|
||
color: #262626;
|
||
line-height: 1.6;
|
||
|
||
p {
|
||
margin: 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
.billing-factors-table {
|
||
border: 1px solid #e8e8e8;
|
||
border-radius: 6px;
|
||
overflow-x: scroll;
|
||
overflow-y: scroll;
|
||
max-height: 400px;
|
||
height: 400px;
|
||
|
||
.factors-table {
|
||
width: 100%;
|
||
min-width: 800px;
|
||
border-collapse: collapse;
|
||
}
|
||
}
|
||
|
||
.basic-info-header {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
margin-top: 0;
|
||
}
|
||
|
||
.basic-info-content {
|
||
padding: 16px 0;
|
||
}
|
||
|
||
.drawer-tab-item {
|
||
padding: 8px 24px;
|
||
}
|
||
|
||
.add-factor-value-modal {
|
||
.add-factor-value-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-bottom: 8px;
|
||
}
|
||
}
|
||
</style> |