Commit 67f451d6ded3c3b66f62ba321d0535bc5d5f0dbb

Authored by 杨刚
1 parent b37151b6

init

.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 &#39;@/stores/auth&#39; @@ -112,7 +118,7 @@ import { useAuthStore } from &#39;@/stores/auth&#39;
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&lt;string, string&gt; = { @@ -131,12 +137,15 @@ const titleMap: Record&lt;string, string&gt; = {
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>