All checks were successful
Build and Deploy Front / deploy (push) Successful in 5m49s
理顺模板配置流程与文案,并统一左侧菜单对比度、会员/券/用户编辑抽屉与因子标签展示。 Co-authored-by: Cursor <cursoragent@cursor.com>
6241 lines
194 KiB
Vue
6241 lines
194 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="() => handleLeftMenuLeafDelete(nodeData)">
|
||
<icon-delete style="margin-right: 6px" />
|
||
{{ t('channel.serviceChannels.actions.delete') }}
|
||
</a-doption>
|
||
</template>
|
||
<template #node-second-row-extra="{ nodeData }">
|
||
<div class="channel-second-row">
|
||
<span class="modify-time">{{
|
||
t('channel.serviceChannels.updateTime', {
|
||
time: nodeData?.updateTime || '--',
|
||
})
|
||
}}</span>
|
||
<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>
|
||
</div>
|
||
</template>
|
||
</CustomLeftMenu>
|
||
</div>
|
||
|
||
<!-- 右侧配置区域 -->
|
||
<div class="channel-content content-panel">
|
||
<!-- 标签页 -->
|
||
<div class="tabs-section">
|
||
<SlidingTabs
|
||
:model-value="activeTab"
|
||
:items="tabs"
|
||
@change="handleTabClick"
|
||
/>
|
||
<div v-if="selectedChannel && activeTab === 'config'" class="tabs-toolbar">
|
||
<a-space :size="8">
|
||
<a-button
|
||
size="small"
|
||
:loading="savingConfig"
|
||
:disabled="!configFormDirty"
|
||
@click="handleSave"
|
||
>
|
||
{{ t('channel.serviceChannels.actions.save') }}
|
||
</a-button>
|
||
<a-button
|
||
type="primary"
|
||
size="small"
|
||
:loading="savingConfig"
|
||
:disabled="!canPublish"
|
||
@click="handlePublish"
|
||
>
|
||
{{ t('channel.serviceChannels.actions.publishConfig') }}
|
||
</a-button>
|
||
</a-space>
|
||
</div>
|
||
<div v-else-if="selectedChannel && activeTab === 'price'" class="tabs-toolbar">
|
||
<a-space :size="8">
|
||
<a-button
|
||
type="primary"
|
||
size="small"
|
||
@click="handleChannelPublishPrice"
|
||
>
|
||
{{ t('channel.serviceChannels.actions.publishPrice') }}
|
||
</a-button>
|
||
</a-space>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 当前渠道上下文 -->
|
||
<div v-if="selectedChannel && selectedChannelContext" class="channel-context-bar">
|
||
<span class="context-item">
|
||
<span class="context-label">{{ t('channel.serviceChannels.context.channel') }}:</span>
|
||
<span class="context-value">{{ selectedChannelContext.channelName }}</span>
|
||
<span v-if="selectedChannelContext.channelCode" class="context-code">({{ selectedChannelContext.channelCode }})</span>
|
||
</span>
|
||
<span class="context-divider">|</span>
|
||
<span class="context-item">
|
||
<span class="context-label">{{ t('channel.serviceChannels.context.group') }}:</span>
|
||
<span class="context-value">{{ selectedChannelContext.groupName }}</span>
|
||
</span>
|
||
<span class="context-divider">|</span>
|
||
<span class="context-item">
|
||
<span class="context-label">{{ t('channel.serviceChannels.context.app') }}:</span>
|
||
<span class="context-value">{{ selectedChannelContext.appName }}</span>
|
||
</span>
|
||
<span class="context-divider">|</span>
|
||
<span class="context-item">
|
||
<span class="context-label">{{ t('channel.serviceChannels.context.apiProvider') }}:</span>
|
||
<span class="context-value">{{ selectedChannelContext.apiProviderName }}</span>
|
||
</span>
|
||
<span class="context-divider">|</span>
|
||
<span class="context-item">
|
||
<span class="context-label">{{ t('channel.serviceChannels.context.updateTime') }}:</span>
|
||
<span class="context-value">{{ selectedChannelContext.updateTime }}</span>
|
||
</span>
|
||
<span class="context-divider">|</span>
|
||
<span class="context-item">
|
||
<span class="context-label">{{ t('channel.serviceChannels.context.updateBy') }}:</span>
|
||
<span class="context-value">{{ selectedChannelContext.updateBy }}</span>
|
||
</span>
|
||
<a-tag v-if="configFormDirty && activeTab === 'config'" color="orangered" size="small" class="context-dirty-tag">
|
||
{{ t('channel.serviceChannels.context.unsaved') }}
|
||
</a-tag>
|
||
<a-tag
|
||
v-else-if="(selectedChannelNeedsRulesPublish || selectedChannelConfigPublishPending) && activeTab === 'config'"
|
||
color="orangered"
|
||
size="small"
|
||
class="context-dirty-tag"
|
||
>
|
||
{{ t('channel.serviceChannels.context.rulesPublishPending') }}
|
||
</a-tag>
|
||
</div>
|
||
|
||
<div v-if="!selectedChannel" class="empty-state-panel">
|
||
<div class="empty-state">
|
||
<p>{{ t('channel.serviceChannels.empty.selectChannel') }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 渠道配置 -->
|
||
<div v-else-if="activeTab === 'config'" class="config-form">
|
||
<div class="form-container">
|
||
<div class="form-row">
|
||
<div class="form-item">
|
||
<label class="form-label label-with-tip">
|
||
{{ t('channel.serviceChannels.form.channelCode') }}
|
||
<a-tooltip>
|
||
<template #content>
|
||
<div style="white-space: pre-line; max-width: 320px; line-height: 1.5;">{{ t('channel.serviceChannels.tip.channelCode') }}</div>
|
||
</template>
|
||
<icon-question-circle class="tip-icon" />
|
||
</a-tooltip>
|
||
</label>
|
||
<a-input
|
||
v-model="formData.channelCode"
|
||
:placeholder="t('channel.serviceChannels.form.placeholder.input')"
|
||
class="form-input"
|
||
disabled
|
||
/>
|
||
</div>
|
||
<div class="form-item">
|
||
<label class="form-label label-with-tip">
|
||
{{ t('channel.serviceChannels.form.channelName') }}
|
||
<a-tooltip>
|
||
<template #content>
|
||
<div style="white-space: pre-line; max-width: 320px; line-height: 1.5;">{{ t('channel.serviceChannels.tip.channelName') }}</div>
|
||
</template>
|
||
<icon-question-circle class="tip-icon" />
|
||
</a-tooltip>
|
||
</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">
|
||
<label class="form-label">{{ t('channel.serviceChannels.form.remoteArea') }}</label>
|
||
<a-select
|
||
v-model="formData.remoteAreaId"
|
||
:placeholder="t('channel.serviceChannels.form.placeholder.select')"
|
||
allow-clear
|
||
class="form-input"
|
||
>
|
||
<a-option
|
||
v-for="option in remoteAreaDivisionOptions"
|
||
:key="option.value"
|
||
:value="option.value"
|
||
>
|
||
{{ option.label }}
|
||
</a-option>
|
||
</a-select>
|
||
</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>
|
||
</div>
|
||
|
||
<!-- 渠道买价tab内容 -->
|
||
<div v-else-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: var(--color-text-3);">生效时间:{{ effectiveTime }}</span>
|
||
<a-tag
|
||
v-if="pricePublishChecking && pricePublishPending == null"
|
||
color="gray"
|
||
size="small"
|
||
class="price-sync-tag"
|
||
>
|
||
{{ t('channel.serviceChannels.publish.badge.checking') }}
|
||
</a-tag>
|
||
<a-tag
|
||
v-else-if="pricePublishPending === true"
|
||
color="orangered"
|
||
size="small"
|
||
class="price-sync-tag"
|
||
>
|
||
{{ t('channel.serviceChannels.publish.badge.pending') }}
|
||
</a-tag>
|
||
<a-tag
|
||
v-else-if="pricePublishPending === false"
|
||
color="green"
|
||
size="small"
|
||
class="price-sync-tag"
|
||
>
|
||
{{ t('channel.serviceChannels.publish.badge.synced') }}
|
||
</a-tag>
|
||
</div>
|
||
<div class="price-header-actions">
|
||
<a-button
|
||
type="secondary"
|
||
size="small"
|
||
class="add-price-btn"
|
||
@click="handleAddPrice"
|
||
>
|
||
<icon-plus class="btn-icon" />
|
||
{{ t('channel.serviceChannels.actions.add') }}
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
<div ref="priceTableContainerRef" class="price-table-container">
|
||
<TablePro
|
||
ref="priceListTableRef"
|
||
v-bind="priceListTableProps"
|
||
class="price-list-table-pro"
|
||
>
|
||
<template #status="{ row }">
|
||
<div
|
||
class="switch-swipe-wrapper"
|
||
@touchstart.passive="onStatusSwipeStart"
|
||
@touchend="(e) => onStatusSwipeEnd(row, e)"
|
||
>
|
||
<a-switch
|
||
v-model="row.status"
|
||
type="circle"
|
||
size="small"
|
||
class="product-active-switch"
|
||
checked-color="#00b42a"
|
||
unchecked-color="#f53f3f"
|
||
@change="() => handleTogglePriceStatus(row)"
|
||
/>
|
||
</div>
|
||
</template>
|
||
<template #costItemCell="{ row }">
|
||
{{ dictLabelByType('fee_item', row.costItem) }}
|
||
</template>
|
||
<template #costUnitCell="{ row }">
|
||
{{ dictLabelByType('cost_unit', row.costUnit) }}
|
||
</template>
|
||
<template #currencyCell="{ row }">
|
||
{{ dictLabelByType('currency', row.currency) }}
|
||
</template>
|
||
<template #billingFactorsCell="{ row }">
|
||
{{ formatPriceTableBillingFactors(row) }}
|
||
</template>
|
||
<template #operations="{ row }">
|
||
<a-space>
|
||
<a-button
|
||
type="text"
|
||
size="small"
|
||
class="action-btn"
|
||
:title="t('channel.serviceChannels.actions.edit')"
|
||
@click="handleEditPrice(row)"
|
||
>
|
||
<icon-edit />
|
||
</a-button>
|
||
<a-button
|
||
type="text"
|
||
size="small"
|
||
class="action-btn delete-btn"
|
||
:title="t('channel.serviceChannels.actions.delete')"
|
||
@click="handleDeletePrice(row)"
|
||
>
|
||
<icon-delete />
|
||
</a-button>
|
||
</a-space>
|
||
</template>
|
||
</TablePro>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 发布记录tab内容 -->
|
||
<div v-else-if="activeTab === 'records'" class="publish-records-section">
|
||
<div class="publish-records-toolbar">
|
||
<SlidingTabs
|
||
:model-value="publishRecordTab"
|
||
:items="publishRecordTabItems"
|
||
@change="handleChangePublishRecordTab"
|
||
/>
|
||
<span class="publish-records-hint">
|
||
{{ t('channel.serviceChannels.record.recordsHint') }}
|
||
</span>
|
||
</div>
|
||
|
||
<div class="publish-records-container">
|
||
<div class="publish-records-left">
|
||
<div v-if="currentListData.length" class="version-list">
|
||
<button
|
||
v-for="(item, index) in currentListData"
|
||
:key="item.id"
|
||
type="button"
|
||
:class="['version-item', { active: publishRecordActiveIndex === index }]"
|
||
@click="handleChangePublishRecordIndex(index)"
|
||
>
|
||
<div class="version-item-header">
|
||
<span class="version-item-number">
|
||
{{ t('channel.serviceChannels.record.versionNumber', { version: item.version }) }}
|
||
</span>
|
||
<span
|
||
:class="['version-item-badge', publishRecordStatusMeta(item.status).className]"
|
||
>
|
||
{{ publishRecordStatusMeta(item.status).label }}
|
||
</span>
|
||
</div>
|
||
<div class="version-item-footer">
|
||
<span class="version-item-user">{{ item.publishUser || '--' }}</span>
|
||
<span class="version-item-time">{{ item.publishTime || '--' }}</span>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
<a-empty
|
||
v-else
|
||
class="publish-records-empty"
|
||
:description="t('channel.serviceChannels.record.emptyList')"
|
||
/>
|
||
</div>
|
||
|
||
<div class="publish-records-right">
|
||
<template v-if="hasCurrentPublishRecord">
|
||
<div class="right-header">
|
||
<div class="version-notes">
|
||
<span>{{ t('channel.serviceChannels.record.versionRemark') }}</span>
|
||
<a-popover placement="top-start">
|
||
<template #content>
|
||
{{ currentPublishRecord.description || t('channel.serviceChannels.record.noRemark') }}
|
||
</template>
|
||
<template #default>
|
||
<icon-question-circle-fill class="notes-icon notes-icon--remark" />
|
||
</template>
|
||
</a-popover>
|
||
</div>
|
||
<a-button
|
||
size="small"
|
||
class="rollback-btn"
|
||
:disabled="!canRollbackPublishRecord"
|
||
@click="handleRollbackToVersion"
|
||
>
|
||
{{ t('channel.serviceChannels.record.actions.rollback') }}
|
||
</a-button>
|
||
</div>
|
||
<div class="code-content">
|
||
<pre class="code-pre">{{ formattedJsonContent || t('channel.serviceChannels.record.emptyContent') }}</pre>
|
||
</div>
|
||
</template>
|
||
<a-empty
|
||
v-else
|
||
class="publish-records-empty"
|
||
:description="t('channel.serviceChannels.record.emptyDetail')"
|
||
/>
|
||
</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>
|
||
|
||
<!-- 新增/编辑价格配置弹窗 -->
|
||
<a-modal
|
||
v-model:visible="priceModalVisible"
|
||
:title="isEditMode ? t('channel.serviceChannels.actions.edit') : t('channel.serviceChannels.actions.add')"
|
||
width="1400px"
|
||
:footer="false"
|
||
:mask-closable="false"
|
||
modal-class="sales-price-config-modal"
|
||
:body-style="{ overflow: 'visible' }"
|
||
@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>
|
||
|
||
<div class="price-modal-pane">
|
||
<!-- 基础信息tab -->
|
||
<div
|
||
v-show="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 required>
|
||
<template #label>
|
||
<span class="label-with-tip">
|
||
{{ t('channel.serviceChannels.drawer.form.costItem') }}
|
||
<a-tooltip :content="t('channel.serviceChannels.drawer.tip.costItem')">
|
||
<icon-question-circle class="tip-icon" />
|
||
</a-tooltip>
|
||
</span>
|
||
</template>
|
||
<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"
|
||
:disabled="isCostItemAlreadyUsed(option.value)"
|
||
>
|
||
{{ option.label }}
|
||
</a-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :span="8">
|
||
<a-form-item required>
|
||
<template #label>
|
||
<span class="label-with-tip">
|
||
{{ t('channel.serviceChannels.drawer.form.costUnit') }}
|
||
<a-tooltip :content="t('channel.serviceChannels.drawer.tip.costUnit')">
|
||
<icon-question-circle class="tip-icon" />
|
||
</a-tooltip>
|
||
</span>
|
||
</template>
|
||
<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 required>
|
||
<template #label>{{ t('channel.serviceChannels.drawer.form.currency') }}</template>
|
||
<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="24">
|
||
<a-form-item hide-label class="charging-condition-form-item">
|
||
<BillingConditionEditor
|
||
v-model="basicInfoForm.chargingCondition"
|
||
:label="t('channel.serviceChannels.drawer.form.chargingCondition')"
|
||
:label-tip="t('channel.serviceChannels.drawer.tip.chargingCondition')"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
</a-form>
|
||
</div>
|
||
|
||
<!-- 计费因素表格 -->
|
||
<div class="billing-factors-section">
|
||
<div class="billing-factors-header form-section-title--with-extra">
|
||
<div class="form-section-title label-with-tip" style="margin: 0; border: none; padding: 0">
|
||
{{ t('channel.serviceChannels.drawer.section.billingFactors') }}
|
||
<a-tooltip :content="t('channel.serviceChannels.drawer.tip.billingFactors')">
|
||
<icon-question-circle class="tip-icon" />
|
||
</a-tooltip>
|
||
</div>
|
||
<a-space>
|
||
<a-button
|
||
type="secondary"
|
||
size="small"
|
||
@click="handleAddBillingFactor"
|
||
>
|
||
<icon-plus />
|
||
{{ t('channel.serviceChannels.drawer.actions.addFactor') }}
|
||
</a-button>
|
||
<a-button
|
||
type="primary"
|
||
size="small"
|
||
@click="handleGeneratePriceList"
|
||
>
|
||
<icon-sync />
|
||
{{ t('channel.serviceChannels.drawer.footer.syncPriceList') }}
|
||
</a-button>
|
||
</a-space>
|
||
</div>
|
||
<div ref="billingFactorsTableWrapRef" class="billing-factors-table-wrap">
|
||
<TablePro
|
||
ref="billingFactorsTableRef"
|
||
v-bind="billingFactorsTableProps"
|
||
class="billing-factors-table-pro"
|
||
>
|
||
<template #sort>
|
||
<div class="billing-factor-sort-cell">
|
||
<icon-drag-arrow />
|
||
</div>
|
||
</template>
|
||
<template #factor="{ row }">
|
||
<a-select
|
||
v-model="row.factor"
|
||
:placeholder="t('channel.serviceChannels.form.placeholder.select')"
|
||
:loading="channelPriceDictLoading"
|
||
size="mini"
|
||
style="width: 100%"
|
||
:fallback-option="pricingFactorFallbackOption"
|
||
@change="(val: string) => handleBillingFactorChange(row, val)"
|
||
>
|
||
<a-option
|
||
v-for="option in factorOptions"
|
||
:key="option.value"
|
||
:value="option.value"
|
||
:label="option.label"
|
||
:disabled="isBillingFactorOptionDisabled(option.value, row)"
|
||
>
|
||
{{ option.label }}
|
||
</a-option>
|
||
</a-select>
|
||
</template>
|
||
<template #factorValues="{ row }">
|
||
<div
|
||
v-for="preview in [getFactorValuePreview(row)]"
|
||
:key="row.id"
|
||
class="factor-values-cell"
|
||
>
|
||
<template v-if="preview.items.length">
|
||
<a-tag
|
||
v-for="item in preview.visible"
|
||
:key="`${row.id}-v-${item.index}`"
|
||
size="small"
|
||
closable
|
||
class="factor-value-tag"
|
||
:title="item.label"
|
||
@close="handleDeleteFactorValue(row, item.index)"
|
||
>
|
||
<span class="factor-value-tag__text">{{ item.label }}</span>
|
||
</a-tag>
|
||
<a-popover
|
||
v-if="preview.extraCount > 0"
|
||
trigger="click"
|
||
position="bl"
|
||
:content-style="{ padding: '10px 12px', maxWidth: '420px' }"
|
||
>
|
||
<a-tag size="small" class="factor-value-more" tabindex="0">
|
||
+{{ preview.extraCount }}
|
||
</a-tag>
|
||
<template #content>
|
||
<div class="factor-values-popover">
|
||
<div class="factor-values-popover__title">
|
||
已选 {{ preview.items.length }} 项
|
||
</div>
|
||
<div class="factor-values-popover__list">
|
||
<a-tag
|
||
v-for="item in preview.items"
|
||
:key="`${row.id}-all-${item.index}`"
|
||
size="small"
|
||
closable
|
||
class="factor-value-tag factor-value-tag--popover"
|
||
:title="item.label"
|
||
@close="handleDeleteFactorValue(row, item.index)"
|
||
>
|
||
<span class="factor-value-tag__text">{{ item.label }}</span>
|
||
</a-tag>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</a-popover>
|
||
</template>
|
||
<span v-else class="factor-values-empty">未配置</span>
|
||
<a-button
|
||
type="text"
|
||
size="mini"
|
||
class="add-value-btn"
|
||
@click="handleAddFactorValue(row)"
|
||
>
|
||
<icon-plus />
|
||
</a-button>
|
||
</div>
|
||
</template>
|
||
<template #action="{ row }">
|
||
<a-button
|
||
type="text"
|
||
size="mini"
|
||
status="danger"
|
||
class="row-action-btn"
|
||
@click="handleDeleteBillingFactor(row)"
|
||
>
|
||
<icon-delete />
|
||
</a-button>
|
||
</template>
|
||
</TablePro>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 价格表tab:Handsontable 虚拟渲染 -->
|
||
<div
|
||
v-if="priceDrawerActiveTab === 'priceList'"
|
||
class="price-list-content"
|
||
>
|
||
<div class="price-list-header">
|
||
<div class="price-list-header__meta">
|
||
<span class="price-list-badge">
|
||
{{
|
||
dictLabelByType('fee_item', basicInfoForm.costItem) ||
|
||
t('channel.serviceChannels.drawer.section.priceListTitle')
|
||
}}
|
||
</span>
|
||
<span class="price-list-hint">{{ t('channel.serviceChannels.drawer.priceList.hint') }}</span>
|
||
<span v-if="priceListData.length" class="price-list-count">
|
||
{{ priceListData.length }} 行
|
||
<template v-if="priceListSelectedIds.length"> · 已选 {{ priceListSelectedIds.length }}</template>
|
||
</span>
|
||
</div>
|
||
<div class="price-list-actions">
|
||
<label class="price-list-merge">
|
||
<span>合并相同值</span>
|
||
<a-switch v-model="priceListMergeSame" size="small" />
|
||
</label>
|
||
<span class="price-list-actions__sep" aria-hidden="true"></span>
|
||
<a-button
|
||
size="small"
|
||
:disabled="!priceListData.length"
|
||
@click="selectAllPriceListRows"
|
||
>
|
||
全选
|
||
</a-button>
|
||
<a-button
|
||
size="small"
|
||
:disabled="!priceListSelectedIds.length"
|
||
@click="clearAllPriceListRowsSelection"
|
||
>
|
||
取消全选
|
||
</a-button>
|
||
<span class="price-list-actions__sep" aria-hidden="true"></span>
|
||
<a-button
|
||
size="small"
|
||
:disabled="!priceListSelectedIds.length"
|
||
@click="openBatchFillModal(false)"
|
||
>
|
||
{{ t('channel.serviceChannels.drawer.priceList.batchFill') }}
|
||
</a-button>
|
||
<a-button
|
||
size="small"
|
||
:disabled="!priceListSelectedIds.length"
|
||
@click="openBatchFillModal(true)"
|
||
>
|
||
{{ t('channel.serviceChannels.drawer.priceList.batchFillEmpty') }}
|
||
</a-button>
|
||
<a-button
|
||
size="small"
|
||
:disabled="!priceListSelectedIds.length"
|
||
@click="handleCopyPrevRowPrices"
|
||
>
|
||
{{ t('channel.serviceChannels.drawer.priceList.copyPrev') }}
|
||
</a-button>
|
||
<span class="price-list-actions__sep" aria-hidden="true"></span>
|
||
<a-button size="small" @click="handleExportPriceList">导出</a-button>
|
||
<a-button size="small" @click="handleImportPriceList">导入</a-button>
|
||
<a-button
|
||
type="primary"
|
||
size="small"
|
||
class="app-btn-primary-soft"
|
||
:loading="savingPriceDrawer"
|
||
@click="handleSavePriceList"
|
||
>
|
||
{{ t('channel.serviceChannels.actions.save') }}
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
<div class="price-list-table-container">
|
||
<PriceListHotTable
|
||
ref="priceListHotRef"
|
||
:key="priceListHotTableKey"
|
||
:columns="priceListHotColumns"
|
||
:data="priceListData"
|
||
:height="PRICE_LIST_HOT_HEIGHT"
|
||
:merge-same-values="priceListMergeSame"
|
||
@selection-change="onPriceListSelectionChange"
|
||
/>
|
||
</div>
|
||
</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-sync />
|
||
{{ t('channel.serviceChannels.drawer.footer.syncPriceList') }}
|
||
</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="batchFillModalVisible"
|
||
:title="batchFillOnlyEmpty
|
||
? t('channel.serviceChannels.drawer.priceList.batchFillEmpty')
|
||
: t('channel.serviceChannels.drawer.priceList.batchFill')"
|
||
width="420px"
|
||
@ok="handleConfirmBatchFill"
|
||
@cancel="batchFillModalVisible = false"
|
||
>
|
||
<div class="batch-fill-form">
|
||
<div class="batch-fill-meta">
|
||
{{ t('channel.serviceChannels.drawer.priceList.batchFillSelected', { count: priceListSelectedIds.length }) }}
|
||
</div>
|
||
<a-form-item :label="t('channel.serviceChannels.drawer.priceList.batchFillValue')" required>
|
||
<a-input-number
|
||
v-model="batchFillValue"
|
||
:min="0"
|
||
:precision="4"
|
||
style="width: 100%"
|
||
:placeholder="t('channel.serviceChannels.form.placeholder.input')"
|
||
/>
|
||
</a-form-item>
|
||
</div>
|
||
</a-modal>
|
||
|
||
<!-- 发布弹窗 -->
|
||
<ModalPro
|
||
:title="t('channel.serviceChannels.publish.title')"
|
||
:width="1280"
|
||
:footer="false"
|
||
@register="registerPublishModal"
|
||
>
|
||
<div class="release-content slim">
|
||
<PublishDiffPanel
|
||
:status="publishDiffStatus"
|
||
:rows="publishDiffRows"
|
||
:diff-length="publishDiffLength"
|
||
:status-title="
|
||
publishDiffStatus === 'synced'
|
||
? t('channel.serviceChannels.publish.status.synced')
|
||
: publishDiffStatus === 'first'
|
||
? t('channel.serviceChannels.publish.status.first')
|
||
: t('channel.serviceChannels.publish.status.changed', {
|
||
count: publishDiffLength,
|
||
})
|
||
"
|
||
:status-desc="
|
||
publishDiffStatus === 'synced'
|
||
? t('channel.serviceChannels.publish.noDiffDetail')
|
||
: undefined
|
||
"
|
||
filter-mode="checkbox"
|
||
v-model:show-only-diff="showOnlyDiff"
|
||
:show-only-diff-label="t('channel.serviceChannels.publish.showOnlyDiff')"
|
||
:legend-labels="{
|
||
add: t('channel.serviceChannels.publish.legend.add'),
|
||
remove: t('channel.serviceChannels.publish.legend.remove'),
|
||
change: t('channel.serviceChannels.publish.legend.change'),
|
||
same: t('channel.serviceChannels.publish.legend.same'),
|
||
}"
|
||
:empty-text="t('channel.serviceChannels.publish.noDiffDetail')"
|
||
:merged-title="t('channel.serviceChannels.publish.pendingCache')"
|
||
:diff-count-text="
|
||
t('channel.serviceChannels.publish.diffCount', {
|
||
count: publishDiffLength,
|
||
})
|
||
"
|
||
raw-mode="always-before-legend"
|
||
:original-title="t('channel.serviceChannels.publish.onlineCache')"
|
||
:modified-title="
|
||
`${t('channel.serviceChannels.publish.pendingCache')} (${t(
|
||
'channel.serviceChannels.publish.diffCount',
|
||
{ count: publishDiffLength },
|
||
)})`
|
||
"
|
||
:original-json="getOriginalValue"
|
||
:modified-json="getModifiedModel"
|
||
same-line-mark="space"
|
||
>
|
||
<template #header-actions>
|
||
<a-button
|
||
type="primary"
|
||
size="small"
|
||
:disabled="publishDiffStatus === 'synced'"
|
||
@click="handlePublishConfirm"
|
||
>
|
||
{{
|
||
publishDiffStatus === 'synced'
|
||
? t('channel.serviceChannels.publish.noNeed')
|
||
: t('channel.serviceChannels.publish.confirm')
|
||
}}
|
||
</a-button>
|
||
</template>
|
||
</PublishDiffPanel>
|
||
</div>
|
||
</ModalPro>
|
||
|
||
<!-- 发布确认弹窗 -->
|
||
<a-modal
|
||
v-model:visible="publishConfirmVisible"
|
||
title="发布确认"
|
||
@cancel="handleCancelPublishConfirm"
|
||
@ok="handleConfirmPublish"
|
||
>
|
||
<a-alert
|
||
v-if="publishOperatorType === 'rules'"
|
||
type="warning"
|
||
:show-icon="true"
|
||
style="margin-bottom: 12px"
|
||
>
|
||
{{ t('channel.serviceChannels.publish.downstreamSyncTip') }}
|
||
</a-alert>
|
||
<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: var(--color-text-1);">
|
||
请输入渠道名称 <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: var(--color-text-1);">
|
||
请输入费用项名称 <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="t('channel.serviceChannels.modal.addProvider')"
|
||
:width="560"
|
||
:mask-closable="false"
|
||
unmount-on-close
|
||
@cancel="handleCancelAddProvider"
|
||
@ok="handleConfirmAddProvider"
|
||
>
|
||
<div class="add-provider-modal">
|
||
<div class="form-section-title">{{ t('channel.serviceChannels.section.providerBasic') }}</div>
|
||
<a-form :model="addProviderForm" layout="vertical" class="channel-edit-form">
|
||
<a-form-item required>
|
||
<template #label>
|
||
<span class="label-with-tip">
|
||
{{ t('channel.serviceChannels.form.app') }}
|
||
<a-tooltip :content="t('channel.serviceChannels.tip.app')">
|
||
<icon-question-circle class="tip-icon" />
|
||
</a-tooltip>
|
||
</span>
|
||
</template>
|
||
<a-select
|
||
v-model="addProviderForm.appId"
|
||
:placeholder="t('channel.serviceChannels.placeholder.selectApp')"
|
||
:options="appOptions"
|
||
allow-search
|
||
allow-clear
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item required>
|
||
<template #label>
|
||
<span class="label-with-tip">
|
||
{{ t('channel.serviceChannels.form.apiProvider') }}
|
||
<a-tooltip :content="t('channel.serviceChannels.tip.apiProvider')">
|
||
<icon-question-circle class="tip-icon" />
|
||
</a-tooltip>
|
||
</span>
|
||
</template>
|
||
<a-select
|
||
v-model="addProviderForm.serviceProviderId"
|
||
:placeholder="t('channel.serviceChannels.placeholder.selectApiProvider')"
|
||
:options="serviceProviderOptions"
|
||
allow-search
|
||
allow-clear
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item required>
|
||
<template #label>
|
||
<span class="label-with-tip">
|
||
{{ t('channel.serviceChannels.form.providerName') }}
|
||
<a-tooltip :content="t('channel.serviceChannels.tip.providerName')">
|
||
<icon-question-circle class="tip-icon" />
|
||
</a-tooltip>
|
||
</span>
|
||
</template>
|
||
<a-input
|
||
v-model="addProviderForm.providerName"
|
||
:placeholder="t('channel.serviceChannels.placeholder.providerName')"
|
||
allow-clear
|
||
/>
|
||
</a-form-item>
|
||
</a-form>
|
||
</div>
|
||
</a-modal>
|
||
|
||
<!-- 新增服务渠道弹窗 -->
|
||
<a-modal
|
||
v-model:visible="addChannelModalVisible"
|
||
:title="t('channel.serviceChannels.modal.addChannel')"
|
||
:width="800"
|
||
:mask-closable="false"
|
||
unmount-on-close
|
||
@cancel="handleCancelAddChannel"
|
||
@ok="handleConfirmAddChannel"
|
||
>
|
||
<div class="add-channel-modal">
|
||
<div class="form-section-title">{{ t('channel.serviceChannels.section.channelBasic') }}</div>
|
||
<a-form :model="addChannelForm" layout="vertical" class="channel-edit-form">
|
||
<a-row :gutter="16">
|
||
<a-col :span="12">
|
||
<a-form-item required>
|
||
<template #label>
|
||
<span class="label-with-tip">
|
||
{{ t('channel.serviceChannels.form.channelCode') }}
|
||
<a-tooltip>
|
||
<template #content>
|
||
<div style="white-space: pre-line; max-width: 320px; line-height: 1.5;">{{ t('channel.serviceChannels.tip.channelCode') }}</div>
|
||
</template>
|
||
<icon-question-circle class="tip-icon" />
|
||
</a-tooltip>
|
||
</span>
|
||
</template>
|
||
<a-input
|
||
:model-value="addChannelForm.channelCode"
|
||
:placeholder="t('channel.serviceChannels.placeholder.channelCode')"
|
||
allow-clear
|
||
@update:model-value="(v) => (addChannelForm.channelCode = normalizeBizCode(v))"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :span="12">
|
||
<a-form-item required>
|
||
<template #label>
|
||
<span class="label-with-tip">
|
||
{{ t('channel.serviceChannels.form.channelName') }}
|
||
<a-tooltip>
|
||
<template #content>
|
||
<div style="white-space: pre-line; max-width: 320px; line-height: 1.5;">{{ t('channel.serviceChannels.tip.channelName') }}</div>
|
||
</template>
|
||
<icon-question-circle class="tip-icon" />
|
||
</a-tooltip>
|
||
</span>
|
||
</template>
|
||
<a-input
|
||
v-model="addChannelForm.channelName"
|
||
:placeholder="t('channel.serviceChannels.placeholder.channelName')"
|
||
allow-clear
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
<a-form-item>
|
||
<template #label>
|
||
<span class="label-with-tip">
|
||
{{ t('channel.serviceChannels.form.timeLimit') }}
|
||
<a-tooltip :content="t('channel.serviceChannels.tip.timeLimit')">
|
||
<icon-question-circle class="tip-icon" />
|
||
</a-tooltip>
|
||
</span>
|
||
</template>
|
||
<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>
|
||
|
||
<div class="form-section-title">{{ t('channel.serviceChannels.section.forecast') }}</div>
|
||
<a-row :gutter="16">
|
||
<a-col :span="12">
|
||
<a-form-item>
|
||
<template #label>
|
||
<span class="label-with-tip">
|
||
{{ t('channel.serviceChannels.form.forecastType') }}
|
||
<a-tooltip :content="t('channel.serviceChannels.tip.forecastType')">
|
||
<icon-question-circle class="tip-icon" />
|
||
</a-tooltip>
|
||
</span>
|
||
</template>
|
||
<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>
|
||
<template #label>
|
||
<span class="label-with-tip">
|
||
{{ t('channel.serviceChannels.form.forecastCode') }}
|
||
<a-tooltip :content="t('channel.serviceChannels.tip.forecastCode')">
|
||
<icon-question-circle class="tip-icon" />
|
||
</a-tooltip>
|
||
</span>
|
||
</template>
|
||
<a-input
|
||
v-model="addChannelForm.forecastCode"
|
||
:placeholder="t('channel.serviceChannels.form.placeholder.forecastCode')"
|
||
allow-clear
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
<a-row :gutter="16">
|
||
<a-col :span="12">
|
||
<a-form-item>
|
||
<template #label>
|
||
<span class="label-with-tip">
|
||
{{ t('channel.serviceChannels.form.remoteArea') }}
|
||
<a-tooltip :content="t('channel.serviceChannels.tip.remoteArea')">
|
||
<icon-question-circle class="tip-icon" />
|
||
</a-tooltip>
|
||
</span>
|
||
</template>
|
||
<a-select
|
||
v-model="addChannelForm.remoteAreaId"
|
||
:placeholder="t('channel.serviceChannels.form.placeholder.select')"
|
||
allow-clear
|
||
>
|
||
<a-option
|
||
v-for="option in remoteAreaDivisionOptions"
|
||
:key="option.value"
|
||
:value="option.value"
|
||
>
|
||
{{ option.label }}
|
||
</a-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
|
||
<div class="form-section-title">{{ t('channel.serviceChannels.section.desc') }}</div>
|
||
<a-form-item>
|
||
<template #label>{{ t('channel.serviceChannels.form.description') }}</template>
|
||
<a-textarea
|
||
v-model="addChannelForm.description"
|
||
:placeholder="t('channel.serviceChannels.form.placeholder.description')"
|
||
:auto-size="{ minRows: 2, maxRows: 4 }"
|
||
allow-clear
|
||
/>
|
||
</a-form-item>
|
||
|
||
<div class="channel-advanced-toggle">
|
||
<a-button type="text" size="small" @click="addChannelShowAdvanced = !addChannelShowAdvanced">
|
||
{{
|
||
addChannelShowAdvanced
|
||
? t('channel.serviceChannels.form.hideAdvanced')
|
||
: t('channel.serviceChannels.form.showAdvanced')
|
||
}}
|
||
</a-button>
|
||
</div>
|
||
<a-form-item v-if="addChannelShowAdvanced">
|
||
<template #label>
|
||
<span class="label-with-tip">
|
||
{{ t('channel.serviceChannels.form.preScript') }}
|
||
<a-tooltip :content="t('channel.serviceChannels.tip.preScript')">
|
||
<icon-question-circle class="tip-icon" />
|
||
</a-tooltip>
|
||
</span>
|
||
</template>
|
||
<a-textarea
|
||
v-model="addChannelForm.preScript"
|
||
:placeholder="t('channel.serviceChannels.form.placeholder.preScript')"
|
||
:auto-size="{ minRows: 3, maxRows: 8 }"
|
||
allow-clear
|
||
/>
|
||
</a-form-item>
|
||
</a-form>
|
||
</div>
|
||
</a-modal>
|
||
|
||
<!-- 添加因素值弹窗(与销售产品卖价一致) -->
|
||
<a-modal
|
||
v-model:visible="addFactorValueModalVisible"
|
||
title="添加因素值"
|
||
width="400px"
|
||
@cancel="handleAddFactorValueModalCancel"
|
||
@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
|
||
:model-value="addFactorValueSelected"
|
||
:options="currentFactorValueOptions"
|
||
:placeholder="currentFactorValueOptions.length ? '请选择因素值' : '该因素暂无可选值'"
|
||
multiple
|
||
allow-clear
|
||
allow-search
|
||
style="width: 100%"
|
||
@change="handleAddFactorValueSelectedChange"
|
||
@press-enter="handleConfirmAddFactorValue"
|
||
/>
|
||
</div>
|
||
</a-modal>
|
||
|
||
<!-- 编辑服务商弹窗 -->
|
||
<a-modal
|
||
v-model:visible="editProviderModalVisible"
|
||
:title="t('channel.serviceChannels.modal.editProvider')"
|
||
:width="560"
|
||
:mask-closable="false"
|
||
unmount-on-close
|
||
@cancel="handleCancelEditProvider"
|
||
@ok="handleConfirmEditProvider"
|
||
>
|
||
<div class="edit-provider-modal">
|
||
<div class="form-section-title">{{ t('channel.serviceChannels.section.providerBasic') }}</div>
|
||
<a-form :model="editProviderForm" layout="vertical" class="channel-edit-form">
|
||
<a-form-item required>
|
||
<template #label>
|
||
<span class="label-with-tip">
|
||
{{ t('channel.serviceChannels.form.app') }}
|
||
<a-tooltip :content="t('channel.serviceChannels.tip.app')">
|
||
<icon-question-circle class="tip-icon" />
|
||
</a-tooltip>
|
||
</span>
|
||
</template>
|
||
<a-select
|
||
v-model="editProviderForm.appId"
|
||
:placeholder="t('channel.serviceChannels.placeholder.selectApp')"
|
||
:options="appOptions"
|
||
allow-search
|
||
allow-clear
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item required>
|
||
<template #label>
|
||
<span class="label-with-tip">
|
||
{{ t('channel.serviceChannels.form.apiProvider') }}
|
||
<a-tooltip :content="t('channel.serviceChannels.tip.apiProvider')">
|
||
<icon-question-circle class="tip-icon" />
|
||
</a-tooltip>
|
||
</span>
|
||
</template>
|
||
<a-select
|
||
v-model="editProviderForm.serviceProviderId"
|
||
:placeholder="t('channel.serviceChannels.placeholder.selectApiProvider')"
|
||
:options="serviceProviderOptions"
|
||
allow-search
|
||
allow-clear
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item required>
|
||
<template #label>
|
||
<span class="label-with-tip">
|
||
{{ t('channel.serviceChannels.form.providerName') }}
|
||
<a-tooltip :content="t('channel.serviceChannels.tip.providerName')">
|
||
<icon-question-circle class="tip-icon" />
|
||
</a-tooltip>
|
||
</span>
|
||
</template>
|
||
<a-input
|
||
v-model="editProviderForm.providerName"
|
||
:placeholder="t('channel.serviceChannels.placeholder.providerName')"
|
||
allow-clear
|
||
/>
|
||
</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: var(--color-text-1);">
|
||
确认要删除服务商 <span style="color: #f53f3f;">"{{ deletingProvider?.name }}"</span> 吗?
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</a-modal>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, shallowRef, reactive, computed, watch, nextTick, onMounted, onBeforeUnmount, h, markRaw } from 'vue'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { IconPlus, IconDelete, IconSearch, IconEdit, IconDragArrow, IconQuestionCircleFill, IconQuestionCircle, IconFolder, IconMore, IconSync } 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 PublishDiffPanel from '@/components/PublishDiff/index.vue'
|
||
import PriceListHotTable, {
|
||
type PriceListHotColumn,
|
||
} from '@/views/channel/salesProducts/components/PriceListHotTable.vue'
|
||
import {
|
||
computeSnapshotDiff,
|
||
formatSnapshotJson,
|
||
type SnapshotDiffRow,
|
||
} from '@/utils/publishSnapshotDiff'
|
||
import dayjs from 'dayjs'
|
||
import * as XLSX from 'xlsx'
|
||
import Sortable from 'sortablejs'
|
||
import {
|
||
buildWeightFactorSelectOptions,
|
||
canonicalWeightFactorCellForApi,
|
||
isWeightPricingFactor,
|
||
sortFactorValuesBySize,
|
||
weightValueToIntervalCell,
|
||
} from '@/utils/priceWeightInterval'
|
||
import {
|
||
estimateFactorValuesColumnWidth,
|
||
resolveVisibleFactorValueCount,
|
||
} from '@/utils/factorValueTagPreview'
|
||
import { getAppGetList } from '@/api/ApplicationServices/apps'
|
||
import {
|
||
getServiceProviderGetServiceProviderDropdownList,
|
||
postServiceProviderUpdate,
|
||
} from '@/api/interface-serviceProvider/serviceProvider'
|
||
import {
|
||
createServiceChannelGroup,
|
||
createServiceChannel,
|
||
createServiceChannelPrice,
|
||
deleteServiceChannel,
|
||
deleteServiceChannelGroup,
|
||
deleteServiceChannelPrice,
|
||
getServiceChannelDetail,
|
||
getServiceChannelGroupList,
|
||
getServiceChannelList,
|
||
getServiceChannelPrice,
|
||
getServiceChannelPriceList,
|
||
getServiceChannelPublishPreview,
|
||
getServiceChannelPublishSyncStatus,
|
||
getServiceChannelPublishPriceList,
|
||
getServiceChannelPublishRulesList,
|
||
publishServiceChannel,
|
||
rollbackServiceChannelPublish,
|
||
toggleServiceChannelPriceStatus,
|
||
buildServiceChannelUpdatePayload,
|
||
updateServiceChannel,
|
||
updateServiceChannelGroup,
|
||
updateServiceChannelPrice,
|
||
} from '@/api/channel/serviceChannel'
|
||
import { getDictList, getDictListByTypes } 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'
|
||
import BillingConditionEditor from '@/views/channel/salesProducts/components/BillingConditionEditor.vue'
|
||
import { ENABLE_STATUS, isEnableStatusEnabled, toEnableStatus } from '@/utils/enableStatus'
|
||
import { pickResolvedAudit } from '@/utils/auditFields'
|
||
|
||
/** 两个 tab 共用固定内容区高度,仅价格表内部滚动 */
|
||
const PRICE_LIST_HOT_HEIGHT = 550
|
||
|
||
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 : ''
|
||
}
|
||
|
||
const CHANNEL_PRICE_DICT_TYPES = [
|
||
'fee_item',
|
||
'cost_unit',
|
||
'currency',
|
||
'pricing_factor',
|
||
'calculation_formula',
|
||
'customer_group',
|
||
'cargo_attribute',
|
||
FORECAST_TYPE_DICT_TYPE,
|
||
] as const
|
||
|
||
async function ensureChannelPriceDictLoaded() {
|
||
if (channelPriceDictFetched.value) return
|
||
if (channelPriceDictLoadPromise) {
|
||
await channelPriceDictLoadPromise
|
||
return
|
||
}
|
||
channelPriceDictLoading.value = true
|
||
channelPriceDictLoadPromise = (async () => {
|
||
try {
|
||
const items = await getDictListByTypes([...CHANNEL_PRICE_DICT_TYPES])
|
||
channelPriceDictRows.value = (items || [])
|
||
.map(normalizeAnyDictRow)
|
||
.filter(Boolean) as NormalizedDictRow[]
|
||
} 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 pricingFactorFallbackOption(val: string | number | boolean | Record<string, unknown>) {
|
||
const value = typeof val === 'object' && val != null && 'value' in val
|
||
? String((val as { value: unknown }).value ?? '')
|
||
: String(val ?? '')
|
||
return { value, label: pricingFactorColumnLabel(value) }
|
||
}
|
||
|
||
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 isPartitionValueFactor(factorKey: string): boolean {
|
||
if (factorKey === '分区' || factorKey === '价格分区') return true
|
||
const m = getPricingFactorMeta(factorKey)
|
||
return m?.dictCode === 'zone' || m?.dictName === '价格分区' || m?.dictName === '分区'
|
||
}
|
||
|
||
function isRemotePartitionValueFactor(factorKey: string): boolean {
|
||
if (factorKey === '偏远分区') return true
|
||
const m = getPricingFactorMeta(factorKey)
|
||
return m?.dictCode === 'remoteZone' || m?.dictName === '偏远分区'
|
||
}
|
||
|
||
function isWarehouseValueFactor(factorKey: string): boolean {
|
||
if (factorKey === '仓库') return true
|
||
const m = getPricingFactorMeta(factorKey)
|
||
return m?.dictCode === 'warehouse'
|
||
}
|
||
|
||
function isWeightValueFactor(factorKey: string): boolean {
|
||
if (isWeightPricingFactor(factorKey)) return true
|
||
const m = getPricingFactorMeta(factorKey)
|
||
return m?.dictCode === 'weight'
|
||
}
|
||
|
||
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 (isRemotePartitionValueFactor(factor)) {
|
||
return vs.map((v) => {
|
||
const s = String(v).trim()
|
||
if (remoteAreaDivisionOptions.value.some((o) => o.value === s)) return s
|
||
const byLabel = remoteAreaDivisionOptions.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
|
||
})
|
||
}
|
||
const mapped = vs.map((v) => String(v))
|
||
if (isWeightValueFactor(factor)) {
|
||
return sortFactorValuesBySize(mapped)
|
||
}
|
||
return mapped
|
||
}
|
||
|
||
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 (isRemotePartitionValueFactor(factorKey)) {
|
||
const opt = remoteAreaDivisionOptions.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 || ''
|
||
}
|
||
if (isWeightValueFactor(factorKey)) {
|
||
const bf = billingFactorsData.value.find((item) => item.factor === factorKey)
|
||
return weightValueToIntervalCell(s, bf?.factorValues ?? [])
|
||
}
|
||
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 }
|
||
|
||
/** 重量因素值:与产品卖价共用(0.5 步进至 30,其后固定 999) */
|
||
const weightFactorOptions: IdSelectOption[] = buildWeightFactorSelectOptions()
|
||
|
||
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[]>([])
|
||
const remoteAreaDivisionOptions = ref<IdSelectOption[]>([])
|
||
|
||
function mapKpiAreasToSelectOptions(items: any[]): IdSelectOption[] {
|
||
return (items || []).map((it: any) => {
|
||
const id = it?.id ?? it?.Id
|
||
const name = String(it?.name ?? it?.Name ?? id ?? '')
|
||
return { label: name, value: String(id ?? '') }
|
||
})
|
||
}
|
||
|
||
function toOptionalPositiveId(v: unknown): number | undefined {
|
||
if (v === '' || v == null) return undefined
|
||
const n = Number(v)
|
||
if (!Number.isFinite(n) || n <= 0) return undefined
|
||
return n
|
||
}
|
||
|
||
async function loadRemoteAreaSelectOptions() {
|
||
try {
|
||
const rmRes = await postForewarningServiceKpiAreaGetKpiAreas({
|
||
pageIndex: 1,
|
||
pageSize: 500,
|
||
areaType: 'remote',
|
||
})
|
||
const rmItems = rmRes?.items ?? (rmRes as any)?.data?.items ?? []
|
||
remoteAreaDivisionOptions.value = mapKpiAreasToSelectOptions(
|
||
filterKpiAreasByType(rmItems, 'remote')
|
||
)
|
||
} catch (e: any) {
|
||
Message.error(e?.message || '加载偏远分区失败')
|
||
remoteAreaDivisionOptions.value = []
|
||
}
|
||
}
|
||
|
||
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 formulaCodeToLabel(code: string): string {
|
||
const c = String(code ?? '').trim()
|
||
if (!c) return ''
|
||
return dictLabelByType('calculation_formula', c) || c
|
||
}
|
||
|
||
function formulaLabelToCode(labelOrCode: string): string {
|
||
const s = String(labelOrCode ?? '').trim()
|
||
if (!s) return defaultCalculationFormulaCode()
|
||
const rows = dictRowsOfType('calculation_formula')
|
||
const byName = rows.find((r) => r.dictName === s)
|
||
if (byName?.dictCode) return byName.dictCode
|
||
const byCode = rows.find((r) => r.dictCode === s)
|
||
if (byCode?.dictCode) return byCode.dictCode
|
||
return s
|
||
}
|
||
|
||
function defaultCalculationFormulaDisplay(): string {
|
||
return formulaCodeToLabel(defaultCalculationFormulaCode())
|
||
}
|
||
|
||
const cargoAttributeValueOptions = computed<IdSelectOption[]>(() =>
|
||
dictRowsOfType('cargo_attribute').map((r) => ({
|
||
label: r.dictName || '(未命名)',
|
||
value: r.dictCode,
|
||
}))
|
||
)
|
||
|
||
const factorValuesOptionsByFactor = computed<Record<string, IdSelectOption[]>>(() => {
|
||
const cargoOpts = cargoAttributeValueOptions.value
|
||
const priceZoneOpts = priceAreaFactorOptions.value
|
||
const remoteZoneOpts = remoteAreaDivisionOptions.value
|
||
const whOpts = warehouseFactorOptions.value
|
||
const base: Record<string, IdSelectOption[]> = {
|
||
货物类型: cargoOpts,
|
||
cargo: cargoOpts,
|
||
分区: priceZoneOpts,
|
||
价格分区: priceZoneOpts,
|
||
zone: priceZoneOpts,
|
||
偏远分区: remoteZoneOpts,
|
||
remoteZone: remoteZoneOpts,
|
||
重量: weightFactorOptions,
|
||
weight: weightFactorOptions,
|
||
仓库: whOpts,
|
||
warehouse: whOpts,
|
||
}
|
||
const out: Record<string, IdSelectOption[]> = { ...base }
|
||
const resolveFactorValueSource = (code: string, name: string): IdSelectOption[] | undefined => {
|
||
const fromBase = (name && base[name]) || base[code]
|
||
if (fromBase) return fromBase
|
||
if (code === 'zone' || name === '分区' || name === '价格分区') return priceZoneOpts
|
||
if (code === 'remoteZone' || name === '偏远分区') return remoteZoneOpts
|
||
if (code === 'cargo' || name === '货物类型') return cargoOpts
|
||
if (code === 'warehouse' || name === '仓库') return whOpts
|
||
if (code === 'weight' || name === '重量') return weightFactorOptions
|
||
return undefined
|
||
}
|
||
for (const r of dictRowsOfType('pricing_factor')) {
|
||
const code = r.dictCode
|
||
const name = r.dictName
|
||
if (!code) continue
|
||
const src = resolveFactorValueSource(code, name)
|
||
if (src) {
|
||
out[code] = src
|
||
if (name) out[name] = src
|
||
}
|
||
}
|
||
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 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
|
||
configFormBaseline.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) {
|
||
void handleChannelSelectFromTreeAsync(keys, info)
|
||
}
|
||
|
||
async function handleChannelSelectFromTreeAsync(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) return
|
||
if (activeTab.value === 'config' && configFormDirty.value) {
|
||
const ok = await confirmDiscardConfigChanges()
|
||
if (!ok) return
|
||
}
|
||
await selectChannel(channel)
|
||
}
|
||
|
||
// 表单数据
|
||
const formData = reactive({
|
||
channelCode: '',
|
||
channelName: '',
|
||
minTime: '',
|
||
maxTime: '',
|
||
forecastType: '',
|
||
forecastCode: '',
|
||
remoteAreaId: '',
|
||
description: '',
|
||
preScript: ''
|
||
})
|
||
|
||
type ConfigFormSnapshot = {
|
||
channelCode: string
|
||
channelName: string
|
||
minTime: string
|
||
maxTime: string
|
||
forecastType: string
|
||
forecastCode: string
|
||
remoteAreaId: string
|
||
description: string
|
||
preScript: string
|
||
}
|
||
|
||
const configFormBaseline = ref<ConfigFormSnapshot | null>(null)
|
||
const savingConfig = ref(false)
|
||
|
||
function snapshotConfigForm(): ConfigFormSnapshot {
|
||
return {
|
||
channelCode: formData.channelCode,
|
||
channelName: formData.channelName,
|
||
minTime: formData.minTime,
|
||
maxTime: formData.maxTime,
|
||
forecastType: formData.forecastType,
|
||
forecastCode: formData.forecastCode,
|
||
remoteAreaId: formData.remoteAreaId,
|
||
description: formData.description,
|
||
preScript: formData.preScript,
|
||
}
|
||
}
|
||
|
||
function resetConfigFormBaseline() {
|
||
configFormBaseline.value = snapshotConfigForm()
|
||
}
|
||
|
||
const configFormDirty = computed(() => {
|
||
const baseline = configFormBaseline.value
|
||
if (!baseline) return false
|
||
return (
|
||
formData.channelCode !== baseline.channelCode
|
||
|| formData.channelName !== baseline.channelName
|
||
|| formData.minTime !== baseline.minTime
|
||
|| formData.maxTime !== baseline.maxTime
|
||
|| formData.forecastType !== baseline.forecastType
|
||
|| formData.forecastCode !== baseline.forecastCode
|
||
|| formData.remoteAreaId !== baseline.remoteAreaId
|
||
|| formData.description !== baseline.description
|
||
|| formData.preScript !== baseline.preScript
|
||
)
|
||
})
|
||
|
||
/** 编辑服务商后,该分组下渠道需重新发布规则快照(按分组 Id) */
|
||
const pendingRulesPublishGroupIds = ref<Set<number>>(new Set())
|
||
/** 本轮待发布中,已完成规则发布的渠道(同组其他渠道仍可点) */
|
||
const rulesPublishDoneChannelIds = ref<Set<number>>(new Set())
|
||
|
||
function getSelectedChannelGroupId(): number {
|
||
const ch = selectedChannel.value as any
|
||
if (!ch) return 0
|
||
const fromChannel = Number(ch.groupId ?? ch.GroupId ?? 0)
|
||
if (Number.isFinite(fromChannel) && fromChannel > 0) return fromChannel
|
||
for (const g of channelGroups.value) {
|
||
if ((g.channels || []).some((c: any) => Number(c.id) === Number(ch.id))) {
|
||
return Number((g as any).id) || 0
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
const selectedChannelNeedsRulesPublish = computed(() => {
|
||
const ch = selectedChannel.value as any
|
||
const channelId = Number(ch?.id)
|
||
const groupId = getSelectedChannelGroupId()
|
||
if (!(groupId > 0) || !pendingRulesPublishGroupIds.value.has(groupId)) return false
|
||
if (Number.isFinite(channelId) && channelId > 0 && rulesPublishDoneChannelIds.value.has(channelId)) {
|
||
return false
|
||
}
|
||
return true
|
||
})
|
||
|
||
/** 渠道配置已保存、待发布规则 */
|
||
const pendingConfigPublishChannelIds = ref<Set<number>>(new Set())
|
||
|
||
const selectedChannelConfigPublishPending = computed(() => {
|
||
const channelId = Number((selectedChannel.value as any)?.id)
|
||
return (
|
||
Number.isFinite(channelId)
|
||
&& channelId > 0
|
||
&& pendingConfigPublishChannelIds.value.has(channelId)
|
||
)
|
||
})
|
||
|
||
/** 有未保存改动时不可发布,须先保存 */
|
||
const canPublish = computed(
|
||
() =>
|
||
!configFormDirty.value
|
||
&& (selectedChannelConfigPublishPending.value || selectedChannelNeedsRulesPublish.value),
|
||
)
|
||
|
||
function markSelectedChannelConfigPublishPending() {
|
||
const channelId = Number((selectedChannel.value as any)?.id)
|
||
if (!Number.isFinite(channelId) || channelId <= 0) return
|
||
if (pendingConfigPublishChannelIds.value.has(channelId)) return
|
||
const next = new Set(pendingConfigPublishChannelIds.value)
|
||
next.add(channelId)
|
||
pendingConfigPublishChannelIds.value = next
|
||
}
|
||
|
||
function clearSelectedChannelConfigPublishPending() {
|
||
const channelId = Number((selectedChannel.value as any)?.id)
|
||
if (!Number.isFinite(channelId) || channelId <= 0) return
|
||
if (!pendingConfigPublishChannelIds.value.has(channelId)) return
|
||
const next = new Set(pendingConfigPublishChannelIds.value)
|
||
next.delete(channelId)
|
||
pendingConfigPublishChannelIds.value = next
|
||
}
|
||
|
||
/** 渠道代码:仅大写、数字、- */
|
||
function normalizeBizCode(value: unknown): string {
|
||
return String(value ?? '')
|
||
.toUpperCase()
|
||
.replace(/[^A-Z0-9-]/g, '')
|
||
}
|
||
|
||
function markGroupRulesPublishPending(groupId: number | string | null | undefined) {
|
||
const id = Number(groupId)
|
||
if (!Number.isFinite(id) || id <= 0) return
|
||
const nextGroups = new Set(pendingRulesPublishGroupIds.value)
|
||
nextGroups.add(id)
|
||
pendingRulesPublishGroupIds.value = nextGroups
|
||
|
||
// 该分组重新进入待发布:清掉同组已发布标记
|
||
const group = channelGroups.value.find((g) => Number((g as any).id) === id)
|
||
const channelIds = (group?.channels || [])
|
||
.map((c: any) => Number(c?.id))
|
||
.filter((cid: number) => Number.isFinite(cid) && cid > 0)
|
||
if (!channelIds.length) return
|
||
const nextDone = new Set(rulesPublishDoneChannelIds.value)
|
||
channelIds.forEach((cid: number) => nextDone.delete(cid))
|
||
rulesPublishDoneChannelIds.value = nextDone
|
||
}
|
||
|
||
function markSelectedChannelRulesPublishDone() {
|
||
const ch = selectedChannel.value as any
|
||
const channelId = Number(ch?.id)
|
||
if (!Number.isFinite(channelId) || channelId <= 0) return
|
||
clearSelectedChannelConfigPublishPending()
|
||
if (rulesPublishDoneChannelIds.value.has(channelId)) return
|
||
const next = new Set(rulesPublishDoneChannelIds.value)
|
||
next.add(channelId)
|
||
rulesPublishDoneChannelIds.value = next
|
||
|
||
// 同组渠道都发完后,去掉分组待发布标记
|
||
const groupId = getSelectedChannelGroupId()
|
||
const group = channelGroups.value.find((g) => Number((g as any).id) === groupId)
|
||
const channelIds = (group?.channels || [])
|
||
.map((c: any) => Number(c?.id))
|
||
.filter((cid: number) => Number.isFinite(cid) && cid > 0)
|
||
if (channelIds.length && channelIds.every((cid: number) => rulesPublishDoneChannelIds.value.has(cid))) {
|
||
const nextGroups = new Set(pendingRulesPublishGroupIds.value)
|
||
nextGroups.delete(groupId)
|
||
pendingRulesPublishGroupIds.value = nextGroups
|
||
}
|
||
}
|
||
|
||
const selectedChannelContext = computed(() => {
|
||
const ch = selectedChannel.value as any
|
||
if (!ch?.id) return null
|
||
let group: any = null
|
||
for (const g of channelGroups.value) {
|
||
if ((g.channels || []).some((c: any) => Number(c.id) === Number(ch.id))) {
|
||
group = g
|
||
break
|
||
}
|
||
}
|
||
const appId = group?.appId
|
||
const spId = group?.apiServiceProviderId ?? group?.serviceProviderId
|
||
const appName = appId != null
|
||
? (appOptions.value.find((o) => o.value === appId)?.label ?? String(appId))
|
||
: '-'
|
||
const apiProviderName = spId != null
|
||
? (serviceProviderOptions.value.find((o) => o.value === spId)?.label ?? String(spId))
|
||
: '-'
|
||
const audit = pickResolvedAudit(ch?.raw || ch)
|
||
return {
|
||
channelName: String(ch.name ?? formData.channelName ?? '-'),
|
||
channelCode: String(ch.channelCode ?? formData.channelCode ?? ''),
|
||
groupName: String(group?.name ?? '-'),
|
||
appName,
|
||
apiProviderName,
|
||
updateTime: audit.updateTime || '--',
|
||
updateBy: audit.updateBy || '--',
|
||
}
|
||
})
|
||
|
||
function confirmDiscardConfigChanges(): Promise<boolean> {
|
||
if (!configFormDirty.value) return Promise.resolve(true)
|
||
return new Promise((resolve) => {
|
||
Modal.confirm({
|
||
title: t('channel.serviceChannels.unsaved.title'),
|
||
content: t('channel.serviceChannels.unsaved.message'),
|
||
okText: t('channel.serviceChannels.unsaved.discard'),
|
||
cancelText: t('channel.serviceChannels.unsaved.stay'),
|
||
onOk: () => resolve(true),
|
||
onCancel: () => resolve(false),
|
||
})
|
||
})
|
||
}
|
||
|
||
async function handleTabClick(tabKey: string) {
|
||
if (tabKey === activeTab.value) return
|
||
if (activeTab.value === 'config' && configFormDirty.value) {
|
||
const ok = await confirmDiscardConfigChanges()
|
||
if (!ok) return
|
||
}
|
||
activeTab.value = tabKey
|
||
}
|
||
|
||
// 搜索/刷新处理
|
||
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 addChannelShowAdvanced = ref(false)
|
||
const addChannelTargetGroupId = ref<number | null>(null)
|
||
const addChannelForm = reactive({
|
||
channelCode: '',
|
||
channelName: '',
|
||
minTime: '',
|
||
maxTime: '',
|
||
forecastType: '',
|
||
forecastCode: '',
|
||
remoteAreaId: '',
|
||
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 editProviderOriginal = reactive({
|
||
appId: undefined as number | undefined,
|
||
serviceProviderId: undefined as number | undefined,
|
||
})
|
||
|
||
function isEditProviderDownstreamChanged() {
|
||
return (
|
||
Number(editProviderForm.appId) !== Number(editProviderOriginal.appId) ||
|
||
Number(editProviderForm.serviceProviderId) !== Number(editProviderOriginal.serviceProviderId)
|
||
)
|
||
}
|
||
|
||
function confirmEditProviderDownstreamChange(): Promise<boolean> {
|
||
return new Promise((resolve) => {
|
||
Modal.confirm({
|
||
title: t('channel.serviceChannels.editProvider.downstreamConfirmTitle'),
|
||
content: () =>
|
||
h(
|
||
'div',
|
||
{ style: 'white-space: pre-line; line-height: 1.6;' },
|
||
t('channel.serviceChannels.editProvider.downstreamConfirmContent'),
|
||
),
|
||
okText: t('channel.serviceChannels.editProvider.downstreamConfirmOk'),
|
||
cancelText: t('channel.serviceChannels.modal.actions.cancel'),
|
||
onOk: () => resolve(true),
|
||
onCancel: () => resolve(false),
|
||
})
|
||
})
|
||
}
|
||
|
||
// 取消编辑服务商
|
||
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[] = []
|
||
let groupListFailed = false
|
||
try {
|
||
groupsRaw = await getServiceChannelGroupList({})
|
||
} catch (e) {
|
||
console.error('加载服务商分组列表失败:', e)
|
||
groupListFailed = true
|
||
groupsRaw = []
|
||
}
|
||
// 仅在接口失败时用兜底,避免「保存成功但列表解析为空」被假数据盖回
|
||
if (groupListFailed && (!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)
|
||
}
|
||
const audit = pickResolvedAudit(it)
|
||
map.get(gid).channels.push({
|
||
id: it.id,
|
||
name: it.channelName,
|
||
channelCode: it.channelCode,
|
||
groupId: it.groupId,
|
||
createTime: audit.createTime,
|
||
updateTime: audit.updateTime,
|
||
createBy: audit.createBy,
|
||
updateBy: audit.updateBy,
|
||
isActive: isEnableStatusEnabled(it.isActive ?? it.IsActive),
|
||
raw: it,
|
||
})
|
||
}
|
||
|
||
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(),
|
||
loadRemoteAreaSelectOptions(),
|
||
])
|
||
referenceOptionsLoaded.value = true
|
||
} finally {
|
||
referenceOptionsLoading.value = false
|
||
}
|
||
}
|
||
|
||
// 组件挂载时仅加载渠道列表
|
||
onMounted(() => {
|
||
loadChannelGroups()
|
||
void loadRemoteAreaSelectOptions()
|
||
})
|
||
|
||
// 计费因素数据
|
||
interface BillingFactor {
|
||
id?: number
|
||
serialNumber?: number
|
||
factor: string
|
||
factorValues: string[]
|
||
}
|
||
|
||
// 默认计费因素(渠道买价:与下游服务商结算,不含客户/客户组)
|
||
const getDefaultBillingFactorsData = () => {
|
||
const preferred = [
|
||
{ code: 'zone', names: ['价格分区', '分区'] },
|
||
{ code: 'weight', names: ['重量'] },
|
||
]
|
||
const rows = dictRowsOfType('pricing_factor')
|
||
const resolveFactor = (pref: { code: string; names: string[] }): string => {
|
||
const byCode = rows.find((r) => r.dictCode === pref.code)
|
||
if (byCode?.dictCode) return byCode.dictCode
|
||
for (const name of pref.names) {
|
||
const hit = rows.find((r) => r.dictName === name || r.dictCode === name)
|
||
if (hit?.dictCode) return hit.dictCode
|
||
}
|
||
return pref.code
|
||
}
|
||
return preferred.map((pref, index) => ({
|
||
id: index + 1,
|
||
serialNumber: index + 1,
|
||
factor: resolveFactor(pref),
|
||
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 }))
|
||
}
|
||
const mapped = factors
|
||
.filter((item: any) => !isCustomerFactor(String(item?.factor ?? item?.name ?? '')))
|
||
.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 ?? [],
|
||
}))
|
||
return mapped.length > 0
|
||
? mapped
|
||
: getDefaultBillingFactorsData().map((item) => ({ ...item }))
|
||
}
|
||
|
||
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
|
||
if (isWeightValueFactor(bf.factor)) {
|
||
const next = weightValueToIntervalCell(String(cur), bf.factorValues ?? [])
|
||
if (next) (prow as any)[k] = next
|
||
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 && !isCustomerFactor(item.factor))
|
||
.map((item, idx) => ({
|
||
serialNumber: idx + 1,
|
||
factor: item.factor,
|
||
factorValues: toCanonicalFactorValues(item.factor, item.factorValues),
|
||
}))
|
||
}
|
||
|
||
function resolveBuyPriceFromDetail(item: any): number {
|
||
const direct = Number(item?.calculatedValue ?? item?.CalculatedValue ?? 0)
|
||
if (direct > 0) return direct
|
||
const cgs = item?.customerGroupValues ?? item?.CustomerGroupValues
|
||
if (cgs && typeof cgs === 'object') {
|
||
const vals = Object.values(cgs as Record<string, unknown>)
|
||
.map((v) => Number(v))
|
||
.filter((n) => Number.isFinite(n) && n > 0)
|
||
if (vals.length) return vals[0]
|
||
}
|
||
return 0
|
||
}
|
||
|
||
/** 将接口返回的 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: formulaCodeToLabel(
|
||
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)
|
||
const raw = fj[label] ?? fj[bf.factor] ?? ''
|
||
row[`factor_${i}`] = isWeightValueFactor(bf.factor)
|
||
? weightValueToIntervalCell(String(raw), bf.factorValues ?? [])
|
||
: raw
|
||
}
|
||
row.calculatedValue = resolveBuyPriceFromDetail(item)
|
||
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)
|
||
let cellVal = v != null && String(v).trim() !== '' ? String(v) : ''
|
||
if (cellVal && isWeightValueFactor(bf.factor)) {
|
||
cellVal = canonicalWeightFactorCellForApi(cellVal, bf.factorValues ?? [])
|
||
}
|
||
factorValuesJson[nameKey] = cellVal
|
||
}
|
||
return {
|
||
id: row.id ?? index + 1,
|
||
factorValuesJson,
|
||
calculationFormula:
|
||
formulaLabelToCode(String(row.calculationFormula ?? '')) || defaultCalculationFormulaCode(),
|
||
calculatedValue: Number(row.calculatedValue ?? 0) || 0,
|
||
}
|
||
})
|
||
}
|
||
|
||
// 价格表数据:shallowRef + 普通对象,避免上千行 deep 响应式拖垮 Handsontable
|
||
const priceListData = shallowRef<any[]>([])
|
||
const priceListSelectedIds = ref<number[]>([])
|
||
const priceListHotRef = ref<{ selectAll: () => void; clearSelection: () => void } | null>(null)
|
||
const batchFillModalVisible = ref(false)
|
||
const batchFillValue = ref<number | undefined>(undefined)
|
||
const batchFillOnlyEmpty = ref(false)
|
||
|
||
function replacePriceListData(rows: any[]) {
|
||
priceListData.value = (rows || []).map((row) => ({
|
||
...row,
|
||
checked: !!(row as any)?.checked,
|
||
}))
|
||
}
|
||
|
||
/** 换新数组引用,触发 Handsontable 浅监听刷新(行对象可复用) */
|
||
function touchPriceListData() {
|
||
priceListData.value = priceListData.value.slice()
|
||
}
|
||
|
||
function clearPriceListChecked() {
|
||
priceListSelectedIds.value = []
|
||
priceListHotRef.value?.clearSelection()
|
||
}
|
||
|
||
function onPriceListSelectionChange(ids: Array<number | string>) {
|
||
priceListSelectedIds.value = ids.map((id) => Number(id)).filter((id) => Number.isFinite(id))
|
||
}
|
||
|
||
function selectAllPriceListRows() {
|
||
priceListHotRef.value?.selectAll()
|
||
}
|
||
|
||
function clearAllPriceListRowsSelection() {
|
||
priceListHotRef.value?.clearSelection()
|
||
}
|
||
|
||
/** 因素值列可用宽度(随表格 resize 更新),用于按字符宽动态算可见标签数 */
|
||
const factorValuesCellWidth = ref(520)
|
||
|
||
function syncFactorValuesCellWidth() {
|
||
const el = billingFactorsTableWrapRef.value
|
||
if (!el) return
|
||
const cell = el.querySelector('.factor-values-cell') as HTMLElement | null
|
||
const w = cell?.clientWidth || estimateFactorValuesColumnWidth(el.clientWidth)
|
||
if (w > 0 && Math.abs(w - factorValuesCellWidth.value) > 2) {
|
||
factorValuesCellWidth.value = w
|
||
}
|
||
}
|
||
|
||
function getFactorValuePreview(row: any) {
|
||
const values = Array.isArray(row?.factorValues) ? row.factorValues : []
|
||
const items = values.map((value: string, index: number) => {
|
||
const label = formatFactorValueTagDisplay(row.factor, value) || String(value ?? '')
|
||
return { value, index, label }
|
||
})
|
||
const limit = resolveVisibleFactorValueCount(
|
||
items.map((it) => it.label),
|
||
factorValuesCellWidth.value,
|
||
)
|
||
return {
|
||
items,
|
||
visible: items.slice(0, limit),
|
||
extraCount: Math.max(0, items.length - limit),
|
||
}
|
||
}
|
||
|
||
// 价格表数据(用于列表展示)
|
||
const priceTableData = ref<any[]>([])
|
||
|
||
// 价格弹窗相关
|
||
const priceModalVisible = ref(false)
|
||
const priceDrawerActiveTab = ref('basic')
|
||
const priceListMergeSame = ref(false)
|
||
const isEditMode = ref(false)
|
||
const editingPriceRecord = ref<any>(null)
|
||
const priceEditPreparing = ref(false)
|
||
|
||
// 基础信息表单
|
||
const basicInfoForm = reactive({
|
||
costItem: '',
|
||
costUnit: '',
|
||
currency: 'CNY',
|
||
chargingCondition: ''
|
||
})
|
||
|
||
// 渠道买价列表(TablePro)
|
||
const priceListTableRef = ref<any>(null)
|
||
const priceTableContainerRef = ref<HTMLElement | null>(null)
|
||
const priceListTableHeight = ref(400)
|
||
let priceListResizeObserver: ResizeObserver | null = null
|
||
let priceListHeightRaf = 0
|
||
|
||
function applyPriceListTableHeight(h: number, force = false) {
|
||
if (h < 160) return
|
||
if (!force && Math.abs(h - priceListTableHeight.value) < 2) return
|
||
priceListTableHeight.value = h
|
||
priceListTableRef.value?.setHeight?.(h)
|
||
}
|
||
|
||
function syncPriceListTableHeight(force = false) {
|
||
const el = priceTableContainerRef.value
|
||
if (!el) return
|
||
const h = Math.floor(el.clientHeight || el.getBoundingClientRect().height)
|
||
applyPriceListTableHeight(h, force)
|
||
}
|
||
|
||
function setupPriceListResizeObserver() {
|
||
if (priceListResizeObserver) return
|
||
if (priceListHeightRaf) {
|
||
cancelAnimationFrame(priceListHeightRaf)
|
||
priceListHeightRaf = 0
|
||
}
|
||
const el = priceTableContainerRef.value
|
||
if (!el || typeof ResizeObserver === 'undefined') return
|
||
priceListResizeObserver = new ResizeObserver(() => {
|
||
if (priceListHeightRaf) cancelAnimationFrame(priceListHeightRaf)
|
||
priceListHeightRaf = requestAnimationFrame(() => {
|
||
priceListHeightRaf = 0
|
||
syncPriceListTableHeight()
|
||
})
|
||
})
|
||
priceListResizeObserver.observe(el)
|
||
nextTick(() => syncPriceListTableHeight(true))
|
||
}
|
||
|
||
const priceColumns = computed(() => [
|
||
{
|
||
field: 'serialNumber',
|
||
title: t('channel.salesProducts.table.providers.serialNo'),
|
||
width: 72,
|
||
align: 'center',
|
||
},
|
||
{
|
||
field: 'status',
|
||
title: t('channel.salesProducts.table.price.status'),
|
||
width: 100,
|
||
align: 'center',
|
||
slots: { default: 'status' },
|
||
},
|
||
{
|
||
field: 'costItem',
|
||
title: t('channel.serviceChannels.drawer.form.costItem'),
|
||
minWidth: 120,
|
||
slots: { default: 'costItemCell' },
|
||
},
|
||
{
|
||
field: 'costUnit',
|
||
title: t('channel.serviceChannels.drawer.form.costUnit'),
|
||
width: 100,
|
||
align: 'center',
|
||
slots: { default: 'costUnitCell' },
|
||
},
|
||
{
|
||
field: 'currency',
|
||
title: t('channel.serviceChannels.drawer.form.currency'),
|
||
width: 100,
|
||
align: 'center',
|
||
slots: { default: 'currencyCell' },
|
||
},
|
||
{
|
||
field: 'billingFactors',
|
||
title: t('channel.serviceChannels.drawer.section.billingFactors'),
|
||
minWidth: 200,
|
||
showOverflow: true,
|
||
slots: { default: 'billingFactorsCell' },
|
||
},
|
||
{
|
||
field: 'modifyTime',
|
||
title: '修改时间',
|
||
width: 180,
|
||
},
|
||
{
|
||
field: 'modifier',
|
||
title: '修改人',
|
||
width: 100,
|
||
},
|
||
{
|
||
field: 'action',
|
||
title: t('channel.salesProducts.table.providers.actions'),
|
||
width: 120,
|
||
align: 'center',
|
||
fixed: 'right',
|
||
slots: { default: 'operations' },
|
||
},
|
||
])
|
||
|
||
const priceListTableProps = computed(() => ({
|
||
columns: priceColumns.value,
|
||
showPager: false,
|
||
showToolbar: false,
|
||
showIndex: false,
|
||
isCellSort: false,
|
||
isColumnsDrag: false,
|
||
useForm: false,
|
||
autoHeight: false,
|
||
keepSource: true,
|
||
size: 'small',
|
||
tableConfig: { height: 400 },
|
||
toolbarConfig: { setting: false, enabled: false },
|
||
rowConfig: { isHover: true, keyField: 'id' },
|
||
}))
|
||
|
||
watch(
|
||
priceTableData,
|
||
(rows) => {
|
||
nextTick(() => {
|
||
priceListTableRef.value?.reloadData?.(rows || [])
|
||
})
|
||
},
|
||
{ deep: false },
|
||
)
|
||
|
||
watch(
|
||
() => activeTab.value,
|
||
(tab) => {
|
||
if (tab !== 'price') {
|
||
priceListResizeObserver?.disconnect()
|
||
priceListResizeObserver = null
|
||
return
|
||
}
|
||
nextTick(() => setupPriceListResizeObserver())
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
// 发布记录相关
|
||
const publishRecordTab = ref('price')
|
||
const publishRecordCounts = reactive({ price: 0, rules: 0 })
|
||
|
||
const publishRecordTabItems = computed(() => [
|
||
{
|
||
key: 'price',
|
||
label: t('channel.serviceChannels.record.tabs.price'),
|
||
badge: publishRecordCounts.price || undefined,
|
||
},
|
||
{
|
||
key: 'rules',
|
||
label: t('channel.serviceChannels.record.tabs.rules'),
|
||
badge: publishRecordCounts.rules || undefined,
|
||
},
|
||
])
|
||
const currentListData = ref<any[]>([])
|
||
const publishRecordActiveIndex = ref(0)
|
||
const currentPublishRecord = computed(() => currentListData.value[publishRecordActiveIndex.value] || {})
|
||
const hasCurrentPublishRecord = computed(() => {
|
||
const rec = currentPublishRecord.value as Record<string, any>
|
||
return rec != null && rec.id != null
|
||
})
|
||
|
||
function isPublishRecordEffectiveRow(row: Record<string, any> | undefined): boolean {
|
||
if (!row) return false
|
||
const s = row.status ?? row.Status
|
||
if (s === '生效') return true
|
||
if (typeof s === 'string' && ['effective', 'active', 'published'].includes(s.toLowerCase())) return true
|
||
if (s === 1 || s === '1') return true
|
||
return false
|
||
}
|
||
|
||
const canRollbackPublishRecord = computed(() => {
|
||
const rec = currentPublishRecord.value as Record<string, any>
|
||
if (!rec || rec.id == null) return false
|
||
const effectiveRow = currentListData.value.find((item) => isPublishRecordEffectiveRow(item))
|
||
if (effectiveRow != null && Number(effectiveRow.id) === Number(rec.id)) return false
|
||
return true
|
||
})
|
||
|
||
function publishRecordStatusMeta(status: unknown) {
|
||
if (status === '生效') {
|
||
return {
|
||
label: t('channel.serviceChannels.record.actions.publish'),
|
||
className: 'type-effective',
|
||
}
|
||
}
|
||
if (status === '回滚') {
|
||
return {
|
||
label: t('channel.serviceChannels.record.status.rollback'),
|
||
className: 'type-rollback',
|
||
}
|
||
}
|
||
return {
|
||
label: status == null || status === '' ? '--' : String(status),
|
||
className: 'type-invalid',
|
||
}
|
||
}
|
||
|
||
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, opts?: { includeDetails?: boolean }) {
|
||
const includeDetails = opts?.includeDetails !== false
|
||
const id = p?.id ?? p?.Id
|
||
const serialNumber = p?.serialNumber ?? p?.SerialNumber ?? idx + 1
|
||
const st = p?.status ?? p?.Status
|
||
const status = isEnableStatusEnabled(st)
|
||
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) => pricingFactorColumnLabel(String(f.factor ?? ''))).filter(Boolean).join('+') ||
|
||
factors.map((f: any) => f.factor).filter(Boolean).join('+') ||
|
||
'-'
|
||
const key =
|
||
id != null && id !== ''
|
||
? id
|
||
: `sc-price-${idx}-${serialNumber}-${String(p?.costItem ?? p?.CostItem ?? '')}`
|
||
const detailsRaw = p?.details ?? p?.Details
|
||
const audit = pickResolvedAudit(p)
|
||
return {
|
||
key,
|
||
id,
|
||
serialNumber,
|
||
status,
|
||
priceName: p?.priceName ?? p?.PriceName,
|
||
costItem: p?.costItem ?? p?.CostItem,
|
||
costUnit: p?.costUnit ?? p?.CostUnit,
|
||
currency: p?.currency ?? p?.Currency,
|
||
chargingCondition: (p?.chargingCondition ?? p?.ChargingCondition) || '',
|
||
billingFactors,
|
||
factors: markRaw(factors),
|
||
details: includeDetails && Array.isArray(detailsRaw) ? detailsRaw : null,
|
||
modifyTime: audit.updateTime,
|
||
modifier: audit.updateBy,
|
||
}
|
||
}
|
||
|
||
let priceTabLoadSeq = 0
|
||
|
||
async function loadPriceTabData(serviceChannelId: number | string) {
|
||
const cid = Number(serviceChannelId)
|
||
const seq = ++priceTabLoadSeq
|
||
try {
|
||
const [prices] = await Promise.all([
|
||
getServiceChannelPriceList(cid, { includeDetails: false }),
|
||
refreshChannelPriceEffectiveInfo(cid),
|
||
])
|
||
if (seq !== priceTabLoadSeq || Number((selectedChannel.value as any)?.id) !== cid) return
|
||
// 映射前确保字典就绪,计费因素列显示中文名而非 code
|
||
await ensureChannelPriceDictLoaded()
|
||
if (seq !== priceTabLoadSeq || Number((selectedChannel.value as any)?.id) !== cid) return
|
||
const list = Array.isArray(prices) ? prices : []
|
||
priceTableData.value = list.map((p: any, i: number) =>
|
||
mapServiceChannelPriceRow(p, i, { includeDetails: false }),
|
||
)
|
||
const runPublishCheck = () => {
|
||
if (seq !== priceTabLoadSeq || Number((selectedChannel.value as any)?.id) !== cid) return
|
||
void refreshPricePublishSyncStatus(cid)
|
||
}
|
||
if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
|
||
;(window as any).requestIdleCallback(runPublishCheck, { timeout: 1200 })
|
||
} else {
|
||
window.setTimeout(runPublishCheck, 300)
|
||
}
|
||
} catch (e) {
|
||
if (seq !== priceTabLoadSeq) return
|
||
console.error(e)
|
||
effectiveVersion.value = '--'
|
||
effectiveTime.value = '--'
|
||
}
|
||
}
|
||
|
||
/** @deprecated use loadPriceTabData */
|
||
async function loadServiceChannelPriceRows(serviceChannelId: number | string) {
|
||
await loadPriceTabData(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 = []
|
||
publishRecordCounts.price = 0
|
||
publishRecordCounts.rules = 0
|
||
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)
|
||
publishRecordCounts[t] = currentListData.value.length
|
||
publishRecordActiveIndex.value = 0
|
||
} catch {
|
||
currentListData.value = []
|
||
publishRecordCounts[t] = 0
|
||
}
|
||
}
|
||
|
||
// 仅跟 tab 切换:渠道切换由 selectChannel 并行加载,避免重复请求
|
||
watch(
|
||
() => activeTab.value,
|
||
async (tab) => {
|
||
const cid = (selectedChannel.value as any)?.id
|
||
if (cid == null) return
|
||
if (tab === 'price') {
|
||
await ensureChannelPriceDictLoaded()
|
||
await loadPriceTabData(cid)
|
||
}
|
||
if (tab === '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 unitPriceHeader: H = {
|
||
key: 'unitPrice',
|
||
label: '买价',
|
||
isCustomer: false,
|
||
}
|
||
|
||
return [...factorHeaders, formulaHeader, unitPriceHeader]
|
||
})
|
||
|
||
function buildPriceRowFactorKey(row: any, factorColKeys: string[]): string {
|
||
return factorColKeys.map((k) => String(row?.[k] ?? '').trim()).join('\u0001')
|
||
}
|
||
|
||
// 按计费因素同步价格表:已有组合保留单价,仅补新增行(对齐产品卖价)
|
||
const syncPriceListData = (opts?: { dropMissing?: boolean }) => {
|
||
const dropMissing = opts?.dropMissing !== false
|
||
const configuredFactors = billingFactorsData.value.filter(
|
||
(item: BillingFactor) => item.factor && !isCustomerFactor(item.factor) && item.factorValues.length > 0
|
||
)
|
||
|
||
if (configuredFactors.length === 0) {
|
||
Message.warning('请至少配置一个计费因素及因素值')
|
||
return null
|
||
}
|
||
|
||
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 = configuredFactors.map((factor) => factor.factorValues)
|
||
const combinations = generateCombinations(factorValueArrays)
|
||
const factorColKeys = configuredFactors.map((factor) => {
|
||
const colIdx = billingFactorsData.value.findIndex((item: BillingFactor) => item.id === factor.id)
|
||
return `factor_${colIdx}`
|
||
})
|
||
|
||
const oldByKey = new Map<string, any>()
|
||
for (const row of priceListData.value) {
|
||
const key = buildPriceRowFactorKey(row, factorColKeys)
|
||
if (!key || key.split('\u0001').every((p) => !p)) continue
|
||
if (!oldByKey.has(key)) oldByKey.set(key, row)
|
||
}
|
||
|
||
const newData: any[] = []
|
||
let kept = 0
|
||
let added = 0
|
||
combinations.forEach((combination, index) => {
|
||
const row: any = {
|
||
id: index + 1,
|
||
calculationFormula: defaultCalculationFormulaDisplay(),
|
||
calculatedValue: 0,
|
||
}
|
||
|
||
configuredFactors.forEach((factor: BillingFactor, factorIndex: number) => {
|
||
const colIdx = billingFactorsData.value.findIndex((item: BillingFactor) => item.id === factor.id)
|
||
const raw = combination[factorIndex]
|
||
row[`factor_${colIdx}`] = isWeightValueFactor(factor.factor)
|
||
? weightValueToIntervalCell(raw, factor.factorValues)
|
||
: raw
|
||
})
|
||
|
||
const key = buildPriceRowFactorKey(row, factorColKeys)
|
||
const oldRow = oldByKey.get(key)
|
||
if (oldRow) {
|
||
kept += 1
|
||
row.calculationFormula = oldRow.calculationFormula || row.calculationFormula
|
||
const n = Number(oldRow.calculatedValue)
|
||
row.calculatedValue = Number.isFinite(n) ? n : 0
|
||
oldByKey.delete(key)
|
||
} else {
|
||
added += 1
|
||
}
|
||
|
||
newData.push(row)
|
||
})
|
||
|
||
const removed = dropMissing ? oldByKey.size : 0
|
||
if (!dropMissing && oldByKey.size > 0) {
|
||
let nextId = newData.length + 1
|
||
for (const oldRow of oldByKey.values()) {
|
||
newData.push({ ...oldRow, id: nextId++ })
|
||
}
|
||
}
|
||
|
||
return { newData, kept, added, removed }
|
||
}
|
||
|
||
const applySyncedPriceList = (result: {
|
||
newData: any[]
|
||
kept: number
|
||
added: number
|
||
removed: number
|
||
}) => {
|
||
replacePriceListData(
|
||
result.newData.map((row) => ({
|
||
...row,
|
||
calculationFormula: formulaCodeToLabel(
|
||
String(row.calculationFormula ?? defaultCalculationFormulaCode()),
|
||
),
|
||
checked: false,
|
||
})),
|
||
)
|
||
clearPriceListChecked()
|
||
priceDrawerActiveTab.value = 'priceList'
|
||
const parts = [`保留 ${result.kept} 行`, `新增 ${result.added} 行`]
|
||
if (result.removed > 0) parts.push(`移除 ${result.removed} 行`)
|
||
Message.success(`价格表已同步(${parts.join(',')})`)
|
||
}
|
||
|
||
// 同步价格表结构(保留已有单价)
|
||
const handleGeneratePriceList = async () => {
|
||
await ensureChannelPriceDictLoaded()
|
||
await loadBillingFactorSelectOptions()
|
||
const preview = syncPriceListData({ dropMissing: true })
|
||
if (!preview) return
|
||
|
||
if (preview.removed > 0 && priceListData.value.length > 0) {
|
||
Modal.confirm({
|
||
title: '同步价格表',
|
||
content: `将新增 ${preview.added} 行、移除 ${preview.removed} 行。被移除行的已填价格会丢失,是否继续?`,
|
||
okText: '继续同步',
|
||
cancelText: '取消',
|
||
onOk: () => {
|
||
applySyncedPriceList(preview)
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
applySyncedPriceList(preview)
|
||
}
|
||
|
||
const openBatchFillModal = (onlyEmpty = false) => {
|
||
if (!priceListSelectedIds.value.length) {
|
||
Message.warning('请先选中要填价的行')
|
||
return
|
||
}
|
||
batchFillOnlyEmpty.value = onlyEmpty
|
||
batchFillValue.value = undefined
|
||
batchFillModalVisible.value = true
|
||
}
|
||
|
||
const handleConfirmBatchFill = () => {
|
||
const val = Number(batchFillValue.value)
|
||
if (!Number.isFinite(val) || val < 0) {
|
||
Message.warning('请输入有效价格(≥0)')
|
||
return
|
||
}
|
||
const selected = new Set(priceListSelectedIds.value)
|
||
let touched = 0
|
||
for (const row of priceListData.value) {
|
||
if (!selected.has(row.id)) continue
|
||
const cur = Number(row.calculatedValue)
|
||
const isEmpty = !Number.isFinite(cur) || cur === 0
|
||
if (batchFillOnlyEmpty.value && !isEmpty) continue
|
||
row.calculatedValue = val
|
||
touched += 1
|
||
}
|
||
batchFillModalVisible.value = false
|
||
touchPriceListData()
|
||
Message.success(touched ? `已填充 ${touched} 行单价` : '没有需要填充的行')
|
||
}
|
||
|
||
/** 将上一行单价复制到勾选行 */
|
||
const handleCopyPrevRowPrices = () => {
|
||
if (!priceListSelectedIds.value.length) {
|
||
Message.warning('请先选中目标行')
|
||
return
|
||
}
|
||
const selected = new Set(priceListSelectedIds.value)
|
||
let copied = 0
|
||
for (let i = 0; i < priceListData.value.length; i++) {
|
||
const row = priceListData.value[i]
|
||
if (!selected.has(row.id) || i === 0) continue
|
||
const prev = priceListData.value[i - 1]
|
||
row.calculatedValue = Number(prev.calculatedValue) || 0
|
||
copied += 1
|
||
}
|
||
touchPriceListData()
|
||
Message.success(copied ? `已将上一行单价复制到 ${copied} 行` : '没有可复制的行')
|
||
}
|
||
|
||
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 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
|
||
? toEnableStatus(!!editingPriceRecord.value.status)
|
||
: ENABLE_STATUS.enabled
|
||
return {
|
||
serviceChannelId: cid,
|
||
serialNumber,
|
||
status,
|
||
priceName,
|
||
costItem: basicInfoForm.costItem,
|
||
costUnit: basicInfoForm.costUnit,
|
||
currency: basicInfoForm.currency,
|
||
chargingCondition: basicInfoForm.chargingCondition || '',
|
||
factors,
|
||
details,
|
||
}
|
||
}
|
||
|
||
async function submitChannelPriceConfig(serviceChannelId: number | string) {
|
||
const body = buildServiceChannelSinglePriceBody(serviceChannelId)
|
||
if (isEditMode.value && editingPriceRecord.value?.id != null) {
|
||
await updateServiceChannelPrice({
|
||
id: editingPriceRecord.value.id,
|
||
...body,
|
||
})
|
||
return '价格配置已更新'
|
||
}
|
||
await createServiceChannelPrice(body)
|
||
await loadServiceChannelPriceRows(serviceChannelId)
|
||
const latest = [...priceTableData.value]
|
||
.reverse()
|
||
.find((row: any) => row.serialNumber === body.serialNumber && row.costItem === body.costItem)
|
||
if (latest?.id != null) {
|
||
isEditMode.value = true
|
||
editingPriceRecord.value = latest
|
||
}
|
||
return '价格配置已添加'
|
||
}
|
||
|
||
// 保存价格表:POST price/create 或 price/update,成功后刷新列表
|
||
const handleSavePriceList = async () => {
|
||
if (!basicInfoForm.costItem?.trim()) {
|
||
Message.warning('请先填写费用项')
|
||
return
|
||
}
|
||
if (isCostItemAlreadyUsed(basicInfoForm.costItem)) {
|
||
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 msg = await submitChannelPriceConfig(cid)
|
||
Message.success(msg)
|
||
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.key === 'unitPrice') {
|
||
rowData[header.label] = Number(row.calculatedValue ?? 0) || 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: defaultCalculationFormulaDisplay(),
|
||
}
|
||
|
||
priceTableHeaders.value.forEach((header) => {
|
||
if (header.key === 'calculationFormula') {
|
||
newRow.calculationFormula = formulaCodeToLabel(
|
||
formulaLabelToCode(String(row[header.label] || '')) ||
|
||
defaultCalculationFormulaCode(),
|
||
)
|
||
} else if (header.key === 'unitPrice') {
|
||
newRow.calculatedValue = Number(row[header.label]) || 0
|
||
} else if (!header.key.startsWith('factor_')) {
|
||
// skip
|
||
} else {
|
||
newRow[header.key] = row[header.label] || ''
|
||
}
|
||
})
|
||
|
||
return newRow
|
||
})
|
||
|
||
replacePriceListData(importedData)
|
||
coercePriceListRowsFactorCells()
|
||
clearPriceListChecked()
|
||
touchPriceListData()
|
||
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 && 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
|
||
if (tabKey === 'basic' && priceModalVisible.value) {
|
||
refreshBillingFactorsTable()
|
||
}
|
||
}
|
||
|
||
|
||
// 切换价格状态(模板中已切换 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, toEnableStatus(!!record.status))
|
||
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
|
||
editProviderOriginal.appId = editProviderForm.appId
|
||
editProviderOriginal.serviceProviderId = editProviderForm.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.remoteAreaId = ''
|
||
addChannelForm.description = ''
|
||
addChannelForm.preScript = ''
|
||
addChannelShowAdvanced.value = false
|
||
addChannelModalVisible.value = true
|
||
await Promise.all([mergeForecastTypeDictFromApi(), loadRemoteAreaSelectOptions()])
|
||
}
|
||
|
||
function applyServiceChannelDetailToForm(d: Record<string, any>, channel?: 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 ?? '')
|
||
const remoteRaw = d.remoteAreaId ?? d.RemoteAreaId
|
||
formData.remoteAreaId = remoteRaw != null && remoteRaw !== '' ? String(remoteRaw) : ''
|
||
formData.description = String(d.description ?? d.Description ?? '')
|
||
formData.preScript = String(d.preScript ?? d.PreScript ?? '')
|
||
// 列表原先不带 createBy/updateBy,详情有;合并进当前渠道供上下文栏展示
|
||
if (channel) {
|
||
const audit = pickResolvedAudit(d)
|
||
channel.createTime = audit.createTime || channel.createTime
|
||
channel.updateTime = audit.updateTime || channel.updateTime
|
||
channel.createBy = audit.createBy || channel.createBy
|
||
channel.updateBy = audit.updateBy || channel.updateBy
|
||
channel.raw = { ...(channel.raw || {}), ...d }
|
||
}
|
||
resetConfigFormBaseline()
|
||
}
|
||
|
||
// 选择渠道
|
||
const selectChannel = async (channel: any) => {
|
||
selectedChannel.value = channel
|
||
if (!channel?.id) {
|
||
configFormBaseline.value = null
|
||
return
|
||
}
|
||
void ensureReferenceOptionsLoaded()
|
||
await ensureChannelPriceDictLoaded()
|
||
|
||
// 买价 tab:详情与价格列表并行,缩短切换等待
|
||
if (activeTab.value === 'price') {
|
||
try {
|
||
const [d] = await Promise.all([
|
||
getServiceChannelDetail(channel.id),
|
||
loadPriceTabData(channel.id),
|
||
])
|
||
if ((selectedChannel.value as any)?.id !== channel.id) return
|
||
if (d == null || typeof d !== 'object') {
|
||
Message.error('渠道详情为空,请稍后重试')
|
||
formData.channelCode = String(channel.channelCode ?? '')
|
||
formData.channelName = String(channel.name ?? '')
|
||
resetConfigFormBaseline()
|
||
return
|
||
}
|
||
applyServiceChannelDetailToForm(d, channel)
|
||
} catch (e) {
|
||
console.error(e)
|
||
Message.error((e as any)?.message || '加载渠道详情失败')
|
||
formData.channelCode = String(channel.channelCode ?? '')
|
||
formData.channelName = String(channel.name ?? '')
|
||
resetConfigFormBaseline()
|
||
}
|
||
return
|
||
}
|
||
|
||
try {
|
||
const d = await getServiceChannelDetail(channel.id)
|
||
if ((selectedChannel.value as any)?.id !== channel.id) return
|
||
if (d == null || typeof d !== 'object') {
|
||
Message.error('渠道详情为空,请稍后重试')
|
||
formData.channelCode = String(channel.channelCode ?? '')
|
||
formData.channelName = String(channel.name ?? '')
|
||
resetConfigFormBaseline()
|
||
return
|
||
}
|
||
applyServiceChannelDetailToForm(d, channel)
|
||
} catch (e) {
|
||
console.error(e)
|
||
Message.error((e as any)?.message || '加载渠道详情失败')
|
||
formData.channelCode = String(channel.channelCode ?? '')
|
||
formData.channelName = String(channel.name ?? '')
|
||
resetConfigFormBaseline()
|
||
return
|
||
}
|
||
if (activeTab.value === 'records') {
|
||
try {
|
||
await loadServiceChannelPublishRecords()
|
||
} catch (e) {
|
||
console.error('加载发布记录失败:', e)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 切换渠道状态(左侧开关会先改 isActive,此处以 UI 目标状态为准)
|
||
const handleToggleChannel = async (channel: any) => {
|
||
if (!channel?.id) return
|
||
const targetEnabled = !!channel.isActive
|
||
const previousActive = !targetEnabled
|
||
try {
|
||
if (!targetEnabled) {
|
||
const confirmed = await new Promise<boolean>((resolve) => {
|
||
Modal.confirm({
|
||
title: t('channel.serviceChannels.messages.toggleDisableTitle'),
|
||
content: t('channel.serviceChannels.messages.toggleDisableConfirm', {
|
||
name: channel.name || channel.channelCode || channel.id,
|
||
}),
|
||
okText: t('channel.serviceChannels.modal.actions.ok'),
|
||
cancelText: t('channel.serviceChannels.modal.actions.cancel'),
|
||
onOk: () => resolve(true),
|
||
onCancel: () => resolve(false),
|
||
})
|
||
})
|
||
if (!confirmed) {
|
||
channel.isActive = previousActive
|
||
return
|
||
}
|
||
}
|
||
const d = await getServiceChannelDetail(channel.id)
|
||
const next = toEnableStatus(targetEnabled)
|
||
await updateServiceChannel(
|
||
buildServiceChannelUpdatePayload(d, { isActive: next })
|
||
)
|
||
channel.isActive = isEnableStatusEnabled(next)
|
||
Message.success(`渠道已${channel.isActive ? '启用' : '禁用'}`)
|
||
await loadChannelGroups()
|
||
} catch (e: any) {
|
||
channel.isActive = previousActive
|
||
Message.error(e?.message || '更新失败')
|
||
}
|
||
}
|
||
|
||
// 删除渠道 - 使用弹窗确认
|
||
|
||
/** 保存渠道配置(不发布) */
|
||
async function executeChannelConfigSave(successMessage: string) {
|
||
if (!selectedChannel.value || !(selectedChannel.value as any).id) {
|
||
Message.warning('请先选择渠道')
|
||
return false
|
||
}
|
||
const ch = selectedChannel.value as any
|
||
const groupId = Number(ch.groupId)
|
||
if (!Number.isFinite(groupId) || groupId <= 0) {
|
||
Message.error('缺少分组信息,无法保存')
|
||
return false
|
||
}
|
||
const isActive = toEnableStatus(!!ch.isActive)
|
||
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,
|
||
remoteAreaId: toOptionalPositiveId(formData.remoteAreaId) ?? null,
|
||
description: formData.description,
|
||
preScript: formData.preScript,
|
||
isActive,
|
||
}
|
||
)
|
||
const savedId = ch.id
|
||
savingConfig.value = true
|
||
try {
|
||
await updateServiceChannel(payload)
|
||
await loadChannelGroups()
|
||
const still = findChannelById(savedId)
|
||
if (still) {
|
||
await selectChannel(still)
|
||
}
|
||
markSelectedChannelConfigPublishPending()
|
||
Message.success(successMessage)
|
||
Message.warning({
|
||
content: t('channel.serviceChannels.messages.downstreamSyncTip'),
|
||
duration: 8000,
|
||
})
|
||
return true
|
||
} catch (e: any) {
|
||
Message.error(e?.message || '保存失败')
|
||
return false
|
||
} finally {
|
||
savingConfig.value = false
|
||
}
|
||
}
|
||
|
||
const handleSave = async () => {
|
||
if (!selectedChannel.value || !(selectedChannel.value as any).id) {
|
||
Message.warning('请先选择渠道')
|
||
return
|
||
}
|
||
if (!configFormDirty.value) return
|
||
await executeChannelConfigSave(t('channel.serviceChannels.messages.saveSuccess'))
|
||
}
|
||
|
||
/** 发布渠道规则快照(不改表单) */
|
||
async function publishChannelRules() {
|
||
if (!selectedChannel.value || !(selectedChannel.value as any).id) {
|
||
Message.warning('请先选择渠道')
|
||
return false
|
||
}
|
||
const savedId = (selectedChannel.value as any).id
|
||
savingConfig.value = true
|
||
try {
|
||
await publishServiceChannel({
|
||
serviceChannelId: savedId,
|
||
operatorType: 'rules',
|
||
description: '发布渠道规则',
|
||
})
|
||
markSelectedChannelRulesPublishDone()
|
||
await loadServiceChannelPublishRecords('rules')
|
||
await loadChannelGroups()
|
||
Message.success(t('channel.serviceChannels.messages.publishSuccess'))
|
||
Message.warning({
|
||
content: t('channel.serviceChannels.messages.downstreamSyncTip'),
|
||
duration: 8000,
|
||
})
|
||
return true
|
||
} catch (e: any) {
|
||
Message.error(
|
||
t('channel.serviceChannels.messages.publishFailed', {
|
||
msg: e?.message || '未知错误',
|
||
}),
|
||
)
|
||
return false
|
||
} finally {
|
||
savingConfig.value = false
|
||
}
|
||
}
|
||
|
||
const handlePublish = async () => {
|
||
if (!selectedChannel.value || !(selectedChannel.value as any).id) {
|
||
Message.warning('请先选择渠道')
|
||
return
|
||
}
|
||
if (configFormDirty.value) {
|
||
Message.warning(t('channel.serviceChannels.messages.saveFirst'))
|
||
return
|
||
}
|
||
if (!canPublish.value) return
|
||
Modal.confirm({
|
||
title: t('channel.serviceChannels.publishConfirm.title'),
|
||
content: () =>
|
||
h(
|
||
'div',
|
||
{ style: 'white-space: pre-line; line-height: 1.6;' },
|
||
t('channel.serviceChannels.publishConfirm.message'),
|
||
),
|
||
okText: t('channel.serviceChannels.publishConfirm.ok'),
|
||
cancelText: t('channel.serviceChannels.publishConfirm.cancel'),
|
||
onOk: async () => {
|
||
await publishChannelRules()
|
||
},
|
||
})
|
||
}
|
||
|
||
// 价格发布:确认后 POST /channel/service-channel/publish(预览快照与卖价对称)
|
||
const publishOperatorType = ref<'price' | 'rules'>('price')
|
||
const publishHasOnlineSnapshot = ref(false)
|
||
const publishDiffLength = ref(0)
|
||
const publishDiffRows = ref<SnapshotDiffRow[]>([])
|
||
const pricePublishPending = ref<boolean | null>(null)
|
||
const pricePublishChecking = ref(false)
|
||
|
||
const publishDiffStatus = computed(() => {
|
||
if (!publishHasOnlineSnapshot.value) return 'first'
|
||
if (publishDiffLength.value === 0) return 'synced'
|
||
if (publishDiffLength.value < 0) return 'changed'
|
||
return 'changed'
|
||
})
|
||
|
||
async function refreshPricePublishSyncStatus(serviceChannelId?: number | string) {
|
||
const cid = serviceChannelId ?? (selectedChannel.value as any)?.id
|
||
if (cid == null || !priceTableData.value.length) {
|
||
pricePublishChecking.value = false
|
||
pricePublishPending.value = null
|
||
return
|
||
}
|
||
pricePublishChecking.value = true
|
||
try {
|
||
const status = await getServiceChannelPublishSyncStatus({
|
||
serviceChannelId: cid,
|
||
operatorType: 'price',
|
||
})
|
||
pricePublishPending.value = !!(status?.pending ?? status?.Pending)
|
||
} catch {
|
||
pricePublishPending.value = null
|
||
} finally {
|
||
pricePublishChecking.value = false
|
||
}
|
||
}
|
||
|
||
function applyPublishDiffState(originalRaw: unknown, modifiedRaw: unknown, hasOnline: boolean) {
|
||
publishHasOnlineSnapshot.value = hasOnline
|
||
const { length, rows } = computeSnapshotDiff(originalRaw, modifiedRaw)
|
||
publishDiffLength.value = length
|
||
publishDiffRows.value = rows
|
||
}
|
||
|
||
async function openChannelPublishDiffModal(operatorType: 'rules' | 'price') {
|
||
const cid = (selectedChannel.value as any)?.id
|
||
if (cid == null) {
|
||
Message.warning('请先选择渠道')
|
||
return
|
||
}
|
||
publishOperatorType.value = operatorType
|
||
const preview = await getServiceChannelPublishPreview({
|
||
serviceChannelId: cid,
|
||
operatorType,
|
||
})
|
||
modifiedValue.value = formatSnapshotJson(preview)
|
||
const pub =
|
||
operatorType === 'price'
|
||
? await getServiceChannelPublishPriceList({ serviceChannelId: cid, page: 1, pageSize: 1 })
|
||
: await getServiceChannelPublishRulesList({ serviceChannelId: cid, page: 1, pageSize: 1 })
|
||
const latest = pub.items?.[0] as Record<string, any> | undefined
|
||
const online = latest?.content ?? latest?.Content
|
||
const hasOnline = online != null
|
||
if (hasOnline) {
|
||
originalValue.value = formatSnapshotJson(online)
|
||
} else {
|
||
originalValue.value = formatSnapshotJson({})
|
||
}
|
||
applyPublishDiffState(online ?? {}, preview, hasOnline)
|
||
showOnlyDiff.value = publishDiffLength.value > 0
|
||
await nextTick()
|
||
;(publishModalMethods as any).openModal?.()
|
||
}
|
||
|
||
const handleChannelPublishPrice = async () => {
|
||
const cid = (selectedChannel.value as any)?.id
|
||
if (cid == null) {
|
||
Message.warning('请先选择渠道')
|
||
return
|
||
}
|
||
if (priceTableData.value.length === 0) {
|
||
Message.warning('暂无可发布的费用项')
|
||
return
|
||
}
|
||
try {
|
||
await openChannelPublishDiffModal('price')
|
||
} catch (e: any) {
|
||
Message.error(e?.message || '加载发布预览失败')
|
||
}
|
||
}
|
||
|
||
function normalizePriceListFormulaDisplays() {
|
||
for (const row of priceListData.value) {
|
||
if (!row || typeof row !== 'object') continue
|
||
row.calculationFormula = formulaCodeToLabel(
|
||
formulaLabelToCode(String(row.calculationFormula ?? '')),
|
||
)
|
||
}
|
||
}
|
||
|
||
function applyPriceEditSourceRecord(sourceRecord: any) {
|
||
billingFactorsData.value = normalizeBillingFactorsFromApi(sourceRecord?.factors)
|
||
for (const row of billingFactorsData.value) {
|
||
row.factorValues = toCanonicalFactorValues(row.factor, row.factorValues)
|
||
}
|
||
replacePriceListData(mapDetailsToPriceListRows(sourceRecord?.details || []))
|
||
coercePriceListRowsFactorCells()
|
||
normalizePriceListFormulaDisplays()
|
||
clearPriceListChecked()
|
||
if (priceModalVisible.value && priceDrawerActiveTab.value === 'basic') {
|
||
refreshBillingFactorsTable()
|
||
}
|
||
}
|
||
|
||
// 新增价格
|
||
const handleAddPrice = async () => {
|
||
await Promise.all([
|
||
ensureChannelPriceDictLoaded(),
|
||
loadBillingFactorSelectOptions(),
|
||
])
|
||
isEditMode.value = false
|
||
editingPriceRecord.value = null
|
||
Object.assign(basicInfoForm, {
|
||
costItem: '',
|
||
costUnit: '',
|
||
currency: '',
|
||
chargingCondition: '无',
|
||
})
|
||
billingFactorsData.value = getDefaultBillingFactorsData().map((item) => ({ ...item }))
|
||
replacePriceListData([])
|
||
clearPriceListChecked()
|
||
priceDrawerActiveTab.value = 'basic'
|
||
priceModalVisible.value = true
|
||
nextTick(() => {
|
||
refreshBillingFactorsTable()
|
||
})
|
||
}
|
||
|
||
async function activateEditPriceListTab() {
|
||
await nextTick()
|
||
await new Promise<void>((resolve) => {
|
||
requestAnimationFrame(() => resolve())
|
||
})
|
||
if (!priceModalVisible.value || !isEditMode.value) return
|
||
// 编辑场景默认进入价格表(详情异步回填后也要切过去)
|
||
priceDrawerActiveTab.value = 'priceList'
|
||
}
|
||
|
||
// 编辑价格配置(先开弹窗再填数据;列表缺 details 时按需拉单条)
|
||
const handleEditPrice = async (record: any) => {
|
||
if (priceEditPreparing.value) return
|
||
priceEditPreparing.value = true
|
||
try {
|
||
isEditMode.value = true
|
||
editingPriceRecord.value = record
|
||
|
||
Object.assign(basicInfoForm, {
|
||
costItem: record.costItem || '',
|
||
costUnit: record.costUnit || '',
|
||
currency: record.currency || '',
|
||
chargingCondition: record.chargingCondition || '无',
|
||
})
|
||
|
||
billingFactorsData.value = normalizeBillingFactorsFromApi(record?.factors)
|
||
replacePriceListData(mapDetailsToPriceListRows(record?.details || []))
|
||
coercePriceListRowsFactorCells()
|
||
clearPriceListChecked()
|
||
|
||
// 列表行常无 details:先开基础信息保证即时反馈,数据就绪后再切价格表
|
||
priceDrawerActiveTab.value = 'basic'
|
||
priceModalVisible.value = true
|
||
|
||
await Promise.all([
|
||
ensureChannelPriceDictLoaded(),
|
||
loadBillingFactorSelectOptions(),
|
||
])
|
||
|
||
applyPriceEditSourceRecord(record)
|
||
normalizePriceListFormulaDisplays()
|
||
await activateEditPriceListTab()
|
||
|
||
const hasLocalDetails = Array.isArray(record?.details) && record.details.length > 0
|
||
if (hasLocalDetails || record?.id == null) return
|
||
try {
|
||
const matched = await getServiceChannelPrice(record.id)
|
||
if (!priceModalVisible.value || editingPriceRecord.value?.id !== record?.id) return
|
||
if (matched) {
|
||
applyPriceEditSourceRecord(matched)
|
||
await activateEditPriceListTab()
|
||
}
|
||
} catch (e: any) {
|
||
Message.warning(e?.message || '加载最新价格配置失败,已使用列表数据回填')
|
||
}
|
||
} finally {
|
||
priceEditPreparing.value = false
|
||
}
|
||
}
|
||
|
||
// 删除价格 - 使用弹窗确认
|
||
|
||
// 切换发布记录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 (!hasCurrentPublishRecord.value) {
|
||
Message.warning('请选择要回滚的版本')
|
||
return
|
||
}
|
||
if (!canRollbackPublishRecord.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
|
||
|
||
// 初始化拖拽排序(TablePro / vxe-table tbody)
|
||
const initSortable = () => {
|
||
if (sortableInstance) {
|
||
sortableInstance.destroy()
|
||
sortableInstance = null
|
||
}
|
||
|
||
nextTick(() => {
|
||
const root = billingFactorsTableRef.value?.$el as HTMLElement | undefined
|
||
const tbody = root?.querySelector(
|
||
'.body--wrapper>.vxe-table--body tbody',
|
||
) as HTMLElement | null
|
||
if (!tbody) return
|
||
|
||
sortableInstance = Sortable.create(tbody, {
|
||
handle: '.sort',
|
||
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
|
||
})
|
||
billingFactorsTableRef.value?.reloadData?.(billingFactorsData.value)
|
||
},
|
||
})
|
||
})
|
||
}
|
||
|
||
// 取消价格弹窗
|
||
const handleCancelPriceDrawer = () => {
|
||
priceModalVisible.value = false
|
||
isEditMode.value = false
|
||
editingPriceRecord.value = null
|
||
clearPriceListChecked()
|
||
if (sortableInstance) {
|
||
sortableInstance.destroy()
|
||
sortableInstance = null
|
||
}
|
||
}
|
||
|
||
// 价格抽屉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')
|
||
.filter(
|
||
(opt) => !isCustomerFactor(String(opt.value)) && !isCustomerFactor(String(opt.label))
|
||
)
|
||
.map((opt) => ({
|
||
value: opt.value,
|
||
label: pricingFactorColumnLabel(opt.value),
|
||
}))
|
||
)
|
||
const costItemOptions = computed(() => mapDictToSelectOptions('fee_item'))
|
||
|
||
/** 同一渠道下已占用的费用项(编辑当前条时排除自身) */
|
||
function isCostItemAlreadyUsed(costItem: string) {
|
||
const code = String(costItem ?? '').trim().toLowerCase()
|
||
if (!code) return false
|
||
const editingId = isEditMode.value ? editingPriceRecord.value?.id : null
|
||
return priceTableData.value.some((row: any) => {
|
||
if (editingId != null && row?.id === editingId) return false
|
||
return String(row?.costItem ?? '').trim().toLowerCase() === code
|
||
})
|
||
}
|
||
const costUnitOptions = computed(() => mapDictToSelectOptions('cost_unit'))
|
||
const currencyOptions = computed(() => mapDictToSelectOptions('currency'))
|
||
const calculationFormulaOptions = computed(() => mapDictToSelectOptions('calculation_formula'))
|
||
|
||
/** 计费因素去重键:同义词(如 zone/价格分区)视为同一因素 */
|
||
function billingFactorDedupKey(factor: string): string {
|
||
const s = String(factor ?? '').trim()
|
||
if (!s) return ''
|
||
if (isPartitionValueFactor(s)) return 'zone'
|
||
if (isRemotePartitionValueFactor(s)) return 'remoteZone'
|
||
if (isWarehouseValueFactor(s)) return 'warehouse'
|
||
if (isWeightValueFactor(s)) return 'weight'
|
||
if (isCustomerGroupValueFactor(s) || isCustomerFactor(s)) return 'customer'
|
||
if (isCargoTypeValueFactor(s)) return 'cargo'
|
||
return s
|
||
}
|
||
|
||
function isBillingFactorAlreadyUsed(factor: string, currentRowId?: number): boolean {
|
||
const key = billingFactorDedupKey(factor)
|
||
if (!key) return false
|
||
return billingFactorsData.value.some(
|
||
(r) =>
|
||
r.id !== currentRowId
|
||
&& billingFactorDedupKey(String(r.factor ?? '')) === key,
|
||
)
|
||
}
|
||
|
||
function isBillingFactorOptionDisabled(
|
||
optionValue: string,
|
||
currentRow: { id: number; factor?: string },
|
||
) {
|
||
const key = billingFactorDedupKey(optionValue)
|
||
if (!key) return false
|
||
if (billingFactorDedupKey(String(currentRow?.factor ?? '')) === key) return false
|
||
return isBillingFactorAlreadyUsed(optionValue, currentRow?.id)
|
||
}
|
||
|
||
function handleBillingFactorChange(
|
||
row: { id: number; factor?: string; factorValues?: string[] },
|
||
value: string,
|
||
) {
|
||
if (!value) return
|
||
if (isBillingFactorAlreadyUsed(value, row.id)) {
|
||
Message.warning('该因素已存在,不能重复添加')
|
||
row.factor = ''
|
||
row.factorValues = []
|
||
}
|
||
}
|
||
|
||
const billingFactorsTableRef = ref<any>(null)
|
||
const billingFactorsTableWrapRef = ref<HTMLElement | null>(null)
|
||
const billingFactorsTableHeight = ref(220)
|
||
let billingFactorsResizeObserver: ResizeObserver | null = null
|
||
let billingFactorsHeightRaf = 0
|
||
|
||
const billingFactorColumns = computed(() => [
|
||
{
|
||
field: 'sort',
|
||
title: t('channel.serviceChannels.drawer.table.billingFactors.serialNo'),
|
||
className: 'sort',
|
||
width: 52,
|
||
align: 'center',
|
||
slots: { default: 'sort' },
|
||
},
|
||
{
|
||
field: 'factor',
|
||
title: t('channel.serviceChannels.drawer.table.billingFactors.factor'),
|
||
width: 140,
|
||
slots: { default: 'factor' },
|
||
},
|
||
{
|
||
field: 'factorValues',
|
||
title: t('channel.serviceChannels.drawer.table.billingFactors.factorValues'),
|
||
minWidth: 420,
|
||
showOverflow: false,
|
||
slots: { default: 'factorValues' },
|
||
},
|
||
{
|
||
field: 'action',
|
||
title: t('channel.serviceChannels.drawer.table.billingFactors.actions'),
|
||
width: 64,
|
||
align: 'center',
|
||
showOverflow: false,
|
||
slots: { default: 'action' },
|
||
},
|
||
])
|
||
|
||
function applyBillingFactorsTableHeight(h: number) {
|
||
if (h < 120) return
|
||
billingFactorsTableHeight.value = h
|
||
const table = billingFactorsTableRef.value
|
||
table?.setHeight?.(h)
|
||
nextTick(() => {
|
||
const grid = table?.xGrid
|
||
if (grid && typeof grid.recalculate === 'function') {
|
||
grid.recalculate(true)
|
||
}
|
||
})
|
||
}
|
||
|
||
function syncBillingFactorsTableHeight(force = false) {
|
||
const el = billingFactorsTableWrapRef.value
|
||
if (!el) return
|
||
const h = Math.floor(el.clientHeight || el.getBoundingClientRect().height)
|
||
if (h < 120) return
|
||
if (!force && Math.abs(h - billingFactorsTableHeight.value) < 2) return
|
||
applyBillingFactorsTableHeight(h)
|
||
}
|
||
|
||
function setupBillingFactorsResizeObserver() {
|
||
billingFactorsResizeObserver?.disconnect()
|
||
billingFactorsResizeObserver = null
|
||
if (billingFactorsHeightRaf) {
|
||
cancelAnimationFrame(billingFactorsHeightRaf)
|
||
billingFactorsHeightRaf = 0
|
||
}
|
||
const el = billingFactorsTableWrapRef.value
|
||
if (!el || typeof ResizeObserver === 'undefined') return
|
||
billingFactorsResizeObserver = new ResizeObserver(() => {
|
||
if (billingFactorsHeightRaf) cancelAnimationFrame(billingFactorsHeightRaf)
|
||
billingFactorsHeightRaf = requestAnimationFrame(() => {
|
||
billingFactorsHeightRaf = 0
|
||
syncBillingFactorsTableHeight()
|
||
syncFactorValuesCellWidth()
|
||
})
|
||
})
|
||
billingFactorsResizeObserver.observe(el)
|
||
nextTick(() => {
|
||
syncBillingFactorsTableHeight(true)
|
||
syncFactorValuesCellWidth()
|
||
setTimeout(() => {
|
||
syncBillingFactorsTableHeight(true)
|
||
syncFactorValuesCellWidth()
|
||
}, 80)
|
||
})
|
||
}
|
||
|
||
const billingFactorsTableProps = computed(() => ({
|
||
columns: billingFactorColumns.value,
|
||
isCellSort: false,
|
||
showToolbar: false,
|
||
isColumnsDrag: false,
|
||
showPager: false,
|
||
showIndex: false,
|
||
data: billingFactorsData.value,
|
||
minHeight: 120,
|
||
keepSource: true,
|
||
size: 'small',
|
||
useForm: false,
|
||
autoHeight: false,
|
||
height: billingFactorsTableHeight.value,
|
||
tableConfig: {
|
||
height: billingFactorsTableHeight.value,
|
||
},
|
||
toolbarConfig: { setting: false, enabled: false },
|
||
rowConfig: { isHover: true, keyField: 'id', height: 52 },
|
||
scrollY: { enabled: true, gt: 0 },
|
||
}))
|
||
|
||
function refreshBillingFactorsTable() {
|
||
nextTick(() => {
|
||
syncBillingFactorsTableHeight()
|
||
billingFactorsTableRef.value?.reloadData?.(billingFactorsData.value)
|
||
initSortable()
|
||
setupBillingFactorsResizeObserver()
|
||
})
|
||
}
|
||
|
||
// 新增计费因素行
|
||
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') {
|
||
refreshBillingFactorsTable()
|
||
}
|
||
}
|
||
|
||
// 删除计费因素行
|
||
const handleDeleteBillingFactor = (row: { id: number } | number) => {
|
||
const index =
|
||
typeof row === 'number'
|
||
? row
|
||
: billingFactorsData.value.findIndex((item) => item.id === row.id)
|
||
if (index < 0) return
|
||
billingFactorsData.value.splice(index, 1)
|
||
billingFactorsData.value.forEach((item, idx) => {
|
||
item.serialNumber = idx + 1
|
||
})
|
||
if (priceModalVisible.value && priceDrawerActiveTab.value === 'basic') {
|
||
refreshBillingFactorsTable()
|
||
}
|
||
}
|
||
|
||
const priceListHotColumns = computed<PriceListHotColumn[]>(() => {
|
||
const formulaSource = Array.from(
|
||
new Set(
|
||
calculationFormulaOptions.value
|
||
.map((o) => String(o.label || o.value || '').trim())
|
||
.filter(Boolean),
|
||
),
|
||
)
|
||
if (!formulaSource.length) {
|
||
const fallback = defaultCalculationFormulaDisplay()
|
||
if (fallback) formulaSource.push(fallback)
|
||
}
|
||
return priceTableHeaders.value.map((header) => {
|
||
if (header.key.startsWith('factor_')) {
|
||
const factorIndex = header.factorIndex
|
||
return {
|
||
key: header.key,
|
||
label: header.label,
|
||
width: 120,
|
||
readOnly: true,
|
||
type: 'text' as const,
|
||
formatter: (row: any) =>
|
||
formatPriceTableFactorCell(factorIndex, String(row?.[header.key] ?? '')),
|
||
}
|
||
}
|
||
if (header.key === 'calculationFormula') {
|
||
return {
|
||
key: header.key,
|
||
label: header.label,
|
||
width: 140,
|
||
type: 'dropdown' as const,
|
||
source: formulaSource,
|
||
}
|
||
}
|
||
// unitPrice 列:数据字段为 calculatedValue
|
||
return {
|
||
key: 'calculatedValue',
|
||
label: header.label,
|
||
width: 110,
|
||
type: 'numeric' as const,
|
||
}
|
||
})
|
||
})
|
||
|
||
/** 仅列结构变化时重挂载 */
|
||
const priceListHotTableKey = computed(
|
||
() => `price-hot-${priceListHotColumns.value.map((c) => c.key).join('|')}`,
|
||
)
|
||
|
||
onBeforeUnmount(() => {
|
||
priceListResizeObserver?.disconnect()
|
||
priceListResizeObserver = null
|
||
billingFactorsResizeObserver?.disconnect()
|
||
billingFactorsResizeObserver = null
|
||
if (sortableInstance) {
|
||
sortableInstance.destroy()
|
||
sortableInstance = null
|
||
}
|
||
})
|
||
|
||
// 添加因素值弹窗(与销售产品卖价一致:多选字典 / 分区 / 仓库等)
|
||
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))
|
||
})
|
||
|
||
function handleAddFactorValueSelectedChange(vals: string[]) {
|
||
const factor = currentFactorRow.value?.factor
|
||
const next = vals || []
|
||
addFactorValueSelected.value =
|
||
factor && isWeightValueFactor(factor) ? sortFactorValuesBySize(next) : next
|
||
}
|
||
|
||
const handleAddFactorValue = async (row: any) => {
|
||
if (!row?.factor) {
|
||
Message.warning('请先选择因素')
|
||
return
|
||
}
|
||
await ensureChannelPriceDictLoaded()
|
||
await loadBillingFactorSelectOptions()
|
||
currentFactorRow.value = row
|
||
const factor = row.factor as string
|
||
const opts = factorValuesOptionsByFactor.value[factor] || []
|
||
const allowed = new Set(opts.map((o) => String(o.value)))
|
||
const canonical = toCanonicalFactorValues(factor, row.factorValues || [])
|
||
const selected = canonical
|
||
.map((v) => String(v).trim())
|
||
.filter((v) => v && allowed.has(v))
|
||
addFactorValueSelected.value = isWeightValueFactor(factor)
|
||
? sortFactorValuesBySize(selected)
|
||
: selected
|
||
addFactorValueModalVisible.value = true
|
||
}
|
||
|
||
function resetAddFactorValueModal() {
|
||
addFactorValueModalVisible.value = false
|
||
addFactorValueSelected.value = []
|
||
currentFactorRow.value = null
|
||
}
|
||
|
||
/** 将弹窗内勾选的因素值写入当前行;forbidEmpty 为 true 且无勾选时提示并返回 false */
|
||
function commitAddFactorSelections(opts: { forbidEmpty: boolean }): boolean {
|
||
const selectedValues = addFactorValueSelected.value || []
|
||
if (selectedValues.length === 0) {
|
||
if (opts.forbidEmpty) {
|
||
Message.warning('请至少选择一个因素值')
|
||
}
|
||
return false
|
||
}
|
||
if (!currentFactorRow.value) return false
|
||
|
||
const existing = new Set(currentFactorRow.value.factorValues || [])
|
||
const toAdd = selectedValues.filter((value) => !existing.has(value))
|
||
if (toAdd.length === 0) {
|
||
Message.warning('所选因素值均已存在')
|
||
return false
|
||
}
|
||
|
||
currentFactorRow.value.factorValues.push(...toAdd)
|
||
if (isWeightValueFactor(currentFactorRow.value.factor)) {
|
||
currentFactorRow.value.factorValues = sortFactorValuesBySize(
|
||
currentFactorRow.value.factorValues,
|
||
)
|
||
}
|
||
Message.success('添加成功')
|
||
return true
|
||
}
|
||
|
||
const handleConfirmAddFactorValue = () => {
|
||
if (!commitAddFactorSelections({ forbidEmpty: true })) return
|
||
resetAddFactorValueModal()
|
||
}
|
||
|
||
/** 点击遮罩关闭时:按当前勾选提交(无勾选则仅关闭);点取消/X/ESC 仍丢弃 */
|
||
function handleAddFactorValueModalCancel(e?: Event) {
|
||
const el = e && 'target' in e ? ((e as MouseEvent).target as HTMLElement | null) : null
|
||
const fromMask = !!el?.classList?.contains('arco-modal-wrapper')
|
||
if (fromMask) {
|
||
commitAddFactorSelections({ forbidEmpty: false })
|
||
resetAddFactorValueModal()
|
||
return
|
||
}
|
||
resetAddFactorValueModal()
|
||
}
|
||
|
||
const handleSelectAllFactorValues = () => {
|
||
if (isAllFactorValuesSelected.value) {
|
||
addFactorValueSelected.value = []
|
||
return
|
||
}
|
||
const enabled = currentFactorValueOptions.value.map((option) => option.value)
|
||
const factor = currentFactorRow.value?.factor
|
||
addFactorValueSelected.value =
|
||
factor && isWeightValueFactor(factor) ? sortFactorValuesBySize(enabled) : enabled
|
||
}
|
||
|
||
// 删除因素值
|
||
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 Promise.reject()
|
||
}
|
||
const downstreamChanged = isEditProviderDownstreamChanged()
|
||
if (downstreamChanged) {
|
||
const confirmed = await confirmEditProviderDownstreamChange()
|
||
if (!confirmed) {
|
||
return Promise.reject()
|
||
}
|
||
}
|
||
try {
|
||
const editedGroupId = Number(editingProviderGroupId.value)
|
||
await updateServiceChannelGroup({
|
||
id: editedGroupId,
|
||
name: editProviderForm.providerName,
|
||
appId: Number(editProviderForm.appId),
|
||
apiServiceProviderId: Number(editProviderForm.serviceProviderId),
|
||
})
|
||
const group = channelGroups.value.find(g => Number((g as any).id) === editedGroupId)
|
||
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()
|
||
// 服务商分组一保存,该分组下渠道即可「发布」以刷新规则快照
|
||
markGroupRulesPublishPending(editedGroupId)
|
||
if (activeTab.value !== 'config') {
|
||
activeTab.value = 'config'
|
||
}
|
||
if (downstreamChanged) {
|
||
Message.warning({
|
||
content: t('channel.serviceChannels.editProvider.downstreamSavedTip'),
|
||
duration: 8000,
|
||
})
|
||
} else {
|
||
Message.info({
|
||
content: t('channel.serviceChannels.context.rulesPublishPending'),
|
||
duration: 5000,
|
||
})
|
||
}
|
||
} catch (error) {
|
||
console.error('编辑服务商失败:', error)
|
||
Message.error('编辑失败')
|
||
return Promise.reject(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
|
||
addChannelShowAdvanced.value = false
|
||
addChannelTargetGroupId.value = null
|
||
addChannelForm.channelCode = ''
|
||
addChannelForm.channelName = ''
|
||
addChannelForm.minTime = ''
|
||
addChannelForm.maxTime = ''
|
||
addChannelForm.forecastType = ''
|
||
addChannelForm.forecastCode = ''
|
||
addChannelForm.remoteAreaId = ''
|
||
addChannelForm.description = ''
|
||
addChannelForm.preScript = ''
|
||
}
|
||
|
||
const handleConfirmAddChannel = async () => {
|
||
addChannelForm.channelCode = normalizeBizCode(addChannelForm.channelCode)
|
||
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 {
|
||
const remoteAreaId = toOptionalPositiveId(addChannelForm.remoteAreaId)
|
||
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: ENABLE_STATUS.enabled,
|
||
...(remoteAreaId != null ? { remoteAreaId } : {}),
|
||
})
|
||
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(() => formatSnapshotJson(originalValue.value || {}))
|
||
|
||
const getModifiedModel = computed(() => formatSnapshotJson(modifiedValue.value || {}))
|
||
|
||
// 差异相关
|
||
const showOnlyDiff = ref(true)
|
||
|
||
// 日期禁用
|
||
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 (publishDiffStatus.value === 'synced') {
|
||
Message.info(t('channel.serviceChannels.publish.status.synced'))
|
||
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 = publishOperatorType.value
|
||
try {
|
||
await publishServiceChannel({
|
||
serviceChannelId: cid,
|
||
operatorType,
|
||
description: publishDescription.value || undefined,
|
||
})
|
||
Message.success('发布成功')
|
||
if (operatorType === 'rules') {
|
||
markSelectedChannelRulesPublishDone()
|
||
Message.warning({
|
||
content: t('channel.serviceChannels.publish.downstreamSyncTip'),
|
||
duration: 8000,
|
||
})
|
||
}
|
||
publishConfirmVisible.value = false
|
||
publishDescription.value = ''
|
||
publishConfirmDate.value = null
|
||
await loadServiceChannelPublishRecords()
|
||
await loadServiceChannelPriceRows(cid)
|
||
await refreshPricePublishSyncStatus(cid)
|
||
await loadChannelGroups()
|
||
;(publishModalMethods as any).closeModal?.()
|
||
} catch (error: any) {
|
||
console.error('发布失败:', error)
|
||
Message.error(error?.message || '发布失败')
|
||
}
|
||
}
|
||
</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;
|
||
|
||
}
|
||
|
||
/* 第二行:修改时间 + 开关(卡片高度由 CustomLeftMenu 固定,内容垂直居中) */
|
||
.channel-second-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
width: 100%;
|
||
height: 16px;
|
||
padding-left: 24px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.modify-time {
|
||
flex: 1;
|
||
min-width: 0;
|
||
font-size: 10px;
|
||
line-height: 16px;
|
||
color: var(--color-text-3);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.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 .modify-time) {
|
||
font-size: 10px;
|
||
color: #86909c !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: 10px 24px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.tabs-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0;
|
||
}
|
||
|
||
.channel-context-bar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 8px 12px;
|
||
padding: 10px 24px;
|
||
background: var(--color-fill-1);
|
||
border-bottom: 1px solid #e8e8e8;
|
||
font-size: 13px;
|
||
color: #595959;
|
||
|
||
.context-label {
|
||
color: var(--color-text-3);
|
||
}
|
||
|
||
.context-value {
|
||
color: var(--color-text-1);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.context-code {
|
||
margin-left: 4px;
|
||
color: var(--color-text-3);
|
||
}
|
||
|
||
.context-divider {
|
||
color: #d9d9d9;
|
||
}
|
||
|
||
.context-dirty-tag {
|
||
margin-left: auto;
|
||
}
|
||
}
|
||
|
||
.empty-state-panel {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 240px;
|
||
}
|
||
|
||
.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: var(--color-text-1);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.label-with-tip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.tip-icon {
|
||
color: var(--color-text-3);
|
||
cursor: help;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.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: var(--color-text-3);
|
||
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: var(--color-text-1);
|
||
|
||
.price-sync-tag {
|
||
margin-left: 12px;
|
||
vertical-align: middle;
|
||
}
|
||
}
|
||
|
||
.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;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
|
||
.price-list-table-pro {
|
||
width: 100%;
|
||
min-height: 0;
|
||
|
||
:deep(.table-pro),
|
||
:deep(.table-pro-surface) {
|
||
height: 100%;
|
||
}
|
||
|
||
:deep(.vxe-toolbar) {
|
||
display: none !important;
|
||
height: 0 !important;
|
||
min-height: 0 !important;
|
||
padding: 0 !important;
|
||
overflow: hidden !important;
|
||
}
|
||
|
||
:deep(.vxe-grid) {
|
||
padding-left: 0 !important;
|
||
padding-right: 0 !important;
|
||
}
|
||
|
||
.action-btn {
|
||
color: var(--app-primary-color);
|
||
|
||
&:hover {
|
||
color: var(--app-primary-color);
|
||
background: color-mix(in srgb, var(--app-primary-color) 8%, #fff);
|
||
}
|
||
|
||
&.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: var(--color-text-3);
|
||
|
||
&: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: 12px;
|
||
height: 100%;
|
||
padding: 0;
|
||
overflow: hidden;
|
||
|
||
.form-section-drawer {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.form-section-title {
|
||
margin: 0 0 12px;
|
||
padding: 0 0 8px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--color-text-2);
|
||
border-bottom: 1px solid var(--color-border-2);
|
||
}
|
||
|
||
.form-section-title--with-extra {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin-bottom: 12px;
|
||
padding-bottom: 8px;
|
||
border-bottom: 1px solid var(--color-border-2);
|
||
}
|
||
|
||
.label-with-tip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.tip-icon {
|
||
color: var(--color-text-3);
|
||
cursor: help;
|
||
font-size: 14px;
|
||
}
|
||
}
|
||
|
||
.form-section-drawer {
|
||
.price-form {
|
||
:deep(.arco-form-item) {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
:deep(.arco-form-item-label) {
|
||
font-weight: 500;
|
||
}
|
||
|
||
:deep(.charging-condition-form-item) {
|
||
margin-bottom: 12px;
|
||
|
||
.arco-form-item-label-col {
|
||
display: none;
|
||
}
|
||
|
||
.arco-form-item-wrapper-col {
|
||
flex: 1 1 100%;
|
||
width: 100%;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.billing-factors-section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
flex: 1;
|
||
|
||
.billing-factors-header {
|
||
flex-shrink: 0;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.billing-factors-table-wrap {
|
||
flex: 1 1 auto;
|
||
min-height: 0;
|
||
width: 100%;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.billing-factors-table-pro {
|
||
width: 100%;
|
||
min-height: 0;
|
||
|
||
:deep(.vxe-toolbar) {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
.billing-factor-sort-cell {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--color-text-3);
|
||
cursor: move;
|
||
font-size: 18px;
|
||
|
||
&:hover {
|
||
color: var(--app-primary-color);
|
||
}
|
||
}
|
||
|
||
.factor-values-cell {
|
||
display: flex;
|
||
flex-wrap: nowrap;
|
||
align-items: center;
|
||
gap: 4px;
|
||
max-width: 100%;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
|
||
.factor-value-tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
flex: 0 1 auto;
|
||
max-width: 148px;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
vertical-align: middle;
|
||
|
||
.factor-value-tag__text {
|
||
display: block;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
:deep(.arco-tag-close-btn) {
|
||
flex-shrink: 0;
|
||
margin-left: 4px;
|
||
}
|
||
}
|
||
|
||
.factor-value-more {
|
||
flex-shrink: 0;
|
||
cursor: pointer;
|
||
color: var(--app-primary-color);
|
||
background: color-mix(in srgb, var(--app-primary-color) 10%, #fff);
|
||
border-color: color-mix(in srgb, var(--app-primary-color) 28%, #fff);
|
||
|
||
&:hover {
|
||
background: color-mix(in srgb, var(--app-primary-color) 16%, #fff);
|
||
}
|
||
}
|
||
|
||
.factor-values-empty {
|
||
flex-shrink: 0;
|
||
color: var(--color-text-4);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.add-value-btn {
|
||
flex-shrink: 0;
|
||
margin-left: auto;
|
||
color: var(--app-primary-color);
|
||
|
||
&:hover {
|
||
color: var(--app-primary-color);
|
||
background: color-mix(in srgb, var(--app-primary-color) 8%, #fff);
|
||
}
|
||
}
|
||
}
|
||
|
||
.row-action-btn {
|
||
color: var(--color-text-3);
|
||
|
||
&:hover {
|
||
color: rgb(var(--danger-6));
|
||
}
|
||
}
|
||
}
|
||
|
||
.factor-value-tag--popover {
|
||
max-width: 100%;
|
||
|
||
.factor-value-tag__text {
|
||
white-space: normal;
|
||
overflow: visible;
|
||
text-overflow: unset;
|
||
}
|
||
}
|
||
|
||
.factor-values-popover {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
max-width: 400px;
|
||
|
||
&__title {
|
||
color: var(--color-text-3);
|
||
font-size: 12px;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
&__list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
max-height: 220px;
|
||
overflow: auto;
|
||
}
|
||
}
|
||
|
||
.price-modal-pane {
|
||
box-sizing: border-box;
|
||
height: 598px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.price-list-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
box-sizing: border-box;
|
||
height: 100%;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
padding: 0;
|
||
|
||
.price-list-header {
|
||
display: flex;
|
||
flex-wrap: nowrap;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
height: 40px;
|
||
flex-shrink: 0;
|
||
margin-bottom: 8px;
|
||
padding: 0 2px;
|
||
}
|
||
|
||
.price-list-header__meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
flex: 1 1 auto;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.price-list-badge {
|
||
flex-shrink: 0;
|
||
height: 24px;
|
||
padding: 0 10px;
|
||
border-radius: 4px;
|
||
background: color-mix(in srgb, var(--app-primary-color) 12%, #fff);
|
||
color: var(--app-primary-color);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
line-height: 24px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.price-list-hint {
|
||
min-width: 0;
|
||
color: var(--color-text-3);
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
line-height: 1.2;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.price-list-count {
|
||
flex-shrink: 0;
|
||
color: var(--color-text-3);
|
||
font-size: 12px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.price-list-merge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
color: var(--color-text-2);
|
||
font-size: 12px;
|
||
white-space: nowrap;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
|
||
.price-list-actions {
|
||
display: flex;
|
||
flex-wrap: nowrap;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
gap: 6px;
|
||
flex: 0 0 auto;
|
||
|
||
&__sep {
|
||
width: 1px;
|
||
height: 16px;
|
||
margin: 0 2px;
|
||
background: var(--color-border-2);
|
||
}
|
||
|
||
:deep(.arco-btn) {
|
||
height: 28px;
|
||
padding: 0 10px;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
}
|
||
}
|
||
|
||
.price-list-table-container {
|
||
box-sizing: border-box;
|
||
flex: 1 1 auto;
|
||
width: 100%;
|
||
height: 500px;
|
||
min-width: 0;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
border: 1px solid var(--color-border-2);
|
||
border-radius: 6px;
|
||
background: #fff;
|
||
}
|
||
}
|
||
|
||
.price-modal-content {
|
||
padding: 0;
|
||
overflow: visible;
|
||
}
|
||
|
||
.price-modal-footer {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
// 发布记录样式
|
||
.publish-records-section {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 16px 24px 24px;
|
||
overflow: hidden;
|
||
gap: 12px;
|
||
}
|
||
|
||
.publish-records-toolbar {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
min-height: 32px;
|
||
}
|
||
|
||
.publish-records-hint {
|
||
font-size: 12px;
|
||
line-height: 18px;
|
||
color: var(--color-text-3);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.publish-records-container {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
gap: 0;
|
||
border: 1px solid var(--color-border-2, #e8e8e8);
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
background: var(--app-card-bg, #fff);
|
||
}
|
||
|
||
.publish-records-left {
|
||
width: 320px;
|
||
flex-shrink: 0;
|
||
border-right: 1px solid var(--color-border-2, #e8e8e8);
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 12px;
|
||
overflow: hidden;
|
||
background-color: var(--app-card-bg, #fff);
|
||
|
||
.version-list {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
padding-right: 2px;
|
||
}
|
||
|
||
.version-item {
|
||
--vr-hover: rgba(15, 16, 17, 0.04);
|
||
--vr-selected: var(--app-primary-color-light);
|
||
--vr-selected-text: var(--app-primary-color);
|
||
--vr-selected-border: color-mix(in srgb, var(--app-primary-color) 32%, transparent);
|
||
--vr-muted: var(--color-text-3);
|
||
|
||
appearance: none;
|
||
-webkit-appearance: none;
|
||
width: 100%;
|
||
border: 1px solid rgba(15, 16, 17, 0.07);
|
||
text-align: left;
|
||
padding: 10px 12px;
|
||
background-color: var(--color-fill-1);
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
box-shadow: none;
|
||
transition:
|
||
background-color 0.2s ease,
|
||
border-color 0.2s ease;
|
||
|
||
&:hover {
|
||
background-color: var(--vr-hover);
|
||
border-color: rgba(15, 16, 17, 0.1);
|
||
}
|
||
|
||
&.active {
|
||
background: var(--vr-selected);
|
||
border-color: var(--vr-selected-border);
|
||
box-shadow: none;
|
||
|
||
.version-item-number {
|
||
color: var(--vr-selected-text);
|
||
font-weight: 560;
|
||
}
|
||
|
||
.version-item-footer,
|
||
.version-item-user,
|
||
.version-item-time {
|
||
color: var(--vr-muted);
|
||
}
|
||
}
|
||
|
||
.version-item-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.version-item-number {
|
||
min-width: 0;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--color-text-1);
|
||
line-height: 18px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.version-item-footer {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 12px;
|
||
color: var(--color-text-3);
|
||
line-height: 18px;
|
||
}
|
||
|
||
.version-item-user,
|
||
.version-item-time {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.version-item-time {
|
||
flex-shrink: 0;
|
||
max-width: 58%;
|
||
}
|
||
}
|
||
}
|
||
|
||
.version-item-badge {
|
||
flex-shrink: 0;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
height: 20px;
|
||
padding: 0 8px;
|
||
border-radius: 10px;
|
||
font-size: 12px;
|
||
line-height: 20px;
|
||
font-weight: 500;
|
||
|
||
&.type-effective {
|
||
color: #508a24;
|
||
background: color-mix(in srgb, #508a24 12%, #fff);
|
||
}
|
||
|
||
&.type-rollback {
|
||
color: #d48806;
|
||
background: color-mix(in srgb, #faad14 16%, #fff);
|
||
}
|
||
|
||
&.type-invalid {
|
||
color: #cf1322;
|
||
background: color-mix(in srgb, #f53f3f 12%, #fff);
|
||
}
|
||
}
|
||
|
||
.publish-records-right {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 16px 20px;
|
||
background-color: var(--app-card-bg, #fff);
|
||
overflow: hidden;
|
||
|
||
.right-header {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 12px;
|
||
padding-bottom: 12px;
|
||
border-bottom: 1px solid var(--color-border-2, #e8e8e8);
|
||
}
|
||
|
||
.rollback-btn {
|
||
color: var(--app-primary-color);
|
||
|
||
&:hover:not(:disabled) {
|
||
color: var(--app-primary-color);
|
||
border-color: color-mix(in srgb, var(--app-primary-color) 45%, #fff);
|
||
background: color-mix(in srgb, var(--app-primary-color) 8%, #fff);
|
||
}
|
||
}
|
||
|
||
.version-notes {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
color: var(--color-text-2);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.notes-icon {
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
vertical-align: middle;
|
||
|
||
&--remark {
|
||
color: #faad14;
|
||
}
|
||
}
|
||
|
||
.code-content {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: auto;
|
||
border: 1px solid var(--color-border-2, #e8e8e8);
|
||
border-radius: 6px;
|
||
background: var(--color-fill-1, #f7f8fa);
|
||
}
|
||
|
||
.code-pre {
|
||
margin: 0;
|
||
padding: 12px 14px;
|
||
font-family: Menlo, Consolas, 'Courier New', monospace;
|
||
font-size: 12px;
|
||
line-height: 1.55;
|
||
color: var(--color-text-1);
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
}
|
||
|
||
.publish-records-empty {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 24px 12px;
|
||
}
|
||
|
||
.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;
|
||
color: var(--color-text-3);
|
||
font-size: 14px;
|
||
|
||
p {
|
||
margin: 0;
|
||
}
|
||
}
|
||
|
||
// 发布弹窗样式
|
||
.release-content.slim {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.mb-15px {
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.delete-confirm-modal {
|
||
padding: 8px 0;
|
||
|
||
.delete-confirm-message {
|
||
font-size: 14px;
|
||
color: var(--color-text-1);
|
||
line-height: 1.6;
|
||
|
||
p {
|
||
margin: 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
.billing-factors-table {
|
||
border: 1px solid var(--color-border-2);
|
||
border-radius: 6px;
|
||
overflow-x: auto;
|
||
overflow-y: auto;
|
||
max-height: 360px;
|
||
|
||
.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;
|
||
}
|
||
|
||
.drawer-tab-item {
|
||
padding: 8px 24px;
|
||
}
|
||
|
||
.add-provider-modal,
|
||
.edit-provider-modal,
|
||
.add-channel-modal {
|
||
.form-section-title {
|
||
margin: 0 0 12px;
|
||
padding: 0 0 8px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--color-text-2);
|
||
border-bottom: 1px solid var(--color-border-2);
|
||
|
||
&:not(:first-child) {
|
||
margin-top: 8px;
|
||
}
|
||
}
|
||
|
||
.label-with-tip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.tip-icon {
|
||
color: var(--color-text-3);
|
||
cursor: help;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.channel-edit-form {
|
||
:deep(.arco-form-item) {
|
||
margin-bottom: 16px;
|
||
}
|
||
}
|
||
|
||
.channel-advanced-toggle {
|
||
margin: -4px 0 8px;
|
||
}
|
||
}
|
||
|
||
.add-factor-value-modal {
|
||
.add-factor-value-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-bottom: 8px;
|
||
}
|
||
}
|
||
|
||
.batch-fill-form {
|
||
.batch-fill-meta {
|
||
margin-bottom: 12px;
|
||
color: var(--color-text-2);
|
||
font-size: 13px;
|
||
}
|
||
}
|
||
</style>
|
||
|
||
<style lang="less">
|
||
/* 价格配置弹窗:允许 Handsontable 下拉层溢出 */
|
||
.arco-modal.sales-price-config-modal {
|
||
.arco-modal-body {
|
||
overflow: visible !important;
|
||
}
|
||
}
|
||
|
||
.arco-modal.sales-price-config-modal .handsontable.listbox,
|
||
.arco-modal.sales-price-config-modal .autocompleteEditor.handsontable.listbox {
|
||
z-index: 5600 !important;
|
||
}
|
||
</style> |