zjk
2026-06-03 1187810eb0fb3aa4fba6a68164fda20e43bf6933
feat: 新增特殊时段播单策略与行程分享配置

交易策略页面:新增特殊时段播单配置功能,支持规则增删、时段重叠校验、输入合法性校验,完善提交与详情加载逻辑
系统配置页面:新增行程分享状态开关,调整表单布局与输入框样式以优化响应式显示
2个文件已修改
424 ■■■■■ 已修改文件
src/views/system/config/index.vue 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/transaction/transactionStrategy/index.vue 379 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/config/index.vue
@@ -17,6 +17,7 @@
  headShowCount: string
  nearPointDistance: any
  operateFree: boolean
  tripShareStatus: boolean
  temperatureUnit: string
}
@@ -38,6 +39,7 @@
  headShowCount: '',
  nearPointDistance: '',
  operateFree: true,
  tripShareStatus: true,
  temperatureUnit: '℃',
})
@@ -129,6 +131,7 @@
      headShowCount: Number(formModel.value.headShowCount ?? 0),
      nearPointDistance: Number(formModel.value.nearPointDistance ?? 0),
      operateFree: formModel.value.operateFree,
      tripShareStatus: formModel.value.tripShareStatus,
      temperatureUnit: formModel.value.temperatureUnit,
      xczSyDistance: Number(formModel.value.xczSyDistance ?? 0),
      xczSyTime: Number(formModel.value.xczSyTime ?? 0),
@@ -164,6 +167,7 @@
      formModel.value.headShowCount = d.headShowCount ? d.headShowCount.toString() : ''
      formModel.value.nearPointDistance = d.nearPointDistance ? d.nearPointDistance.toString() : ''
      formModel.value.operateFree = d.operateFree
      formModel.value.tripShareStatus = d.tripShareStatus
      formModel.value.temperatureUnit = d.temperatureUnit || '℃'
      formModel.value.xczSyDistance = d.xczSyDistance ? d.xczSyDistance.toString() : ''
      formModel.value.xczSyTime = d.xczSyTime ? d.xczSyTime.toString() : ''
@@ -200,29 +204,17 @@
          <div class="form-row gap-x-40px!">
            <n-form-item label="客服电话" path="customerServicePhone" required class="row-item">
              <n-input
                v-model:value="formModel.customerServicePhone" placeholder="请输入" style="width: 339px;"
                v-model:value="formModel.customerServicePhone" placeholder="请输入" style="width: 100%;"
                :allow-input="onlyAllowNumberAndDash"
              />
            </n-form-item>
            <n-form-item label="常见问题首页展示数" path="headShowCount" required class="row-item">
              <n-input
                :value="formModel.headShowCount" placeholder="请输入"
                :allow-input="onlyAllowNumberAndDash" style="width: 339px;"
                @update:value="handleHeadShowCountChange"
              />
            </n-form-item>
            <n-form-item label="是否开启运营期免收费" path="operateFree" required class="row-item">
              <n-switch v-model:value="formModel.operateFree" :unchecked-value="0" :checked-value="1" />
            </n-form-item>
          </div>
          <div class="form-row gap-x-20px!">
            <n-form-item label="小程序附近热门站点推荐距离" path="nearPointDistance" required class="row-item">
              <div class="inline-input">
                <n-input-group style="width: 222px;">
              <div class="inline-input w-full">
                <n-input-group style="width: 100%;">
                  <n-input
                    :value="formModel.nearPointDistance" placeholder="请输入"
                    :allow-input="onlyAllowNumberAndDash"
                    style="width: 100%;"
                    @update:value="handleNearPointDistanceChange"
                  />
                  <n-input-group-label>m</n-input-group-label>
@@ -230,12 +222,22 @@
                <span class="inline-note">内</span>
              </div>
            </n-form-item>
          </div>
          <div class="form-row gap-x-40px!">
            <n-form-item label="常见问题首页展示数" path="headShowCount" required class="row-item">
              <n-input
                :value="formModel.headShowCount" placeholder="请输入"
                :allow-input="onlyAllowNumberAndDash" style="width: 100%;"
                @update:value="handleHeadShowCountChange"
              />
            </n-form-item>
            <n-form-item label="小程序天气单位" path="temperatureUnit" required class="row-item">
              <n-select
                v-model:value="formModel.temperatureUnit"
                :options="[{ label: '℃', value: '℃' }, { label: '℉', value: '℉' }]"
                placeholder="请选择"
                style="width: 362px;"
                style="width: 100%;"
                @update:show="isWeatherUnitOpen = $event"
              >
                <template #arrow>
