front/src/views/channel/serviceChannels/index.vue
zzh 8c47c2772a
All checks were successful
Build and Deploy Front / deploy (push) Successful in 5m49s
feat: 优化数据权限模板与营销抽屉等运营配置体验
理顺模板配置流程与文案,并统一左侧菜单对比度、会员/券/用户编辑抽屉与因子标签展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 18:25:31 +08:00

6241 lines
194 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>