front/src/views/channel/serviceChannels/index.vue
2026-05-05 18:14:43 +08:00

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