@@ -247,6 +249,14 @@
                  >
                </template>
              </n-select>
            </n-form-item>
          </div>
          <div class="form-row gap-x-20px!">
            <n-form-item label="是否开启运营期免收费" path="operateFree" required class="row-item">
              <n-switch v-model:value="formModel.operateFree" :unchecked-value="0" :checked-value="1" />
            </n-form-item>
            <n-form-item label="是否开启行程分享" path="tripShareStatus" required class="row-item">
              <n-switch v-model:value="formModel.tripShareStatus" :unchecked-value="0" :checked-value="1" />
            </n-form-item>
          </div>
        </n-form>
@@ -477,6 +487,7 @@
.row-item {
  min-width: 340px;
  flex: 1;
}
.inline-input {
src/views/transaction/transactionStrategy/index.vue
@@ -16,6 +16,7 @@
  setFalse: endLoading,
} = useBoolean(false)
const message = useMessage()
const dialog = useDialog()
const router = useRouter()
const route = useRoute()
const tabStore = useTabStore()
@@ -41,6 +42,7 @@
  fourPushTime: null, // 四轮推单时间
  fivePushDistance: null, // 五轮推单距离
  fivePushTime: null, // 五轮推单时间
  pushTimeList: [] as any[], // 特殊时段播单策略
  passwordFreeAmount: null, // 免密授权金额
  prepaidLimit: null, // 预付额度 订单大于金额需预付
  ruleType: 1, // 规则类型
@@ -74,6 +76,240 @@
const formRef = ref()
const isRoleSelectOpen = ref(false)
const isCityListOpen = ref(false)
const showSpecialTimeRuleValidation = ref(false)
type PushStrategyFieldKey
  = | 'onePushDistance'
    | 'onePushTime'
    | 'twoPushDistance'
    | 'twoPushTime'
    | 'threePushDistance'
    | 'threePushTime'
    | 'fourPushDistance'
    | 'fourPushTime'
    | 'fivePushDistance'
    | 'fivePushTime'
