Commit 67f451d6ded3c3b66f62ba321d0535bc5d5f0dbb
1 parent
b37151b6
init
Showing
7 changed files
with
2073 additions
and
909 deletions
.codex
0 → 100644
src/api/index.ts
| @@ -136,6 +136,27 @@ export const deliveryOrderApi = { | @@ -136,6 +136,27 @@ export const deliveryOrderApi = { | ||
| 136 | request.post('/api/open/delivery/order/cancel', null, { params: { outOrderNo } }), | 136 | request.post('/api/open/delivery/order/cancel', null, { params: { outOrderNo } }), |
| 137 | } | 137 | } |
| 138 | 138 | ||
| 139 | +export const adminFeePlanApi = { | ||
| 140 | + list: (cityId?: number) => request.get('/api/admin/fee-plan/list', { params: { cityId } }), | ||
| 141 | + detail: (planId: number, cityId?: number) => request.get(`/api/admin/fee-plan/${planId}`, { params: { cityId } }), | ||
| 142 | + create: (data: any, cityId?: number) => request.post('/api/admin/fee-plan', data, { params: { cityId } }), | ||
| 143 | + initDefault: (cityId?: number) => request.post('/api/admin/fee-plan/init-default', null, { params: { cityId } }), | ||
| 144 | + update: (planId: number, data: any, cityId?: number) => request.put(`/api/admin/fee-plan/${planId}`, data, { params: { cityId } }), | ||
| 145 | + copy: (planId: number, cityId?: number) => request.post(`/api/admin/fee-plan/${planId}/copy`, null, { params: { cityId } }), | ||
| 146 | + setDefault: (planId: number, cityId?: number) => request.post(`/api/admin/fee-plan/${planId}/default`, null, { params: { cityId } }), | ||
| 147 | + del: (planId: number, cityId?: number) => request.delete(`/api/admin/fee-plan/${planId}`, { params: { cityId } }), | ||
| 148 | + preview: (data: any, cityId?: number) => request.post('/api/admin/fee-plan/preview', data, { params: { cityId } }), | ||
| 149 | +} | ||
| 150 | + | ||
| 151 | +export const dispatchRuleApi = { | ||
| 152 | + list: (cityId?: number) => request.get('/api/admin/dispatch/rule/list', { params: { cityId } }), | ||
| 153 | + getActive: (cityId?: number) => request.get('/api/admin/dispatch/rule', { params: { cityId } }), | ||
| 154 | + save: (data: any) => request.post('/api/admin/dispatch/rule/save', data), | ||
| 155 | + activate: (templateId: number, cityId?: number) => request.post('/api/admin/dispatch/rule/activate', { templateId }, { params: { cityId } }), | ||
| 156 | + del: (id: number, cityId?: number) => request.delete(`/api/admin/dispatch/rule/${id}`, { params: { cityId } }), | ||
| 157 | + copy: (templateId: number, newName: string, cityId?: number) => request.post('/api/admin/dispatch/rule/copy', { templateId, newName }, { params: { cityId } }), | ||
| 158 | +} | ||
| 159 | + | ||
| 139 | // 附近骑手 | 160 | // 附近骑手 |
| 140 | export const locationApi = { | 161 | export const locationApi = { |
| 141 | nearby: (cityId: number, lng: string, lat: string) => | 162 | nearby: (cityId: number, lng: string, lat: string) => |
src/layouts/MainLayout.vue
| @@ -25,11 +25,11 @@ | @@ -25,11 +25,11 @@ | ||
| 25 | <template #icon><home-outlined /></template> | 25 | <template #icon><home-outlined /></template> |
| 26 | 工作台 | 26 | 工作台 |
| 27 | </a-menu-item> | 27 | </a-menu-item> |
| 28 | - <a-menu-item key="/city"> | 28 | + <a-menu-item v-if="isAdmin" key="/city"> |
| 29 | <template #icon><global-outlined /></template> | 29 | <template #icon><global-outlined /></template> |
| 30 | 租户管理 | 30 | 租户管理 |
| 31 | </a-menu-item> | 31 | </a-menu-item> |
| 32 | - <a-menu-item key="/substation"> | 32 | + <a-menu-item v-if="isAdmin" key="/substation"> |
| 33 | <template #icon><apartment-outlined /></template> | 33 | <template #icon><apartment-outlined /></template> |
| 34 | 分站管理 | 34 | 分站管理 |
| 35 | </a-menu-item> | 35 | </a-menu-item> |
| @@ -54,6 +54,12 @@ | @@ -54,6 +54,12 @@ | ||
| 54 | <a-menu-item key="/refund">退款管理</a-menu-item> | 54 | <a-menu-item key="/refund">退款管理</a-menu-item> |
| 55 | <a-menu-item key="/delivery/order">配送订单</a-menu-item> | 55 | <a-menu-item key="/delivery/order">配送订单</a-menu-item> |
| 56 | </a-sub-menu> | 56 | </a-sub-menu> |
| 57 | + <a-sub-menu key="config"> | ||
| 58 | + <template #icon><control-outlined /></template> | ||
| 59 | + <template #title>配置中心</template> | ||
| 60 | + <a-menu-item key="/config/fee-plan">配送费配置</a-menu-item> | ||
| 61 | + <a-menu-item key="/dispatch/rule">调度配置</a-menu-item> | ||
| 62 | + </a-sub-menu> | ||
| 57 | <a-sub-menu key="open"> | 63 | <a-sub-menu key="open"> |
| 58 | <template #icon><api-outlined /></template> | 64 | <template #icon><api-outlined /></template> |
| 59 | <template #title>开放平台</template> | 65 | <template #title>开放平台</template> |
| @@ -112,7 +118,7 @@ import { useAuthStore } from '@/stores/auth' | @@ -112,7 +118,7 @@ import { useAuthStore } from '@/stores/auth' | ||
| 112 | import { | 118 | import { |
| 113 | GlobalOutlined, ApartmentOutlined, ShopOutlined, | 119 | GlobalOutlined, ApartmentOutlined, ShopOutlined, |
| 114 | UserOutlined, UnorderedListOutlined, ApiOutlined, DownOutlined, StarOutlined, | 120 | UserOutlined, UnorderedListOutlined, ApiOutlined, DownOutlined, StarOutlined, |
| 115 | - CalendarOutlined, MenuFoldOutlined, MenuUnfoldOutlined, HomeOutlined | 121 | + CalendarOutlined, MenuFoldOutlined, MenuUnfoldOutlined, HomeOutlined, ControlOutlined |
| 116 | } from '@ant-design/icons-vue' | 122 | } from '@ant-design/icons-vue' |
| 117 | 123 | ||
| 118 | const router = useRouter() | 124 | const router = useRouter() |
| @@ -131,12 +137,15 @@ const titleMap: Record<string, string> = { | @@ -131,12 +137,15 @@ const titleMap: Record<string, string> = { | ||
| 131 | '/order': '订单列表', | 137 | '/order': '订单列表', |
| 132 | '/refund': '退款管理', | 138 | '/refund': '退款管理', |
| 133 | '/delivery/order': '配送订单', | 139 | '/delivery/order': '配送订单', |
| 140 | + '/config/fee-plan': '配送费配置', | ||
| 141 | + '/dispatch/rule': '调度配置', | ||
| 134 | '/open': '开放平台', | 142 | '/open': '开放平台', |
| 135 | '/open/mock-delivery': '模拟推单', | 143 | '/open/mock-delivery': '模拟推单', |
| 136 | } | 144 | } |
| 137 | 145 | ||
| 138 | watch(() => route.path, (p) => { selectedKeys.value = [p] }) | 146 | watch(() => route.path, (p) => { selectedKeys.value = [p] }) |
| 139 | 147 | ||
| 148 | +const isAdmin = computed(() => auth.userInfo?.role === 'admin') | ||
| 140 | const currentTitle = computed(() => titleMap[route.path] || '外卖管理') | 149 | const currentTitle = computed(() => titleMap[route.path] || '外卖管理') |
| 141 | const avatarText = computed(() => (auth.userInfo?.userNickname || '管理员').slice(0, 1)) | 150 | const avatarText = computed(() => (auth.userInfo?.userNickname || '管理员').slice(0, 1)) |
| 142 | const todayLabel = computed(() => new Intl.DateTimeFormat('zh-CN', { | 151 | const todayLabel = computed(() => new Intl.DateTimeFormat('zh-CN', { |
src/router/index.ts
| @@ -70,6 +70,18 @@ const router = createRouter({ | @@ -70,6 +70,18 @@ const router = createRouter({ | ||
| 70 | meta: { title: '配送订单' }, | 70 | meta: { title: '配送订单' }, |
| 71 | }, | 71 | }, |
| 72 | { | 72 | { |
| 73 | + path: 'config/fee-plan', | ||
| 74 | + name: 'FeePlan', | ||
| 75 | + component: () => import('@/views/config/FeePlanList.vue'), | ||
| 76 | + meta: { title: '配送费配置' }, | ||
| 77 | + }, | ||
| 78 | + { | ||
| 79 | + path: 'dispatch/rule', | ||
| 80 | + name: 'DispatchRule', | ||
| 81 | + component: () => import('@/views/dispatch/DispatchRuleList.vue'), | ||
| 82 | + meta: { title: '调度规则' }, | ||
| 83 | + }, | ||
| 84 | + { | ||
| 73 | path: 'rider/evaluate', | 85 | path: 'rider/evaluate', |
| 74 | name: 'RiderEvaluate', | 86 | name: 'RiderEvaluate', |
| 75 | component: () => import('@/views/rider/RiderEvaluateList.vue'), | 87 | component: () => import('@/views/rider/RiderEvaluateList.vue'), |
src/views/city/CityList.vue
| @@ -44,389 +44,6 @@ | @@ -44,389 +44,6 @@ | ||
| 44 | </a-form> | 44 | </a-form> |
| 45 | </a-modal> | 45 | </a-modal> |
| 46 | 46 | ||
| 47 | - <a-modal v-model:open="configVisible" :title="`配送费配置 - ${currentCityName}`" width="1320px" :footer="null"> | ||
| 48 | - <div class="plan-layout"> | ||
| 49 | - <div class="plan-sidebar"> | ||
| 50 | - <div class="plan-sidebar-header"> | ||
| 51 | - <div> | ||
| 52 | - <div class="plan-sidebar-title">计价方案</div> | ||
| 53 | - <div class="plan-sidebar-subtitle">同一租户可维护多套外卖配送规则</div> | ||
| 54 | - </div> | ||
| 55 | - <a-button type="primary" size="small" @click="createPlan">新增</a-button> | ||
| 56 | - </div> | ||
| 57 | - <a-spin :spinning="planLoading"> | ||
| 58 | - <div class="plan-list"> | ||
| 59 | - <button | ||
| 60 | - v-for="item in planList" | ||
| 61 | - :key="item.id" | ||
| 62 | - type="button" | ||
| 63 | - class="plan-item" | ||
| 64 | - :class="{ active: item.id === selectedPlanId }" | ||
| 65 | - @click="selectPlan(item.id)" | ||
| 66 | - > | ||
| 67 | - <div class="plan-item-top"> | ||
| 68 | - <span class="plan-item-name">{{ item.name }}</span> | ||
| 69 | - <a-tag v-if="item.isDefault === 1" color="green">默认</a-tag> | ||
| 70 | - </div> | ||
| 71 | - <div class="plan-item-bottom"> | ||
| 72 | - <span>{{ item.status === 1 ? '启用中' : '已停用' }}</span> | ||
| 73 | - <span>排序 {{ item.listOrder ?? 0 }}</span> | ||
| 74 | - </div> | ||
| 75 | - </button> | ||
| 76 | - <a-empty v-if="!planList.length" description="暂无计价方案" /> | ||
| 77 | - </div> | ||
| 78 | - </a-spin> | ||
| 79 | - </div> | ||
| 80 | - | ||
| 81 | - <div class="plan-content"> | ||
| 82 | - <template v-if="currentPlan && config"> | ||
| 83 | - <div class="soft-note-card plan-note-card"> | ||
| 84 | - <strong>当前配置说明</strong> | ||
| 85 | - <p>当前弹窗只维护外卖配送规则。你可以在同一租户下维护多套方案,并设置默认启用方案用于实时计价。</p> | ||
| 86 | - </div> | ||
| 87 | - | ||
| 88 | - <div class="plan-toolbar"> | ||
| 89 | - <a-space wrap> | ||
| 90 | - <a-button @click="copyPlan" :disabled="!selectedPlanId">复制当前方案</a-button> | ||
| 91 | - <a-button @click="setDefaultPlan" :disabled="currentPlan.isDefault === 1 || currentPlan.status !== 1">设为默认</a-button> | ||
| 92 | - <a-popconfirm title="确认删除当前方案?" @confirm="deletePlan"> | ||
| 93 | - <a-button danger :disabled="currentPlan.isDefault === 1">删除方案</a-button> | ||
| 94 | - </a-popconfirm> | ||
| 95 | - </a-space> | ||
| 96 | - <a-space wrap> | ||
| 97 | - <a-button @click="previewPlan" :loading="previewing">试算配送费</a-button> | ||
| 98 | - <a-button type="primary" @click="saveCurrentPlan" :loading="planSaving">保存当前方案</a-button> | ||
| 99 | - </a-space> | ||
| 100 | - </div> | ||
| 101 | - | ||
| 102 | - <div class="plan-section"> | ||
| 103 | - <div class="soft-section-header"> | ||
| 104 | - <div class="soft-section-heading"> | ||
| 105 | - <h3 class="soft-section-title">方案基础信息</h3> | ||
| 106 | - <p class="soft-section-subtitle">维护方案名称、状态和备注说明。</p> | ||
| 107 | - </div> | ||
| 108 | - </div> | ||
| 109 | - | ||
| 110 | - <a-row :gutter="16"> | ||
| 111 | - <a-col :span="8"> | ||
| 112 | - <a-form-item label="方案名称"> | ||
| 113 | - <a-input v-model:value="currentPlan.name" placeholder="如:标准午高峰方案" /> | ||
| 114 | - </a-form-item> | ||
| 115 | - </a-col> | ||
| 116 | - <a-col :span="8"> | ||
| 117 | - <a-form-item label="状态"> | ||
| 118 | - <a-select v-model:value="currentPlan.status"> | ||
| 119 | - <a-select-option :value="1">启用</a-select-option> | ||
| 120 | - <a-select-option :value="0">停用</a-select-option> | ||
| 121 | - </a-select> | ||
| 122 | - </a-form-item> | ||
| 123 | - </a-col> | ||
| 124 | - <a-col :span="8"> | ||
| 125 | - <a-form-item label="排序"> | ||
| 126 | - <a-input-number v-model:value="currentPlan.listOrder" :min="0" style="width:100%" /> | ||
| 127 | - </a-form-item> | ||
| 128 | - </a-col> | ||
| 129 | - </a-row> | ||
| 130 | - <a-form-item label="备注"> | ||
| 131 | - <a-input v-model:value="currentPlan.remark" placeholder="可填写适用业务场景说明" /> | ||
| 132 | - </a-form-item> | ||
| 133 | - </div> | ||
| 134 | - | ||
| 135 | - <a-card class="preview-card" :bordered="false"> | ||
| 136 | - <template #title>草稿试算</template> | ||
| 137 | - <div class="soft-section-subtitle preview-subtitle">保存前可先按草稿参数试算配送费,确认距离、时段和附加项是否符合预期。</div> | ||
| 138 | - <a-row :gutter="12"> | ||
| 139 | - <a-col :span="6"> | ||
| 140 | - <a-form-item label="起点经度"> | ||
| 141 | - <a-input v-model:value="previewForm.startLng" placeholder="121.4737" /> | ||
| 142 | - </a-form-item> | ||
| 143 | - </a-col> | ||
| 144 | - <a-col :span="6"> | ||
| 145 | - <a-form-item label="起点纬度"> | ||
| 146 | - <a-input v-model:value="previewForm.startLat" placeholder="31.2304" /> | ||
| 147 | - </a-form-item> | ||
| 148 | - </a-col> | ||
| 149 | - <a-col :span="6"> | ||
| 150 | - <a-form-item label="终点经度"> | ||
| 151 | - <a-input v-model:value="previewForm.endLng" placeholder="121.4879" /> | ||
| 152 | - </a-form-item> | ||
| 153 | - </a-col> | ||
| 154 | - <a-col :span="6"> | ||
| 155 | - <a-form-item label="终点纬度"> | ||
| 156 | - <a-input v-model:value="previewForm.endLat" placeholder="31.2492" /> | ||
| 157 | - </a-form-item> | ||
| 158 | - </a-col> | ||
| 159 | - </a-row> | ||
| 160 | - <a-row :gutter="12"> | ||
| 161 | - <a-col :span="8"> | ||
| 162 | - <a-form-item label="重量(kg)"> | ||
| 163 | - <a-input-number v-model:value="previewForm.weight" :min="0" :step="0.1" style="width:100%" /> | ||
| 164 | - </a-form-item> | ||
| 165 | - </a-col> | ||
| 166 | - <a-col :span="8"> | ||
| 167 | - <a-form-item label="件数"> | ||
| 168 | - <a-input-number v-model:value="previewForm.pieces" :min="0" style="width:100%" /> | ||
| 169 | - </a-form-item> | ||
| 170 | - </a-col> | ||
| 171 | - <a-col :span="8"> | ||
| 172 | - <a-form-item label="服务时间戳(秒,可空)"> | ||
| 173 | - <a-input v-model:value="previewForm.serviceTime" placeholder="留空则按当前时间" /> | ||
| 174 | - </a-form-item> | ||
| 175 | - </a-col> | ||
| 176 | - </a-row> | ||
| 177 | - <div v-if="previewResult" class="preview-result"> | ||
| 178 | - <a-tag color="processing">总配送费 {{ previewResult.totalFee ?? 0 }} 元</a-tag> | ||
| 179 | - <a-tag>基础 {{ previewResult.moneyBasic ?? 0 }}</a-tag> | ||
| 180 | - <a-tag>里程 {{ previewResult.moneyDistance ?? 0 }}</a-tag> | ||
| 181 | - <a-tag>重量 {{ previewResult.moneyWeight ?? 0 }}</a-tag> | ||
| 182 | - <a-tag>件数 {{ previewResult.moneyPiece ?? 0 }}</a-tag> | ||
| 183 | - <a-tag>时段 {{ previewResult.moneyTime ?? 0 }}</a-tag> | ||
| 184 | - <a-tag>预计送达 {{ previewResult.estimatedMinutes ?? 0 }} 分钟</a-tag> | ||
| 185 | - <a-tag>里程 {{ previewResult.distance ?? 0 }} km</a-tag> | ||
| 186 | - <a-tag v-if="previewResult.minFeeApplied === 1" color="gold">已触发保底 {{ previewResult.minFee ?? 0 }}</a-tag> | ||
| 187 | - <a-tag v-if="previewResult.moneyTime > 0" color="purple">当前服务时间命中时段加价</a-tag> | ||
| 188 | - </div> | ||
| 189 | - </a-card> | ||
| 190 | - | ||
| 191 | - <a-form :model="config" layout="vertical"> | ||
| 192 | - <div class="plan-section"> | ||
| 193 | - <div class="soft-section-header"> | ||
| 194 | - <div class="soft-section-heading"> | ||
| 195 | - <h3 class="soft-section-title">费用总览</h3> | ||
| 196 | - <p class="soft-section-subtitle">基础费与保底费会参与最终总价计算。</p> | ||
| 197 | - </div> | ||
| 198 | - </div> | ||
| 199 | - <a-row :gutter="16"> | ||
| 200 | - <a-col :span="12"> | ||
| 201 | - <a-form-item label="保底费用(元)"> | ||
| 202 | - <a-input-number v-model:value="config.type6.minFee" :min="0" :step="0.1" style="width:100%" /> | ||
| 203 | - </a-form-item> | ||
| 204 | - </a-col> | ||
| 205 | - <a-col :span="12"> | ||
| 206 | - <a-form-item label="基础费(元/单)"> | ||
| 207 | - <a-input-number v-model:value="config.type6.baseFee" :min="0" :step="0.1" style="width:100%" /> | ||
| 208 | - </a-form-item> | ||
| 209 | - </a-col> | ||
| 210 | - </a-row> | ||
| 211 | - </div> | ||
| 212 | - | ||
| 213 | - <div class="plan-section"> | ||
| 214 | - <div class="soft-section-header"> | ||
| 215 | - <div class="soft-section-heading"> | ||
| 216 | - <h3 class="soft-section-title">里程阶梯</h3> | ||
| 217 | - <p class="soft-section-subtitle">先配置起步距离,再追加超出后的阶梯规则。</p> | ||
| 218 | - </div> | ||
| 219 | - <div class="soft-section-actions"> | ||
| 220 | - <a-button type="dashed" @click="addDistanceStep">新增阶梯段</a-button> | ||
| 221 | - </div> | ||
| 222 | - </div> | ||
| 223 | - <a-row :gutter="16"> | ||
| 224 | - <a-col :span="12"> | ||
| 225 | - <a-form-item label="起步里程(km内)"> | ||
| 226 | - <a-input-number v-model:value="config.type6.distanceBasic" :min="0" :step="0.1" style="width:100%" /> | ||
| 227 | - </a-form-item> | ||
| 228 | - </a-col> | ||
| 229 | - <a-col :span="12"> | ||
| 230 | - <a-form-item label="起步费用(元)"> | ||
| 231 | - <a-input-number v-model:value="config.type6.distanceBasicMoney" :min="0" :step="0.1" style="width:100%" /> | ||
| 232 | - </a-form-item> | ||
| 233 | - </a-col> | ||
| 234 | - </a-row> | ||
| 235 | - <div v-if="distanceSteps.length" class="soft-dashed-block"> | ||
| 236 | - <a-row | ||
| 237 | - v-for="(step, index) in distanceSteps" | ||
| 238 | - :key="index" | ||
| 239 | - :gutter="12" | ||
| 240 | - class="plan-dynamic-row" | ||
| 241 | - > | ||
| 242 | - <a-col :span="7"> | ||
| 243 | - <a-form-item :label="index === 0 ? '结束里程(km)' : ''"> | ||
| 244 | - <a-input-number v-model:value="step.endDistance" :min="0" :step="0.1" style="width:100%" /> | ||
| 245 | - </a-form-item> | ||
| 246 | - </a-col> | ||
| 247 | - <a-col :span="7"> | ||
| 248 | - <a-form-item :label="index === 0 ? '每档里程(km)' : ''"> | ||
| 249 | - <a-input-number v-model:value="step.unitDistance" :min="0.1" :step="0.1" style="width:100%" /> | ||
| 250 | - </a-form-item> | ||
| 251 | - </a-col> | ||
| 252 | - <a-col :span="7"> | ||
| 253 | - <a-form-item :label="index === 0 ? '每档加价(元)' : ''"> | ||
| 254 | - <a-input-number v-model:value="step.unitFee" :min="0" :step="0.1" style="width:100%" /> | ||
| 255 | - </a-form-item> | ||
| 256 | - </a-col> | ||
| 257 | - <a-col :span="3"> | ||
| 258 | - <a-form-item :label="index === 0 ? '操作' : ''"> | ||
| 259 | - <a-button danger block @click="removeDistanceStep(index)">删除</a-button> | ||
| 260 | - </a-form-item> | ||
| 261 | - </a-col> | ||
| 262 | - </a-row> | ||
| 263 | - </div> | ||
| 264 | - <a-empty v-else description="暂无里程阶梯配置" /> | ||
| 265 | - </div> | ||
| 266 | - | ||
| 267 | - <div class="plan-section"> | ||
| 268 | - <div class="soft-section-header"> | ||
| 269 | - <div class="soft-section-heading"> | ||
| 270 | - <h3 class="soft-section-title">重量计费</h3> | ||
| 271 | - <p class="soft-section-subtitle">重量按首重和续重单价计算,可设置费用封顶。</p> | ||
| 272 | - </div> | ||
| 273 | - </div> | ||
| 274 | - <a-row :gutter="16"> | ||
| 275 | - <a-col :span="12"> | ||
| 276 | - <a-form-item label="首重(kg)"> | ||
| 277 | - <a-input-number v-model:value="config.type6.weightFirst" :min="0" :step="0.1" style="width:100%" /> | ||
| 278 | - </a-form-item> | ||
| 279 | - </a-col> | ||
| 280 | - <a-col :span="12"> | ||
| 281 | - <a-form-item label="首重费用(元)"> | ||
| 282 | - <a-input-number v-model:value="config.type6.weightFirstFee" :min="0" :step="0.1" style="width:100%" /> | ||
| 283 | - </a-form-item> | ||
| 284 | - </a-col> | ||
| 285 | - </a-row> | ||
| 286 | - <a-row :gutter="16"> | ||
| 287 | - <a-col :span="12"> | ||
| 288 | - <a-form-item label="续重单价(元/kg)"> | ||
| 289 | - <a-input-number v-model:value="config.type6.weightUnitFee" :min="0" :step="0.1" style="width:100%" /> | ||
| 290 | - </a-form-item> | ||
| 291 | - </a-col> | ||
| 292 | - <a-col :span="12"> | ||
| 293 | - <a-form-item label="封顶费用(元)"> | ||
| 294 | - <a-input-number v-model:value="config.type6.weightCapFee" :min="0" :step="0.1" style="width:100%" /> | ||
| 295 | - </a-form-item> | ||
| 296 | - </a-col> | ||
| 297 | - </a-row> | ||
| 298 | - </div> | ||
| 299 | - | ||
| 300 | - <div class="plan-section"> | ||
| 301 | - <div class="soft-section-header"> | ||
| 302 | - <div class="soft-section-heading"> | ||
| 303 | - <h3 class="soft-section-title">件数计费</h3> | ||
| 304 | - <p class="soft-section-subtitle">适合按商品件数额外加价的场景。</p> | ||
| 305 | - </div> | ||
| 306 | - <div class="soft-section-actions"> | ||
| 307 | - <a-button type="dashed" @click="addPieceRule">新增件数区间</a-button> | ||
| 308 | - </div> | ||
| 309 | - </div> | ||
| 310 | - <div v-if="pieceRules.length" class="soft-dashed-block"> | ||
| 311 | - <a-row | ||
| 312 | - v-for="(rule, index) in pieceRules" | ||
| 313 | - :key="index" | ||
| 314 | - :gutter="12" | ||
| 315 | - class="plan-dynamic-row" | ||
| 316 | - > | ||
| 317 | - <a-col :span="6"> | ||
| 318 | - <a-form-item :label="index === 0 ? '起始件数' : ''"> | ||
| 319 | - <a-input-number v-model:value="rule.startPiece" :min="0" style="width:100%" /> | ||
| 320 | - </a-form-item> | ||
| 321 | - </a-col> | ||
| 322 | - <a-col :span="6"> | ||
| 323 | - <a-form-item :label="index === 0 ? '结束件数' : ''"> | ||
| 324 | - <a-input-number v-model:value="rule.endPiece" :min="0" style="width:100%" /> | ||
| 325 | - </a-form-item> | ||
| 326 | - </a-col> | ||
| 327 | - <a-col :span="8"> | ||
| 328 | - <a-form-item :label="index === 0 ? '费用(元)' : ''"> | ||
| 329 | - <a-input-number v-model:value="rule.fee" :min="0" :step="0.1" style="width:100%" /> | ||
| 330 | - </a-form-item> | ||
| 331 | - </a-col> | ||
| 332 | - <a-col :span="4"> | ||
| 333 | - <a-form-item :label="index === 0 ? '操作' : ''"> | ||
| 334 | - <a-button danger block @click="removePieceRule(index)">删除</a-button> | ||
| 335 | - </a-form-item> | ||
| 336 | - </a-col> | ||
| 337 | - </a-row> | ||
| 338 | - </div> | ||
| 339 | - <a-empty v-else description="暂无件数区间配置" /> | ||
| 340 | - </div> | ||
| 341 | - | ||
| 342 | - <div class="plan-section"> | ||
| 343 | - <div class="soft-section-header"> | ||
| 344 | - <div class="soft-section-heading"> | ||
| 345 | - <h3 class="soft-section-title">时段附加费</h3> | ||
| 346 | - <p class="soft-section-subtitle">用于午晚高峰或夜间等时段加价。</p> | ||
| 347 | - </div> | ||
| 348 | - <div class="soft-section-actions"> | ||
| 349 | - <a-button type="dashed" @click="addTimePeriod">新增时段</a-button> | ||
| 350 | - </div> | ||
| 351 | - </div> | ||
| 352 | - <div v-if="timePeriods.length" class="soft-dashed-block"> | ||
| 353 | - <a-row | ||
| 354 | - v-for="(period, index) in timePeriods" | ||
| 355 | - :key="index" | ||
| 356 | - :gutter="12" | ||
| 357 | - class="plan-dynamic-row" | ||
| 358 | - > | ||
| 359 | - <a-col :span="5"> | ||
| 360 | - <a-form-item :label="index === 0 ? '开始时间' : ''"> | ||
| 361 | - <a-input v-model:value="period.startText" placeholder="22:00" /> | ||
| 362 | - </a-form-item> | ||
| 363 | - </a-col> | ||
| 364 | - <a-col :span="5"> | ||
| 365 | - <a-form-item :label="index === 0 ? '结束时间' : ''"> | ||
| 366 | - <a-input v-model:value="period.endText" placeholder="06:00" /> | ||
| 367 | - </a-form-item> | ||
| 368 | - </a-col> | ||
| 369 | - <a-col :span="5"> | ||
| 370 | - <a-form-item :label="index === 0 ? '附加费(元)' : ''"> | ||
| 371 | - <a-input-number v-model:value="period.money" :min="0" :step="0.1" style="width:100%" /> | ||
| 372 | - </a-form-item> | ||
| 373 | - </a-col> | ||
| 374 | - <a-col :span="5"> | ||
| 375 | - <a-form-item :label="index === 0 ? '状态' : ''"> | ||
| 376 | - <a-select v-model:value="period.isOpen"> | ||
| 377 | - <a-select-option :value="1">启用</a-select-option> | ||
| 378 | - <a-select-option :value="0">关闭</a-select-option> | ||
| 379 | - </a-select> | ||
| 380 | - </a-form-item> | ||
| 381 | - </a-col> | ||
| 382 | - <a-col :span="4"> | ||
| 383 | - <a-form-item :label="index === 0 ? '操作' : ''"> | ||
| 384 | - <a-button danger block @click="removeTimePeriod(index)">删除</a-button> | ||
| 385 | - </a-form-item> | ||
| 386 | - </a-col> | ||
| 387 | - </a-row> | ||
| 388 | - </div> | ||
| 389 | - <a-empty v-else description="暂无时段附加费配置" /> | ||
| 390 | - </div> | ||
| 391 | - | ||
| 392 | - <div class="plan-section plan-section-last"> | ||
| 393 | - <div class="soft-section-header"> | ||
| 394 | - <div class="soft-section-heading"> | ||
| 395 | - <h3 class="soft-section-title">预计送达与展示</h3> | ||
| 396 | - <p class="soft-section-subtitle">控制预计送达时间和骑手可视距离。</p> | ||
| 397 | - </div> | ||
| 398 | - </div> | ||
| 399 | - <a-row :gutter="16"> | ||
| 400 | - <a-col :span="12"> | ||
| 401 | - <a-form-item label="预计送达基础时间(分钟)"> | ||
| 402 | - <a-input-number v-model:value="config.distanceBasicTime" :min="0" style="width:100%" /> | ||
| 403 | - </a-form-item> | ||
| 404 | - </a-col> | ||
| 405 | - <a-col :span="12"> | ||
| 406 | - <a-form-item label="超出每km增加时间(分钟)"> | ||
| 407 | - <a-input-number v-model:value="config.distanceMoreTime" :min="0" style="width:100%" /> | ||
| 408 | - </a-form-item> | ||
| 409 | - </a-col> | ||
| 410 | - </a-row> | ||
| 411 | - <a-form-item label="附近骑手显示范围(km)"> | ||
| 412 | - <a-input-number v-model:value="config.riderDistance" :min="0" :step="0.1" style="width:100%" /> | ||
| 413 | - </a-form-item> | ||
| 414 | - </div> | ||
| 415 | - </a-form> | ||
| 416 | - </template> | ||
| 417 | - <div v-else class="plan-empty-state"> | ||
| 418 | - <a-empty description="当前租户还没有配送费方案"> | ||
| 419 | - <template #extra> | ||
| 420 | - <a-space> | ||
| 421 | - <a-button type="primary" @click="initializeDefaultPlan" :loading="planSaving">初始化默认方案</a-button> | ||
| 422 | - <a-button @click="createPlan" :loading="planSaving">新增空白方案</a-button> | ||
| 423 | - </a-space> | ||
| 424 | - </template> | ||
| 425 | - </a-empty> | ||
| 426 | - </div> | ||
| 427 | - </div> | ||
| 428 | - </div> | ||
| 429 | - </a-modal> | ||
| 430 | 47 | ||
| 431 | <a-modal v-model:open="levelVisible" :title="`骑手等级配置 - ${levelCityName}`" width="900px" :footer="null"> | 48 | <a-modal v-model:open="levelVisible" :title="`骑手等级配置 - ${levelCityName}`" width="900px" :footer="null"> |
| 432 | <div style="margin-bottom:16px;text-align:right"> | 49 | <div style="margin-bottom:16px;text-align:right"> |
| @@ -519,63 +136,22 @@ | @@ -519,63 +136,22 @@ | ||
| 519 | 136 | ||
| 520 | <script setup lang="ts"> | 137 | <script setup lang="ts"> |
| 521 | import { onMounted, reactive, ref } from 'vue' | 138 | import { onMounted, reactive, ref } from 'vue' |
| 139 | +import { useRouter } from 'vue-router' | ||
| 522 | import { message } from 'ant-design-vue' | 140 | import { message } from 'ant-design-vue' |
| 523 | import { cityApi, riderLevelApi } from '@/api' | 141 | import { cityApi, riderLevelApi } from '@/api' |
| 524 | 142 | ||
| 525 | -type TimePeriodForm = { | ||
| 526 | - startText: string | ||
| 527 | - endText: string | ||
| 528 | - isOpen: number | ||
| 529 | - money: number | null | ||
| 530 | -} | ||
| 531 | - | ||
| 532 | -type DistanceStepForm = { | ||
| 533 | - endDistance: number | null | ||
| 534 | - unitDistance: number | null | ||
| 535 | - unitFee: number | null | ||
| 536 | -} | ||
| 537 | - | ||
| 538 | -type PieceRuleForm = { | ||
| 539 | - startPiece: number | null | ||
| 540 | - endPiece: number | null | ||
| 541 | - fee: number | null | ||
| 542 | -} | ||
| 543 | - | 143 | +const router = useRouter() |
| 544 | const loading = ref(false) | 144 | const loading = ref(false) |
| 545 | const saving = ref(false) | 145 | const saving = ref(false) |
| 546 | const list = ref<any[]>([]) | 146 | const list = ref<any[]>([]) |
| 547 | const modalVisible = ref(false) | 147 | const modalVisible = ref(false) |
| 548 | -const configVisible = ref(false) | ||
| 549 | const levelVisible = ref(false) | 148 | const levelVisible = ref(false) |
| 550 | const levelEditVisible = ref(false) | 149 | const levelEditVisible = ref(false) |
| 551 | const editingId = ref<number | null>(null) | 150 | const editingId = ref<number | null>(null) |
| 552 | -const currentCityId = ref<number>(0) | ||
| 553 | -const currentCityName = ref('') | ||
| 554 | const levelCityId = ref<number>(0) | 151 | const levelCityId = ref<number>(0) |
| 555 | const levelCityName = ref('') | 152 | const levelCityName = ref('') |
| 556 | const form = reactive({ name: '', areaCode: '', rate: 0, listOrder: 0 }) | 153 | const form = reactive({ name: '', areaCode: '', rate: 0, listOrder: 0 }) |
| 557 | 154 | ||
| 558 | -const config = ref<any>(null) | ||
| 559 | -const planList = ref<any[]>([]) | ||
| 560 | -const planLoading = ref(false) | ||
| 561 | -const planSaving = ref(false) | ||
| 562 | -const selectedPlanId = ref<number | null>(null) | ||
| 563 | -const currentPlan = ref<any>(null) | ||
| 564 | -const timePeriods = ref<TimePeriodForm[]>([]) | ||
| 565 | -const distanceSteps = ref<DistanceStepForm[]>([]) | ||
| 566 | -const pieceRules = ref<PieceRuleForm[]>([]) | ||
| 567 | -const previewing = ref(false) | ||
| 568 | -const previewResult = ref<any>(null) | ||
| 569 | -const previewForm = reactive({ | ||
| 570 | - startLng: '', | ||
| 571 | - startLat: '', | ||
| 572 | - endLng: '', | ||
| 573 | - endLat: '', | ||
| 574 | - weight: 0, | ||
| 575 | - pieces: 1, | ||
| 576 | - serviceTime: '', | ||
| 577 | -}) | ||
| 578 | - | ||
| 579 | const levelLoading = ref(false) | 155 | const levelLoading = ref(false) |
| 580 | const levelSaving = ref(false) | 156 | const levelSaving = ref(false) |
| 581 | const levelList = ref<any[]>([]) | 157 | const levelList = ref<any[]>([]) |
| @@ -663,193 +239,8 @@ async function toggleStatus(record: any) { | @@ -663,193 +239,8 @@ async function toggleStatus(record: any) { | ||
| 663 | loadList() | 239 | loadList() |
| 664 | } | 240 | } |
| 665 | 241 | ||
| 666 | -async function openConfig(record: any) { | ||
| 667 | - currentCityId.value = record.id | ||
| 668 | - currentCityName.value = record.name | ||
| 669 | - configVisible.value = true | ||
| 670 | - previewResult.value = null | ||
| 671 | - Object.assign(previewForm, { startLng: '', startLat: '', endLng: '', endLat: '', weight: 0, pieces: 1, serviceTime: '' }) | ||
| 672 | - await loadPlanList(record.id) | ||
| 673 | -} | ||
| 674 | - | ||
| 675 | -async function loadPlanList(cityId: number, preferPlanId?: number) { | ||
| 676 | - planLoading.value = true | ||
| 677 | - try { | ||
| 678 | - const res: any = await cityApi.listFeePlans(cityId) | ||
| 679 | - planList.value = Array.isArray(res?.data) ? res.data : [] | ||
| 680 | - const targetPlanId = preferPlanId || planList.value.find(item => item.isDefault === 1)?.id || planList.value[0]?.id | ||
| 681 | - if (targetPlanId) { | ||
| 682 | - await selectPlan(targetPlanId) | ||
| 683 | - } else { | ||
| 684 | - selectedPlanId.value = null | ||
| 685 | - currentPlan.value = null | ||
| 686 | - config.value = null | ||
| 687 | - } | ||
| 688 | - } finally { | ||
| 689 | - planLoading.value = false | ||
| 690 | - } | ||
| 691 | -} | ||
| 692 | - | ||
| 693 | -async function selectPlan(planId: number) { | ||
| 694 | - selectedPlanId.value = planId | ||
| 695 | - const res: any = await cityApi.getFeePlan(currentCityId.value, planId) | ||
| 696 | - const detail = res?.data || {} | ||
| 697 | - currentPlan.value = { | ||
| 698 | - id: detail.id, | ||
| 699 | - name: detail.name || '', | ||
| 700 | - status: detail.status ?? 1, | ||
| 701 | - listOrder: detail.listOrder ?? 0, | ||
| 702 | - remark: detail.remark || '', | ||
| 703 | - isDefault: detail.isDefault ?? 0, | ||
| 704 | - } | ||
| 705 | - config.value = normalizeConfig(detail.config) | ||
| 706 | - distanceSteps.value = (config.value.type6.distanceSteps || []).map((step: any) => ({ | ||
| 707 | - endDistance: step.endDistance ?? 0, | ||
| 708 | - unitDistance: step.unitDistance ?? 1, | ||
| 709 | - unitFee: step.unitFee ?? 0, | ||
| 710 | - })) | ||
| 711 | - pieceRules.value = (config.value.type6.pieceRules || []).map((rule: any) => ({ | ||
| 712 | - startPiece: rule.startPiece ?? 0, | ||
| 713 | - endPiece: rule.endPiece ?? 0, | ||
| 714 | - fee: rule.fee ?? 0, | ||
| 715 | - })) | ||
| 716 | - timePeriods.value = (config.value.type6.times || []).map((period: any) => ({ | ||
| 717 | - startText: minuteToText(period.start), | ||
| 718 | - endText: minuteToText(period.end), | ||
| 719 | - isOpen: period.isOpen === 0 ? 0 : 1, | ||
| 720 | - money: period.money ?? 0, | ||
| 721 | - })) | ||
| 722 | - previewResult.value = null | ||
| 723 | -} | ||
| 724 | - | ||
| 725 | -async function createPlan() { | ||
| 726 | - if (!currentCityId.value) return | ||
| 727 | - planSaving.value = true | ||
| 728 | - try { | ||
| 729 | - const payload = { | ||
| 730 | - name: `方案${planList.value.length + 1}`, | ||
| 731 | - status: 1, | ||
| 732 | - listOrder: planList.value.length, | ||
| 733 | - remark: '', | ||
| 734 | - config: deepClone(config.value || normalizeConfig(null)), | ||
| 735 | - } | ||
| 736 | - const res: any = await cityApi.createFeePlan(currentCityId.value, payload) | ||
| 737 | - message.success('方案已创建') | ||
| 738 | - await loadPlanList(currentCityId.value, res?.data) | ||
| 739 | - } catch (err: any) { | ||
| 740 | - message.error(err?.message || '方案创建失败') | ||
| 741 | - } finally { | ||
| 742 | - planSaving.value = false | ||
| 743 | - } | ||
| 744 | -} | ||
| 745 | - | ||
| 746 | -async function initializeDefaultPlan() { | ||
| 747 | - if (!currentCityId.value) return | ||
| 748 | - planSaving.value = true | ||
| 749 | - try { | ||
| 750 | - const res: any = await cityApi.initDefaultFeePlan(currentCityId.value) | ||
| 751 | - message.success('默认方案已初始化') | ||
| 752 | - await loadPlanList(currentCityId.value, res?.data) | ||
| 753 | - } catch (err: any) { | ||
| 754 | - message.error(err?.message || '默认方案初始化失败') | ||
| 755 | - } finally { | ||
| 756 | - planSaving.value = false | ||
| 757 | - } | ||
| 758 | -} | ||
| 759 | - | ||
| 760 | -async function copyPlan() { | ||
| 761 | - if (!selectedPlanId.value) return | ||
| 762 | - const res: any = await cityApi.copyFeePlan(currentCityId.value, selectedPlanId.value) | ||
| 763 | - message.success('方案已复制') | ||
| 764 | - await loadPlanList(currentCityId.value, res?.data) | ||
| 765 | -} | ||
| 766 | - | ||
| 767 | -async function deletePlan() { | ||
| 768 | - if (!selectedPlanId.value) return | ||
| 769 | - await cityApi.deleteFeePlan(currentCityId.value, selectedPlanId.value) | ||
| 770 | - message.success('方案已删除') | ||
| 771 | - await loadPlanList(currentCityId.value) | ||
| 772 | -} | ||
| 773 | - | ||
| 774 | -async function setDefaultPlan() { | ||
| 775 | - if (!selectedPlanId.value) return | ||
| 776 | - if (currentPlan.value?.status !== 1) { | ||
| 777 | - message.error('请先启用当前方案,再设为默认') | ||
| 778 | - return | ||
| 779 | - } | ||
| 780 | - await cityApi.setDefaultFeePlan(currentCityId.value, selectedPlanId.value) | ||
| 781 | - message.success('默认方案已更新') | ||
| 782 | - await loadPlanList(currentCityId.value, selectedPlanId.value) | ||
| 783 | -} | ||
| 784 | - | ||
| 785 | -async function saveCurrentPlan() { | ||
| 786 | - if (!selectedPlanId.value || !currentPlan.value) return | ||
| 787 | - try { | ||
| 788 | - const payload = buildPlanPayload() | ||
| 789 | - planSaving.value = true | ||
| 790 | - await cityApi.updateFeePlan(currentCityId.value, selectedPlanId.value, payload) | ||
| 791 | - message.success('方案保存成功') | ||
| 792 | - await loadPlanList(currentCityId.value, selectedPlanId.value) | ||
| 793 | - } catch (err: any) { | ||
| 794 | - message.error(err?.message || '方案保存失败') | ||
| 795 | - } finally { | ||
| 796 | - planSaving.value = false | ||
| 797 | - } | ||
| 798 | -} | ||
| 799 | - | ||
| 800 | -async function previewPlan() { | ||
| 801 | - try { | ||
| 802 | - const payload = buildPlanPayload() | ||
| 803 | - if (!previewForm.startLng || !previewForm.startLat || !previewForm.endLng || !previewForm.endLat) { | ||
| 804 | - message.error('请填写完整的试算经纬度') | ||
| 805 | - return | ||
| 806 | - } | ||
| 807 | - previewing.value = true | ||
| 808 | - const res: any = await cityApi.previewFeePlan(currentCityId.value, { | ||
| 809 | - config: payload.config, | ||
| 810 | - calc: { | ||
| 811 | - startLng: previewForm.startLng, | ||
| 812 | - startLat: previewForm.startLat, | ||
| 813 | - endLng: previewForm.endLng, | ||
| 814 | - endLat: previewForm.endLat, | ||
| 815 | - weight: previewForm.weight ?? 0, | ||
| 816 | - pieces: previewForm.pieces ?? 0, | ||
| 817 | - serviceTime: previewForm.serviceTime ? Number(previewForm.serviceTime) : 0, | ||
| 818 | - }, | ||
| 819 | - }) | ||
| 820 | - previewResult.value = res?.data || null | ||
| 821 | - } catch (err: any) { | ||
| 822 | - message.error(err?.message || '试算失败') | ||
| 823 | - } finally { | ||
| 824 | - previewing.value = false | ||
| 825 | - } | ||
| 826 | -} | ||
| 827 | - | ||
| 828 | -function buildPlanPayload() { | ||
| 829 | - if (!currentPlan.value) { | ||
| 830 | - throw new Error('请选择计价方案') | ||
| 831 | - } | ||
| 832 | - const planName = String(currentPlan.value.name || '').trim() | ||
| 833 | - if (!planName) { | ||
| 834 | - throw new Error('请填写方案名称') | ||
| 835 | - } | ||
| 836 | - const nextConfig = deepClone(config.value || normalizeConfig(null)) | ||
| 837 | - nextConfig.type = [6] | ||
| 838 | - nextConfig.type6.baseSwitch = nextConfig.type6.baseFee > 0 ? 1 : 0 | ||
| 839 | - nextConfig.type6.distanceSwitch = 1 | ||
| 840 | - nextConfig.type6.weightSwitch = | ||
| 841 | - nextConfig.type6.weightFirst > 0 || nextConfig.type6.weightFirstFee > 0 || nextConfig.type6.weightUnitFee > 0 ? 1 : 0 | ||
| 842 | - nextConfig.type6.pieceSwitch = pieceRules.value.length ? 1 : 0 | ||
| 843 | - nextConfig.type6.distanceSteps = buildDistanceStepsPayload() | ||
| 844 | - nextConfig.type6.pieceRules = buildPieceRulesPayload() | ||
| 845 | - nextConfig.type6.times = buildTimesPayload() | ||
| 846 | - return { | ||
| 847 | - name: planName, | ||
| 848 | - status: currentPlan.value.status ?? 1, | ||
| 849 | - listOrder: currentPlan.value.listOrder ?? 0, | ||
| 850 | - remark: currentPlan.value.remark || '', | ||
| 851 | - config: nextConfig, | ||
| 852 | - } | 242 | +function openConfig(record: any) { |
| 243 | + router.push({ path: '/config/fee-plan', query: { cityId: String(record.id) } }) | ||
| 853 | } | 244 | } |
| 854 | 245 | ||
| 855 | function openLevelConfig(record: any) { | 246 | function openLevelConfig(record: any) { |
| @@ -948,298 +339,5 @@ function formatLevelRule(record: any) { | @@ -948,298 +339,5 @@ function formatLevelRule(record: any) { | ||
| 948 | return `起始${record.distanceBasic ?? 0}米/${record.distanceBasicMoney ?? 0}元,超出每公里${record.distanceMoreMoney ?? 0}元,上限${record.distanceMaxMoney ?? 0}元` | 339 | return `起始${record.distanceBasic ?? 0}米/${record.distanceBasicMoney ?? 0}元,超出每公里${record.distanceMoreMoney ?? 0}元,上限${record.distanceMaxMoney ?? 0}元` |
| 949 | } | 340 | } |
| 950 | 341 | ||
| 951 | -function createDefaultType6() { | ||
| 952 | - return { | ||
| 953 | - minFee: 0, | ||
| 954 | - baseSwitch: 0, | ||
| 955 | - baseFee: 0, | ||
| 956 | - feeMode: 2, | ||
| 957 | - fixMoney: 0, | ||
| 958 | - distanceSwitch: 1, | ||
| 959 | - distanceBasic: 3, | ||
| 960 | - distanceBasicMoney: 4, | ||
| 961 | - distanceMoreMoney: 1.5, | ||
| 962 | - distanceType: 1, | ||
| 963 | - distanceSteps: [], | ||
| 964 | - weightSwitch: 1, | ||
| 965 | - weightFirst: 5, | ||
| 966 | - weightFirstFee: 0, | ||
| 967 | - weightUnitFee: 1, | ||
| 968 | - weightCapFee: 30, | ||
| 969 | - weightBasic: 0, | ||
| 970 | - weightBasicMoney: 0, | ||
| 971 | - weightMoreMoney: 0, | ||
| 972 | - weightType: 1, | ||
| 973 | - pieceSwitch: 0, | ||
| 974 | - pieceRules: [], | ||
| 975 | - times: [], | ||
| 976 | - } | ||
| 977 | -} | ||
| 978 | - | ||
| 979 | -function normalizeConfig(raw: any) { | ||
| 980 | - const next = raw || {} | ||
| 981 | - const type6 = { ...createDefaultType6(), ...(next.type6 || {}) } | ||
| 982 | - return { | ||
| 983 | - ...next, | ||
| 984 | - type: [6], | ||
| 985 | - type6: { | ||
| 986 | - ...type6, | ||
| 987 | - distanceSteps: Array.isArray(type6.distanceSteps) ? type6.distanceSteps : [], | ||
| 988 | - pieceRules: Array.isArray(type6.pieceRules) ? type6.pieceRules : [], | ||
| 989 | - times: Array.isArray(type6.times) ? type6.times : [], | ||
| 990 | - }, | ||
| 991 | - distanceBasic: next.distanceBasic ?? 3, | ||
| 992 | - distanceBasicTime: next.distanceBasicTime ?? 30, | ||
| 993 | - distanceMoreTime: next.distanceMoreTime ?? 10, | ||
| 994 | - riderDistance: next.riderDistance ?? 3, | ||
| 995 | - } | ||
| 996 | -} | ||
| 997 | - | ||
| 998 | -function addTimePeriod() { | ||
| 999 | - timePeriods.value.push({ startText: '', endText: '', isOpen: 1, money: 0 }) | ||
| 1000 | -} | ||
| 1001 | - | ||
| 1002 | -function removeTimePeriod(index: number) { | ||
| 1003 | - timePeriods.value.splice(index, 1) | ||
| 1004 | -} | ||
| 1005 | - | ||
| 1006 | -function addDistanceStep() { | ||
| 1007 | - distanceSteps.value.push({ endDistance: 0, unitDistance: 1, unitFee: 0 }) | ||
| 1008 | -} | ||
| 1009 | - | ||
| 1010 | -function removeDistanceStep(index: number) { | ||
| 1011 | - distanceSteps.value.splice(index, 1) | ||
| 1012 | -} | ||
| 1013 | - | ||
| 1014 | -function addPieceRule() { | ||
| 1015 | - pieceRules.value.push({ startPiece: 0, endPiece: 0, fee: 0 }) | ||
| 1016 | -} | ||
| 1017 | - | ||
| 1018 | -function removePieceRule(index: number) { | ||
| 1019 | - pieceRules.value.splice(index, 1) | ||
| 1020 | -} | ||
| 1021 | - | ||
| 1022 | -function buildDistanceStepsPayload() { | ||
| 1023 | - let prevEnd = config.value.type6.distanceBasic ?? 0 | ||
| 1024 | - return distanceSteps.value.map((step, index) => { | ||
| 1025 | - const endDistance = step.endDistance ?? 0 | ||
| 1026 | - const unitDistance = step.unitDistance ?? 0 | ||
| 1027 | - const unitFee = step.unitFee ?? 0 | ||
| 1028 | - if (endDistance <= prevEnd) { | ||
| 1029 | - throw new Error(`第${index + 1}条里程阶梯结束里程必须大于上一阶梯`) | ||
| 1030 | - } | ||
| 1031 | - if (unitDistance <= 0) { | ||
| 1032 | - throw new Error(`第${index + 1}条里程阶梯每档里程必须大于0`) | ||
| 1033 | - } | ||
| 1034 | - prevEnd = endDistance | ||
| 1035 | - return { endDistance, unitDistance, unitFee, listOrder: index } | ||
| 1036 | - }) | ||
| 1037 | -} | ||
| 1038 | - | ||
| 1039 | -function buildPieceRulesPayload() { | ||
| 1040 | - const payload = pieceRules.value | ||
| 1041 | - .map((rule, index) => { | ||
| 1042 | - const startPiece = rule.startPiece ?? 0 | ||
| 1043 | - const endPiece = rule.endPiece ?? 0 | ||
| 1044 | - if (startPiece > endPiece) { | ||
| 1045 | - throw new Error(`第${index + 1}条件数区间起始值不能大于结束值`) | ||
| 1046 | - } | ||
| 1047 | - return { startPiece, endPiece, fee: rule.fee ?? 0, listOrder: index } | ||
| 1048 | - }) | ||
| 1049 | - .sort((a, b) => a.startPiece - b.startPiece) | ||
| 1050 | - | ||
| 1051 | - for (let index = 1; index < payload.length; index += 1) { | ||
| 1052 | - if (payload[index].startPiece <= payload[index - 1].endPiece) { | ||
| 1053 | - throw new Error('件数区间不能重叠') | ||
| 1054 | - } | ||
| 1055 | - } | ||
| 1056 | - return payload | ||
| 1057 | -} | ||
| 1058 | - | ||
| 1059 | -function buildTimesPayload() { | ||
| 1060 | - return timePeriods.value | ||
| 1061 | - .map((period, index) => { | ||
| 1062 | - const hasContent = period.startText || period.endText || period.money | ||
| 1063 | - if (!hasContent) return null | ||
| 1064 | - const start = textToMinute(period.startText, `第${index + 1}条时段开始时间格式错误`) | ||
| 1065 | - const end = textToMinute(period.endText, `第${index + 1}条时段结束时间格式错误`) | ||
| 1066 | - if (start === end) { | ||
| 1067 | - throw new Error(`第${index + 1}条时段开始时间不能等于结束时间`) | ||
| 1068 | - } | ||
| 1069 | - return { | ||
| 1070 | - start, | ||
| 1071 | - end, | ||
| 1072 | - isOpen: period.isOpen === 0 ? 0 : 1, | ||
| 1073 | - money: period.money ?? 0, | ||
| 1074 | - } | ||
| 1075 | - }) | ||
| 1076 | - .filter(Boolean) | ||
| 1077 | -} | ||
| 1078 | - | ||
| 1079 | -function textToMinute(text: string, errorMessage: string) { | ||
| 1080 | - const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec((text || '').trim()) | ||
| 1081 | - if (!match) { | ||
| 1082 | - throw new Error(errorMessage) | ||
| 1083 | - } | ||
| 1084 | - return Number(match[1]) * 60 + Number(match[2]) | ||
| 1085 | -} | ||
| 1086 | - | ||
| 1087 | -function minuteToText(value: number | null | undefined) { | ||
| 1088 | - if (typeof value !== 'number' || Number.isNaN(value)) return '' | ||
| 1089 | - const hour = Math.floor(value / 60) | ||
| 1090 | - const minute = value % 60 | ||
| 1091 | - return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}` | ||
| 1092 | -} | ||
| 1093 | - | ||
| 1094 | -function deepClone<T>(value: T): T { | ||
| 1095 | - return JSON.parse(JSON.stringify(value)) | ||
| 1096 | -} | ||
| 1097 | - | ||
| 1098 | onMounted(loadList) | 342 | onMounted(loadList) |
| 1099 | </script> | 343 | </script> |
| 1100 | - | ||
| 1101 | -<style scoped> | ||
| 1102 | -.plan-layout { | ||
| 1103 | - display: flex; | ||
| 1104 | - gap: 20px; | ||
| 1105 | - min-height: 720px; | ||
| 1106 | -} | ||
| 1107 | - | ||
| 1108 | -.plan-sidebar { | ||
| 1109 | - width: 280px; | ||
| 1110 | - flex-shrink: 0; | ||
| 1111 | - display: flex; | ||
| 1112 | - flex-direction: column; | ||
| 1113 | - border-radius: 16px; | ||
| 1114 | - background: #f7f8fc; | ||
| 1115 | - padding: 16px; | ||
| 1116 | -} | ||
| 1117 | - | ||
| 1118 | -.plan-sidebar-header { | ||
| 1119 | - display: flex; | ||
| 1120 | - align-items: flex-start; | ||
| 1121 | - justify-content: space-between; | ||
| 1122 | - gap: 12px; | ||
| 1123 | - margin-bottom: 16px; | ||
| 1124 | -} | ||
| 1125 | - | ||
| 1126 | -.plan-sidebar-title { | ||
| 1127 | - font-size: 16px; | ||
| 1128 | - font-weight: 600; | ||
| 1129 | - color: #1f2430; | ||
| 1130 | -} | ||
| 1131 | - | ||
| 1132 | -.plan-sidebar-subtitle { | ||
| 1133 | - margin-top: 4px; | ||
| 1134 | - font-size: 12px; | ||
| 1135 | - color: #7a8091; | ||
| 1136 | - line-height: 1.5; | ||
| 1137 | -} | ||
| 1138 | - | ||
| 1139 | -.plan-list { | ||
| 1140 | - display: flex; | ||
| 1141 | - flex-direction: column; | ||
| 1142 | - gap: 12px; | ||
| 1143 | - max-height: 640px; | ||
| 1144 | - overflow-y: auto; | ||
| 1145 | - padding-right: 4px; | ||
| 1146 | -} | ||
| 1147 | - | ||
| 1148 | -.plan-item { | ||
| 1149 | - width: 100%; | ||
| 1150 | - border: 0; | ||
| 1151 | - border-radius: 14px; | ||
| 1152 | - background: #fff; | ||
| 1153 | - padding: 14px; | ||
| 1154 | - text-align: left; | ||
| 1155 | - cursor: pointer; | ||
| 1156 | - box-shadow: 0 8px 20px rgba(31, 36, 48, 0.06); | ||
| 1157 | - transition: all 0.2s ease; | ||
| 1158 | -} | ||
| 1159 | - | ||
| 1160 | -.plan-item.active { | ||
| 1161 | - background: #eef2ff; | ||
| 1162 | - box-shadow: 0 10px 24px rgba(99, 102, 241, 0.18); | ||
| 1163 | -} | ||
| 1164 | - | ||
| 1165 | -.plan-item-top { | ||
| 1166 | - display: flex; | ||
| 1167 | - align-items: center; | ||
| 1168 | - justify-content: space-between; | ||
| 1169 | - gap: 8px; | ||
| 1170 | - margin-bottom: 8px; | ||
| 1171 | -} | ||
| 1172 | - | ||
| 1173 | -.plan-item-name { | ||
| 1174 | - font-size: 14px; | ||
| 1175 | - font-weight: 600; | ||
| 1176 | - color: #1f2430; | ||
| 1177 | -} | ||
| 1178 | - | ||
| 1179 | -.plan-item-bottom { | ||
| 1180 | - display: flex; | ||
| 1181 | - justify-content: space-between; | ||
| 1182 | - font-size: 12px; | ||
| 1183 | - color: #7a8091; | ||
| 1184 | -} | ||
| 1185 | - | ||
| 1186 | -.plan-content { | ||
| 1187 | - flex: 1; | ||
| 1188 | - min-width: 0; | ||
| 1189 | - max-height: 720px; | ||
| 1190 | - overflow-y: auto; | ||
| 1191 | - padding-right: 4px; | ||
| 1192 | -} | ||
| 1193 | - | ||
| 1194 | -.plan-note-card { | ||
| 1195 | - margin-bottom: 16px; | ||
| 1196 | -} | ||
| 1197 | - | ||
| 1198 | -.plan-toolbar { | ||
| 1199 | - display: flex; | ||
| 1200 | - align-items: center; | ||
| 1201 | - justify-content: space-between; | ||
| 1202 | - gap: 12px; | ||
| 1203 | - margin-bottom: 16px; | ||
| 1204 | -} | ||
| 1205 | - | ||
| 1206 | -.plan-section { | ||
| 1207 | - margin-bottom: 22px; | ||
| 1208 | - padding-bottom: 18px; | ||
| 1209 | - border-bottom: 1px solid rgba(206, 196, 244, 0.2); | ||
| 1210 | -} | ||
| 1211 | - | ||
| 1212 | -.plan-section-last { | ||
| 1213 | - margin-bottom: 0; | ||
| 1214 | - padding-bottom: 0; | ||
| 1215 | - border-bottom: 0; | ||
| 1216 | -} | ||
| 1217 | - | ||
| 1218 | -.plan-dynamic-row { | ||
| 1219 | - margin-bottom: 12px; | ||
| 1220 | - align-items: flex-start; | ||
| 1221 | -} | ||
| 1222 | - | ||
| 1223 | -.preview-card { | ||
| 1224 | - margin-bottom: 20px; | ||
| 1225 | - border-radius: 16px; | ||
| 1226 | - background: #fafbff; | ||
| 1227 | -} | ||
| 1228 | - | ||
| 1229 | -.preview-subtitle { | ||
| 1230 | - margin-bottom: 14px; | ||
| 1231 | -} | ||
| 1232 | - | ||
| 1233 | -.preview-result { | ||
| 1234 | - display: flex; | ||
| 1235 | - flex-wrap: wrap; | ||
| 1236 | - gap: 8px; | ||
| 1237 | -} | ||
| 1238 | - | ||
| 1239 | -.plan-empty-state { | ||
| 1240 | - min-height: 520px; | ||
| 1241 | - display: flex; | ||
| 1242 | - align-items: center; | ||
| 1243 | - justify-content: center; | ||
| 1244 | -} | ||
| 1245 | -</style> |
src/views/config/FeePlanList.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <div> | ||
| 3 | + <a-card title="配送费配置" :bordered="false" class="list-table-card"> | ||
| 4 | + <div class="list-toolbar"> | ||
| 5 | + <div class="list-toolbar-left"> | ||
| 6 | + <a-select | ||
| 7 | + v-if="isAdmin" | ||
| 8 | + v-model:value="selectedCityId" | ||
| 9 | + placeholder="选择租户" | ||
| 10 | + class="list-filter" | ||
| 11 | + @change="handleCityChange" | ||
| 12 | + > | ||
| 13 | + <a-select-option v-for="c in cityList" :key="c.id" :value="c.id">{{ c.name }}</a-select-option> | ||
| 14 | + </a-select> | ||
| 15 | + <div v-else class="managed-city-pill">当前租户:{{ currentCityName || `租户#${selectedCityId}` }}</div> | ||
| 16 | + </div> | ||
| 17 | + <div class="list-toolbar-right"> | ||
| 18 | + <a-button type="primary" :disabled="!selectedCityId" @click="createPlan">新增方案</a-button> | ||
| 19 | + </div> | ||
| 20 | + </div> | ||
| 21 | + | ||
| 22 | + <div v-if="selectedCityId" class="plan-layout"> | ||
| 23 | + <div class="plan-sidebar"> | ||
| 24 | + <div class="plan-sidebar-header"> | ||
| 25 | + <div> | ||
| 26 | + <div class="plan-sidebar-title">计价方案</div> | ||
| 27 | + <div class="plan-sidebar-subtitle">同一租户可维护多套外卖配送规则</div> | ||
| 28 | + </div> | ||
| 29 | + </div> | ||
| 30 | + <a-spin :spinning="planLoading"> | ||
| 31 | + <div class="plan-list"> | ||
| 32 | + <button | ||
| 33 | + v-for="item in planList" | ||
| 34 | + :key="item.id" | ||
| 35 | + type="button" | ||
| 36 | + class="plan-item" | ||
| 37 | + :class="{ active: item.id === selectedPlanId }" | ||
| 38 | + @click="selectPlan(item.id)" | ||
| 39 | + > | ||
| 40 | + <div class="plan-item-top"> | ||
| 41 | + <span class="plan-item-name">{{ item.name }}</span> | ||
| 42 | + <a-tag v-if="item.isDefault === 1" color="green">默认</a-tag> | ||
| 43 | + </div> | ||
| 44 | + <div class="plan-item-bottom"> | ||
| 45 | + <span>{{ item.status === 1 ? '启用中' : '已停用' }}</span> | ||
| 46 | + <span>排序 {{ item.listOrder ?? 0 }}</span> | ||
| 47 | + </div> | ||
| 48 | + </button> | ||
| 49 | + <a-empty v-if="!planList.length" description="暂无计价方案" /> | ||
| 50 | + </div> | ||
| 51 | + </a-spin> | ||
| 52 | + </div> | ||
| 53 | + | ||
| 54 | + <div class="plan-content"> | ||
| 55 | + <template v-if="currentPlan && config"> | ||
| 56 | + <div class="plan-content-top"> | ||
| 57 | + <div class="soft-note-card plan-note-card"> | ||
| 58 | + <strong>当前配置说明</strong> | ||
| 59 | + <p>同一租户可维护多套配送费方案,并指定默认启用方案用于实时计价。</p> | ||
| 60 | + </div> | ||
| 61 | + | ||
| 62 | + <div class="plan-toolbar"> | ||
| 63 | + <div class="plan-toolbar-meta"> | ||
| 64 | + <span class="plan-toolbar-eyebrow">当前编辑</span> | ||
| 65 | + <strong>{{ currentPlan.name || '未命名方案' }}</strong> | ||
| 66 | + </div> | ||
| 67 | + <a-space wrap> | ||
| 68 | + <a-button @click="copyPlan" :disabled="!selectedPlanId">复制当前方案</a-button> | ||
| 69 | + <a-button @click="setDefaultPlan" :disabled="currentPlan.isDefault === 1 || currentPlan.status !== 1">设为默认</a-button> | ||
| 70 | + <a-popconfirm title="确认删除当前方案?" @confirm="deletePlan"> | ||
| 71 | + <a-button danger :disabled="currentPlan.isDefault === 1">删除方案</a-button> | ||
| 72 | + </a-popconfirm> | ||
| 73 | + </a-space> | ||
| 74 | + <div class="plan-toolbar-submit"> | ||
| 75 | + <span class="plan-toolbar-tip">支持先试算,再保存当前方案</span> | ||
| 76 | + <a-space wrap> | ||
| 77 | + <a-button @click="previewPlan" :loading="previewing">试算配送费</a-button> | ||
| 78 | + <a-button type="primary" class="plan-save-button" @click="saveCurrentPlan" :loading="planSaving">保存当前方案</a-button> | ||
| 79 | + </a-space> | ||
| 80 | + </div> | ||
| 81 | + </div> | ||
| 82 | + </div> | ||
| 83 | + | ||
| 84 | + <div class="plan-content-body"> | ||
| 85 | + <div class="plan-section"> | ||
| 86 | + <div class="plan-section-head"> | ||
| 87 | + <div class="plan-section-chip">基础</div> | ||
| 88 | + <div class="soft-section-header"> | ||
| 89 | + <div class="soft-section-heading"> | ||
| 90 | + <h3 class="soft-section-title">方案基础信息</h3> | ||
| 91 | + <p class="soft-section-subtitle">维护方案名称、状态和备注说明。</p> | ||
| 92 | + </div> | ||
| 93 | + </div> | ||
| 94 | + </div> | ||
| 95 | + | ||
| 96 | + <a-row :gutter="16"> | ||
| 97 | + <a-col :span="8"> | ||
| 98 | + <a-form-item label="方案名称"> | ||
| 99 | + <a-input v-model:value="currentPlan.name" placeholder="如:标准午高峰方案" /> | ||
| 100 | + </a-form-item> | ||
| 101 | + </a-col> | ||
| 102 | + <a-col :span="8"> | ||
| 103 | + <a-form-item label="状态"> | ||
| 104 | + <a-select v-model:value="currentPlan.status"> | ||
| 105 | + <a-select-option :value="1">启用</a-select-option> | ||
| 106 | + <a-select-option :value="0">停用</a-select-option> | ||
| 107 | + </a-select> | ||
| 108 | + </a-form-item> | ||
| 109 | + </a-col> | ||
| 110 | + <a-col :span="8"> | ||
| 111 | + <a-form-item label="排序"> | ||
| 112 | + <a-input-number v-model:value="currentPlan.listOrder" :min="0" style="width:100%" /> | ||
| 113 | + </a-form-item> | ||
| 114 | + </a-col> | ||
| 115 | + </a-row> | ||
| 116 | + <a-form-item label="备注"> | ||
| 117 | + <a-input v-model:value="currentPlan.remark" placeholder="可填写适用业务场景说明" /> | ||
| 118 | + </a-form-item> | ||
| 119 | + </div> | ||
| 120 | + | ||
| 121 | + <a-card class="preview-card" :bordered="false"> | ||
| 122 | + <template #title>草稿试算</template> | ||
| 123 | + <div class="soft-section-subtitle preview-subtitle">保存前可先按草稿参数试算配送费,确认距离、时段和附加项是否符合预期。</div> | ||
| 124 | + <a-row :gutter="12"> | ||
| 125 | + <a-col :span="6"> | ||
| 126 | + <a-form-item label="起点经度"> | ||
| 127 | + <a-input v-model:value="previewForm.startLng" placeholder="121.4737" /> | ||
| 128 | + </a-form-item> | ||
| 129 | + </a-col> | ||
| 130 | + <a-col :span="6"> | ||
| 131 | + <a-form-item label="起点纬度"> | ||
| 132 | + <a-input v-model:value="previewForm.startLat" placeholder="31.2304" /> | ||
| 133 | + </a-form-item> | ||
| 134 | + </a-col> | ||
| 135 | + <a-col :span="6"> | ||
| 136 | + <a-form-item label="终点经度"> | ||
| 137 | + <a-input v-model:value="previewForm.endLng" placeholder="121.4879" /> | ||
| 138 | + </a-form-item> | ||
| 139 | + </a-col> | ||
| 140 | + <a-col :span="6"> | ||
| 141 | + <a-form-item label="终点纬度"> | ||
| 142 | + <a-input v-model:value="previewForm.endLat" placeholder="31.2492" /> | ||
| 143 | + </a-form-item> | ||
| 144 | + </a-col> | ||
| 145 | + </a-row> | ||
| 146 | + <a-row :gutter="12"> | ||
| 147 | + <a-col :span="8"> | ||
| 148 | + <a-form-item label="重量(kg)"> | ||
| 149 | + <a-input-number v-model:value="previewForm.weight" :min="0" :step="0.1" style="width:100%" /> | ||
| 150 | + </a-form-item> | ||
| 151 | + </a-col> | ||
| 152 | + <a-col :span="8"> | ||
| 153 | + <a-form-item label="件数"> | ||
| 154 | + <a-input-number v-model:value="previewForm.pieces" :min="0" style="width:100%" /> | ||
| 155 | + </a-form-item> | ||
| 156 | + </a-col> | ||
| 157 | + <a-col :span="8"> | ||
| 158 | + <a-form-item label="服务时间戳(秒,可空)"> | ||
| 159 | + <a-input v-model:value="previewForm.serviceTime" placeholder="留空则按当前时间" /> | ||
| 160 | + </a-form-item> | ||
| 161 | + </a-col> | ||
| 162 | + </a-row> | ||
| 163 | + <div v-if="previewResult" class="preview-result"> | ||
| 164 | + <a-tag color="processing">总配送费 {{ previewResult.totalFee ?? 0 }} 元</a-tag> | ||
| 165 | + <a-tag>基础 {{ previewResult.moneyBasic ?? 0 }}</a-tag> | ||
| 166 | + <a-tag>里程 {{ previewResult.moneyDistance ?? 0 }}</a-tag> | ||
| 167 | + <a-tag>重量 {{ previewResult.moneyWeight ?? 0 }}</a-tag> | ||
| 168 | + <a-tag>件数 {{ previewResult.moneyPiece ?? 0 }}</a-tag> | ||
| 169 | + <a-tag>时段 {{ previewResult.moneyTime ?? 0 }}</a-tag> | ||
| 170 | + <a-tag>预计送达 {{ previewResult.estimatedMinutes ?? 0 }} 分钟</a-tag> | ||
| 171 | + <a-tag>里程 {{ previewResult.distance ?? 0 }} km</a-tag> | ||
| 172 | + <a-tag v-if="previewResult.minFeeApplied === 1" color="gold">已触发保底 {{ previewResult.minFee ?? 0 }}</a-tag> | ||
| 173 | + <a-tag v-if="previewResult.moneyTime > 0" color="purple">当前服务时间命中时段加价</a-tag> | ||
| 174 | + </div> | ||
| 175 | + </a-card> | ||
| 176 | + | ||
| 177 | + <a-form :model="config" layout="vertical"> | ||
| 178 | + <div class="plan-section"> | ||
| 179 | + <div class="plan-section-head"> | ||
| 180 | + <div class="plan-section-chip">总览</div> | ||
| 181 | + <div class="soft-section-header"> | ||
| 182 | + <div class="soft-section-heading"> | ||
| 183 | + <h3 class="soft-section-title">费用总览</h3> | ||
| 184 | + <p class="soft-section-subtitle">基础费与保底费会参与最终总价计算。</p> | ||
| 185 | + </div> | ||
| 186 | + </div> | ||
| 187 | + </div> | ||
| 188 | + <a-row :gutter="16"> | ||
| 189 | + <a-col :span="12"> | ||
| 190 | + <a-form-item label="保底费用(元)"> | ||
| 191 | + <a-input-number v-model:value="config.type6.minFee" :min="0" :step="0.1" style="width:100%" /> | ||
| 192 | + </a-form-item> | ||
| 193 | + </a-col> | ||
| 194 | + <a-col :span="12"> | ||
| 195 | + <a-form-item label="基础费(元/单)"> | ||
| 196 | + <a-input-number v-model:value="config.type6.baseFee" :min="0" :step="0.1" style="width:100%" /> | ||
| 197 | + </a-form-item> | ||
| 198 | + </a-col> | ||
| 199 | + </a-row> | ||
| 200 | + </div> | ||
| 201 | + | ||
| 202 | + <div class="plan-section"> | ||
| 203 | + <div class="plan-section-head"> | ||
| 204 | + <div class="plan-section-chip">里程</div> | ||
| 205 | + <div class="soft-section-header"> | ||
| 206 | + <div class="soft-section-heading"> | ||
| 207 | + <h3 class="soft-section-title">里程阶梯</h3> | ||
| 208 | + <p class="soft-section-subtitle">先配置起步距离,再追加超出后的阶梯规则。</p> | ||
| 209 | + </div> | ||
| 210 | + <div class="soft-section-actions"> | ||
| 211 | + <a-button type="dashed" @click="addDistanceStep">新增阶梯段</a-button> | ||
| 212 | + </div> | ||
| 213 | + </div> | ||
| 214 | + </div> | ||
| 215 | + <a-row :gutter="16"> | ||
| 216 | + <a-col :span="12"> | ||
| 217 | + <a-form-item label="起步里程(km内)"> | ||
| 218 | + <a-input-number v-model:value="config.type6.distanceBasic" :min="0" :step="0.1" style="width:100%" /> | ||
| 219 | + </a-form-item> | ||
| 220 | + </a-col> | ||
| 221 | + <a-col :span="12"> | ||
| 222 | + <a-form-item label="起步费用(元)"> | ||
| 223 | + <a-input-number v-model:value="config.type6.distanceBasicMoney" :min="0" :step="0.1" style="width:100%" /> | ||
| 224 | + </a-form-item> | ||
| 225 | + </a-col> | ||
| 226 | + </a-row> | ||
| 227 | + <div v-if="distanceSteps.length" class="soft-dashed-block"> | ||
| 228 | + <a-row v-for="(step, index) in distanceSteps" :key="index" :gutter="12" class="plan-dynamic-row"> | ||
| 229 | + <a-col :span="7"> | ||
| 230 | + <a-form-item :label="index === 0 ? '结束里程(km)' : ''"> | ||
| 231 | + <a-input-number v-model:value="step.endDistance" :min="0" :step="0.1" style="width:100%" /> | ||
| 232 | + </a-form-item> | ||
| 233 | + </a-col> | ||
| 234 | + <a-col :span="7"> | ||
| 235 | + <a-form-item :label="index === 0 ? '每档里程(km)' : ''"> | ||
| 236 | + <a-input-number v-model:value="step.unitDistance" :min="0.1" :step="0.1" style="width:100%" /> | ||
| 237 | + </a-form-item> | ||
| 238 | + </a-col> | ||
| 239 | + <a-col :span="7"> | ||
| 240 | + <a-form-item :label="index === 0 ? '每档加价(元)' : ''"> | ||
| 241 | + <a-input-number v-model:value="step.unitFee" :min="0" :step="0.1" style="width:100%" /> | ||
| 242 | + </a-form-item> | ||
| 243 | + </a-col> | ||
| 244 | + <a-col :span="3"> | ||
| 245 | + <a-form-item :label="index === 0 ? '操作' : ''"> | ||
| 246 | + <a-button danger block @click="removeDistanceStep(index)">删除</a-button> | ||
| 247 | + </a-form-item> | ||
| 248 | + </a-col> | ||
| 249 | + </a-row> | ||
| 250 | + </div> | ||
| 251 | + <a-empty v-else description="暂无里程阶梯配置" /> | ||
| 252 | + </div> | ||
| 253 | + | ||
| 254 | + <div class="plan-section"> | ||
| 255 | + <div class="plan-section-head"> | ||
| 256 | + <div class="plan-section-chip">重量</div> | ||
| 257 | + <div class="soft-section-header"> | ||
| 258 | + <div class="soft-section-heading"> | ||
| 259 | + <h3 class="soft-section-title">重量计费</h3> | ||
| 260 | + <p class="soft-section-subtitle">重量按首重和续重单价计算,可设置费用封顶。</p> | ||
| 261 | + </div> | ||
| 262 | + </div> | ||
| 263 | + </div> | ||
| 264 | + <a-row :gutter="16"> | ||
| 265 | + <a-col :span="12"> | ||
| 266 | + <a-form-item label="首重(kg)"> | ||
| 267 | + <a-input-number v-model:value="config.type6.weightFirst" :min="0" :step="0.1" style="width:100%" /> | ||
| 268 | + </a-form-item> | ||
| 269 | + </a-col> | ||
| 270 | + <a-col :span="12"> | ||
| 271 | + <a-form-item label="首重费用(元)"> | ||
| 272 | + <a-input-number v-model:value="config.type6.weightFirstFee" :min="0" :step="0.1" style="width:100%" /> | ||
| 273 | + </a-form-item> | ||
| 274 | + </a-col> | ||
| 275 | + </a-row> | ||
| 276 | + <a-row :gutter="16"> | ||
| 277 | + <a-col :span="12"> | ||
| 278 | + <a-form-item label="续重单价(元/kg)"> | ||
| 279 | + <a-input-number v-model:value="config.type6.weightUnitFee" :min="0" :step="0.1" style="width:100%" /> | ||
| 280 | + </a-form-item> | ||
| 281 | + </a-col> | ||
| 282 | + <a-col :span="12"> | ||
| 283 | + <a-form-item label="封顶费用(元)"> | ||
| 284 | + <a-input-number v-model:value="config.type6.weightCapFee" :min="0" :step="0.1" style="width:100%" /> | ||
| 285 | + </a-form-item> | ||
| 286 | + </a-col> | ||
| 287 | + </a-row> | ||
| 288 | + </div> | ||
| 289 | + | ||
| 290 | + <div class="plan-section"> | ||
| 291 | + <div class="plan-section-head"> | ||
| 292 | + <div class="plan-section-chip">件数</div> | ||
| 293 | + <div class="soft-section-header"> | ||
| 294 | + <div class="soft-section-heading"> | ||
| 295 | + <h3 class="soft-section-title">件数计费</h3> | ||
| 296 | + <p class="soft-section-subtitle">适合按商品件数额外加价的场景。</p> | ||
| 297 | + </div> | ||
| 298 | + <div class="soft-section-actions"> | ||
| 299 | + <a-button type="dashed" @click="addPieceRule">新增件数区间</a-button> | ||
| 300 | + </div> | ||
| 301 | + </div> | ||
| 302 | + </div> | ||
| 303 | + <div v-if="pieceRules.length" class="soft-dashed-block"> | ||
| 304 | + <a-row v-for="(rule, index) in pieceRules" :key="index" :gutter="12" class="plan-dynamic-row"> | ||
| 305 | + <a-col :span="6"> | ||
| 306 | + <a-form-item :label="index === 0 ? '起始件数' : ''"> | ||
| 307 | + <a-input-number v-model:value="rule.startPiece" :min="0" style="width:100%" /> | ||
| 308 | + </a-form-item> | ||
| 309 | + </a-col> | ||
| 310 | + <a-col :span="6"> | ||
| 311 | + <a-form-item :label="index === 0 ? '结束件数' : ''"> | ||
| 312 | + <a-input-number v-model:value="rule.endPiece" :min="0" style="width:100%" /> | ||
| 313 | + </a-form-item> | ||
| 314 | + </a-col> | ||
| 315 | + <a-col :span="8"> | ||
| 316 | + <a-form-item :label="index === 0 ? '费用(元)' : ''"> | ||
| 317 | + <a-input-number v-model:value="rule.fee" :min="0" :step="0.1" style="width:100%" /> | ||
| 318 | + </a-form-item> | ||
| 319 | + </a-col> | ||
| 320 | + <a-col :span="4"> | ||
| 321 | + <a-form-item :label="index === 0 ? '操作' : ''"> | ||
| 322 | + <a-button danger block @click="removePieceRule(index)">删除</a-button> | ||
| 323 | + </a-form-item> | ||
| 324 | + </a-col> | ||
| 325 | + </a-row> | ||
| 326 | + </div> | ||
| 327 | + <a-empty v-else description="暂无件数区间配置" /> | ||
| 328 | + </div> | ||
| 329 | + | ||
| 330 | + <div class="plan-section"> | ||
| 331 | + <div class="plan-section-head"> | ||
| 332 | + <div class="plan-section-chip">时段</div> | ||
| 333 | + <div class="soft-section-header"> | ||
| 334 | + <div class="soft-section-heading"> | ||
| 335 | + <h3 class="soft-section-title">时段附加费</h3> | ||
| 336 | + <p class="soft-section-subtitle">用于午晚高峰或夜间等时段加价。</p> | ||
| 337 | + </div> | ||
| 338 | + <div class="soft-section-actions"> | ||
| 339 | + <a-button type="dashed" @click="addTimePeriod">新增时段</a-button> | ||
| 340 | + </div> | ||
| 341 | + </div> | ||
| 342 | + </div> | ||
| 343 | + <div v-if="timePeriods.length" class="soft-dashed-block"> | ||
| 344 | + <a-row v-for="(period, index) in timePeriods" :key="index" :gutter="12" class="plan-dynamic-row"> | ||
| 345 | + <a-col :span="5"> | ||
| 346 | + <a-form-item :label="index === 0 ? '开始时间' : ''"> | ||
| 347 | + <a-input v-model:value="period.startText" placeholder="22:00" /> | ||
| 348 | + </a-form-item> | ||
| 349 | + </a-col> | ||
| 350 | + <a-col :span="5"> | ||
| 351 | + <a-form-item :label="index === 0 ? '结束时间' : ''"> | ||
| 352 | + <a-input v-model:value="period.endText" placeholder="06:00" /> | ||
| 353 | + </a-form-item> | ||
| 354 | + </a-col> | ||
| 355 | + <a-col :span="5"> | ||
| 356 | + <a-form-item :label="index === 0 ? '附加费(元)' : ''"> | ||
| 357 | + <a-input-number v-model:value="period.money" :min="0" :step="0.1" style="width:100%" /> | ||
| 358 | + </a-form-item> | ||
| 359 | + </a-col> | ||
| 360 | + <a-col :span="5"> | ||
| 361 | + <a-form-item :label="index === 0 ? '状态' : ''"> | ||
| 362 | + <a-select v-model:value="period.isOpen"> | ||
| 363 | + <a-select-option :value="1">启用</a-select-option> | ||
| 364 | + <a-select-option :value="0">关闭</a-select-option> | ||
| 365 | + </a-select> | ||
| 366 | + </a-form-item> | ||
| 367 | + </a-col> | ||
| 368 | + <a-col :span="4"> | ||
| 369 | + <a-form-item :label="index === 0 ? '操作' : ''"> | ||
| 370 | + <a-button danger block @click="removeTimePeriod(index)">删除</a-button> | ||
| 371 | + </a-form-item> | ||
| 372 | + </a-col> | ||
| 373 | + </a-row> | ||
| 374 | + </div> | ||
| 375 | + <a-empty v-else description="暂无时段附加费配置" /> | ||
| 376 | + </div> | ||
| 377 | + | ||
| 378 | + <div class="plan-section plan-section-last"> | ||
| 379 | + <div class="plan-section-head"> | ||
| 380 | + <div class="plan-section-chip">展示</div> | ||
| 381 | + <div class="soft-section-header"> | ||
| 382 | + <div class="soft-section-heading"> | ||
| 383 | + <h3 class="soft-section-title">预计送达与展示</h3> | ||
| 384 | + <p class="soft-section-subtitle">控制预计送达时间和骑手可视距离。</p> | ||
| 385 | + </div> | ||
| 386 | + </div> | ||
| 387 | + </div> | ||
| 388 | + <a-row :gutter="16"> | ||
| 389 | + <a-col :span="12"> | ||
| 390 | + <a-form-item label="预计送达基础时间(分钟)"> | ||
| 391 | + <a-input-number v-model:value="config.distanceBasicTime" :min="0" style="width:100%" /> | ||
| 392 | + </a-form-item> | ||
| 393 | + </a-col> | ||
| 394 | + <a-col :span="12"> | ||
| 395 | + <a-form-item label="超出每km增加时间(分钟)"> | ||
| 396 | + <a-input-number v-model:value="config.distanceMoreTime" :min="0" style="width:100%" /> | ||
| 397 | + </a-form-item> | ||
| 398 | + </a-col> | ||
| 399 | + </a-row> | ||
| 400 | + <a-form-item label="附近骑手显示范围(km)"> | ||
| 401 | + <a-input-number v-model:value="config.riderDistance" :min="0" :step="0.1" style="width:100%" /> | ||
| 402 | + </a-form-item> | ||
| 403 | + </div> | ||
| 404 | + </a-form> | ||
| 405 | + </div> | ||
| 406 | + </template> | ||
| 407 | + | ||
| 408 | + <div v-else class="plan-empty-state"> | ||
| 409 | + <a-empty description="当前租户还没有配送费方案"> | ||
| 410 | + <template #extra> | ||
| 411 | + <a-space> | ||
| 412 | + <a-button type="primary" @click="initializeDefaultPlan" :loading="planSaving">初始化默认方案</a-button> | ||
| 413 | + <a-button @click="createPlan" :loading="planSaving">新增空白方案</a-button> | ||
| 414 | + </a-space> | ||
| 415 | + </template> | ||
| 416 | + </a-empty> | ||
| 417 | + </div> | ||
| 418 | + </div> | ||
| 419 | + </div> | ||
| 420 | + | ||
| 421 | + <a-empty v-else description="请先选择租户" /> | ||
| 422 | + </a-card> | ||
| 423 | + </div> | ||
| 424 | +</template> | ||
| 425 | + | ||
| 426 | +<script setup lang="ts"> | ||
| 427 | +import { computed, onMounted, reactive, ref } from 'vue' | ||
| 428 | +import { useRoute } from 'vue-router' | ||
| 429 | +import { message } from 'ant-design-vue' | ||
| 430 | +import { adminFeePlanApi, cityApi } from '@/api' | ||
| 431 | +import { useAuthStore } from '@/stores/auth' | ||
| 432 | + | ||
| 433 | +type TimePeriodForm = { | ||
| 434 | + startText: string | ||
| 435 | + endText: string | ||
| 436 | + isOpen: number | ||
| 437 | + money: number | null | ||
| 438 | +} | ||
| 439 | + | ||
| 440 | +type DistanceStepForm = { | ||
| 441 | + endDistance: number | null | ||
| 442 | + unitDistance: number | null | ||
| 443 | + unitFee: number | null | ||
| 444 | +} | ||
| 445 | + | ||
| 446 | +type PieceRuleForm = { | ||
| 447 | + startPiece: number | null | ||
| 448 | + endPiece: number | null | ||
| 449 | + fee: number | null | ||
| 450 | +} | ||
| 451 | + | ||
| 452 | +const route = useRoute() | ||
| 453 | +const auth = useAuthStore() | ||
| 454 | +const isAdmin = computed(() => auth.userInfo?.role === 'admin') | ||
| 455 | +const managedCityId = computed<number | undefined>(() => auth.userInfo?.cityId) | ||
| 456 | +const cityList = ref<any[]>([]) | ||
| 457 | +const selectedCityId = ref<number | undefined>() | ||
| 458 | +const currentCityName = computed(() => cityList.value.find(item => item.id === selectedCityId.value)?.name || auth.userInfo?.cityName || '') | ||
| 459 | + | ||
| 460 | +const config = ref<any>(null) | ||
| 461 | +const planList = ref<any[]>([]) | ||
| 462 | +const planLoading = ref(false) | ||
| 463 | +const planSaving = ref(false) | ||
| 464 | +const selectedPlanId = ref<number | null>(null) | ||
| 465 | +const currentPlan = ref<any>(null) | ||
| 466 | +const timePeriods = ref<TimePeriodForm[]>([]) | ||
| 467 | +const distanceSteps = ref<DistanceStepForm[]>([]) | ||
| 468 | +const pieceRules = ref<PieceRuleForm[]>([]) | ||
| 469 | +const previewing = ref(false) | ||
| 470 | +const previewResult = ref<any>(null) | ||
| 471 | +const previewForm = reactive({ | ||
| 472 | + startLng: '', | ||
| 473 | + startLat: '', | ||
| 474 | + endLng: '', | ||
| 475 | + endLat: '', | ||
| 476 | + weight: 0, | ||
| 477 | + pieces: 1, | ||
| 478 | + serviceTime: '', | ||
| 479 | +}) | ||
| 480 | + | ||
| 481 | +async function loadCities() { | ||
| 482 | + if (isAdmin.value) { | ||
| 483 | + const res: any = await cityApi.openList() | ||
| 484 | + cityList.value = Array.isArray(res?.data) ? res.data : [] | ||
| 485 | + const queryCityId = Number(route.query.cityId || 0) || undefined | ||
| 486 | + if (queryCityId && cityList.value.some(item => item.id === queryCityId)) { | ||
| 487 | + selectedCityId.value = queryCityId | ||
| 488 | + } | ||
| 489 | + if (!selectedCityId.value && cityList.value.length) { | ||
| 490 | + selectedCityId.value = cityList.value[0].id | ||
| 491 | + } | ||
| 492 | + } else { | ||
| 493 | + selectedCityId.value = managedCityId.value | ||
| 494 | + cityList.value = selectedCityId.value ? [{ id: selectedCityId.value, name: auth.userInfo?.cityName || `租户#${selectedCityId.value}` }] : [] | ||
| 495 | + } | ||
| 496 | + | ||
| 497 | + if (selectedCityId.value) { | ||
| 498 | + await loadPlanList(selectedCityId.value) | ||
| 499 | + } | ||
| 500 | +} | ||
| 501 | + | ||
| 502 | +async function handleCityChange() { | ||
| 503 | + previewResult.value = null | ||
| 504 | + await loadPlanList(selectedCityId.value) | ||
| 505 | +} | ||
| 506 | + | ||
| 507 | +async function loadPlanList(cityId?: number, preferPlanId?: number) { | ||
| 508 | + if (!cityId) return | ||
| 509 | + planLoading.value = true | ||
| 510 | + try { | ||
| 511 | + const res: any = await adminFeePlanApi.list(isAdmin.value ? cityId : undefined) | ||
| 512 | + planList.value = Array.isArray(res?.data) ? res.data : [] | ||
| 513 | + const targetPlanId = preferPlanId || planList.value.find(item => item.isDefault === 1)?.id || planList.value[0]?.id | ||
| 514 | + if (targetPlanId) { | ||
| 515 | + await selectPlan(targetPlanId) | ||
| 516 | + } else { | ||
| 517 | + selectedPlanId.value = null | ||
| 518 | + currentPlan.value = null | ||
| 519 | + config.value = null | ||
| 520 | + } | ||
| 521 | + } finally { | ||
| 522 | + planLoading.value = false | ||
| 523 | + } | ||
| 524 | +} | ||
| 525 | + | ||
| 526 | +async function selectPlan(planId: number) { | ||
| 527 | + selectedPlanId.value = planId | ||
| 528 | + const res: any = await adminFeePlanApi.detail(planId, isAdmin.value ? selectedCityId.value : undefined) | ||
| 529 | + const detail = res?.data || {} | ||
| 530 | + currentPlan.value = { | ||
| 531 | + id: detail.id, | ||
| 532 | + name: detail.name || '', | ||
| 533 | + status: detail.status ?? 1, | ||
| 534 | + listOrder: detail.listOrder ?? 0, | ||
| 535 | + remark: detail.remark || '', | ||
| 536 | + isDefault: detail.isDefault ?? 0, | ||
| 537 | + } | ||
| 538 | + config.value = normalizeConfig(detail.config) | ||
| 539 | + distanceSteps.value = (config.value.type6.distanceSteps || []).map((step: any) => ({ | ||
| 540 | + endDistance: step.endDistance ?? 0, | ||
| 541 | + unitDistance: step.unitDistance ?? 1, | ||
| 542 | + unitFee: step.unitFee ?? 0, | ||
| 543 | + })) | ||
| 544 | + pieceRules.value = (config.value.type6.pieceRules || []).map((rule: any) => ({ | ||
| 545 | + startPiece: rule.startPiece ?? 0, | ||
| 546 | + endPiece: rule.endPiece ?? 0, | ||
| 547 | + fee: rule.fee ?? 0, | ||
| 548 | + })) | ||
| 549 | + timePeriods.value = (config.value.type6.times || []).map((period: any) => ({ | ||
| 550 | + startText: minuteToText(period.start), | ||
| 551 | + endText: minuteToText(period.end), | ||
| 552 | + isOpen: period.isOpen === 0 ? 0 : 1, | ||
| 553 | + money: period.money ?? 0, | ||
| 554 | + })) | ||
| 555 | + previewResult.value = null | ||
| 556 | +} | ||
| 557 | + | ||
| 558 | +async function createPlan() { | ||
| 559 | + if (!selectedCityId.value) return | ||
| 560 | + planSaving.value = true | ||
| 561 | + try { | ||
| 562 | + const payload = { | ||
| 563 | + name: `方案${planList.value.length + 1}`, | ||
| 564 | + status: 1, | ||
| 565 | + listOrder: planList.value.length, | ||
| 566 | + remark: '', | ||
| 567 | + config: deepClone(config.value || normalizeConfig(null)), | ||
| 568 | + } | ||
| 569 | + const res: any = await adminFeePlanApi.create(payload, isAdmin.value ? selectedCityId.value : undefined) | ||
| 570 | + message.success('方案已创建') | ||
| 571 | + await loadPlanList(selectedCityId.value, res?.data) | ||
| 572 | + } catch (err: any) { | ||
| 573 | + message.error(err?.message || '方案创建失败') | ||
| 574 | + } finally { | ||
| 575 | + planSaving.value = false | ||
| 576 | + } | ||
| 577 | +} | ||
| 578 | + | ||
| 579 | +async function initializeDefaultPlan() { | ||
| 580 | + if (!selectedCityId.value) return | ||
| 581 | + planSaving.value = true | ||
| 582 | + try { | ||
| 583 | + const res: any = await adminFeePlanApi.initDefault(isAdmin.value ? selectedCityId.value : undefined) | ||
| 584 | + message.success('默认方案已初始化') | ||
| 585 | + await loadPlanList(selectedCityId.value, res?.data) | ||
| 586 | + } catch (err: any) { | ||
| 587 | + message.error(err?.message || '默认方案初始化失败') | ||
| 588 | + } finally { | ||
| 589 | + planSaving.value = false | ||
| 590 | + } | ||
| 591 | +} | ||
| 592 | + | ||
| 593 | +async function copyPlan() { | ||
| 594 | + if (!selectedPlanId.value || !selectedCityId.value) return | ||
| 595 | + const res: any = await adminFeePlanApi.copy(selectedPlanId.value, isAdmin.value ? selectedCityId.value : undefined) | ||
| 596 | + message.success('方案已复制') | ||
| 597 | + await loadPlanList(selectedCityId.value, res?.data) | ||
| 598 | +} | ||
| 599 | + | ||
| 600 | +async function deletePlan() { | ||
| 601 | + if (!selectedPlanId.value || !selectedCityId.value) return | ||
| 602 | + await adminFeePlanApi.del(selectedPlanId.value, isAdmin.value ? selectedCityId.value : undefined) | ||
| 603 | + message.success('方案已删除') | ||
| 604 | + await loadPlanList(selectedCityId.value) | ||
| 605 | +} | ||
| 606 | + | ||
| 607 | +async function setDefaultPlan() { | ||
| 608 | + if (!selectedPlanId.value || !selectedCityId.value) return | ||
| 609 | + if (currentPlan.value?.status !== 1) { | ||
| 610 | + message.error('请先启用当前方案,再设为默认') | ||
| 611 | + return | ||
| 612 | + } | ||
| 613 | + await adminFeePlanApi.setDefault(selectedPlanId.value, isAdmin.value ? selectedCityId.value : undefined) | ||
| 614 | + message.success('默认方案已更新') | ||
| 615 | + await loadPlanList(selectedCityId.value, selectedPlanId.value) | ||
| 616 | +} | ||
| 617 | + | ||
| 618 | +async function saveCurrentPlan() { | ||
| 619 | + if (!selectedPlanId.value || !currentPlan.value || !selectedCityId.value) return | ||
| 620 | + try { | ||
| 621 | + const payload = buildPlanPayload() | ||
| 622 | + planSaving.value = true | ||
| 623 | + await adminFeePlanApi.update(selectedPlanId.value, payload, isAdmin.value ? selectedCityId.value : undefined) | ||
| 624 | + message.success('方案保存成功') | ||
| 625 | + await loadPlanList(selectedCityId.value, selectedPlanId.value) | ||
| 626 | + } catch (err: any) { | ||
| 627 | + message.error(err?.message || '方案保存失败') | ||
| 628 | + } finally { | ||
| 629 | + planSaving.value = false | ||
| 630 | + } | ||
| 631 | +} | ||
| 632 | + | ||
| 633 | +async function previewPlan() { | ||
| 634 | + if (!selectedCityId.value) return | ||
| 635 | + try { | ||
| 636 | + const payload = buildPlanPayload() | ||
| 637 | + if (!previewForm.startLng || !previewForm.startLat || !previewForm.endLng || !previewForm.endLat) { | ||
| 638 | + message.error('请填写完整的试算经纬度') | ||
| 639 | + return | ||
| 640 | + } | ||
| 641 | + previewing.value = true | ||
| 642 | + const res: any = await adminFeePlanApi.preview({ | ||
| 643 | + config: payload.config, | ||
| 644 | + calc: { | ||
| 645 | + startLng: previewForm.startLng, | ||
| 646 | + startLat: previewForm.startLat, | ||
| 647 | + endLng: previewForm.endLng, | ||
| 648 | + endLat: previewForm.endLat, | ||
| 649 | + weight: previewForm.weight ?? 0, | ||
| 650 | + pieces: previewForm.pieces ?? 0, | ||
| 651 | + serviceTime: previewForm.serviceTime ? Number(previewForm.serviceTime) : 0, | ||
| 652 | + }, | ||
| 653 | + }, isAdmin.value ? selectedCityId.value : undefined) | ||
| 654 | + previewResult.value = res?.data || null | ||
| 655 | + } catch (err: any) { | ||
| 656 | + message.error(err?.message || '试算失败') | ||
| 657 | + } finally { | ||
| 658 | + previewing.value = false | ||
| 659 | + } | ||
| 660 | +} | ||
| 661 | + | ||
| 662 | +function buildPlanPayload() { | ||
| 663 | + if (!currentPlan.value) { | ||
| 664 | + throw new Error('请选择计价方案') | ||
| 665 | + } | ||
| 666 | + const planName = String(currentPlan.value.name || '').trim() | ||
| 667 | + if (!planName) { | ||
| 668 | + throw new Error('请填写方案名称') | ||
| 669 | + } | ||
| 670 | + const nextConfig = deepClone(config.value || normalizeConfig(null)) | ||
| 671 | + nextConfig.type = [6] | ||
| 672 | + nextConfig.type6.baseSwitch = nextConfig.type6.baseFee > 0 ? 1 : 0 | ||
| 673 | + nextConfig.type6.distanceSwitch = 1 | ||
| 674 | + nextConfig.type6.weightSwitch = | ||
| 675 | + nextConfig.type6.weightFirst > 0 || nextConfig.type6.weightFirstFee > 0 || nextConfig.type6.weightUnitFee > 0 ? 1 : 0 | ||
| 676 | + nextConfig.type6.pieceSwitch = pieceRules.value.length ? 1 : 0 | ||
| 677 | + nextConfig.type6.distanceSteps = buildDistanceStepsPayload() | ||
| 678 | + nextConfig.type6.pieceRules = buildPieceRulesPayload() | ||
| 679 | + nextConfig.type6.times = buildTimesPayload() | ||
| 680 | + return { | ||
| 681 | + name: planName, | ||
| 682 | + status: currentPlan.value.status ?? 1, | ||
| 683 | + listOrder: currentPlan.value.listOrder ?? 0, | ||
| 684 | + remark: currentPlan.value.remark || '', | ||
| 685 | + config: nextConfig, | ||
| 686 | + } | ||
| 687 | +} | ||
| 688 | + | ||
| 689 | +function createDefaultType6() { | ||
| 690 | + return { | ||
| 691 | + minFee: 0, | ||
| 692 | + baseSwitch: 0, | ||
| 693 | + baseFee: 0, | ||
| 694 | + feeMode: 2, | ||
| 695 | + fixMoney: 0, | ||
| 696 | + distanceSwitch: 1, | ||
| 697 | + distanceBasic: 3, | ||
| 698 | + distanceBasicMoney: 4, | ||
| 699 | + distanceMoreMoney: 1.5, | ||
| 700 | + distanceType: 1, | ||
| 701 | + distanceSteps: [], | ||
| 702 | + weightSwitch: 1, | ||
| 703 | + weightFirst: 5, | ||
| 704 | + weightFirstFee: 0, | ||
| 705 | + weightUnitFee: 1, | ||
| 706 | + weightCapFee: 30, | ||
| 707 | + weightBasic: 0, | ||
| 708 | + weightBasicMoney: 0, | ||
| 709 | + weightMoreMoney: 0, | ||
| 710 | + weightType: 1, | ||
| 711 | + pieceSwitch: 0, | ||
| 712 | + pieceRules: [], | ||
| 713 | + times: [], | ||
| 714 | + } | ||
| 715 | +} | ||
| 716 | + | ||
| 717 | +function normalizeConfig(raw: any) { | ||
| 718 | + const next = raw || {} | ||
| 719 | + const type6 = { ...createDefaultType6(), ...(next.type6 || {}) } | ||
| 720 | + return { | ||
| 721 | + ...next, | ||
| 722 | + type: [6], | ||
| 723 | + type6: { | ||
| 724 | + ...type6, | ||
| 725 | + distanceSteps: Array.isArray(type6.distanceSteps) ? type6.distanceSteps : [], | ||
| 726 | + pieceRules: Array.isArray(type6.pieceRules) ? type6.pieceRules : [], | ||
| 727 | + times: Array.isArray(type6.times) ? type6.times : [], | ||
| 728 | + }, | ||
| 729 | + distanceBasic: next.distanceBasic ?? 3, | ||
| 730 | + distanceBasicTime: next.distanceBasicTime ?? 30, | ||
| 731 | + distanceMoreTime: next.distanceMoreTime ?? 10, | ||
| 732 | + riderDistance: next.riderDistance ?? 3, | ||
| 733 | + } | ||
| 734 | +} | ||
| 735 | + | ||
| 736 | +function addTimePeriod() { | ||
| 737 | + timePeriods.value.push({ startText: '', endText: '', isOpen: 1, money: 0 }) | ||
| 738 | +} | ||
| 739 | + | ||
| 740 | +function removeTimePeriod(index: number) { | ||
| 741 | + timePeriods.value.splice(index, 1) | ||
| 742 | +} | ||
| 743 | + | ||
| 744 | +function addDistanceStep() { | ||
| 745 | + distanceSteps.value.push({ endDistance: 0, unitDistance: 1, unitFee: 0 }) | ||
| 746 | +} | ||
| 747 | + | ||
| 748 | +function removeDistanceStep(index: number) { | ||
| 749 | + distanceSteps.value.splice(index, 1) | ||
| 750 | +} | ||
| 751 | + | ||
| 752 | +function addPieceRule() { | ||
| 753 | + pieceRules.value.push({ startPiece: 0, endPiece: 0, fee: 0 }) | ||
| 754 | +} | ||
| 755 | + | ||
| 756 | +function removePieceRule(index: number) { | ||
| 757 | + pieceRules.value.splice(index, 1) | ||
| 758 | +} | ||
| 759 | + | ||
| 760 | +function buildDistanceStepsPayload() { | ||
| 761 | + let prevEnd = config.value.type6.distanceBasic ?? 0 | ||
| 762 | + return distanceSteps.value.map((step, index) => { | ||
| 763 | + const endDistance = step.endDistance ?? 0 | ||
| 764 | + const unitDistance = step.unitDistance ?? 0 | ||
| 765 | + const unitFee = step.unitFee ?? 0 | ||
| 766 | + if (endDistance <= prevEnd) { | ||
| 767 | + throw new Error(`第${index + 1}条里程阶梯结束里程必须大于上一阶梯`) | ||
| 768 | + } | ||
| 769 | + if (unitDistance <= 0) { | ||
| 770 | + throw new Error(`第${index + 1}条里程阶梯每档里程必须大于0`) | ||
| 771 | + } | ||
| 772 | + prevEnd = endDistance | ||
| 773 | + return { endDistance, unitDistance, unitFee, listOrder: index } | ||
| 774 | + }) | ||
| 775 | +} | ||
| 776 | + | ||
| 777 | +function buildPieceRulesPayload() { | ||
| 778 | + const payload = pieceRules.value | ||
| 779 | + .map((rule, index) => { | ||
| 780 | + const startPiece = rule.startPiece ?? 0 | ||
| 781 | + const endPiece = rule.endPiece ?? 0 | ||
| 782 | + if (startPiece > endPiece) { | ||
| 783 | + throw new Error(`第${index + 1}条件数区间起始值不能大于结束值`) | ||
| 784 | + } | ||
| 785 | + return { startPiece, endPiece, fee: rule.fee ?? 0, listOrder: index } | ||
| 786 | + }) | ||
| 787 | + .sort((a, b) => a.startPiece - b.startPiece) | ||
| 788 | + | ||
| 789 | + for (let index = 1; index < payload.length; index += 1) { | ||
| 790 | + if (payload[index].startPiece <= payload[index - 1].endPiece) { | ||
| 791 | + throw new Error('件数区间不能重叠') | ||
| 792 | + } | ||
| 793 | + } | ||
| 794 | + return payload | ||
| 795 | +} | ||
| 796 | + | ||
| 797 | +function buildTimesPayload() { | ||
| 798 | + return timePeriods.value | ||
| 799 | + .map((period, index) => { | ||
| 800 | + const hasContent = period.startText || period.endText || period.money | ||
| 801 | + if (!hasContent) return null | ||
| 802 | + const start = textToMinute(period.startText, `第${index + 1}条时段开始时间格式错误`) | ||
| 803 | + const end = textToMinute(period.endText, `第${index + 1}条时段结束时间格式错误`) | ||
| 804 | + if (start === end) { | ||
| 805 | + throw new Error(`第${index + 1}条时段开始时间不能等于结束时间`) | ||
| 806 | + } | ||
| 807 | + return { | ||
| 808 | + start, | ||
| 809 | + end, | ||
| 810 | + isOpen: period.isOpen === 0 ? 0 : 1, | ||
| 811 | + money: period.money ?? 0, | ||
| 812 | + } | ||
| 813 | + }) | ||
| 814 | + .filter(Boolean) | ||
| 815 | +} | ||
| 816 | + | ||
| 817 | +function textToMinute(text: string, errorMessage: string) { | ||
| 818 | + const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec((text || '').trim()) | ||
| 819 | + if (!match) { | ||
| 820 | + throw new Error(errorMessage) | ||
| 821 | + } | ||
| 822 | + return Number(match[1]) * 60 + Number(match[2]) | ||
| 823 | +} | ||
| 824 | + | ||
| 825 | +function minuteToText(value: number | null | undefined) { | ||
| 826 | + if (typeof value !== 'number' || Number.isNaN(value)) return '' | ||
| 827 | + const hour = Math.floor(value / 60) | ||
| 828 | + const minute = value % 60 | ||
| 829 | + return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}` | ||
| 830 | +} | ||
| 831 | + | ||
| 832 | +function deepClone<T>(value: T): T { | ||
| 833 | + return JSON.parse(JSON.stringify(value)) | ||
| 834 | +} | ||
| 835 | + | ||
| 836 | +onMounted(loadCities) | ||
| 837 | +</script> | ||
| 838 | + | ||
| 839 | +<style scoped> | ||
| 840 | +.plan-layout { | ||
| 841 | + display: grid; | ||
| 842 | + grid-template-columns: 280px minmax(0, 1fr); | ||
| 843 | + gap: 18px; | ||
| 844 | + min-height: 720px; | ||
| 845 | +} | ||
| 846 | + | ||
| 847 | +.plan-sidebar, | ||
| 848 | +.plan-content { | ||
| 849 | + border-radius: 24px; | ||
| 850 | + border: 1px solid rgba(194, 185, 239, 0.22); | ||
| 851 | + background: rgba(255, 255, 255, 0.58); | ||
| 852 | + padding: 18px; | ||
| 853 | +} | ||
| 854 | + | ||
| 855 | +.plan-content { | ||
| 856 | + display: flex; | ||
| 857 | + flex-direction: column; | ||
| 858 | + gap: 18px; | ||
| 859 | + min-width: 0; | ||
| 860 | +} | ||
| 861 | + | ||
| 862 | +.plan-content-top { | ||
| 863 | + position: sticky; | ||
| 864 | + top: 0; | ||
| 865 | + z-index: 2; | ||
| 866 | + display: flex; | ||
| 867 | + flex-direction: column; | ||
| 868 | + gap: 12px; | ||
| 869 | + padding-bottom: 6px; | ||
| 870 | + background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(255, 255, 255, 0.76) 78%, rgba(255, 255, 255, 0)); | ||
| 871 | +} | ||
| 872 | + | ||
| 873 | +.plan-content-body { | ||
| 874 | + display: flex; | ||
| 875 | + flex-direction: column; | ||
| 876 | + gap: 18px; | ||
| 877 | +} | ||
| 878 | + | ||
| 879 | +.plan-sidebar-header, | ||
| 880 | +.plan-toolbar, | ||
| 881 | +.soft-section-header { | ||
| 882 | + display: flex; | ||
| 883 | + align-items: center; | ||
| 884 | + justify-content: space-between; | ||
| 885 | + gap: 12px; | ||
| 886 | +} | ||
| 887 | + | ||
| 888 | +.plan-sidebar-title, | ||
| 889 | +.plan-item-name, | ||
| 890 | +.soft-section-title { | ||
| 891 | + font-family: var(--font-display); | ||
| 892 | + color: var(--text-dark); | ||
| 893 | +} | ||
| 894 | + | ||
| 895 | +.plan-sidebar-title { | ||
| 896 | + font-size: 16px; | ||
| 897 | + font-weight: 700; | ||
| 898 | +} | ||
| 899 | + | ||
| 900 | +.plan-sidebar-subtitle, | ||
| 901 | +.plan-item-bottom, | ||
| 902 | +.soft-section-subtitle, | ||
| 903 | +.plan-toolbar-eyebrow, | ||
| 904 | +.plan-toolbar-tip, | ||
| 905 | +.managed-city-pill { | ||
| 906 | + color: var(--text-soft); | ||
| 907 | + font-size: 12px; | ||
| 908 | +} | ||
| 909 | + | ||
| 910 | +.managed-city-pill { | ||
| 911 | + display: inline-flex; | ||
| 912 | + align-items: center; | ||
| 913 | + min-height: 36px; | ||
| 914 | + padding: 0 14px; | ||
| 915 | + border-radius: 999px; | ||
| 916 | + border: 1px solid rgba(194, 185, 239, 0.28); | ||
| 917 | + background: rgba(246, 242, 255, 0.84); | ||
| 918 | +} | ||
| 919 | + | ||
| 920 | +.plan-list { | ||
| 921 | + display: flex; | ||
| 922 | + flex-direction: column; | ||
| 923 | + gap: 10px; | ||
| 924 | + margin-top: 14px; | ||
| 925 | +} | ||
| 926 | + | ||
| 927 | +.plan-item { | ||
| 928 | + border: 1px solid rgba(194, 185, 239, 0.22); | ||
| 929 | + background: rgba(255, 255, 255, 0.7); | ||
| 930 | + border-radius: 18px; | ||
| 931 | + padding: 14px; | ||
| 932 | + text-align: left; | ||
| 933 | + cursor: pointer; | ||
| 934 | +} | ||
| 935 | + | ||
| 936 | +.plan-item.active { | ||
| 937 | + border-color: rgba(140, 124, 240, 0.44); | ||
| 938 | + background: rgba(246, 242, 255, 0.95); | ||
| 939 | + box-shadow: 0 10px 24px rgba(140, 124, 240, 0.12); | ||
| 940 | +} | ||
| 941 | + | ||
| 942 | +.plan-item-top, | ||
| 943 | +.plan-item-bottom { | ||
| 944 | + display: flex; | ||
| 945 | + justify-content: space-between; | ||
| 946 | + gap: 10px; | ||
| 947 | +} | ||
| 948 | + | ||
| 949 | +.plan-item-bottom { | ||
| 950 | + margin-top: 6px; | ||
| 951 | +} | ||
| 952 | + | ||
| 953 | +.plan-toolbar { | ||
| 954 | + flex-wrap: wrap; | ||
| 955 | + align-items: center; | ||
| 956 | + padding: 14px 16px; | ||
| 957 | + border: 1px solid rgba(194, 185, 239, 0.22); | ||
| 958 | + border-radius: 20px; | ||
| 959 | + background: rgba(255, 255, 255, 0.88); | ||
| 960 | + box-shadow: 0 10px 24px rgba(140, 124, 240, 0.08); | ||
| 961 | +} | ||
| 962 | + | ||
| 963 | +.plan-toolbar-meta, | ||
| 964 | +.plan-toolbar-submit { | ||
| 965 | + display: flex; | ||
| 966 | + flex-direction: column; | ||
| 967 | + gap: 4px; | ||
| 968 | +} | ||
| 969 | + | ||
| 970 | +.plan-toolbar-meta strong { | ||
| 971 | + color: var(--text-dark); | ||
| 972 | + font-size: 15px; | ||
| 973 | + line-height: 1.4; | ||
| 974 | +} | ||
| 975 | + | ||
| 976 | +.plan-toolbar-submit { | ||
| 977 | + margin-left: auto; | ||
| 978 | + align-items: flex-end; | ||
| 979 | +} | ||
| 980 | + | ||
| 981 | +.plan-save-button { | ||
| 982 | + min-width: 108px; | ||
| 983 | + height: 36px; | ||
| 984 | + padding-inline: 16px; | ||
| 985 | + border: none; | ||
| 986 | + border-radius: 12px; | ||
| 987 | + background: linear-gradient(135deg, #8c7cf0, #a98ff7 55%, #e5b5dc); | ||
| 988 | + box-shadow: 0 8px 18px rgba(140, 124, 240, 0.18); | ||
| 989 | +} | ||
| 990 | + | ||
| 991 | +.plan-save-button:hover, | ||
| 992 | +.plan-save-button:focus { | ||
| 993 | + background: linear-gradient(135deg, #8372ee, #a188f6 55%, #e2add7); | ||
| 994 | +} | ||
| 995 | + | ||
| 996 | +.plan-section { | ||
| 997 | + padding: 18px 20px 20px; | ||
| 998 | + border: 1px solid rgba(194, 185, 239, 0.18); | ||
| 999 | + border-radius: 22px; | ||
| 1000 | + background: rgba(255, 255, 255, 0.72); | ||
| 1001 | +} | ||
| 1002 | + | ||
| 1003 | +.plan-section-last { | ||
| 1004 | + margin-bottom: 0; | ||
| 1005 | +} | ||
| 1006 | + | ||
| 1007 | +.plan-section-head { | ||
| 1008 | + display: flex; | ||
| 1009 | + flex-direction: column; | ||
| 1010 | + gap: 10px; | ||
| 1011 | + margin-bottom: 16px; | ||
| 1012 | +} | ||
| 1013 | + | ||
| 1014 | +.plan-section-chip { | ||
| 1015 | + display: inline-flex; | ||
| 1016 | + align-items: center; | ||
| 1017 | + align-self: flex-start; | ||
| 1018 | + min-height: 26px; | ||
| 1019 | + padding: 0 12px; | ||
| 1020 | + border-radius: 999px; | ||
| 1021 | + background: rgba(246, 242, 255, 0.95); | ||
| 1022 | + border: 1px solid rgba(140, 124, 240, 0.18); | ||
| 1023 | + color: #7f6de5; | ||
| 1024 | + font-size: 12px; | ||
| 1025 | + font-weight: 700; | ||
| 1026 | +} | ||
| 1027 | + | ||
| 1028 | +.soft-section-heading { | ||
| 1029 | + display: flex; | ||
| 1030 | + flex-direction: column; | ||
| 1031 | + gap: 4px; | ||
| 1032 | +} | ||
| 1033 | + | ||
| 1034 | +.soft-section-title { | ||
| 1035 | + margin: 0; | ||
| 1036 | + font-size: 18px; | ||
| 1037 | + line-height: 1.35; | ||
| 1038 | +} | ||
| 1039 | + | ||
| 1040 | +.soft-section-subtitle { | ||
| 1041 | + margin: 0; | ||
| 1042 | + line-height: 1.6; | ||
| 1043 | +} | ||
| 1044 | + | ||
| 1045 | +.plan-dynamic-row { | ||
| 1046 | + margin-bottom: 12px; | ||
| 1047 | + align-items: flex-start; | ||
| 1048 | +} | ||
| 1049 | + | ||
| 1050 | +.preview-card { | ||
| 1051 | + border-radius: 16px; | ||
| 1052 | + background: #fafbff; | ||
| 1053 | +} | ||
| 1054 | + | ||
| 1055 | +.preview-subtitle { | ||
| 1056 | + margin-bottom: 14px; | ||
| 1057 | +} | ||
| 1058 | + | ||
| 1059 | +.preview-result { | ||
| 1060 | + display: flex; | ||
| 1061 | + flex-wrap: wrap; | ||
| 1062 | + gap: 8px; | ||
| 1063 | +} | ||
| 1064 | + | ||
| 1065 | +.plan-empty-state { | ||
| 1066 | + min-height: 520px; | ||
| 1067 | + display: flex; | ||
| 1068 | + align-items: center; | ||
| 1069 | + justify-content: center; | ||
| 1070 | +} | ||
| 1071 | + | ||
| 1072 | +@media (max-width: 960px) { | ||
| 1073 | + .plan-layout { | ||
| 1074 | + grid-template-columns: 1fr; | ||
| 1075 | + } | ||
| 1076 | + | ||
| 1077 | + .plan-content-top { | ||
| 1078 | + position: static; | ||
| 1079 | + padding-bottom: 0; | ||
| 1080 | + background: transparent; | ||
| 1081 | + } | ||
| 1082 | + | ||
| 1083 | + .plan-toolbar, | ||
| 1084 | + .soft-section-header, | ||
| 1085 | + .plan-toolbar-submit { | ||
| 1086 | + flex-direction: column; | ||
| 1087 | + align-items: flex-start; | ||
| 1088 | + } | ||
| 1089 | + | ||
| 1090 | + .plan-save-button { | ||
| 1091 | + width: 100%; | ||
| 1092 | + } | ||
| 1093 | +} | ||
| 1094 | +</style> |
src/views/dispatch/DispatchRuleList.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <div> | ||
| 3 | + <a-card title="调度规则" :bordered="false" class="list-table-card"> | ||
| 4 | + <div class="list-toolbar"> | ||
| 5 | + <div class="list-toolbar-left"> | ||
| 6 | + <a-select | ||
| 7 | + v-if="isAdmin" | ||
| 8 | + v-model:value="selectedCityId" | ||
| 9 | + placeholder="选择租户" | ||
| 10 | + class="list-filter" | ||
| 11 | + @change="handleCityChange" | ||
| 12 | + > | ||
| 13 | + <a-select-option v-for="c in cityList" :key="c.id" :value="c.id">{{ c.name }}</a-select-option> | ||
| 14 | + </a-select> | ||
| 15 | + <div v-else class="managed-city-pill">当前租户:{{ currentCityName || `租户#${selectedCityId}` }}</div> | ||
| 16 | + </div> | ||
| 17 | + <div class="list-toolbar-right"> | ||
| 18 | + <a-button type="primary" :disabled="!selectedCityId" @click="createTemplate">新增模板</a-button> | ||
| 19 | + </div> | ||
| 20 | + </div> | ||
| 21 | + | ||
| 22 | + <div v-if="selectedCityId" class="plan-layout dispatch-layout"> | ||
| 23 | + <div class="plan-sidebar"> | ||
| 24 | + <div class="plan-sidebar-header"> | ||
| 25 | + <div> | ||
| 26 | + <div class="plan-sidebar-title">规则模板</div> | ||
| 27 | + <div class="plan-sidebar-subtitle">按城市维护多套抢派规则</div> | ||
| 28 | + </div> | ||
| 29 | + </div> | ||
| 30 | + <a-spin :spinning="loadingTemplates"> | ||
| 31 | + <div class="plan-list"> | ||
| 32 | + <button | ||
| 33 | + v-for="item in templateList" | ||
| 34 | + :key="item.id" | ||
| 35 | + type="button" | ||
| 36 | + class="plan-item" | ||
| 37 | + :class="{ active: item.id === selectedTemplateId }" | ||
| 38 | + @click="selectTemplate(item.id)" | ||
| 39 | + > | ||
| 40 | + <div class="plan-item-top"> | ||
| 41 | + <span class="plan-item-name">{{ item.name }}</span> | ||
| 42 | + <a-tag v-if="item.isActive === 1" color="green">生效中</a-tag> | ||
| 43 | + </div> | ||
| 44 | + <div class="plan-item-bottom"> | ||
| 45 | + <span>{{ item.autoDispatch === 1 ? '自动派单开启' : '仅抢单' }}</span> | ||
| 46 | + <span>抢单 {{ item.grabTimeout || 0 }} 分钟</span> | ||
| 47 | + </div> | ||
| 48 | + </button> | ||
| 49 | + <a-empty v-if="!templateList.length" description="当前城市暂无调度规则模板" /> | ||
| 50 | + </div> | ||
| 51 | + </a-spin> | ||
| 52 | + </div> | ||
| 53 | + | ||
| 54 | + <div class="plan-content"> | ||
| 55 | + <template v-if="editorVisible"> | ||
| 56 | + <div class="plan-content-top"> | ||
| 57 | + <div class="soft-note-card plan-note-card"> | ||
| 58 | + <strong>配置说明</strong> | ||
| 59 | + <p>当前页面仅配置自营抢派模式与自营骑手内部优先级,不包含第三方运力相关维度。</p> | ||
| 60 | + </div> | ||
| 61 | + | ||
| 62 | + <div class="plan-toolbar"> | ||
| 63 | + <div class="plan-toolbar-meta"> | ||
| 64 | + <span class="plan-toolbar-eyebrow">当前编辑</span> | ||
| 65 | + <strong>{{ form.name || '未命名模板' }}</strong> | ||
| 66 | + </div> | ||
| 67 | + <a-space wrap> | ||
| 68 | + <a-button @click="openCopyModal" :disabled="!form.id">复制模板</a-button> | ||
| 69 | + <a-button @click="activateTemplate" :disabled="form.isActive === 1">激活模板</a-button> | ||
| 70 | + <a-popconfirm title="确认删除当前模板?" @confirm="deleteTemplate"> | ||
| 71 | + <a-button danger :disabled="form.isActive === 1">删除模板</a-button> | ||
| 72 | + </a-popconfirm> | ||
| 73 | + </a-space> | ||
| 74 | + <div class="plan-toolbar-submit"> | ||
| 75 | + <span class="plan-toolbar-tip">完成配置后保存当前模板</span> | ||
| 76 | + <a-button type="primary" class="plan-save-button" @click="saveTemplate" :loading="saving">保存模板</a-button> | ||
| 77 | + </div> | ||
| 78 | + </div> | ||
| 79 | + </div> | ||
| 80 | + | ||
| 81 | + <div class="plan-content-body"> | ||
| 82 | + <div class="plan-section"> | ||
| 83 | + <div class="plan-section-head"> | ||
| 84 | + <div class="plan-section-chip">基础</div> | ||
| 85 | + <div class="soft-section-header"> | ||
| 86 | + <div class="soft-section-heading"> | ||
| 87 | + <h3 class="soft-section-title">模板基础信息</h3> | ||
| 88 | + <p class="soft-section-subtitle">维护模板名称和当前生效状态。</p> | ||
| 89 | + </div> | ||
| 90 | + </div> | ||
| 91 | + </div> | ||
| 92 | + <a-row :gutter="[16, 4]" class="plan-form-grid"> | ||
| 93 | + <a-col :span="12"> | ||
| 94 | + <a-form-item label="模板名称"> | ||
| 95 | + <a-input v-model:value="form.name" placeholder="如:默认规则模板" /> | ||
| 96 | + </a-form-item> | ||
| 97 | + </a-col> | ||
| 98 | + <a-col :span="12"> | ||
| 99 | + <a-form-item label="当前状态"> | ||
| 100 | + <a-tag :color="form.isActive === 1 ? 'green' : 'default'"> | ||
| 101 | + {{ form.isActive === 1 ? '生效中' : '未生效' }} | ||
| 102 | + </a-tag> | ||
| 103 | + </a-form-item> | ||
| 104 | + </a-col> | ||
| 105 | + </a-row> | ||
| 106 | + </div> | ||
| 107 | + | ||
| 108 | + <div class="plan-section"> | ||
| 109 | + <div class="plan-section-head"> | ||
| 110 | + <div class="plan-section-chip">抢派</div> | ||
| 111 | + <div class="soft-section-header"> | ||
| 112 | + <div class="soft-section-heading"> | ||
| 113 | + <h3 class="soft-section-title">自营抢派模式</h3> | ||
| 114 | + <p class="soft-section-subtitle">配置抢单时长、可见范围、持单上限和自动派单。</p> | ||
| 115 | + </div> | ||
| 116 | + </div> | ||
| 117 | + </div> | ||
| 118 | + <a-row :gutter="[16, 4]" class="plan-form-grid"> | ||
| 119 | + <a-col :span="8"> | ||
| 120 | + <a-form-item label="启用抢单模式"> | ||
| 121 | + <a-switch v-model:checked="grabEnabledChecked" /> | ||
| 122 | + </a-form-item> | ||
| 123 | + </a-col> | ||
| 124 | + <a-col :span="8"> | ||
| 125 | + <a-form-item label="抢单时间(分钟)"> | ||
| 126 | + <a-input-number v-model:value="form.grabTimeout" :min="1" style="width:100%" /> | ||
| 127 | + </a-form-item> | ||
| 128 | + </a-col> | ||
| 129 | + <a-col :span="8"> | ||
| 130 | + <a-form-item label="单人最大抢单量"> | ||
| 131 | + <a-input-number v-model:value="form.grabMaxPerRider" :min="1" style="width:100%" /> | ||
| 132 | + </a-form-item> | ||
| 133 | + </a-col> | ||
| 134 | + </a-row> | ||
| 135 | + <a-row :gutter="[16, 4]" class="plan-form-grid plan-form-grid-compact"> | ||
| 136 | + <a-col :span="12"> | ||
| 137 | + <a-form-item label="抢单可见范围"> | ||
| 138 | + <a-radio-group v-model:value="form.grabScope" class="grab-scope-group"> | ||
| 139 | + <a-radio :value="1" class="grab-scope-option"> | ||
| 140 | + <div class="grab-scope-card"> | ||
| 141 | + <strong>订单所属区域骑手</strong> | ||
| 142 | + <span>优先限制在订单所属区域内的自营骑手可见</span> | ||
| 143 | + </div> | ||
| 144 | + </a-radio> | ||
| 145 | + <a-radio :value="2" class="grab-scope-option"> | ||
| 146 | + <div class="grab-scope-card"> | ||
| 147 | + <strong>全部自营骑手</strong> | ||
| 148 | + <span>放开到当前城市下全部自营骑手参与抢单</span> | ||
| 149 | + </div> | ||
| 150 | + </a-radio> | ||
| 151 | + </a-radio-group> | ||
| 152 | + </a-form-item> | ||
| 153 | + </a-col> | ||
| 154 | + <a-col :span="12"> | ||
| 155 | + <a-form-item label="同步自动派单"> | ||
| 156 | + <a-switch v-model:checked="autoDispatchChecked" /> | ||
| 157 | + </a-form-item> | ||
| 158 | + </a-col> | ||
| 159 | + </a-row> | ||
| 160 | + </div> | ||
| 161 | + | ||
| 162 | + <div class="plan-section"> | ||
| 163 | + <div class="plan-section-head"> | ||
| 164 | + <div class="plan-section-chip">优先级</div> | ||
| 165 | + <div class="soft-section-header"> | ||
| 166 | + <div class="soft-section-heading"> | ||
| 167 | + <h3 class="soft-section-title">自营骑手内部派单优先级</h3> | ||
| 168 | + <p class="soft-section-subtitle">按条件顺序依次决策,暂不接入拖拽,使用上下移动调整顺序。</p> | ||
| 169 | + </div> | ||
| 170 | + </div> | ||
| 171 | + </div> | ||
| 172 | + | ||
| 173 | + <div class="soft-dashed-block dispatch-condition-list"> | ||
| 174 | + <div | ||
| 175 | + v-for="(item, index) in form.conditions" | ||
| 176 | + :key="item.conditionType" | ||
| 177 | + class="dispatch-condition-row" | ||
| 178 | + :class="{ disabled: item.enabled !== 1 }" | ||
| 179 | + > | ||
| 180 | + <div class="dispatch-condition-rank">{{ Number(index) + 1 }}</div> | ||
| 181 | + | ||
| 182 | + <div class="dispatch-condition-content"> | ||
| 183 | + <div class="dispatch-condition-main"> | ||
| 184 | + <div class="dispatch-condition-title-wrap"> | ||
| 185 | + <div class="dispatch-condition-title"> | ||
| 186 | + <strong>{{ item.conditionDesc || item.conditionType }}</strong> | ||
| 187 | + <a-tag v-if="item.enabled === 1" color="processing">启用</a-tag> | ||
| 188 | + <a-tag v-else>停用</a-tag> | ||
| 189 | + </div> | ||
| 190 | + <div class="dispatch-condition-meta">条件编码:{{ item.conditionType }}</div> | ||
| 191 | + </div> | ||
| 192 | + <div class="dispatch-condition-actions"> | ||
| 193 | + <a-switch :checked="item.enabled === 1" @change="(checked:boolean) => item.enabled = checked ? 1 : 0" /> | ||
| 194 | + <a-button size="small" @click="moveCondition(Number(index), -1)" :disabled="Number(index) === 0">上移</a-button> | ||
| 195 | + <a-button size="small" @click="moveCondition(Number(index), 1)" :disabled="Number(index) === form.conditions.length - 1">下移</a-button> | ||
| 196 | + </div> | ||
| 197 | + </div> | ||
| 198 | + | ||
| 199 | + <div class="dispatch-condition-body"> | ||
| 200 | + <div v-if="item.hasThreshold" class="dispatch-condition-input"> | ||
| 201 | + <span class="dispatch-condition-label">阈值</span> | ||
| 202 | + <a-input-number v-model:value="item.thresholdValue" :min="0" :step="0.1" style="width:160px" /> | ||
| 203 | + </div> | ||
| 204 | + <div v-else class="dispatch-condition-label">当前条件无需阈值配置</div> | ||
| 205 | + <div class="dispatch-condition-order">优先级:{{ Number(index) + 1 }}</div> | ||
| 206 | + </div> | ||
| 207 | + </div> | ||
| 208 | + </div> | ||
| 209 | + </div> | ||
| 210 | + </div> | ||
| 211 | + </div> | ||
| 212 | + </template> | ||
| 213 | + | ||
| 214 | + <a-empty v-else description="请选择或创建一个模板后开始配置" /> | ||
| 215 | + </div> | ||
| 216 | + </div> | ||
| 217 | + | ||
| 218 | + <a-empty v-else description="请先选择租户" /> | ||
| 219 | + </a-card> | ||
| 220 | + | ||
| 221 | + <a-modal v-model:open="copyVisible" title="复制模板" @ok="confirmCopy" :confirmLoading="saving"> | ||
| 222 | + <div class="soft-page-stack"> | ||
| 223 | + <div class="soft-note-card"> | ||
| 224 | + <strong>复制说明</strong> | ||
| 225 | + <p>复制后会生成一份新模板,条件和抢派模式配置会一并复制。</p> | ||
| 226 | + </div> | ||
| 227 | + <a-form layout="vertical"> | ||
| 228 | + <a-form-item label="新模板名称"> | ||
| 229 | + <a-input v-model:value="copyName" placeholder="请输入新模板名称" /> | ||
| 230 | + </a-form-item> | ||
| 231 | + </a-form> | ||
| 232 | + </div> | ||
| 233 | + </a-modal> | ||
| 234 | + </div> | ||
| 235 | +</template> | ||
| 236 | + | ||
| 237 | +<script setup lang="ts"> | ||
| 238 | +import { computed, onMounted, reactive, ref } from 'vue' | ||
| 239 | +import { message } from 'ant-design-vue' | ||
| 240 | +import { cityApi, dispatchRuleApi } from '@/api' | ||
| 241 | +import { useAuthStore } from '@/stores/auth' | ||
| 242 | + | ||
| 243 | +const auth = useAuthStore() | ||
| 244 | +const isAdmin = computed(() => auth.userInfo?.role === 'admin') | ||
| 245 | +const managedCityId = computed<number | undefined>(() => auth.userInfo?.cityId) | ||
| 246 | +const cityList = ref<any[]>([]) | ||
| 247 | +const selectedCityId = ref<number | undefined>() | ||
| 248 | +const currentCityName = computed(() => cityList.value.find(item => item.id === selectedCityId.value)?.name || auth.userInfo?.cityName || '') | ||
| 249 | +const templateList = ref<any[]>([]) | ||
| 250 | +const selectedTemplateId = ref<number | null>(null) | ||
| 251 | +const loadingTemplates = ref(false) | ||
| 252 | +const saving = ref(false) | ||
| 253 | +const copyVisible = ref(false) | ||
| 254 | +const copyName = ref('') | ||
| 255 | +const creatingNew = ref(false) | ||
| 256 | + | ||
| 257 | +const form = reactive<any>({ | ||
| 258 | + id: null, | ||
| 259 | + cityId: undefined, | ||
| 260 | + name: '', | ||
| 261 | + isActive: 0, | ||
| 262 | + grabEnabled: 1, | ||
| 263 | + grabTimeout: 30, | ||
| 264 | + grabScope: 1, | ||
| 265 | + grabMaxPerRider: 3, | ||
| 266 | + autoDispatch: 1, | ||
| 267 | + conditions: [], | ||
| 268 | +}) | ||
| 269 | + | ||
| 270 | +const defaultConditions = [ | ||
| 271 | + { conditionType: 'distance', conditionDesc: '订单优先发给距离取单地址较近的骑手', enabled: 1, thresholdValue: 5, sortOrder: 0, hasThreshold: true }, | ||
| 272 | + { conditionType: 'detour', conditionDesc: '订单优先发给顺路距离较短的骑手', enabled: 1, thresholdValue: 2, sortOrder: 1, hasThreshold: true }, | ||
| 273 | + { conditionType: 'wait', conditionDesc: '订单优先发给等待新订单时间较长的骑手', enabled: 1, thresholdValue: 10, sortOrder: 2, hasThreshold: true }, | ||
| 274 | + { conditionType: 'currentLoad', conditionDesc: '订单优先发给当前持单量较少的骑手', enabled: 1, thresholdValue: 3, sortOrder: 3, hasThreshold: true }, | ||
| 275 | + { conditionType: 'daily', conditionDesc: '订单优先发给当日总接单量较少的骑手', enabled: 1, thresholdValue: 20, sortOrder: 4, hasThreshold: true }, | ||
| 276 | + { conditionType: 'direction', conditionDesc: '订单优先发给目的地与骑手当前方向一致的骑手', enabled: 1, thresholdValue: 0, sortOrder: 5, hasThreshold: false }, | ||
| 277 | + { conditionType: 'rookie', conditionDesc: '订单优先发给新手骑手', enabled: 1, thresholdValue: 7, sortOrder: 6, hasThreshold: true }, | ||
| 278 | + { conditionType: 'praise', conditionDesc: '订单优先发给好评率更高的骑手', enabled: 1, thresholdValue: 95, sortOrder: 7, hasThreshold: true }, | ||
| 279 | + { conditionType: 'areaMatch', conditionDesc: '订单优先发给当前所在区域与订单区域匹配的骑手', enabled: 1, thresholdValue: 0, sortOrder: 8, hasThreshold: false }, | ||
| 280 | +] | ||
| 281 | + | ||
| 282 | +const grabEnabledChecked = computed({ | ||
| 283 | + get: () => form.grabEnabled === 1, | ||
| 284 | + set: (val: boolean) => { form.grabEnabled = val ? 1 : 0 } | ||
| 285 | +}) | ||
| 286 | + | ||
| 287 | +const autoDispatchChecked = computed({ | ||
| 288 | + get: () => form.autoDispatch === 1, | ||
| 289 | + set: (val: boolean) => { form.autoDispatch = val ? 1 : 0 } | ||
| 290 | +}) | ||
| 291 | + | ||
| 292 | +const editorVisible = computed(() => !!form.id || creatingNew.value) | ||
| 293 | + | ||
| 294 | +async function loadCities() { | ||
| 295 | + if (isAdmin.value) { | ||
| 296 | + const res: any = await cityApi.openList() | ||
| 297 | + cityList.value = res.data || [] | ||
| 298 | + if (!selectedCityId.value && cityList.value.length) { | ||
| 299 | + selectedCityId.value = cityList.value[0].id | ||
| 300 | + } | ||
| 301 | + } else { | ||
| 302 | + selectedCityId.value = managedCityId.value | ||
| 303 | + cityList.value = selectedCityId.value ? [{ id: selectedCityId.value, name: auth.userInfo?.cityName || `租户#${selectedCityId.value}` }] : [] | ||
| 304 | + } | ||
| 305 | + | ||
| 306 | + if (selectedCityId.value) { | ||
| 307 | + await loadTemplates() | ||
| 308 | + } | ||
| 309 | +} | ||
| 310 | + | ||
| 311 | +async function handleCityChange() { | ||
| 312 | + resetForm() | ||
| 313 | + await loadTemplates() | ||
| 314 | +} | ||
| 315 | + | ||
| 316 | +async function loadTemplates(preferId?: number | null) { | ||
| 317 | + if (!selectedCityId.value) return | ||
| 318 | + loadingTemplates.value = true | ||
| 319 | + try { | ||
| 320 | + const res: any = await dispatchRuleApi.list(isAdmin.value ? selectedCityId.value : undefined) | ||
| 321 | + templateList.value = res.data || [] | ||
| 322 | + if (!templateList.value.length) { | ||
| 323 | + resetForm() | ||
| 324 | + return | ||
| 325 | + } | ||
| 326 | + const targetId = preferId | ||
| 327 | + || templateList.value.find((item: any) => item.isActive === 1)?.id | ||
| 328 | + || templateList.value[0]?.id | ||
| 329 | + selectTemplate(targetId) | ||
| 330 | + } finally { | ||
| 331 | + loadingTemplates.value = false | ||
| 332 | + } | ||
| 333 | +} | ||
| 334 | + | ||
| 335 | +function selectTemplate(id: number) { | ||
| 336 | + creatingNew.value = false | ||
| 337 | + selectedTemplateId.value = id | ||
| 338 | + const current = templateList.value.find((item: any) => item.id === id) | ||
| 339 | + if (!current) return | ||
| 340 | + Object.assign(form, { | ||
| 341 | + id: current.id, | ||
| 342 | + cityId: current.cityId, | ||
| 343 | + name: current.name, | ||
| 344 | + isActive: current.isActive, | ||
| 345 | + grabEnabled: current.grabEnabled, | ||
| 346 | + grabTimeout: current.grabTimeout, | ||
| 347 | + grabScope: current.grabScope, | ||
| 348 | + grabMaxPerRider: current.grabMaxPerRider, | ||
| 349 | + autoDispatch: current.autoDispatch, | ||
| 350 | + conditions: normalizeConditions(current.conditions), | ||
| 351 | + }) | ||
| 352 | +} | ||
| 353 | + | ||
| 354 | +function normalizeConditions(conditions: any[]) { | ||
| 355 | + const currentMap = new Map((conditions || []).map((item: any) => [item.conditionType, item])) | ||
| 356 | + return defaultConditions.map((base, index) => { | ||
| 357 | + const found = currentMap.get(base.conditionType) | ||
| 358 | + return { | ||
| 359 | + ...base, | ||
| 360 | + ...found, | ||
| 361 | + sortOrder: found?.sortOrder ?? index, | ||
| 362 | + thresholdValue: found?.thresholdValue ?? base.thresholdValue, | ||
| 363 | + enabled: found?.enabled ?? base.enabled, | ||
| 364 | + } | ||
| 365 | + }).sort((a, b) => a.sortOrder - b.sortOrder) | ||
| 366 | +} | ||
| 367 | + | ||
| 368 | +function createTemplate() { | ||
| 369 | + if (!selectedCityId.value) { | ||
| 370 | + message.error('请先选择租户') | ||
| 371 | + return | ||
| 372 | + } | ||
| 373 | + creatingNew.value = true | ||
| 374 | + selectedTemplateId.value = null | ||
| 375 | + Object.assign(form, { | ||
| 376 | + id: null, | ||
| 377 | + cityId: selectedCityId.value, | ||
| 378 | + name: '', | ||
| 379 | + isActive: 0, | ||
| 380 | + grabEnabled: 1, | ||
| 381 | + grabTimeout: 30, | ||
| 382 | + grabScope: 1, | ||
| 383 | + grabMaxPerRider: 3, | ||
| 384 | + autoDispatch: 1, | ||
| 385 | + conditions: normalizeConditions([]), | ||
| 386 | + }) | ||
| 387 | +} | ||
| 388 | + | ||
| 389 | +function openCopyModal() { | ||
| 390 | + copyName.value = `${form.name || '新模板'}-副本` | ||
| 391 | + copyVisible.value = true | ||
| 392 | +} | ||
| 393 | + | ||
| 394 | +async function confirmCopy() { | ||
| 395 | + if (!form.id) return | ||
| 396 | + if (!copyName.value.trim()) { | ||
| 397 | + message.error('请输入新模板名称') | ||
| 398 | + return | ||
| 399 | + } | ||
| 400 | + saving.value = true | ||
| 401 | + try { | ||
| 402 | + const res: any = await dispatchRuleApi.copy(form.id, copyName.value.trim(), isAdmin.value ? selectedCityId.value : undefined) | ||
| 403 | + message.success('复制成功') | ||
| 404 | + copyVisible.value = false | ||
| 405 | + await loadTemplates(res.data) | ||
| 406 | + } finally { | ||
| 407 | + saving.value = false | ||
| 408 | + } | ||
| 409 | +} | ||
| 410 | + | ||
| 411 | +async function activateTemplate() { | ||
| 412 | + if (!form.id) return | ||
| 413 | + await dispatchRuleApi.activate(form.id, isAdmin.value ? selectedCityId.value : undefined) | ||
| 414 | + message.success('已激活模板') | ||
| 415 | + await loadTemplates(form.id) | ||
| 416 | +} | ||
| 417 | + | ||
| 418 | +async function deleteTemplate() { | ||
| 419 | + if (!form.id) return | ||
| 420 | + await dispatchRuleApi.del(form.id, isAdmin.value ? selectedCityId.value : undefined) | ||
| 421 | + message.success('删除成功') | ||
| 422 | + await loadTemplates() | ||
| 423 | +} | ||
| 424 | + | ||
| 425 | +async function saveTemplate() { | ||
| 426 | + if (!selectedCityId.value) { | ||
| 427 | + message.error('请选择租户') | ||
| 428 | + return | ||
| 429 | + } | ||
| 430 | + if (!form.name?.trim()) { | ||
| 431 | + message.error('请输入模板名称') | ||
| 432 | + return | ||
| 433 | + } | ||
| 434 | + if (!form.grabTimeout || form.grabTimeout < 1) { | ||
| 435 | + message.error('抢单时间必须大于0') | ||
| 436 | + return | ||
| 437 | + } | ||
| 438 | + if (!form.grabMaxPerRider || form.grabMaxPerRider < 1) { | ||
| 439 | + message.error('单人最大抢单量必须大于0') | ||
| 440 | + return | ||
| 441 | + } | ||
| 442 | + | ||
| 443 | + saving.value = true | ||
| 444 | + try { | ||
| 445 | + const payload = { | ||
| 446 | + id: form.id || undefined, | ||
| 447 | + cityId: selectedCityId.value, | ||
| 448 | + name: form.name, | ||
| 449 | + grabEnabled: form.grabEnabled, | ||
| 450 | + grabTimeout: form.grabTimeout, | ||
| 451 | + grabScope: form.grabScope, | ||
| 452 | + grabMaxPerRider: form.grabMaxPerRider, | ||
| 453 | + autoDispatch: form.autoDispatch, | ||
| 454 | + conditions: (form.conditions || []).map((item: any, index: number) => ({ | ||
| 455 | + conditionType: item.conditionType, | ||
| 456 | + enabled: item.enabled, | ||
| 457 | + thresholdValue: item.hasThreshold ? (item.thresholdValue ?? 0) : 0, | ||
| 458 | + sortOrder: index, | ||
| 459 | + })), | ||
| 460 | + } | ||
| 461 | + const res: any = await dispatchRuleApi.save({ | ||
| 462 | + ...payload, | ||
| 463 | + cityId: selectedCityId.value, | ||
| 464 | + }) | ||
| 465 | + message.success('保存成功') | ||
| 466 | + await loadTemplates(res.data) | ||
| 467 | + } finally { | ||
| 468 | + saving.value = false | ||
| 469 | + } | ||
| 470 | +} | ||
| 471 | + | ||
| 472 | +function moveCondition(index: number, offset: number) { | ||
| 473 | + const target = index + offset | ||
| 474 | + if (target < 0 || target >= form.conditions.length) return | ||
| 475 | + const list = [...form.conditions] | ||
| 476 | + const temp = list[index] | ||
| 477 | + list[index] = list[target] | ||
| 478 | + list[target] = temp | ||
| 479 | + list.forEach((item, idx) => { item.sortOrder = idx }) | ||
| 480 | + form.conditions = list | ||
| 481 | +} | ||
| 482 | + | ||
| 483 | +function resetForm() { | ||
| 484 | + creatingNew.value = false | ||
| 485 | + Object.assign(form, { | ||
| 486 | + id: null, | ||
| 487 | + cityId: selectedCityId.value, | ||
| 488 | + name: '', | ||
| 489 | + isActive: 0, | ||
| 490 | + grabEnabled: 1, | ||
| 491 | + grabTimeout: 30, | ||
| 492 | + grabScope: 1, | ||
| 493 | + grabMaxPerRider: 3, | ||
| 494 | + autoDispatch: 1, | ||
| 495 | + conditions: [], | ||
| 496 | + }) | ||
| 497 | + selectedTemplateId.value = null | ||
| 498 | +} | ||
| 499 | + | ||
| 500 | +onMounted(loadCities) | ||
| 501 | +</script> | ||
| 502 | + | ||
| 503 | +<style scoped> | ||
| 504 | +.dispatch-layout { | ||
| 505 | + margin-top: 8px; | ||
| 506 | +} | ||
| 507 | + | ||
| 508 | +.plan-layout { | ||
| 509 | + display: grid; | ||
| 510 | + grid-template-columns: 280px minmax(0, 1fr); | ||
| 511 | + gap: 18px; | ||
| 512 | + min-height: 640px; | ||
| 513 | +} | ||
| 514 | + | ||
| 515 | +.plan-sidebar, | ||
| 516 | +.plan-content { | ||
| 517 | + border-radius: 24px; | ||
| 518 | + border: 1px solid rgba(194, 185, 239, 0.22); | ||
| 519 | + background: rgba(255, 255, 255, 0.58); | ||
| 520 | + padding: 18px; | ||
| 521 | +} | ||
| 522 | + | ||
| 523 | +.plan-content { | ||
| 524 | + display: flex; | ||
| 525 | + flex-direction: column; | ||
| 526 | + gap: 18px; | ||
| 527 | + min-width: 0; | ||
| 528 | +} | ||
| 529 | + | ||
| 530 | +.plan-content-top { | ||
| 531 | + position: sticky; | ||
| 532 | + top: 0; | ||
| 533 | + z-index: 2; | ||
| 534 | + display: flex; | ||
| 535 | + flex-direction: column; | ||
| 536 | + gap: 12px; | ||
| 537 | + padding-bottom: 6px; | ||
| 538 | + background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(255, 255, 255, 0.76) 78%, rgba(255, 255, 255, 0)); | ||
| 539 | +} | ||
| 540 | + | ||
| 541 | +.plan-content-body { | ||
| 542 | + display: flex; | ||
| 543 | + flex-direction: column; | ||
| 544 | + gap: 18px; | ||
| 545 | +} | ||
| 546 | + | ||
| 547 | +.plan-sidebar-header, | ||
| 548 | +.plan-toolbar, | ||
| 549 | +.soft-section-header, | ||
| 550 | +.dispatch-condition-main, | ||
| 551 | +.dispatch-condition-body { | ||
| 552 | + display: flex; | ||
| 553 | + align-items: center; | ||
| 554 | + justify-content: space-between; | ||
| 555 | + gap: 12px; | ||
| 556 | +} | ||
| 557 | + | ||
| 558 | +.plan-sidebar-title, | ||
| 559 | +.plan-item-name, | ||
| 560 | +.soft-section-title { | ||
| 561 | + font-family: var(--font-display); | ||
| 562 | + color: var(--text-dark); | ||
| 563 | +} | ||
| 564 | + | ||
| 565 | +.plan-sidebar-title { | ||
| 566 | + font-size: 16px; | ||
| 567 | + font-weight: 700; | ||
| 568 | +} | ||
| 569 | + | ||
| 570 | +.plan-sidebar-subtitle, | ||
| 571 | +.plan-item-bottom, | ||
| 572 | +.soft-section-subtitle, | ||
| 573 | +.dispatch-condition-label, | ||
| 574 | +.dispatch-condition-order, | ||
| 575 | +.managed-city-pill { | ||
| 576 | + color: var(--text-soft); | ||
| 577 | + font-size: 12px; | ||
| 578 | +} | ||
| 579 | + | ||
| 580 | +.managed-city-pill { | ||
| 581 | + display: inline-flex; | ||
| 582 | + align-items: center; | ||
| 583 | + min-height: 36px; | ||
| 584 | + padding: 0 14px; | ||
| 585 | + border-radius: 999px; | ||
| 586 | + border: 1px solid rgba(194, 185, 239, 0.28); | ||
| 587 | + background: rgba(246, 242, 255, 0.84); | ||
| 588 | +} | ||
| 589 | + | ||
| 590 | +.plan-list { | ||
| 591 | + display: flex; | ||
| 592 | + flex-direction: column; | ||
| 593 | + gap: 10px; | ||
| 594 | + margin-top: 14px; | ||
| 595 | +} | ||
| 596 | + | ||
| 597 | +.plan-item { | ||
| 598 | + border: 1px solid rgba(194, 185, 239, 0.22); | ||
| 599 | + background: rgba(255, 255, 255, 0.7); | ||
| 600 | + border-radius: 18px; | ||
| 601 | + padding: 14px; | ||
| 602 | + text-align: left; | ||
| 603 | + cursor: pointer; | ||
| 604 | +} | ||
| 605 | + | ||
| 606 | +.plan-item.active { | ||
| 607 | + border-color: rgba(140, 124, 240, 0.44); | ||
| 608 | + background: rgba(246, 242, 255, 0.95); | ||
| 609 | + box-shadow: 0 10px 24px rgba(140, 124, 240, 0.12); | ||
| 610 | +} | ||
| 611 | + | ||
| 612 | +.plan-item-top, | ||
| 613 | +.plan-item-bottom { | ||
| 614 | + display: flex; | ||
| 615 | + justify-content: space-between; | ||
| 616 | + gap: 10px; | ||
| 617 | +} | ||
| 618 | + | ||
| 619 | +.plan-item-bottom { | ||
| 620 | + margin-top: 6px; | ||
| 621 | +} | ||
| 622 | + | ||
| 623 | +.plan-note-card, | ||
| 624 | +.plan-section, | ||
| 625 | +.plan-toolbar { | ||
| 626 | + margin-bottom: 0; | ||
| 627 | +} | ||
| 628 | + | ||
| 629 | +.plan-toolbar { | ||
| 630 | + flex-wrap: wrap; | ||
| 631 | + align-items: center; | ||
| 632 | + padding: 14px 16px; | ||
| 633 | + border: 1px solid rgba(194, 185, 239, 0.22); | ||
| 634 | + border-radius: 20px; | ||
| 635 | + background: rgba(255, 255, 255, 0.88); | ||
| 636 | + box-shadow: 0 10px 24px rgba(140, 124, 240, 0.08); | ||
| 637 | +} | ||
| 638 | + | ||
| 639 | +.plan-toolbar-meta, | ||
| 640 | +.plan-toolbar-submit { | ||
| 641 | + display: flex; | ||
| 642 | + flex-direction: column; | ||
| 643 | + gap: 4px; | ||
| 644 | +} | ||
| 645 | + | ||
| 646 | +.plan-toolbar-meta { | ||
| 647 | + min-width: 0; | ||
| 648 | +} | ||
| 649 | + | ||
| 650 | +.plan-toolbar-meta strong { | ||
| 651 | + color: var(--text-dark); | ||
| 652 | + font-size: 15px; | ||
| 653 | + line-height: 1.4; | ||
| 654 | +} | ||
| 655 | + | ||
| 656 | +.plan-toolbar-eyebrow, | ||
| 657 | +.plan-toolbar-tip { | ||
| 658 | + color: var(--text-soft); | ||
| 659 | + font-size: 12px; | ||
| 660 | + line-height: 1.5; | ||
| 661 | +} | ||
| 662 | + | ||
| 663 | +.plan-toolbar-submit { | ||
| 664 | + margin-left: auto; | ||
| 665 | + align-items: flex-end; | ||
| 666 | +} | ||
| 667 | + | ||
| 668 | +.plan-save-button { | ||
| 669 | + min-width: 108px; | ||
| 670 | + height: 36px; | ||
| 671 | + padding-inline: 16px; | ||
| 672 | + border: none; | ||
| 673 | + border-radius: 12px; | ||
| 674 | + background: linear-gradient(135deg, #8c7cf0, #a98ff7 55%, #e5b5dc); | ||
| 675 | + box-shadow: 0 8px 18px rgba(140, 124, 240, 0.18); | ||
| 676 | +} | ||
| 677 | + | ||
| 678 | +.plan-save-button:hover, | ||
| 679 | +.plan-save-button:focus { | ||
| 680 | + background: linear-gradient(135deg, #8372ee, #a188f6 55%, #e2add7); | ||
| 681 | +} | ||
| 682 | + | ||
| 683 | +.plan-section { | ||
| 684 | + padding: 18px 20px 20px; | ||
| 685 | + border: 1px solid rgba(194, 185, 239, 0.18); | ||
| 686 | + border-radius: 22px; | ||
| 687 | + background: rgba(255, 255, 255, 0.72); | ||
| 688 | +} | ||
| 689 | + | ||
| 690 | +.plan-section-head { | ||
| 691 | + display: flex; | ||
| 692 | + flex-direction: column; | ||
| 693 | + gap: 10px; | ||
| 694 | + margin-bottom: 16px; | ||
| 695 | +} | ||
| 696 | + | ||
| 697 | +.plan-section-chip { | ||
| 698 | + display: inline-flex; | ||
| 699 | + align-items: center; | ||
| 700 | + align-self: flex-start; | ||
| 701 | + min-height: 26px; | ||
| 702 | + padding: 0 12px; | ||
| 703 | + border-radius: 999px; | ||
| 704 | + background: rgba(246, 242, 255, 0.95); | ||
| 705 | + border: 1px solid rgba(140, 124, 240, 0.18); | ||
| 706 | + color: #7f6de5; | ||
| 707 | + font-size: 12px; | ||
| 708 | + font-weight: 700; | ||
| 709 | +} | ||
| 710 | + | ||
| 711 | +.soft-section-heading { | ||
| 712 | + display: flex; | ||
| 713 | + flex-direction: column; | ||
| 714 | + gap: 4px; | ||
| 715 | +} | ||
| 716 | + | ||
| 717 | +.soft-section-title { | ||
| 718 | + margin: 0; | ||
| 719 | + font-size: 18px; | ||
| 720 | + line-height: 1.35; | ||
| 721 | +} | ||
| 722 | + | ||
| 723 | +.soft-section-subtitle { | ||
| 724 | + margin: 0; | ||
| 725 | + line-height: 1.6; | ||
| 726 | +} | ||
| 727 | + | ||
| 728 | +.plan-form-grid { | ||
| 729 | + margin-bottom: 4px; | ||
| 730 | +} | ||
| 731 | + | ||
| 732 | +.plan-form-grid-compact { | ||
| 733 | + margin-bottom: 0; | ||
| 734 | +} | ||
| 735 | + | ||
| 736 | +:deep(.plan-form-grid .ant-form-item) { | ||
| 737 | + margin-bottom: 14px; | ||
| 738 | +} | ||
| 739 | + | ||
| 740 | +:deep(.plan-form-grid .ant-form-item-label > label) { | ||
| 741 | + font-size: 13px; | ||
| 742 | + font-weight: 600; | ||
| 743 | + color: var(--text-dark); | ||
| 744 | +} | ||
| 745 | + | ||
| 746 | +:deep(.plan-form-grid .ant-form-item-control-input) { | ||
| 747 | + min-height: 40px; | ||
| 748 | +} | ||
| 749 | + | ||
| 750 | +:deep(.grab-scope-group) { | ||
| 751 | + display: grid; | ||
| 752 | + grid-template-columns: repeat(2, minmax(0, 1fr)); | ||
| 753 | + gap: 12px; | ||
| 754 | + width: 100%; | ||
| 755 | +} | ||
| 756 | + | ||
| 757 | +:deep(.grab-scope-option) { | ||
| 758 | + margin-inline-end: 0; | ||
| 759 | +} | ||
| 760 | + | ||
| 761 | +:deep(.grab-scope-option .ant-radio) { | ||
| 762 | + display: none; | ||
| 763 | +} | ||
| 764 | + | ||
| 765 | +:deep(.grab-scope-option) { | ||
| 766 | + margin: 0; | ||
| 767 | +} | ||
| 768 | + | ||
| 769 | +:deep(.grab-scope-option > span:last-child) { | ||
| 770 | + width: 100%; | ||
| 771 | + padding-inline-start: 0; | ||
| 772 | +} | ||
| 773 | + | ||
| 774 | +.grab-scope-card { | ||
| 775 | + display: flex; | ||
| 776 | + flex-direction: column; | ||
| 777 | + gap: 6px; | ||
| 778 | + min-height: 88px; | ||
| 779 | + padding: 14px 16px; | ||
| 780 | + border-radius: 18px; | ||
| 781 | + border: 1px solid rgba(194, 185, 239, 0.22); | ||
| 782 | + background: rgba(255, 255, 255, 0.84); | ||
| 783 | + transition: all 0.2s ease; | ||
| 784 | +} | ||
| 785 | + | ||
| 786 | +.grab-scope-card strong { | ||
| 787 | + color: var(--text-dark); | ||
| 788 | + font-size: 14px; | ||
| 789 | +} | ||
| 790 | + | ||
| 791 | +.grab-scope-card span { | ||
| 792 | + color: var(--text-soft); | ||
| 793 | + font-size: 12px; | ||
| 794 | + line-height: 1.6; | ||
| 795 | +} | ||
| 796 | + | ||
| 797 | +:deep(.grab-scope-option-checked .grab-scope-card), | ||
| 798 | +:deep(.grab-scope-option.ant-radio-wrapper-checked .grab-scope-card) { | ||
| 799 | + border-color: rgba(140, 124, 240, 0.5); | ||
| 800 | + background: rgba(246, 242, 255, 0.96); | ||
| 801 | + box-shadow: 0 10px 24px rgba(140, 124, 240, 0.12); | ||
| 802 | +} | ||
| 803 | + | ||
| 804 | +:deep(.grab-scope-option:hover .grab-scope-card) { | ||
| 805 | + border-color: rgba(140, 124, 240, 0.34); | ||
| 806 | +} | ||
| 807 | + | ||
| 808 | +.dispatch-condition-list { | ||
| 809 | + display: flex; | ||
| 810 | + flex-direction: column; | ||
| 811 | + gap: 12px; | ||
| 812 | +} | ||
| 813 | + | ||
| 814 | +.dispatch-condition-row { | ||
| 815 | + display: grid; | ||
| 816 | + grid-template-columns: 48px minmax(0, 1fr); | ||
| 817 | + gap: 14px; | ||
| 818 | + border: 1px solid rgba(194, 185, 239, 0.22); | ||
| 819 | + border-radius: 20px; | ||
| 820 | + background: rgba(255, 255, 255, 0.9); | ||
| 821 | + padding: 16px; | ||
| 822 | + box-shadow: 0 12px 24px rgba(140, 124, 240, 0.08); | ||
| 823 | +} | ||
| 824 | + | ||
| 825 | +.dispatch-condition-row.disabled { | ||
| 826 | + opacity: 0.76; | ||
| 827 | +} | ||
| 828 | + | ||
| 829 | +.dispatch-condition-rank { | ||
| 830 | + width: 48px; | ||
| 831 | + height: 48px; | ||
| 832 | + border-radius: 16px; | ||
| 833 | + display: grid; | ||
| 834 | + place-items: center; | ||
| 835 | + font-family: var(--font-display); | ||
| 836 | + font-size: 18px; | ||
| 837 | + font-weight: 700; | ||
| 838 | + color: #7f6de5; | ||
| 839 | + background: rgba(246, 242, 255, 0.92); | ||
| 840 | + border: 1px solid rgba(140, 124, 240, 0.18); | ||
| 841 | +} | ||
| 842 | + | ||
| 843 | +.dispatch-condition-content { | ||
| 844 | + min-width: 0; | ||
| 845 | +} | ||
| 846 | + | ||
| 847 | +.dispatch-condition-title-wrap { | ||
| 848 | + min-width: 0; | ||
| 849 | +} | ||
| 850 | + | ||
| 851 | +.dispatch-condition-title { | ||
| 852 | + display: flex; | ||
| 853 | + align-items: center; | ||
| 854 | + gap: 8px; | ||
| 855 | + flex-wrap: wrap; | ||
| 856 | +} | ||
| 857 | + | ||
| 858 | +.dispatch-condition-meta { | ||
| 859 | + margin-top: 6px; | ||
| 860 | + color: var(--text-soft); | ||
| 861 | + font-size: 12px; | ||
| 862 | +} | ||
| 863 | + | ||
| 864 | +.dispatch-condition-actions { | ||
| 865 | + display: flex; | ||
| 866 | + align-items: center; | ||
| 867 | + gap: 8px; | ||
| 868 | + flex-wrap: wrap; | ||
| 869 | +} | ||
| 870 | + | ||
| 871 | +.dispatch-condition-body { | ||
| 872 | + margin-top: 14px; | ||
| 873 | + padding-top: 14px; | ||
| 874 | + border-top: 1px dashed rgba(194, 185, 239, 0.36); | ||
| 875 | +} | ||
| 876 | + | ||
| 877 | +.dispatch-condition-input { | ||
| 878 | + display: flex; | ||
| 879 | + align-items: center; | ||
| 880 | + gap: 10px; | ||
| 881 | + flex-wrap: wrap; | ||
| 882 | +} | ||
| 883 | + | ||
| 884 | +@media (max-width: 960px) { | ||
| 885 | + .plan-layout { | ||
| 886 | + grid-template-columns: 1fr; | ||
| 887 | + } | ||
| 888 | + | ||
| 889 | + .plan-content-top { | ||
| 890 | + position: static; | ||
| 891 | + padding-bottom: 0; | ||
| 892 | + background: transparent; | ||
| 893 | + } | ||
| 894 | + | ||
| 895 | + .plan-section { | ||
| 896 | + padding: 16px; | ||
| 897 | + } | ||
| 898 | + | ||
| 899 | + :deep(.plan-form-grid .ant-col) { | ||
| 900 | + max-width: 100%; | ||
| 901 | + flex: 0 0 100%; | ||
| 902 | + } | ||
| 903 | + | ||
| 904 | + :deep(.grab-scope-group) { | ||
| 905 | + grid-template-columns: 1fr; | ||
| 906 | + } | ||
| 907 | + | ||
| 908 | + .plan-toolbar, | ||
| 909 | + .soft-section-header, | ||
| 910 | + .dispatch-condition-main, | ||
| 911 | + .dispatch-condition-body, | ||
| 912 | + .plan-toolbar-submit { | ||
| 913 | + flex-direction: column; | ||
| 914 | + align-items: flex-start; | ||
| 915 | + } | ||
| 916 | + | ||
| 917 | + .plan-save-button { | ||
| 918 | + width: 100%; | ||
| 919 | + } | ||
| 920 | + | ||
| 921 | + .dispatch-condition-row { | ||
| 922 | + grid-template-columns: 1fr; | ||
| 923 | + } | ||
| 924 | + | ||
| 925 | + .dispatch-condition-rank { | ||
| 926 | + width: 40px; | ||
| 927 | + height: 40px; | ||
| 928 | + } | ||
| 929 | +} | ||
| 930 | +</style> |