Commit 67f451d6ded3c3b66f62ba321d0535bc5d5f0dbb

Authored by 杨刚
1 parent b37151b6

init

.codex 0 → 100644
src/api/index.ts
... ... @@ -136,6 +136,27 @@ export const deliveryOrderApi = {
136 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 161 export const locationApi = {
141 162 nearby: (cityId: number, lng: string, lat: string) =>
... ...
src/layouts/MainLayout.vue
... ... @@ -25,11 +25,11 @@
25 25 <template #icon><home-outlined /></template>
26 26 工作台
27 27 </a-menu-item>
28   - <a-menu-item key="/city">
  28 + <a-menu-item v-if="isAdmin" key="/city">
29 29 <template #icon><global-outlined /></template>
30 30 租户管理
31 31 </a-menu-item>
32   - <a-menu-item key="/substation">
  32 + <a-menu-item v-if="isAdmin" key="/substation">
33 33 <template #icon><apartment-outlined /></template>
34 34 分站管理
35 35 </a-menu-item>
... ... @@ -54,6 +54,12 @@
54 54 <a-menu-item key="/refund">退款管理</a-menu-item>
55 55 <a-menu-item key="/delivery/order">配送订单</a-menu-item>
56 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 63 <a-sub-menu key="open">
58 64 <template #icon><api-outlined /></template>
59 65 <template #title>开放平台</template>
... ... @@ -112,7 +118,7 @@ import { useAuthStore } from &#39;@/stores/auth&#39;
112 118 import {
113 119 GlobalOutlined, ApartmentOutlined, ShopOutlined,
114 120 UserOutlined, UnorderedListOutlined, ApiOutlined, DownOutlined, StarOutlined,
115   - CalendarOutlined, MenuFoldOutlined, MenuUnfoldOutlined, HomeOutlined
  121 + CalendarOutlined, MenuFoldOutlined, MenuUnfoldOutlined, HomeOutlined, ControlOutlined
116 122 } from '@ant-design/icons-vue'
117 123  
118 124 const router = useRouter()
... ... @@ -131,12 +137,15 @@ const titleMap: Record&lt;string, string&gt; = {
131 137 '/order': '订单列表',
132 138 '/refund': '退款管理',
133 139 '/delivery/order': '配送订单',
  140 + '/config/fee-plan': '配送费配置',
  141 + '/dispatch/rule': '调度配置',
134 142 '/open': '开放平台',
135 143 '/open/mock-delivery': '模拟推单',
136 144 }
137 145  
138 146 watch(() => route.path, (p) => { selectedKeys.value = [p] })
139 147  
  148 +const isAdmin = computed(() => auth.userInfo?.role === 'admin')
140 149 const currentTitle = computed(() => titleMap[route.path] || '外卖管理')
141 150 const avatarText = computed(() => (auth.userInfo?.userNickname || '管理员').slice(0, 1))
142 151 const todayLabel = computed(() => new Intl.DateTimeFormat('zh-CN', {
... ...
src/router/index.ts
... ... @@ -70,6 +70,18 @@ const router = createRouter({
70 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 85 path: 'rider/evaluate',
74 86 name: 'RiderEvaluate',
75 87 component: () => import('@/views/rider/RiderEvaluateList.vue'),
... ...
src/views/city/CityList.vue
... ... @@ -44,389 +44,6 @@
44 44 </a-form>
45 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 48 <a-modal v-model:open="levelVisible" :title="`骑手等级配置 - ${levelCityName}`" width="900px" :footer="null">
432 49 <div style="margin-bottom:16px;text-align:right">
... ... @@ -519,63 +136,22 @@
519 136  
520 137 <script setup lang="ts">
521 138 import { onMounted, reactive, ref } from 'vue'
  139 +import { useRouter } from 'vue-router'
522 140 import { message } from 'ant-design-vue'
523 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 144 const loading = ref(false)
545 145 const saving = ref(false)
546 146 const list = ref<any[]>([])
547 147 const modalVisible = ref(false)
548   -const configVisible = ref(false)
549 148 const levelVisible = ref(false)
550 149 const levelEditVisible = ref(false)
551 150 const editingId = ref<number | null>(null)
552   -const currentCityId = ref<number>(0)
553   -const currentCityName = ref('')
554 151 const levelCityId = ref<number>(0)
555 152 const levelCityName = ref('')
556 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 155 const levelLoading = ref(false)
580 156 const levelSaving = ref(false)
581 157 const levelList = ref<any[]>([])
... ... @@ -663,193 +239,8 @@ async function toggleStatus(record: any) {
663 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 246 function openLevelConfig(record: any) {
... ... @@ -948,298 +339,5 @@ function formatLevelRule(record: any) {
948 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 342 onMounted(loadList)
1099 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>
... ...