interface PushStrategyRoundConfig {
  label: string
  distanceKey: Extract<PushStrategyFieldKey, `${string}Distance`>
  timeKey: Extract<PushStrategyFieldKey, `${string}Time`>
}
interface SpecialTimeStrategyItem {
  id?: string | number
  tradingStrategyId?: string | number
  localId: number
  startTime: string | null
  endTime: string | null
  onePushDistance: number | null
  onePushTime: number | null
  twoPushDistance: number | null
  twoPushTime: number | null
  threePushDistance: number | null
  threePushTime: number | null
  fourPushDistance: number | null
  fourPushTime: number | null
  fivePushDistance: number | null
  fivePushTime: number | null
}
const pushStrategyRounds: PushStrategyRoundConfig[] = [
  { label: '第一轮推单', distanceKey: 'onePushDistance', timeKey: 'onePushTime' },
  { label: '第二轮推单', distanceKey: 'twoPushDistance', timeKey: 'twoPushTime' },
  { label: '第三轮推单', distanceKey: 'threePushDistance', timeKey: 'threePushTime' },
  { label: '第四轮推单', distanceKey: 'fourPushDistance', timeKey: 'fourPushTime' },
  { label: '第五轮推单', distanceKey: 'fivePushDistance', timeKey: 'fivePushTime' },
]
const specialTimeStrategyRoundGroups = [
  pushStrategyRounds.slice(0, 2),
  pushStrategyRounds.slice(2, 4),
  pushStrategyRounds.slice(4, 5),
]
const specialTimeNumericKeys = pushStrategyRounds.flatMap(round => [round.distanceKey, round.timeKey]) as PushStrategyFieldKey[]
let specialTimeStrategySeed = 0
function createSpecialTimeStrategyItem(data: Partial<SpecialTimeStrategyItem> = {}): SpecialTimeStrategyItem {
  specialTimeStrategySeed += 1
  return {
    id: data.id,
    tradingStrategyId: data.tradingStrategyId,
    localId: data.localId ?? specialTimeStrategySeed,
    startTime: data.startTime ?? null,
    endTime: data.endTime ?? null,
    onePushDistance: data.onePushDistance ?? null,
    onePushTime: data.onePushTime ?? null,
    twoPushDistance: data.twoPushDistance ?? null,
    twoPushTime: data.twoPushTime ?? null,
    threePushDistance: data.threePushDistance ?? null,
    threePushTime: data.threePushTime ?? null,
    fourPushDistance: data.fourPushDistance ?? null,
    fourPushTime: data.fourPushTime ?? null,
    fivePushDistance: data.fivePushDistance ?? null,
    fivePushTime: data.fivePushTime ?? null,
  }
}
function normalizeSpecialTimeStrategyList(source: unknown): SpecialTimeStrategyItem[] {
  let list: any[] = []
  if (Array.isArray(source)) {
    list = source
  }
  else if (typeof source === 'string') {
    try {
      const parsed = JSON.parse(source)
      list = Array.isArray(parsed) ? parsed : []
    }
    catch {
      list = []
    }
  }
  return list.map(item => createSpecialTimeStrategyItem(item))
}
function timeStringToMinutes(time: string | null | undefined) {
  if (!time)
    return null
  const [hourText, minuteText] = time.split(':')
  const hour = Number(hourText)
  const minute = Number(minuteText)
  if (!Number.isInteger(hour) || !Number.isInteger(minute))
    return null
  return hour * 60 + minute
}
function isPositiveNumber(value: unknown) {
  return typeof value === 'number' && Number.isFinite(value) && value > 0
}
function getSpecialTimeRuleErrorKey(rule: SpecialTimeStrategyItem, field: string) {
  return `${rule.localId}-${field}`
}
function buildSpecialTimeRuleValidationMap(list: SpecialTimeStrategyItem[]) {
  const errors: Record<string, string> = {}
  const setError = (rule: SpecialTimeStrategyItem, field: string, text: string) => {
    const key = getSpecialTimeRuleErrorKey(rule, field)
    if (!errors[key])
      errors[key] = text
  }
  // 先做单行校验,再做区间重叠校验,保证错误提示稳定。
  list.forEach((rule) => {
    if (!rule.startTime)
      setError(rule, 'startTime', '请选择开始时间')
    if (!rule.endTime)
      setError(rule, 'endTime', '请选择结束时间')
    const startMinutes = timeStringToMinutes(rule.startTime)
    const endMinutes = timeStringToMinutes(rule.endTime)
    if (startMinutes !== null && endMinutes !== null && endMinutes <= startMinutes)
      setError(rule, 'endTime', '结束时间需大于开始时间')
    specialTimeNumericKeys.forEach((field) => {
      if (!isPositiveNumber(rule[field]))
        setError(rule, field, '请输入有效的数值')
    })
  })
  const validRules = list
    .map(rule => ({
      rule,
      startMinutes: timeStringToMinutes(rule.startTime),
      endMinutes: timeStringToMinutes(rule.endTime),
    }))
    .filter(item =>
      item.startMinutes !== null
      && item.endMinutes !== null
      && item.endMinutes > item.startMinutes)
  for (let i = 0; i < validRules.length; i += 1) {
    for (let j = i + 1; j < validRules.length; j += 1) {
      const current = validRules[i]
      const next = validRules[j]
      if (current.startMinutes! < next.endMinutes! && next.startMinutes! < current.endMinutes!) {
        setError(current.rule, 'startTime', '时段规则存在重叠,请调整时间范围')
        setError(current.rule, 'endTime', '时段规则存在重叠,请调整时间范围')
        setError(next.rule, 'startTime', '时段规则存在重叠,请调整时间范围')
        setError(next.rule, 'endTime', '时段规则存在重叠,请调整时间范围')
      }
    }
  }
  return errors
}
const specialTimeRuleValidationMap = computed(() => {
  if (!showSpecialTimeRuleValidation.value)
    return {}
  return buildSpecialTimeRuleValidationMap(model.value.pushTimeList || [])
})
function getSpecialTimeRuleFieldError(rule: SpecialTimeStrategyItem, field: string) {
  return specialTimeRuleValidationMap.value[getSpecialTimeRuleErrorKey(rule, field)] || ''
}
function getSpecialTimeRuleFeedback(rule: SpecialTimeStrategyItem, fields: string[]) {
  return fields.map(field => getSpecialTimeRuleFieldError(rule, field)).find(Boolean) || ''
}
function getSpecialTimeRuleValidationStatus(rule: SpecialTimeStrategyItem, fields: string[]) {
  return getSpecialTimeRuleFeedback(rule, fields) ? 'error' : undefined
}
function validateSpecialTimeRuleList() {
  showSpecialTimeRuleValidation.value = true
  const errors = Object.values(buildSpecialTimeRuleValidationMap(model.value.pushTimeList || []))
  if (!errors.length)
    return true
  if (errors.includes('时段规则存在重叠,请调整时间范围')) {
    message.error('时段规则存在重叠,请调整时间范围')
    return false
  }
  if (errors.includes('结束时间需大于开始时间')) {
    message.error('结束时间需大于开始时间')
    return false
  }
  if (errors.includes('请选择开始时间') || errors.includes('请选择结束时间')) {
    message.error('请完善特殊时段配置')
    return false
  }
  message.error('请输入有效的数值')
  return false
}
function handleAddSpecialTimeRule() {
  model.value.pushTimeList.push(createSpecialTimeStrategyItem())
}
function isSpecialTimeRuleEffective(rule: SpecialTimeStrategyItem) {
  const currentMinutes = dayjs().hour() * 60 + dayjs().minute()
  const startMinutes = timeStringToMinutes(rule.startTime)
  const endMinutes = timeStringToMinutes(rule.endTime)
  if (startMinutes === null || endMinutes === null || endMinutes <= startMinutes)
    return false
  return currentMinutes >= startMinutes && currentMinutes < endMinutes
}
function handleDeleteSpecialTimeRule(rule: SpecialTimeStrategyItem) {
  dialog.warning({
    title: '删除确认',
    content: isSpecialTimeRuleEffective(rule)
      ? '该时段规则正在生效,删除后订单将按基础策略执行,是否确认删除?'
      : '确认删除该组特殊时段规则?',
    positiveText: '确认',
    negativeText: '取消',
    onPositiveClick: () => {
      model.value.pushTimeList = model.value.pushTimeList.filter(
        (item: SpecialTimeStrategyItem) => item.localId !== rule.localId,
      )
    },
  })
}
const options = computed(() => [
  {
@@ -131,6 +367,7 @@
async function handleSubmit() {
  try {
    await formRef.value?.validate()
    showSpecialTimeRuleValidation.value = true
    if (!model.value.cancleCount) {
      message.error('请输入每日取消订单数')
      return
@@ -150,6 +387,8 @@
      message.error('请输入播单策略')
      return
    }
    if (!validateSpecialTimeRuleList())
      return
    if (!model.value.ruleType) {
      message.error('请选择举手规则')
      return
@@ -192,6 +431,10 @@
      exemptionRole: model.value.exemptionRole.join(','),
      exemptionEndTime: model.value.exemptionEndTime ? dayjs(model.value.exemptionEndTime).format('YYYY-MM-DD HH:mm:ss') : null,
      exemptionStartTime: model.value.exemptionStartTime ? dayjs(model.value.exemptionStartTime).format('YYYY-MM-DD HH:mm:ss') : null,
      pushTimeList: (model.value.pushTimeList || []).map((item: SpecialTimeStrategyItem) => {
        const { localId, ...rest } = item
        return rest
      }),
    }
    delete params.tendencyPreference
    delete params.tendencyOrderValueDims
@@ -216,8 +459,15 @@
function getDetail() {
  getTradingStrategyDetail().then((res) => {
    if (res.code === 200) {
      model.value = { ...res.data, exemptionRole: res.data.exemptionRole ? res.data.exemptionRole.split(',') || [] : [] }
      const pushTimeList = normalizeSpecialTimeStrategyList(res.data.pushTimeList)
      model.value = {
        ...initialModel,
        ...res.data,
        exemptionRole: res.data.exemptionRole ? res.data.exemptionRole.split(',') || [] : [],
        pushTimeList,
      }
      model.value.cityCode = res.data.cityCode ? res.data.cityCode.split(',') || [] : []
      showSpecialTimeRuleValidation.value = false
      // 解析分单倾向配置:从四个属性中找到值为 10 的那个,如果没有则默认为 tendencyOrderValue
      if (model.value.tendencyOrderValue === 10) {
@@ -480,6 +730,90 @@
                </n-form-item>
              </NSpace>
              <div class="special-time-strategy-section">
                <NButton type="primary" class="w-240px!" secondary @click="handleAddSpecialTimeRule">
                  <template #icon>
                    <img src="@/assets/pricingManagement/add.svg" class="w-16px h-16px">
                  </template>
                  添加特殊时段播单策略
                </NButton>
                <div v-if="model.pushTimeList.length > 0" class="special-time-rule-list">
                  <div
                    v-for="(rule) in model.pushTimeList"
                    :key="rule.localId"
                    class="special-time-rule-item"
                  >
                    <n-form-item
                      label="时段:" required label-style="color: rgba(23, 31, 38, 0.8);"
                      :validation-status="getSpecialTimeRuleValidationStatus(rule, ['startTime', 'endTime'])"
                      :feedback="getSpecialTimeRuleFeedback(rule, ['startTime', 'endTime'])"
                      :show-feedback="Boolean(getSpecialTimeRuleFeedback(rule, ['startTime', 'endTime']))"
                    >
                      <n-time-picker
                        v-model:formatted-value="rule.startTime"
                        format="HH:mm"
                        value-format="HH:mm"
                        clearable
                        close-on-select
                        :actions="['now']"
                        class="w-140px"
                        placeholder="开始时间"
                      />
                      <span class="mx-8px text-[#171F2660]">至</span>
                      <n-time-picker
                        v-model:formatted-value="rule.endTime"
                        format="HH:mm"
                        value-format="HH:mm"
                        clearable
                        close-on-select
                        :actions="['now']"
                        class="w-140px"
                        placeholder="结束时间"
                      />
                      <icon-park-outline-delete class="special-time-delete-btn" @click="handleDeleteSpecialTimeRule(rule)" />
                    </n-form-item>
                    <NSpace
                      v-for="(group, groupIndex) in specialTimeStrategyRoundGroups"
                      :key="`${rule.localId}-${groupIndex}`"
                      :size="[24, 16]"
                    >
                      <n-form-item
                        v-for="round in group"
                        :key="`${rule.localId}-${round.distanceKey}`"
                        :label="`${round.label}:`"
                        required
                        label-style="color: rgba(23, 31, 38, 0.8);"
                        :validation-status="getSpecialTimeRuleValidationStatus(rule, [round.distanceKey, round.timeKey])"
                        :feedback="getSpecialTimeRuleFeedback(rule, [round.distanceKey, round.timeKey])"
                        :show-feedback="Boolean(getSpecialTimeRuleFeedback(rule, [round.distanceKey, round.timeKey]))"
                      >
                        推单距离为
                        <n-input-number
                          v-model:value="rule[round.distanceKey]"
                          min="0"
                          :show-button="false"
                          class="mx-2 w-102px"
                          placeholder="请输入"
                          :input-props="{ onKeydown: handleInputNumberKeyDown }"
                        />
                        公里,推单时间为
                        <n-input-number
                          v-model:value="rule[round.timeKey]"
                          min="0"
                          :show-button="false"
                          class="mx-2 w-102px"
                          placeholder="请输入"
                          :input-props="{ onKeydown: handleInputNumberKeyDown }"
                        />
                        秒
                      </n-form-item>
                    </NSpace>
                  </div>
                </div>
              </div>
              <!-- 排队相关配置 -->
              <n-form-item
                label="排队时,每" path="queuePollingTime" label-style="color: rgba(23, 31, 38, 0.8);"
@@ -721,6 +1055,14 @@
  box-sizing: border-box;
}
.n-button--secondary {
  width: 84px;
  box-sizing: border-box;
  color: #0068FF;
  border: 1px solid #7ABDFF;
  background-color: #E6F4FF;
}
.box {
  padding: 24px;
  background-color: #fff;
@@ -771,4 +1113,39 @@
  box-shadow: 0px 0px 5px 0px rgba(23, 31, 38, 0.05);
  padding: 16px 24px;
}
.special-time-strategy-section {
  display: flex;
  flex-direction: column;
  gap: 16px;
}
.special-time-rule-list {
  display: flex;
  flex-direction: column;
  gap: 16px;
}
.special-time-rule-item {
  display: flex;
  flex-direction: column;
  gap: 16px;
  padding-bottom: 16px;
  border-bottom: 1px solid rgba(23, 31, 38, 0.08);
  &:last-child {
    border-bottom: none;
    padding-bottom: 0;
  }
}
.special-time-delete-btn {
  margin-left: 12px;
  color: rgba(23, 31, 38, 0.45);
  cursor: pointer;
  transition: color 0.2s ease;
  &:hover {
    color: #0068ff;
  }
}
</style>