Commit 054f8469e8efa8b2eed1f9e744d8a3cf322dbef5

Authored by 杨刚
1 parent fdbd231e

init

src/api/index.ts
... ... @@ -8,9 +8,23 @@ export const cityApi = {
8 8 edit: (data: any) => request.put('/api/platform/city/edit', data),
9 9 setStatus: (cityId: number, status: number) =>
10 10 request.post('/api/platform/city/setStatus', null, { params: { cityId, status } }),
11   - getConfig: (cityId: number) => request.get(`/api/platform/city/config/${cityId}`),
12   - updateConfig: (cityId: number, config: any) =>
13   - request.post(`/api/platform/city/config/${cityId}`, config),
  11 + listFeePlans: (cityId: number) => request.get(`/api/platform/city/${cityId}/fee-plans`),
  12 + getFeePlan: (cityId: number, planId: number) =>
  13 + request.get(`/api/platform/city/${cityId}/fee-plans/${planId}`),
  14 + createFeePlan: (cityId: number, data: any) =>
  15 + request.post(`/api/platform/city/${cityId}/fee-plans`, data),
  16 + initDefaultFeePlan: (cityId: number) =>
  17 + request.post(`/api/platform/city/${cityId}/fee-plans/init-default`),
  18 + updateFeePlan: (cityId: number, planId: number, data: any) =>
  19 + request.put(`/api/platform/city/${cityId}/fee-plans/${planId}`, data),
  20 + copyFeePlan: (cityId: number, planId: number) =>
  21 + request.post(`/api/platform/city/${cityId}/fee-plans/${planId}/copy`),
  22 + setDefaultFeePlan: (cityId: number, planId: number) =>
  23 + request.post(`/api/platform/city/${cityId}/fee-plans/${planId}/default`),
  24 + deleteFeePlan: (cityId: number, planId: number) =>
  25 + request.delete(`/api/platform/city/${cityId}/fee-plans/${planId}`),
  26 + previewFeePlan: (cityId: number, data: any) =>
  27 + request.post(`/api/platform/city/${cityId}/fee-plans/preview`, data),
14 28 }
15 29  
16 30 // 分站管理
... ...
src/layouts/MainLayout.vue
... ... @@ -159,8 +159,8 @@ function handleLogout() {
159 159 min-height: 100vh;
160 160 display: grid;
161 161 grid-template-columns: 280px minmax(0, 1fr);
162   - gap: 22px;
163   - padding: 22px;
  162 + gap: 16px;
  163 + padding: 16px;
164 164 }
165 165  
166 166 .soft-sider,
... ... @@ -173,10 +173,10 @@ function handleLogout() {
173 173  
174 174 .soft-sider {
175 175 position: sticky;
176   - top: 22px;
177   - height: calc(100vh - 44px);
178   - border-radius: 34px;
179   - padding: 18px 14px 18px;
  176 + top: 16px;
  177 + height: calc(100vh - 32px);
  178 + border-radius: 26px;
  179 + padding: 14px 12px 14px;
180 180 display: flex;
181 181 flex-direction: column;
182 182 overflow: hidden;
... ... @@ -205,9 +205,9 @@ function handleLogout() {
205 205  
206 206 .sider-toggle {
207 207 align-self: flex-end;
208   - width: 44px;
209   - height: 44px;
210   - border-radius: 16px;
  208 + width: 38px;
  209 + height: 38px;
  210 + border-radius: 12px;
211 211 border: none;
212 212 background: rgba(246, 242, 255, 0.9);
213 213 color: #7f6de5;
... ... @@ -218,14 +218,14 @@ function handleLogout() {
218 218 .brand-block {
219 219 display: flex;
220 220 align-items: center;
221   - gap: 14px;
222   - padding: 10px 10px 20px;
  221 + gap: 12px;
  222 + padding: 6px 8px 14px;
223 223 }
224 224  
225 225 .brand-mark {
226   - width: 52px;
227   - height: 52px;
228   - border-radius: 18px;
  226 + width: 44px;
  227 + height: 44px;
  228 + border-radius: 14px;
229 229 background: linear-gradient(145deg, #8c7cf0, #e6b5dc);
230 230 color: white;
231 231 font-family: 'Outfit', sans-serif;
... ... @@ -240,6 +240,17 @@ function handleLogout() {
240 240 flex-direction: column;
241 241 }
242 242  
  243 +.brand-copy strong {
  244 + font-size: 15px;
  245 + line-height: 1.35;
  246 +}
  247 +
  248 +.brand-copy span {
  249 + margin-top: 2px;
  250 + font-size: 11px;
  251 + line-height: 1.4;
  252 +}
  253 +
243 254 .brand-copy strong,
244 255 .profile-copy strong,
245 256 .hero-copy h2,
... ... @@ -264,11 +275,11 @@ h1 {
264 275 }
265 276  
266 277 .sider-foot {
267   - margin-top: 14px;
  278 + margin-top: 10px;
268 279 flex-shrink: 0;
269   - border-radius: 24px;
  280 + border-radius: 18px;
270 281 background: linear-gradient(180deg, rgba(245, 241, 255, 0.85), rgba(255, 247, 250, 0.92));
271   - padding: 16px;
  282 + padding: 12px;
272 283 }
273 284  
274 285 .soft-chip {
... ... @@ -290,8 +301,8 @@ h1 {
290 301 }
291 302  
292 303 .soft-topbar {
293   - border-radius: 30px;
294   - padding: 18px 24px;
  304 + border-radius: 22px;
  305 + padding: 12px 16px;
295 306 display: flex;
296 307 align-items: center;
297 308 justify-content: space-between;
... ... @@ -300,21 +311,22 @@ h1 {
300 311  
301 312 .soft-topbar h1 {
302 313 margin: 4px 0 0;
303   - font-size: 30px;
  314 + font-size: 22px;
  315 + line-height: 1.3;
304 316 }
305 317  
306 318 .eyebrow {
307 319 margin: 0;
308 320 text-transform: uppercase;
309 321 letter-spacing: 0.12em;
310   - font-size: 11px;
  322 + font-size: 10px;
311 323 font-weight: 700;
312 324 }
313 325  
314 326 .topbar-actions {
315 327 display: flex;
316 328 align-items: center;
317   - gap: 14px;
  329 + gap: 10px;
318 330 }
319 331  
320 332 .date-pill,
... ... @@ -325,7 +337,8 @@ h1 {
325 337 border-radius: 999px;
326 338 background: rgba(255, 255, 255, 0.82);
327 339 border: 1px solid rgba(194, 184, 237, 0.38);
328   - padding: 10px 14px;
  340 + padding: 7px 10px;
  341 + font-size: 12px;
329 342 }
330 343  
331 344 .profile-button {
... ... @@ -334,14 +347,15 @@ h1 {
334 347 }
335 348  
336 349 .profile-avatar {
337   - width: 38px;
338   - height: 38px;
339   - border-radius: 14px;
  350 + width: 34px;
  351 + height: 34px;
  352 + border-radius: 12px;
340 353 display: grid;
341 354 place-items: center;
342 355 background: linear-gradient(135deg, #a48ef4, #f1bfd8);
343 356 color: white;
344 357 font-weight: 700;
  358 + font-size: 13px;
345 359 }
346 360  
347 361 .profile-copy {
... ... @@ -350,6 +364,26 @@ h1 {
350 364 text-align: left;
351 365 }
352 366  
  367 +.profile-copy strong {
  368 + font-size: 13px;
  369 + line-height: 1.35;
  370 +}
  371 +
  372 +.profile-copy small {
  373 + font-size: 11px;
  374 + line-height: 1.35;
  375 +}
  376 +
  377 +:deep(.ant-menu-item),
  378 +:deep(.ant-menu-submenu-title) {
  379 + min-height: 38px !important;
  380 + line-height: 38px !important;
  381 +}
  382 +
  383 +:deep(.ant-menu-title-content) {
  384 + font-size: 13px;
  385 +}
  386 +
353 387 @media (max-width: 960px) {
354 388 .layout-shell {
355 389 grid-template-columns: 1fr;
... ... @@ -378,7 +412,7 @@ h1 {
378 412 }
379 413  
380 414 .soft-topbar h1 {
381   - font-size: 26px;
  415 + font-size: 20px;
382 416 }
383 417  
384 418 .hero-copy h2 {
... ...
src/style.css
... ... @@ -30,6 +30,10 @@
30 30 --radius-sm: 14px;
31 31 --font-display: 'Outfit', 'Avenir Next', 'Segoe UI', sans-serif;
32 32 --font-body: 'Plus Jakarta Sans', 'Segoe UI', sans-serif;
  33 + --font-size-base: 14px;
  34 + --font-size-sm: 12px;
  35 + --font-size-md: 13px;
  36 + --font-size-lg: 15px;
33 37 color: var(--text-main);
34 38 font-family: var(--font-body);
35 39 line-height: 1.5;
... ... @@ -55,6 +59,7 @@ body {
55 59 margin: 0;
56 60 background: var(--app-bg);
57 61 color: var(--text-main);
  62 + font-size: var(--font-size-base);
58 63 }
59 64  
60 65 a {
... ... @@ -69,6 +74,9 @@ a {
69 74 border-radius: 999px;
70 75 font-weight: 600;
71 76 box-shadow: none;
  77 + font-size: var(--font-size-md);
  78 + height: 32px;
  79 + padding-inline: 14px;
72 80 }
73 81  
74 82 .ant-btn-primary {
... ... @@ -97,18 +105,19 @@ a {
97 105  
98 106 .ant-card .ant-card-head {
99 107 border-bottom: 1px solid rgba(188, 180, 230, 0.18);
100   - min-height: 72px;
  108 + min-height: 56px;
  109 + padding-inline: 20px;
101 110 }
102 111  
103 112 .ant-card .ant-card-head-title {
104 113 font-family: var(--font-display);
105   - font-size: 1.1rem;
  114 + font-size: var(--font-size-lg);
106 115 color: var(--text-dark);
107 116 font-weight: 700;
108 117 }
109 118  
110 119 .ant-card .ant-card-body {
111   - padding: 24px;
  120 + padding: 18px 20px;
112 121 }
113 122  
114 123 .ant-table-wrapper .ant-table {
... ... @@ -126,11 +135,17 @@ a {
126 135 color: var(--text-dark);
127 136 border-bottom: none;
128 137 font-weight: 700;
  138 + font-size: var(--font-size-md);
  139 + padding-top: 12px;
  140 + padding-bottom: 12px;
129 141 }
130 142  
131 143 .ant-table-wrapper .ant-table-tbody > tr > td {
132 144 border-bottom: 1px solid rgba(226, 220, 247, 0.72);
133 145 background: rgba(255, 255, 255, 0.48);
  146 + font-size: var(--font-size-md);
  147 + padding-top: 11px;
  148 + padding-bottom: 11px;
134 149 }
135 150  
136 151 .ant-table-wrapper .ant-table-tbody > tr:hover > td {
... ... @@ -148,6 +163,30 @@ a {
148 163 border-color: rgba(189, 180, 234, 0.4) !important;
149 164 background: rgba(255, 255, 255, 0.78) !important;
150 165 box-shadow: none !important;
  166 + font-size: var(--font-size-md);
  167 +}
  168 +
  169 +.ant-input,
  170 +.ant-input-affix-wrapper,
  171 +.ant-input-password,
  172 +.ant-picker {
  173 + min-height: 34px;
  174 + padding-top: 5px;
  175 + padding-bottom: 5px;
  176 +}
  177 +
  178 +.ant-input-number {
  179 + min-height: 34px;
  180 +}
  181 +
  182 +.ant-input-number-input {
  183 + height: 32px;
  184 +}
  185 +
  186 +.ant-select-selector {
  187 + min-height: 34px !important;
  188 + padding-top: 1px !important;
  189 + padding-bottom: 1px !important;
151 190 }
152 191  
153 192 .ant-input:focus,
... ... @@ -161,7 +200,7 @@ a {
161 200  
162 201 .ant-modal .ant-modal-content,
163 202 .ant-dropdown .ant-dropdown-menu {
164   - border-radius: 28px;
  203 + border-radius: 22px;
165 204 border: 1px solid rgba(228, 223, 247, 0.7);
166 205 background: rgba(255, 255, 255, 0.92);
167 206 backdrop-filter: blur(24px);
... ... @@ -171,12 +210,22 @@ a {
171 210 .ant-modal .ant-modal-header {
172 211 background: transparent;
173 212 border-bottom: 1px solid rgba(189, 180, 234, 0.18);
  213 + padding: 18px 20px 12px;
174 214 }
175 215  
176 216 .ant-modal .ant-modal-title {
177 217 font-family: var(--font-display);
178 218 color: var(--text-dark);
179 219 font-weight: 700;
  220 + font-size: var(--font-size-lg);
  221 +}
  222 +
  223 +.ant-modal .ant-modal-body {
  224 + padding: 18px 20px 20px;
  225 +}
  226 +
  227 +.ant-modal .ant-modal-footer {
  228 + padding: 12px 20px 18px;
180 229 }
181 230  
182 231 .ant-tag {
... ... @@ -184,18 +233,32 @@ a {
184 233 border: none;
185 234 padding-inline: 10px;
186 235 font-weight: 600;
  236 + font-size: var(--font-size-sm);
187 237 }
188 238  
189 239 .ant-menu {
190 240 background: transparent !important;
  241 + font-size: var(--font-size-md);
191 242 }
192 243  
193 244 .ant-menu-item,
194 245 .ant-menu-submenu-title {
195   - border-radius: 18px !important;
  246 + border-radius: 14px !important;
196 247 margin-inline: 8px !important;
197   - margin-block: 6px !important;
  248 + margin-block: 4px !important;
198 249 width: calc(100% - 16px) !important;
  250 + font-size: var(--font-size-md) !important;
  251 +}
  252 +
  253 +.ant-form-item {
  254 + margin-bottom: 16px;
  255 +}
  256 +
  257 +.ant-form-item .ant-form-item-label > label,
  258 +.ant-empty-description,
  259 +.ant-dropdown-menu-item,
  260 +.ant-pagination {
  261 + font-size: var(--font-size-md);
199 262 }
200 263  
201 264 .ant-menu-light .ant-menu-item-selected,
... ... @@ -223,7 +286,7 @@ a {
223 286 }
224 287  
225 288 .soft-page-shell {
226   - padding: 24px 24px 28px;
  289 + padding: 18px 18px 22px;
227 290 }
228 291  
229 292 .soft-section-card {
... ...
src/views/city/CityList.vue
... ... @@ -4,8 +4,7 @@
4 4 <template #extra>
5 5 <a-button type="primary" @click="openAdd">新增</a-button>
6 6 </template>
7   - <a-table :dataSource="list" :columns="columns" :loading="loading"
8   - rowKey="id" :pagination="false">
  7 + <a-table :dataSource="list" :columns="columns" :loading="loading" rowKey="id" :pagination="false">
9 8 <template #bodyCell="{ column, record }">
10 9 <template v-if="column.key === 'status'">
11 10 <a-tag :color="record.status === 1 ? 'green' : 'default'">
... ... @@ -17,9 +16,7 @@
17 16 <a @click="openEdit(record)">编辑</a>
18 17 <a @click="openConfig(record)">配送费配置</a>
19 18 <a @click="openLevelConfig(record)">骑手等级</a>
20   - <a-popconfirm
21   - :title="record.status === 1 ? '确认关闭?' : '确认开通?'"
22   - @confirm="toggleStatus(record)">
  19 + <a-popconfirm :title="record.status === 1 ? '确认关闭?' : '确认开通?'" @confirm="toggleStatus(record)">
23 20 <a>{{ record.status === 1 ? '关闭' : '开通' }}</a>
24 21 </a-popconfirm>
25 22 </a-space>
... ... @@ -28,9 +25,7 @@
28 25 </a-table>
29 26 </a-card>
30 27  
31   - <!-- 新增/编辑弹窗 -->
32   - <a-modal v-model:open="modalVisible" :title="editingId ? '编辑' : '新增租户'"
33   - @ok="handleSave" :confirmLoading="saving">
  28 + <a-modal v-model:open="modalVisible" :title="editingId ? '编辑' : '新增租户'" @ok="handleSave" :confirmLoading="saving">
34 29 <a-form :model="form" layout="vertical">
35 30 <a-form-item label="名称">
36 31 <a-input v-model:value="form.name" placeholder="如:华东一区 / 某租户名" />
... ... @@ -47,161 +42,331 @@
47 42 </a-form>
48 43 </a-modal>
49 44  
50   - <!-- 配送费配置弹窗 -->
51   - <a-modal v-model:open="configVisible" title="配送费配置" width="760px"
52   - @ok="handleConfigSave" :confirmLoading="saving">
53   - <a-form :model="config" layout="vertical" v-if="config">
54   - <a-form-item label="计费模式">
55   - <a-radio-group v-model:value="config.type6.feeMode">
56   - <a-radio :value="1">固定费用</a-radio>
57   - <a-radio :value="2">按距离/重量计费</a-radio>
58   - </a-radio-group>
59   - </a-form-item>
  45 + <a-modal v-model:open="configVisible" :title="`配送费配置 - ${currentCityName}`" width="1320px" :footer="null">
  46 + <div class="plan-layout">
  47 + <div class="plan-sidebar">
  48 + <div class="plan-sidebar-header">
  49 + <div>
  50 + <div class="plan-sidebar-title">计价方案</div>
  51 + <div class="plan-sidebar-subtitle">同一租户可维护多套外卖配送规则</div>
  52 + </div>
  53 + <a-button type="primary" size="small" @click="createPlan">新增</a-button>
  54 + </div>
  55 + <a-spin :spinning="planLoading">
  56 + <div class="plan-list">
  57 + <button
  58 + v-for="item in planList"
  59 + :key="item.id"
  60 + type="button"
  61 + class="plan-item"
  62 + :class="{ active: item.id === selectedPlanId }"
  63 + @click="selectPlan(item.id)"
  64 + >
  65 + <div class="plan-item-top">
  66 + <span class="plan-item-name">{{ item.name }}</span>
  67 + <a-tag v-if="item.isDefault === 1" color="green">默认</a-tag>
  68 + </div>
  69 + <div class="plan-item-bottom">
  70 + <span>{{ item.status === 1 ? '启用中' : '已停用' }}</span>
  71 + <span>排序 {{ item.listOrder ?? 0 }}</span>
  72 + </div>
  73 + </button>
  74 + <a-empty v-if="!planList.length" description="暂无计价方案" />
  75 + </div>
  76 + </a-spin>
  77 + </div>
60 78  
61   - <template v-if="config.type6.feeMode === 1">
62   - <a-form-item label="固定费用(元)">
63   - <a-input-number v-model:value="config.type6.fixMoney" :min="0" style="width:100%" />
64   - </a-form-item>
65   - </template>
  79 + <div class="plan-content">
  80 + <template v-if="currentPlan && config">
  81 + <div class="plan-toolbar">
  82 + <a-space wrap>
  83 + <a-button @click="copyPlan" :disabled="!selectedPlanId">复制当前方案</a-button>
  84 + <a-button @click="setDefaultPlan" :disabled="currentPlan.isDefault === 1 || currentPlan.status !== 1">设为默认</a-button>
  85 + <a-popconfirm title="确认删除当前方案?" @confirm="deletePlan">
  86 + <a-button danger :disabled="currentPlan.isDefault === 1">删除方案</a-button>
  87 + </a-popconfirm>
  88 + </a-space>
  89 + <a-space wrap>
  90 + <a-button @click="previewPlan" :loading="previewing">试算配送费</a-button>
  91 + <a-button type="primary" @click="saveCurrentPlan" :loading="planSaving">保存当前方案</a-button>
  92 + </a-space>
  93 + </div>
66 94  
67   - <template v-else>
68   - <a-divider orientation="left">距离计费</a-divider>
69   - <a-form-item label="启用距离计费">
70   - <a-radio-group v-model:value="config.type6.distanceSwitch">
71   - <a-radio :value="1">启用</a-radio>
72   - <a-radio :value="0">关闭</a-radio>
73   - </a-radio-group>
74   - </a-form-item>
75   - <template v-if="config.type6.distanceSwitch === 1">
76   - <a-row :gutter="16">
77   - <a-col :span="12">
78   - <a-form-item label="基础距离(km)">
79   - <a-input-number v-model:value="config.type6.distanceBasic" :min="0" :step="0.1" style="width:100%" />
80   - </a-form-item>
81   - </a-col>
82   - <a-col :span="12">
83   - <a-form-item label="基础距离内费用(元)">
84   - <a-input-number v-model:value="config.type6.distanceBasicMoney" :min="0" :step="0.1" style="width:100%" />
85   - </a-form-item>
86   - </a-col>
87   - </a-row>
88   - <a-row :gutter="16">
89   - <a-col :span="12">
90   - <a-form-item label="超出每km费用(元)">
91   - <a-input-number v-model:value="config.type6.distanceMoreMoney" :min="0" :step="0.1" style="width:100%" />
92   - </a-form-item>
93   - </a-col>
94   - <a-col :span="12">
95   - <a-form-item label="距离取整方式">
96   - <a-select v-model:value="config.type6.distanceType">
97   - <a-select-option :value="1">四舍五入</a-select-option>
98   - <a-select-option :value="2">向上取整</a-select-option>
99   - <a-select-option :value="3">向下取整</a-select-option>
100   - </a-select>
101   - </a-form-item>
102   - </a-col>
103   - </a-row>
104   - </template>
105   -
106   - <a-divider orientation="left">重量计费</a-divider>
107   - <a-form-item label="启用重量计费">
108   - <a-radio-group v-model:value="config.type6.weightSwitch">
109   - <a-radio :value="1">启用</a-radio>
110   - <a-radio :value="0">关闭</a-radio>
111   - </a-radio-group>
112   - </a-form-item>
113   - <template v-if="config.type6.weightSwitch === 1">
114 95 <a-row :gutter="16">
115   - <a-col :span="12">
116   - <a-form-item label="基础重量(kg)">
117   - <a-input-number v-model:value="config.type6.weightBasic" :min="0" :step="0.1" style="width:100%" />
  96 + <a-col :span="8">
  97 + <a-form-item label="方案名称">
  98 + <a-input v-model:value="currentPlan.name" placeholder="如:标准午高峰方案" />
118 99 </a-form-item>
119 100 </a-col>
120   - <a-col :span="12">
121   - <a-form-item label="基础重量内费用(元)">
122   - <a-input-number v-model:value="config.type6.weightBasicMoney" :min="0" :step="0.1" style="width:100%" />
123   - </a-form-item>
124   - </a-col>
125   - </a-row>
126   - <a-row :gutter="16">
127   - <a-col :span="12">
128   - <a-form-item label="超出每kg费用(元)">
129   - <a-input-number v-model:value="config.type6.weightMoreMoney" :min="0" :step="0.1" style="width:100%" />
130   - </a-form-item>
131   - </a-col>
132   - <a-col :span="12">
133   - <a-form-item label="重量取整方式">
134   - <a-select v-model:value="config.type6.weightType">
135   - <a-select-option :value="1">四舍五入</a-select-option>
136   - <a-select-option :value="2">向上取整</a-select-option>
137   - <a-select-option :value="3">向下取整</a-select-option>
138   - </a-select>
139   - </a-form-item>
140   - </a-col>
141   - </a-row>
142   - </template>
143   -
144   - <a-divider orientation="left">时段附加费</a-divider>
145   - <div style="margin-bottom:12px">
146   - <a-button type="dashed" block @click="addTimePeriod">新增时段</a-button>
147   - </div>
148   - <div v-if="timePeriods.length">
149   - <a-row
150   - v-for="(period, index) in timePeriods"
151   - :key="index"
152   - :gutter="12"
153   - style="margin-bottom:12px;align-items:flex-start"
154   - >
155   - <a-col :span="5">
156   - <a-form-item :label="index === 0 ? '开始时间' : ''">
157   - <a-input v-model:value="period.startText" placeholder="08:00" />
158   - </a-form-item>
159   - </a-col>
160   - <a-col :span="5">
161   - <a-form-item :label="index === 0 ? '结束时间' : ''">
162   - <a-input v-model:value="period.endText" placeholder="22:00" />
163   - </a-form-item>
164   - </a-col>
165   - <a-col :span="5">
166   - <a-form-item :label="index === 0 ? '附加费(元)' : ''">
167   - <a-input-number v-model:value="period.money" :min="0" :step="0.1" style="width:100%" />
168   - </a-form-item>
169   - </a-col>
170   - <a-col :span="5">
171   - <a-form-item :label="index === 0 ? '状态' : ''">
172   - <a-select v-model:value="period.isOpen">
  101 + <a-col :span="8">
  102 + <a-form-item label="状态">
  103 + <a-select v-model:value="currentPlan.status">
173 104 <a-select-option :value="1">启用</a-select-option>
174   - <a-select-option :value="0">关闭</a-select-option>
  105 + <a-select-option :value="0">停用</a-select-option>
175 106 </a-select>
176 107 </a-form-item>
177 108 </a-col>
178   - <a-col :span="4">
179   - <a-form-item :label="index === 0 ? '操作' : ''">
180   - <a-button danger block @click="removeTimePeriod(index)">删除</a-button>
  109 + <a-col :span="8">
  110 + <a-form-item label="排序">
  111 + <a-input-number v-model:value="currentPlan.listOrder" :min="0" style="width:100%" />
181 112 </a-form-item>
182 113 </a-col>
183 114 </a-row>
184   - </div>
185   - <a-empty v-else description="暂无时段附加费配置" />
186   - </template>
187   -
188   - <a-divider orientation="left">预计送达与展示</a-divider>
189   - <a-row :gutter="16">
190   - <a-col :span="12">
191   - <a-form-item label="预计送达基础时间(分钟)">
192   - <a-input-number v-model:value="config.distanceBasicTime" :min="0" style="width:100%" />
193   - </a-form-item>
194   - </a-col>
195   - <a-col :span="12">
196   - <a-form-item label="超出每km增加时间(分钟)">
197   - <a-input-number v-model:value="config.distanceMoreTime" :min="0" style="width:100%" />
  115 + <a-form-item label="备注">
  116 + <a-input v-model:value="currentPlan.remark" placeholder="可填写适用业务场景说明" />
198 117 </a-form-item>
199   - </a-col>
200   - </a-row>
201   - <a-form-item label="附近骑手显示范围(km)">
202   - <a-input-number v-model:value="config.riderDistance" :min="0" :step="0.1" style="width:100%" />
203   - </a-form-item>
204   - </a-form>
  118 +
  119 + <a-card class="preview-card" :bordered="false">
  120 + <template #title>草稿试算</template>
  121 + <a-row :gutter="12">
  122 + <a-col :span="6">
  123 + <a-form-item label="起点经度">
  124 + <a-input v-model:value="previewForm.startLng" placeholder="121.4737" />
  125 + </a-form-item>
  126 + </a-col>
  127 + <a-col :span="6">
  128 + <a-form-item label="起点纬度">
  129 + <a-input v-model:value="previewForm.startLat" placeholder="31.2304" />
  130 + </a-form-item>
  131 + </a-col>
  132 + <a-col :span="6">
  133 + <a-form-item label="终点经度">
  134 + <a-input v-model:value="previewForm.endLng" placeholder="121.4879" />
  135 + </a-form-item>
  136 + </a-col>
  137 + <a-col :span="6">
  138 + <a-form-item label="终点纬度">
  139 + <a-input v-model:value="previewForm.endLat" placeholder="31.2492" />
  140 + </a-form-item>
  141 + </a-col>
  142 + </a-row>
  143 + <a-row :gutter="12">
  144 + <a-col :span="8">
  145 + <a-form-item label="重量(kg)">
  146 + <a-input-number v-model:value="previewForm.weight" :min="0" :step="0.1" style="width:100%" />
  147 + </a-form-item>
  148 + </a-col>
  149 + <a-col :span="8">
  150 + <a-form-item label="件数">
  151 + <a-input-number v-model:value="previewForm.pieces" :min="0" style="width:100%" />
  152 + </a-form-item>
  153 + </a-col>
  154 + <a-col :span="8">
  155 + <a-form-item label="服务时间戳(秒,可空)">
  156 + <a-input v-model:value="previewForm.serviceTime" placeholder="留空则按当前时间" />
  157 + </a-form-item>
  158 + </a-col>
  159 + </a-row>
  160 + <div v-if="previewResult" class="preview-result">
  161 + <a-tag color="processing">总配送费 {{ previewResult.totalFee ?? 0 }} 元</a-tag>
  162 + <a-tag>基础 {{ previewResult.moneyBasic ?? 0 }}</a-tag>
  163 + <a-tag>里程 {{ previewResult.moneyDistance ?? 0 }}</a-tag>
  164 + <a-tag>重量 {{ previewResult.moneyWeight ?? 0 }}</a-tag>
  165 + <a-tag>件数 {{ previewResult.moneyPiece ?? 0 }}</a-tag>
  166 + <a-tag>时段 {{ previewResult.moneyTime ?? 0 }}</a-tag>
  167 + <a-tag>预计送达 {{ previewResult.estimatedMinutes ?? 0 }} 分钟</a-tag>
  168 + <a-tag>里程 {{ previewResult.distance ?? 0 }} km</a-tag>
  169 + <a-tag v-if="previewResult.minFeeApplied === 1" color="gold">已触发保底 {{ previewResult.minFee ?? 0 }}</a-tag>
  170 + <a-tag v-if="previewResult.moneyTime > 0" color="purple">当前服务时间命中时段加价</a-tag>
  171 + </div>
  172 + </a-card>
  173 +
  174 + <a-form :model="config" layout="vertical">
  175 + <a-divider orientation="left">费用总览</a-divider>
  176 + <a-row :gutter="16">
  177 + <a-col :span="12">
  178 + <a-form-item label="保底费用(元)">
  179 + <a-input-number v-model:value="config.type6.minFee" :min="0" :step="0.1" style="width:100%" />
  180 + </a-form-item>
  181 + </a-col>
  182 + <a-col :span="12">
  183 + <a-form-item label="基础费(元/单)">
  184 + <a-input-number v-model:value="config.type6.baseFee" :min="0" :step="0.1" style="width:100%" />
  185 + </a-form-item>
  186 + </a-col>
  187 + </a-row>
  188 +
  189 + <a-divider orientation="left">里程阶梯</a-divider>
  190 + <a-row :gutter="16">
  191 + <a-col :span="12">
  192 + <a-form-item label="起步里程(km内)">
  193 + <a-input-number v-model:value="config.type6.distanceBasic" :min="0" :step="0.1" style="width:100%" />
  194 + </a-form-item>
  195 + </a-col>
  196 + <a-col :span="12">
  197 + <a-form-item label="起步费用(元)">
  198 + <a-input-number v-model:value="config.type6.distanceBasicMoney" :min="0" :step="0.1" style="width:100%" />
  199 + </a-form-item>
  200 + </a-col>
  201 + </a-row>
  202 + <div style="margin-bottom:12px">
  203 + <a-button type="dashed" block @click="addDistanceStep">新增阶梯段</a-button>
  204 + </div>
  205 + <div v-if="distanceSteps.length">
  206 + <a-row
  207 + v-for="(step, index) in distanceSteps"
  208 + :key="index"
  209 + :gutter="12"
  210 + style="margin-bottom:12px;align-items:flex-start"
  211 + >
  212 + <a-col :span="7">
  213 + <a-form-item :label="index === 0 ? '结束里程(km)' : ''">
  214 + <a-input-number v-model:value="step.endDistance" :min="0" :step="0.1" style="width:100%" />
  215 + </a-form-item>
  216 + </a-col>
  217 + <a-col :span="7">
  218 + <a-form-item :label="index === 0 ? '每档里程(km)' : ''">
  219 + <a-input-number v-model:value="step.unitDistance" :min="0.1" :step="0.1" style="width:100%" />
  220 + </a-form-item>
  221 + </a-col>
  222 + <a-col :span="7">
  223 + <a-form-item :label="index === 0 ? '每档加价(元)' : ''">
  224 + <a-input-number v-model:value="step.unitFee" :min="0" :step="0.1" style="width:100%" />
  225 + </a-form-item>
  226 + </a-col>
  227 + <a-col :span="3">
  228 + <a-form-item :label="index === 0 ? '操作' : ''">
  229 + <a-button danger block @click="removeDistanceStep(index)">删除</a-button>
  230 + </a-form-item>
  231 + </a-col>
  232 + </a-row>
  233 + </div>
  234 + <a-empty v-else description="暂无里程阶梯配置" />
  235 +
  236 + <a-divider orientation="left">重量计费</a-divider>
  237 + <a-row :gutter="16">
  238 + <a-col :span="12">
  239 + <a-form-item label="首重(kg)">
  240 + <a-input-number v-model:value="config.type6.weightFirst" :min="0" :step="0.1" style="width:100%" />
  241 + </a-form-item>
  242 + </a-col>
  243 + <a-col :span="12">
  244 + <a-form-item label="首重费用(元)">
  245 + <a-input-number v-model:value="config.type6.weightFirstFee" :min="0" :step="0.1" style="width:100%" />
  246 + </a-form-item>
  247 + </a-col>
  248 + </a-row>
  249 + <a-row :gutter="16">
  250 + <a-col :span="12">
  251 + <a-form-item label="续重单价(元/kg)">
  252 + <a-input-number v-model:value="config.type6.weightUnitFee" :min="0" :step="0.1" style="width:100%" />
  253 + </a-form-item>
  254 + </a-col>
  255 + <a-col :span="12">
  256 + <a-form-item label="封顶费用(元)">
  257 + <a-input-number v-model:value="config.type6.weightCapFee" :min="0" :step="0.1" style="width:100%" />
  258 + </a-form-item>
  259 + </a-col>
  260 + </a-row>
  261 +
  262 + <a-divider orientation="left">件数计费</a-divider>
  263 + <div style="margin-bottom:12px">
  264 + <a-button type="dashed" block @click="addPieceRule">新增件数区间</a-button>
  265 + </div>
  266 + <div v-if="pieceRules.length">
  267 + <a-row
  268 + v-for="(rule, index) in pieceRules"
  269 + :key="index"
  270 + :gutter="12"
  271 + style="margin-bottom:12px;align-items:flex-start"
  272 + >
  273 + <a-col :span="6">
  274 + <a-form-item :label="index === 0 ? '起始件数' : ''">
  275 + <a-input-number v-model:value="rule.startPiece" :min="0" style="width:100%" />
  276 + </a-form-item>
  277 + </a-col>
  278 + <a-col :span="6">
  279 + <a-form-item :label="index === 0 ? '结束件数' : ''">
  280 + <a-input-number v-model:value="rule.endPiece" :min="0" style="width:100%" />
  281 + </a-form-item>
  282 + </a-col>
  283 + <a-col :span="8">
  284 + <a-form-item :label="index === 0 ? '费用(元)' : ''">
  285 + <a-input-number v-model:value="rule.fee" :min="0" :step="0.1" style="width:100%" />
  286 + </a-form-item>
  287 + </a-col>
  288 + <a-col :span="4">
  289 + <a-form-item :label="index === 0 ? '操作' : ''">
  290 + <a-button danger block @click="removePieceRule(index)">删除</a-button>
  291 + </a-form-item>
  292 + </a-col>
  293 + </a-row>
  294 + </div>
  295 + <a-empty v-else description="暂无件数区间配置" />
  296 +
  297 + <a-divider orientation="left">时段附加费</a-divider>
  298 + <div style="margin-bottom:12px">
  299 + <a-button type="dashed" block @click="addTimePeriod">新增时段</a-button>
  300 + </div>
  301 + <div v-if="timePeriods.length">
  302 + <a-row
  303 + v-for="(period, index) in timePeriods"
  304 + :key="index"
  305 + :gutter="12"
  306 + style="margin-bottom:12px;align-items:flex-start"
  307 + >
  308 + <a-col :span="5">
  309 + <a-form-item :label="index === 0 ? '开始时间' : ''">
  310 + <a-input v-model:value="period.startText" placeholder="22:00" />
  311 + </a-form-item>
  312 + </a-col>
  313 + <a-col :span="5">
  314 + <a-form-item :label="index === 0 ? '结束时间' : ''">
  315 + <a-input v-model:value="period.endText" placeholder="06:00" />
  316 + </a-form-item>
  317 + </a-col>
  318 + <a-col :span="5">
  319 + <a-form-item :label="index === 0 ? '附加费(元)' : ''">
  320 + <a-input-number v-model:value="period.money" :min="0" :step="0.1" style="width:100%" />
  321 + </a-form-item>
  322 + </a-col>
  323 + <a-col :span="5">
  324 + <a-form-item :label="index === 0 ? '状态' : ''">
  325 + <a-select v-model:value="period.isOpen">
  326 + <a-select-option :value="1">启用</a-select-option>
  327 + <a-select-option :value="0">关闭</a-select-option>
  328 + </a-select>
  329 + </a-form-item>
  330 + </a-col>
  331 + <a-col :span="4">
  332 + <a-form-item :label="index === 0 ? '操作' : ''">
  333 + <a-button danger block @click="removeTimePeriod(index)">删除</a-button>
  334 + </a-form-item>
  335 + </a-col>
  336 + </a-row>
  337 + </div>
  338 + <a-empty v-else description="暂无时段附加费配置" />
  339 +
  340 + <a-divider orientation="left">预计送达与展示</a-divider>
  341 + <a-row :gutter="16">
  342 + <a-col :span="12">
  343 + <a-form-item label="预计送达基础时间(分钟)">
  344 + <a-input-number v-model:value="config.distanceBasicTime" :min="0" style="width:100%" />
  345 + </a-form-item>
  346 + </a-col>
  347 + <a-col :span="12">
  348 + <a-form-item label="超出每km增加时间(分钟)">
  349 + <a-input-number v-model:value="config.distanceMoreTime" :min="0" style="width:100%" />
  350 + </a-form-item>
  351 + </a-col>
  352 + </a-row>
  353 + <a-form-item label="附近骑手显示范围(km)">
  354 + <a-input-number v-model:value="config.riderDistance" :min="0" :step="0.1" style="width:100%" />
  355 + </a-form-item>
  356 + </a-form>
  357 + </template>
  358 + <div v-else class="plan-empty-state">
  359 + <a-empty description="当前租户还没有配送费方案">
  360 + <template #extra>
  361 + <a-space>
  362 + <a-button type="primary" @click="initializeDefaultPlan" :loading="planSaving">初始化默认方案</a-button>
  363 + <a-button @click="createPlan" :loading="planSaving">新增空白方案</a-button>
  364 + </a-space>
  365 + </template>
  366 + </a-empty>
  367 + </div>
  368 + </div>
  369 + </div>
205 370 </a-modal>
206 371  
207 372 <a-modal v-model:open="levelVisible" :title="`骑手等级配置 - ${levelCityName}`" width="900px" :footer="null">
... ... @@ -234,8 +399,7 @@
234 399 </a-table>
235 400 </a-modal>
236 401  
237   - <a-modal v-model:open="levelEditVisible" :title="levelEditingId ? '编辑骑手等级' : '新增骑手等级'"
238   - @ok="handleSaveLevel" :confirmLoading="levelSaving">
  402 + <a-modal v-model:open="levelEditVisible" :title="levelEditingId ? '编辑骑手等级' : '新增骑手等级'" @ok="handleSaveLevel" :confirmLoading="levelSaving">
239 403 <a-form :model="levelForm" layout="vertical">
240 404 <a-form-item label="等级编号">
241 405 <a-input-number v-model:value="levelForm.levelId" :min="1" style="width:100%" />
... ... @@ -295,7 +459,7 @@
295 459 </template>
296 460  
297 461 <script setup lang="ts">
298   -import { ref, reactive, onMounted } from 'vue'
  462 +import { onMounted, reactive, ref } from 'vue'
299 463 import { message } from 'ant-design-vue'
300 464 import { cityApi, riderLevelApi } from '@/api'
301 465  
... ... @@ -306,6 +470,18 @@ type TimePeriodForm = {
306 470 money: number | null
307 471 }
308 472  
  473 +type DistanceStepForm = {
  474 + endDistance: number | null
  475 + unitDistance: number | null
  476 + unitFee: number | null
  477 +}
  478 +
  479 +type PieceRuleForm = {
  480 + startPiece: number | null
  481 + endPiece: number | null
  482 + fee: number | null
  483 +}
  484 +
309 485 const loading = ref(false)
310 486 const saving = ref(false)
311 487 const list = ref<any[]>([])
... ... @@ -315,11 +491,32 @@ const levelVisible = ref(false)
315 491 const levelEditVisible = ref(false)
316 492 const editingId = ref<number | null>(null)
317 493 const currentCityId = ref<number>(0)
  494 +const currentCityName = ref('')
318 495 const levelCityId = ref<number>(0)
319 496 const levelCityName = ref('')
320 497 const form = reactive({ name: '', areaCode: '', rate: 0, listOrder: 0 })
  498 +
321 499 const config = ref<any>(null)
  500 +const planList = ref<any[]>([])
  501 +const planLoading = ref(false)
  502 +const planSaving = ref(false)
  503 +const selectedPlanId = ref<number | null>(null)
  504 +const currentPlan = ref<any>(null)
322 505 const timePeriods = ref<TimePeriodForm[]>([])
  506 +const distanceSteps = ref<DistanceStepForm[]>([])
  507 +const pieceRules = ref<PieceRuleForm[]>([])
  508 +const previewing = ref(false)
  509 +const previewResult = ref<any>(null)
  510 +const previewForm = reactive({
  511 + startLng: '',
  512 + startLat: '',
  513 + endLng: '',
  514 + endLat: '',
  515 + weight: 0,
  516 + pieces: 1,
  517 + serviceTime: '',
  518 +})
  519 +
323 520 const levelLoading = ref(false)
324 521 const levelSaving = ref(false)
325 522 const levelList = ref<any[]>([])
... ... @@ -367,7 +564,7 @@ async function loadList() {
367 564 loading.value = true
368 565 try {
369 566 const res: any = await cityApi.tree()
370   - list.value = res.data // 现在直接是一级列表
  567 + list.value = res.data
371 568 } finally {
372 569 loading.value = false
373 570 }
... ... @@ -385,13 +582,6 @@ function openEdit(record: any) {
385 582 modalVisible.value = true
386 583 }
387 584  
388   -function openLevelConfig(record: any) {
389   - levelCityId.value = record.id
390   - levelCityName.value = record.name
391   - levelVisible.value = true
392   - loadLevels()
393   -}
394   -
395 585 async function handleSave() {
396 586 saving.value = true
397 587 try {
... ... @@ -416,34 +606,198 @@ async function toggleStatus(record: any) {
416 606  
417 607 async function openConfig(record: any) {
418 608 currentCityId.value = record.id
419   - const res: any = await cityApi.getConfig(record.id)
420   - config.value = normalizeConfig(res.data)
  609 + currentCityName.value = record.name
  610 + configVisible.value = true
  611 + previewResult.value = null
  612 + Object.assign(previewForm, { startLng: '', startLat: '', endLng: '', endLat: '', weight: 0, pieces: 1, serviceTime: '' })
  613 + await loadPlanList(record.id)
  614 +}
  615 +
  616 +async function loadPlanList(cityId: number, preferPlanId?: number) {
  617 + planLoading.value = true
  618 + try {
  619 + const res: any = await cityApi.listFeePlans(cityId)
  620 + planList.value = Array.isArray(res?.data) ? res.data : []
  621 + const targetPlanId = preferPlanId || planList.value.find(item => item.isDefault === 1)?.id || planList.value[0]?.id
  622 + if (targetPlanId) {
  623 + await selectPlan(targetPlanId)
  624 + } else {
  625 + selectedPlanId.value = null
  626 + currentPlan.value = null
  627 + config.value = null
  628 + }
  629 + } finally {
  630 + planLoading.value = false
  631 + }
  632 +}
  633 +
  634 +async function selectPlan(planId: number) {
  635 + selectedPlanId.value = planId
  636 + const res: any = await cityApi.getFeePlan(currentCityId.value, planId)
  637 + const detail = res?.data || {}
  638 + currentPlan.value = {
  639 + id: detail.id,
  640 + name: detail.name || '',
  641 + status: detail.status ?? 1,
  642 + listOrder: detail.listOrder ?? 0,
  643 + remark: detail.remark || '',
  644 + isDefault: detail.isDefault ?? 0,
  645 + }
  646 + config.value = normalizeConfig(detail.config)
  647 + distanceSteps.value = (config.value.type6.distanceSteps || []).map((step: any) => ({
  648 + endDistance: step.endDistance ?? 0,
  649 + unitDistance: step.unitDistance ?? 1,
  650 + unitFee: step.unitFee ?? 0,
  651 + }))
  652 + pieceRules.value = (config.value.type6.pieceRules || []).map((rule: any) => ({
  653 + startPiece: rule.startPiece ?? 0,
  654 + endPiece: rule.endPiece ?? 0,
  655 + fee: rule.fee ?? 0,
  656 + }))
421 657 timePeriods.value = (config.value.type6.times || []).map((period: any) => ({
422 658 startText: minuteToText(period.start),
423 659 endText: minuteToText(period.end),
424 660 isOpen: period.isOpen === 0 ? 0 : 1,
425 661 money: period.money ?? 0,
426 662 }))
427   - configVisible.value = true
  663 + previewResult.value = null
  664 +}
  665 +
  666 +async function createPlan() {
  667 + if (!currentCityId.value) return
  668 + planSaving.value = true
  669 + try {
  670 + const payload = {
  671 + name: `方案${planList.value.length + 1}`,
  672 + status: 1,
  673 + listOrder: planList.value.length,
  674 + remark: '',
  675 + config: deepClone(config.value || normalizeConfig(null)),
  676 + }
  677 + const res: any = await cityApi.createFeePlan(currentCityId.value, payload)
  678 + message.success('方案已创建')
  679 + await loadPlanList(currentCityId.value, res?.data)
  680 + } catch (err: any) {
  681 + message.error(err?.message || '方案创建失败')
  682 + } finally {
  683 + planSaving.value = false
  684 + }
428 685 }
429 686  
430   -async function handleConfigSave() {
  687 +async function initializeDefaultPlan() {
  688 + if (!currentCityId.value) return
  689 + planSaving.value = true
431 690 try {
432   - config.value.type = [6]
433   - config.value.type6.times = buildTimesPayload()
  691 + const res: any = await cityApi.initDefaultFeePlan(currentCityId.value)
  692 + message.success('默认方案已初始化')
  693 + await loadPlanList(currentCityId.value, res?.data)
434 694 } catch (err: any) {
435   - message.error(err.message || '时段配置有误')
  695 + message.error(err?.message || '默认方案初始化失败')
  696 + } finally {
  697 + planSaving.value = false
  698 + }
  699 +}
  700 +
  701 +async function copyPlan() {
  702 + if (!selectedPlanId.value) return
  703 + const res: any = await cityApi.copyFeePlan(currentCityId.value, selectedPlanId.value)
  704 + message.success('方案已复制')
  705 + await loadPlanList(currentCityId.value, res?.data)
  706 +}
  707 +
  708 +async function deletePlan() {
  709 + if (!selectedPlanId.value) return
  710 + await cityApi.deleteFeePlan(currentCityId.value, selectedPlanId.value)
  711 + message.success('方案已删除')
  712 + await loadPlanList(currentCityId.value)
  713 +}
  714 +
  715 +async function setDefaultPlan() {
  716 + if (!selectedPlanId.value) return
  717 + if (currentPlan.value?.status !== 1) {
  718 + message.error('请先启用当前方案,再设为默认')
436 719 return
437 720 }
  721 + await cityApi.setDefaultFeePlan(currentCityId.value, selectedPlanId.value)
  722 + message.success('默认方案已更新')
  723 + await loadPlanList(currentCityId.value, selectedPlanId.value)
  724 +}
438 725  
439   - saving.value = true
  726 +async function saveCurrentPlan() {
  727 + if (!selectedPlanId.value || !currentPlan.value) return
440 728 try {
441   - await cityApi.updateConfig(currentCityId.value, config.value)
442   - message.success('配置保存成功')
443   - configVisible.value = false
  729 + const payload = buildPlanPayload()
  730 + planSaving.value = true
  731 + await cityApi.updateFeePlan(currentCityId.value, selectedPlanId.value, payload)
  732 + message.success('方案保存成功')
  733 + await loadPlanList(currentCityId.value, selectedPlanId.value)
  734 + } catch (err: any) {
  735 + message.error(err?.message || '方案保存失败')
444 736 } finally {
445   - saving.value = false
  737 + planSaving.value = false
  738 + }
  739 +}
  740 +
  741 +async function previewPlan() {
  742 + try {
  743 + const payload = buildPlanPayload()
  744 + if (!previewForm.startLng || !previewForm.startLat || !previewForm.endLng || !previewForm.endLat) {
  745 + message.error('请填写完整的试算经纬度')
  746 + return
  747 + }
  748 + previewing.value = true
  749 + const res: any = await cityApi.previewFeePlan(currentCityId.value, {
  750 + config: payload.config,
  751 + calc: {
  752 + startLng: previewForm.startLng,
  753 + startLat: previewForm.startLat,
  754 + endLng: previewForm.endLng,
  755 + endLat: previewForm.endLat,
  756 + weight: previewForm.weight ?? 0,
  757 + pieces: previewForm.pieces ?? 0,
  758 + serviceTime: previewForm.serviceTime ? Number(previewForm.serviceTime) : 0,
  759 + },
  760 + })
  761 + previewResult.value = res?.data || null
  762 + } catch (err: any) {
  763 + message.error(err?.message || '试算失败')
  764 + } finally {
  765 + previewing.value = false
  766 + }
  767 +}
  768 +
  769 +function buildPlanPayload() {
  770 + if (!currentPlan.value) {
  771 + throw new Error('请选择计价方案')
  772 + }
  773 + const planName = String(currentPlan.value.name || '').trim()
  774 + if (!planName) {
  775 + throw new Error('请填写方案名称')
446 776 }
  777 + const nextConfig = deepClone(config.value || normalizeConfig(null))
  778 + nextConfig.type = [6]
  779 + nextConfig.type6.baseSwitch = nextConfig.type6.baseFee > 0 ? 1 : 0
  780 + nextConfig.type6.distanceSwitch = 1
  781 + nextConfig.type6.weightSwitch =
  782 + nextConfig.type6.weightFirst > 0 || nextConfig.type6.weightFirstFee > 0 || nextConfig.type6.weightUnitFee > 0 ? 1 : 0
  783 + nextConfig.type6.pieceSwitch = pieceRules.value.length ? 1 : 0
  784 + nextConfig.type6.distanceSteps = buildDistanceStepsPayload()
  785 + nextConfig.type6.pieceRules = buildPieceRulesPayload()
  786 + nextConfig.type6.times = buildTimesPayload()
  787 + return {
  788 + name: planName,
  789 + status: currentPlan.value.status ?? 1,
  790 + listOrder: currentPlan.value.listOrder ?? 0,
  791 + remark: currentPlan.value.remark || '',
  792 + config: nextConfig,
  793 + }
  794 +}
  795 +
  796 +function openLevelConfig(record: any) {
  797 + levelCityId.value = record.id
  798 + levelCityName.value = record.name
  799 + levelVisible.value = true
  800 + loadLevels()
447 801 }
448 802  
449 803 async function loadLevels() {
... ... @@ -537,18 +891,28 @@ function formatLevelRule(record: any) {
537 891  
538 892 function createDefaultType6() {
539 893 return {
540   - feeMode: 1,
  894 + minFee: 0,
  895 + baseSwitch: 0,
  896 + baseFee: 0,
  897 + feeMode: 2,
541 898 fixMoney: 0,
542 899 distanceSwitch: 1,
543 900 distanceBasic: 3,
544 901 distanceBasicMoney: 4,
545 902 distanceMoreMoney: 1.5,
546 903 distanceType: 1,
547   - weightSwitch: 0,
  904 + distanceSteps: [],
  905 + weightSwitch: 1,
  906 + weightFirst: 5,
  907 + weightFirstFee: 0,
  908 + weightUnitFee: 1,
  909 + weightCapFee: 30,
548 910 weightBasic: 0,
549 911 weightBasicMoney: 0,
550 912 weightMoreMoney: 0,
551 913 weightType: 1,
  914 + pieceSwitch: 0,
  915 + pieceRules: [],
552 916 times: [],
553 917 }
554 918 }
... ... @@ -561,6 +925,8 @@ function normalizeConfig(raw: any) {
561 925 type: [6],
562 926 type6: {
563 927 ...type6,
  928 + distanceSteps: Array.isArray(type6.distanceSteps) ? type6.distanceSteps : [],
  929 + pieceRules: Array.isArray(type6.pieceRules) ? type6.pieceRules : [],
564 930 times: Array.isArray(type6.times) ? type6.times : [],
565 931 },
566 932 distanceBasic: next.distanceBasic ?? 3,
... ... @@ -571,30 +937,76 @@ function normalizeConfig(raw: any) {
571 937 }
572 938  
573 939 function addTimePeriod() {
574   - timePeriods.value.push({
575   - startText: '',
576   - endText: '',
577   - isOpen: 1,
578   - money: 0,
579   - })
  940 + timePeriods.value.push({ startText: '', endText: '', isOpen: 1, money: 0 })
580 941 }
581 942  
582 943 function removeTimePeriod(index: number) {
583 944 timePeriods.value.splice(index, 1)
584 945 }
585 946  
  947 +function addDistanceStep() {
  948 + distanceSteps.value.push({ endDistance: 0, unitDistance: 1, unitFee: 0 })
  949 +}
  950 +
  951 +function removeDistanceStep(index: number) {
  952 + distanceSteps.value.splice(index, 1)
  953 +}
  954 +
  955 +function addPieceRule() {
  956 + pieceRules.value.push({ startPiece: 0, endPiece: 0, fee: 0 })
  957 +}
  958 +
  959 +function removePieceRule(index: number) {
  960 + pieceRules.value.splice(index, 1)
  961 +}
  962 +
  963 +function buildDistanceStepsPayload() {
  964 + let prevEnd = config.value.type6.distanceBasic ?? 0
  965 + return distanceSteps.value.map((step, index) => {
  966 + const endDistance = step.endDistance ?? 0
  967 + const unitDistance = step.unitDistance ?? 0
  968 + const unitFee = step.unitFee ?? 0
  969 + if (endDistance <= prevEnd) {
  970 + throw new Error(`第${index + 1}条里程阶梯结束里程必须大于上一阶梯`)
  971 + }
  972 + if (unitDistance <= 0) {
  973 + throw new Error(`第${index + 1}条里程阶梯每档里程必须大于0`)
  974 + }
  975 + prevEnd = endDistance
  976 + return { endDistance, unitDistance, unitFee, listOrder: index }
  977 + })
  978 +}
  979 +
  980 +function buildPieceRulesPayload() {
  981 + const payload = pieceRules.value
  982 + .map((rule, index) => {
  983 + const startPiece = rule.startPiece ?? 0
  984 + const endPiece = rule.endPiece ?? 0
  985 + if (startPiece > endPiece) {
  986 + throw new Error(`第${index + 1}条件数区间起始值不能大于结束值`)
  987 + }
  988 + return { startPiece, endPiece, fee: rule.fee ?? 0, listOrder: index }
  989 + })
  990 + .sort((a, b) => a.startPiece - b.startPiece)
  991 +
  992 + for (let index = 1; index < payload.length; index += 1) {
  993 + if (payload[index].startPiece <= payload[index - 1].endPiece) {
  994 + throw new Error('件数区间不能重叠')
  995 + }
  996 + }
  997 + return payload
  998 +}
  999 +
586 1000 function buildTimesPayload() {
587 1001 return timePeriods.value
588 1002 .map((period, index) => {
589 1003 const hasContent = period.startText || period.endText || period.money
590 1004 if (!hasContent) return null
591   -
592 1005 const start = textToMinute(period.startText, `第${index + 1}条时段开始时间格式错误`)
593 1006 const end = textToMinute(period.endText, `第${index + 1}条时段结束时间格式错误`)
594   - if (start >= end) {
595   - throw new Error(`第${index + 1}条时段结束时间必须晚于开始时间`)
  1007 + if (start === end) {
  1008 + throw new Error(`第${index + 1}条时段开始时间不能等于结束时间`)
596 1009 }
597   -
598 1010 return {
599 1011 start,
600 1012 end,
... ... @@ -603,7 +1015,6 @@ function buildTimesPayload() {
603 1015 }
604 1016 })
605 1017 .filter(Boolean)
606   - .sort((a: any, b: any) => a.start - b.start)
607 1018 }
608 1019  
609 1020 function textToMinute(text: string, errorMessage: string) {
... ... @@ -621,5 +1032,130 @@ function minuteToText(value: number | null | undefined) {
621 1032 return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
622 1033 }
623 1034  
  1035 +function deepClone<T>(value: T): T {
  1036 + return JSON.parse(JSON.stringify(value))
  1037 +}
  1038 +
624 1039 onMounted(loadList)
625 1040 </script>
  1041 +
  1042 +<style scoped>
  1043 +.plan-layout {
  1044 + display: flex;
  1045 + gap: 20px;
  1046 + min-height: 720px;
  1047 +}
  1048 +
  1049 +.plan-sidebar {
  1050 + width: 280px;
  1051 + flex-shrink: 0;
  1052 + display: flex;
  1053 + flex-direction: column;
  1054 + border-radius: 16px;
  1055 + background: #f7f8fc;
  1056 + padding: 16px;
  1057 +}
  1058 +
  1059 +.plan-sidebar-header {
  1060 + display: flex;
  1061 + align-items: flex-start;
  1062 + justify-content: space-between;
  1063 + gap: 12px;
  1064 + margin-bottom: 16px;
  1065 +}
  1066 +
  1067 +.plan-sidebar-title {
  1068 + font-size: 16px;
  1069 + font-weight: 600;
  1070 + color: #1f2430;
  1071 +}
  1072 +
  1073 +.plan-sidebar-subtitle {
  1074 + margin-top: 4px;
  1075 + font-size: 12px;
  1076 + color: #7a8091;
  1077 + line-height: 1.5;
  1078 +}
  1079 +
  1080 +.plan-list {
  1081 + display: flex;
  1082 + flex-direction: column;
  1083 + gap: 12px;
  1084 + max-height: 640px;
  1085 + overflow-y: auto;
  1086 + padding-right: 4px;
  1087 +}
  1088 +
  1089 +.plan-item {
  1090 + width: 100%;
  1091 + border: 0;
  1092 + border-radius: 14px;
  1093 + background: #fff;
  1094 + padding: 14px;
  1095 + text-align: left;
  1096 + cursor: pointer;
  1097 + box-shadow: 0 8px 20px rgba(31, 36, 48, 0.06);
  1098 + transition: all 0.2s ease;
  1099 +}
  1100 +
  1101 +.plan-item.active {
  1102 + background: #eef2ff;
  1103 + box-shadow: 0 10px 24px rgba(99, 102, 241, 0.18);
  1104 +}
  1105 +
  1106 +.plan-item-top {
  1107 + display: flex;
  1108 + align-items: center;
  1109 + justify-content: space-between;
  1110 + gap: 8px;
  1111 + margin-bottom: 8px;
  1112 +}
  1113 +
  1114 +.plan-item-name {
  1115 + font-size: 14px;
  1116 + font-weight: 600;
  1117 + color: #1f2430;
  1118 +}
  1119 +
  1120 +.plan-item-bottom {
  1121 + display: flex;
  1122 + justify-content: space-between;
  1123 + font-size: 12px;
  1124 + color: #7a8091;
  1125 +}
  1126 +
  1127 +.plan-content {
  1128 + flex: 1;
  1129 + min-width: 0;
  1130 + max-height: 720px;
  1131 + overflow-y: auto;
  1132 + padding-right: 4px;
  1133 +}
  1134 +
  1135 +.plan-toolbar {
  1136 + display: flex;
  1137 + align-items: center;
  1138 + justify-content: space-between;
  1139 + gap: 12px;
  1140 + margin-bottom: 16px;
  1141 +}
  1142 +
  1143 +.preview-card {
  1144 + margin-bottom: 20px;
  1145 + border-radius: 16px;
  1146 + background: #fafbff;
  1147 +}
  1148 +
  1149 +.preview-result {
  1150 + display: flex;
  1151 + flex-wrap: wrap;
  1152 + gap: 8px;
  1153 +}
  1154 +
  1155 +.plan-empty-state {
  1156 + min-height: 520px;
  1157 + display: flex;
  1158 + align-items: center;
  1159 + justify-content: center;
  1160 +}
  1161 +</style>
... ...
src/views/dashboard/DashboardHome.vue
... ... @@ -65,15 +65,15 @@ function go(path: string) {
65 65 <style scoped>
66 66 .dashboard-home {
67 67 display: grid;
68   - gap: 22px;
  68 + gap: 16px;
69 69 }
70 70  
71 71 .hero-card {
72 72 display: grid;
73 73 grid-template-columns: minmax(0, 1.2fr) minmax(260px, 0.9fr);
74   - gap: 18px;
75   - border-radius: 34px;
76   - padding: 28px;
  74 + gap: 14px;
  75 + border-radius: 24px;
  76 + padding: 20px;
77 77 background:
78 78 linear-gradient(160deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.62)),
79 79 linear-gradient(135deg, rgba(198, 185, 255, 0.72), rgba(255, 217, 236, 0.78));
... ... @@ -83,7 +83,7 @@ function go(path: string) {
83 83  
84 84 .hero-pill {
85 85 display: inline-flex;
86   - padding: 8px 14px;
  86 + padding: 6px 12px;
87 87 border-radius: 999px;
88 88 background: rgba(255, 255, 255, 0.76);
89 89 color: #6f5fe2;
... ... @@ -94,9 +94,9 @@ function go(path: string) {
94 94 }
95 95  
96 96 .hero-copy h2 {
97   - margin: 16px 0 10px;
98   - font-size: 34px;
99   - line-height: 1.08;
  97 + margin: 12px 0 8px;
  98 + font-size: 28px;
  99 + line-height: 1.15;
100 100 font-family: 'Outfit', sans-serif;
101 101 color: #2f2946;
102 102 }
... ... @@ -110,16 +110,16 @@ function go(path: string) {
110 110  
111 111 .hero-grid {
112 112 display: flex;
113   - gap: 14px;
  113 + gap: 12px;
114 114 flex-wrap: wrap;
115   - margin-top: 24px;
  115 + margin-top: 18px;
116 116 }
117 117  
118 118 .hero-metric {
119 119 min-width: 190px;
120   - border-radius: 22px;
  120 + border-radius: 18px;
121 121 background: rgba(255, 255, 255, 0.74);
122   - padding: 14px 16px;
  122 + padding: 12px 14px;
123 123 }
124 124  
125 125 .hero-metric span {
... ... @@ -141,7 +141,7 @@ function go(path: string) {
141 141 display: flex;
142 142 align-items: flex-end;
143 143 justify-content: center;
144   - min-height: 260px;
  144 + min-height: 220px;
145 145 }
146 146  
147 147 .hero-visual img {
... ... @@ -175,21 +175,21 @@ function go(path: string) {
175 175 .content-grid {
176 176 display: grid;
177 177 grid-template-columns: 1.2fr 0.8fr;
178   - gap: 18px;
  178 + gap: 14px;
179 179 }
180 180  
181 181 .quick-links {
182 182 display: grid;
183 183 grid-template-columns: repeat(2, minmax(0, 1fr));
184   - gap: 14px;
  184 + gap: 12px;
185 185 }
186 186  
187 187 .quick-link {
188 188 text-align: left;
189 189 border: 1px solid rgba(202, 193, 240, 0.34);
190 190 background: rgba(255, 255, 255, 0.78);
191   - border-radius: 24px;
192   - padding: 18px;
  191 + border-radius: 18px;
  192 + padding: 14px;
193 193 cursor: pointer;
194 194 }
195 195  
... ... @@ -210,7 +210,7 @@ function go(path: string) {
210 210 }
211 211  
212 212 .hero-copy h2 {
213   - font-size: 28px;
  213 + font-size: 24px;
214 214 }
215 215 }
216 216 </style>
... ...
src/views/open/OpenMockDelivery.vue 0 → 100644
  1 +<template>
  2 + <div class="mock-delivery-page">
  3 + <a-card title="模拟推送配送单" :bordered="false">
  4 + <template #extra>
  5 + <a-space>
  6 + <a-button @click="fillDemo">填充示例</a-button>
  7 + <a-button @click="resetForm">重置</a-button>
  8 + <a-button :loading="calcLoading" @click="handleCalcFee">按应用租户试算配送费</a-button>
  9 + <a-button type="primary" :loading="saving" @click="handleSubmit">发送推单</a-button>
  10 + </a-space>
  11 + </template>
  12 +
  13 + <a-form layout="vertical">
  14 + <a-row :gutter="16">
  15 + <a-col :xs="24" :lg="12">
  16 + <a-form-item label="开放应用">
  17 + <a-select
  18 + v-model:value="form.appId"
  19 + placeholder="请选择应用"
  20 + show-search
  21 + :filter-option="filterAppOption"
  22 + @change="handleAppChange"
  23 + >
  24 + <a-select-option v-for="item in appList" :key="item.id" :value="item.id">
  25 + {{ item.appName }}({{ item.appKey }})
  26 + </a-select-option>
  27 + </a-select>
  28 + </a-form-item>
  29 + </a-col>
  30 + <a-col :xs="24" :lg="12">
  31 + <a-form-item label="所属租户">
  32 + <a-input :value="selectedTenantName" disabled />
  33 + </a-form-item>
  34 + </a-col>
  35 + </a-row>
  36 +
  37 + <a-row :gutter="16">
  38 + <a-col :xs="24" :lg="12">
  39 + <a-form-item label="外部订单号">
  40 + <a-input v-model:value="form.outOrderNo" placeholder="请输入外部订单号" />
  41 + </a-form-item>
  42 + </a-col>
  43 + <a-col :xs="24" :lg="12">
  44 + <a-form-item label="期望配送时间戳(秒)">
  45 + <a-input-number v-model:value="form.serviceTime" :min="0" style="width:100%" />
  46 + </a-form-item>
  47 + </a-col>
  48 + </a-row>
  49 +
  50 + <a-divider orientation="left">发货方信息</a-divider>
  51 + <a-alert
  52 + type="info"
  53 + show-icon
  54 + message="如果已同步过门店,优先填写接入方门店编号;未填写时请手动补全发货门店名称、地址和经纬度。"
  55 + style="margin-bottom: 16px"
  56 + />
  57 + <a-row :gutter="16">
  58 + <a-col :xs="24" :lg="12">
  59 + <a-form-item label="接入方门店编号">
  60 + <a-input v-model:value="form.outStoreId" placeholder="可选,填写后可自动补门店信息" />
  61 + </a-form-item>
  62 + </a-col>
  63 + <a-col :xs="24" :lg="12">
  64 + <a-form-item label="发货门店名称">
  65 + <a-input v-model:value="form.storeName" placeholder="未填写门店编号时必填" />
  66 + </a-form-item>
  67 + </a-col>
  68 + </a-row>
  69 + <a-form-item label="发货门店地址">
  70 + <a-input v-model:value="form.storeAddr" placeholder="请输入发货门店地址" />
  71 + </a-form-item>
  72 + <a-row :gutter="16">
  73 + <a-col :xs="24" :lg="12">
  74 + <a-form-item label="发货经度">
  75 + <a-input v-model:value="form.storeLng" placeholder="如:113.264385" />
  76 + </a-form-item>
  77 + </a-col>
  78 + <a-col :xs="24" :lg="12">
  79 + <a-form-item label="发货纬度">
  80 + <a-input v-model:value="form.storeLat" placeholder="如:23.129112" />
  81 + </a-form-item>
  82 + </a-col>
  83 + </a-row>
  84 +
  85 + <a-divider orientation="left">收件人信息</a-divider>
  86 + <a-row :gutter="16">
  87 + <a-col :xs="24" :lg="12">
  88 + <a-form-item label="收件人姓名">
  89 + <a-input v-model:value="form.recipName" placeholder="请输入收件人姓名" />
  90 + </a-form-item>
  91 + </a-col>
  92 + <a-col :xs="24" :lg="12">
  93 + <a-form-item label="收件人电话">
  94 + <a-input v-model:value="form.recipPhone" placeholder="请输入收件人手机号" />
  95 + </a-form-item>
  96 + </a-col>
  97 + </a-row>
  98 + <a-form-item label="收件人地址">
  99 + <a-input v-model:value="form.recipAddr" placeholder="请输入收件地址" />
  100 + </a-form-item>
  101 + <a-row :gutter="16">
  102 + <a-col :xs="24" :lg="12">
  103 + <a-form-item label="收件经度">
  104 + <a-input v-model:value="form.recipLng" placeholder="如:113.270000" />
  105 + </a-form-item>
  106 + </a-col>
  107 + <a-col :xs="24" :lg="12">
  108 + <a-form-item label="收件纬度">
  109 + <a-input v-model:value="form.recipLat" placeholder="如:23.135000" />
  110 + </a-form-item>
  111 + </a-col>
  112 + </a-row>
  113 +
  114 + <a-divider orientation="left">订单信息</a-divider>
  115 + <a-row :gutter="16">
  116 + <a-col :xs="24" :lg="12">
  117 + <a-form-item label="货物重量(kg)">
  118 + <a-input-number v-model:value="form.weight" :min="0" :step="0.1" style="width:100%" />
  119 + </a-form-item>
  120 + </a-col>
  121 + <a-col :xs="24" :lg="12">
  122 + <a-form-item label="订单级回调地址">
  123 + <a-input v-model:value="form.callbackUrl" placeholder="可选,留空则使用应用级 Webhook" />
  124 + </a-form-item>
  125 + </a-col>
  126 + </a-row>
  127 + <a-form-item label="整单备注">
  128 + <a-textarea v-model:value="form.remark" :rows="3" placeholder="如:请尽快送达" />
  129 + </a-form-item>
  130 + <a-form-item label="货物备注">
  131 + <a-textarea v-model:value="form.itemRemark" :rows="2" placeholder="如:饮品分开装、轻拿轻放" />
  132 + </a-form-item>
  133 +
  134 + <a-divider orientation="left">货物清单</a-divider>
  135 + <div style="margin-bottom:12px">
  136 + <a-button type="dashed" block @click="addItem">新增货物</a-button>
  137 + </div>
  138 + <div v-if="form.items.length">
  139 + <a-row
  140 + v-for="(item, index) in form.items"
  141 + :key="index"
  142 + :gutter="12"
  143 + style="margin-bottom:12px;align-items:flex-start"
  144 + >
  145 + <a-col :xs="24" :lg="6">
  146 + <a-form-item :label="index === 0 ? '名称' : ''">
  147 + <a-input v-model:value="item.name" placeholder="如:招牌套餐" />
  148 + </a-form-item>
  149 + </a-col>
  150 + <a-col :xs="24" :lg="4">
  151 + <a-form-item :label="index === 0 ? '数量' : ''">
  152 + <a-input-number v-model:value="item.quantity" :min="1" style="width:100%" />
  153 + </a-form-item>
  154 + </a-col>
  155 + <a-col :xs="24" :lg="6">
  156 + <a-form-item :label="index === 0 ? '规格' : ''">
  157 + <a-input v-model:value="item.spec" placeholder="如:大杯 / 双拼" />
  158 + </a-form-item>
  159 + </a-col>
  160 + <a-col :xs="24" :lg="6">
  161 + <a-form-item :label="index === 0 ? '备注' : ''">
  162 + <a-input v-model:value="item.remark" placeholder="可选" />
  163 + </a-form-item>
  164 + </a-col>
  165 + <a-col :xs="24" :lg="2">
  166 + <a-form-item :label="index === 0 ? '操作' : ''">
  167 + <a-button danger block @click="removeItem(index)">删除</a-button>
  168 + </a-form-item>
  169 + </a-col>
  170 + </a-row>
  171 + </div>
  172 + <a-empty v-else description="暂无货物清单" />
  173 + </a-form>
  174 + </a-card>
  175 +
  176 + <a-card v-if="feeResult" title="按应用租户试算结果" :bordered="false">
  177 + <a-alert
  178 + type="info"
  179 + show-icon
  180 + message="试算时不会手填租户,系统会自动使用当前开放应用绑定的租户;实际推单金额以后端创建订单时实时计算为准。"
  181 + style="margin-bottom: 16px"
  182 + />
  183 + <a-descriptions bordered :column="2">
  184 + <a-descriptions-item label="配送费">¥{{ feeResult.totalFee }}</a-descriptions-item>
  185 + <a-descriptions-item label="配送距离">{{ feeResult.distance }} km</a-descriptions-item>
  186 + <a-descriptions-item label="预计送达">{{ feeResult.estimatedMinutes }} 分钟</a-descriptions-item>
  187 + <a-descriptions-item label="基础费用">
  188 + ¥{{ feeResult.moneyBasic }}{{ feeResult.moneyBasicTxt ? ` ${feeResult.moneyBasicTxt}` : '' }}
  189 + </a-descriptions-item>
  190 + <a-descriptions-item label="超距费用">
  191 + ¥{{ feeResult.moneyDistance }}{{ feeResult.moneyDistanceTxt ? ` ${feeResult.moneyDistanceTxt}` : '' }}
  192 + </a-descriptions-item>
  193 + <a-descriptions-item label="超重费用">
  194 + ¥{{ feeResult.moneyWeight }}{{ feeResult.moneyWeightTxt ? ` ${feeResult.moneyWeightTxt}` : '' }}
  195 + </a-descriptions-item>
  196 + <a-descriptions-item label="件数费用">
  197 + ¥{{ feeResult.moneyPiece }}{{ feeResult.moneyPieceTxt ? ` ${feeResult.moneyPieceTxt}` : '' }}
  198 + </a-descriptions-item>
  199 + <a-descriptions-item label="时段附加费">¥{{ feeResult.moneyTime }}</a-descriptions-item>
  200 + <a-descriptions-item label="保底费用">¥{{ feeResult.minFee }}{{ feeResult.minFeeApplied === 1 ? '(已命中)' : '' }}</a-descriptions-item>
  201 + <a-descriptions-item label="计费重量">{{ feeResult.weight }} kg</a-descriptions-item>
  202 + <a-descriptions-item label="计费件数">{{ feeResult.pieces || 0 }} 件</a-descriptions-item>
  203 + </a-descriptions>
  204 + </a-card>
  205 +
  206 + <a-card v-if="result" title="推单结果" :bordered="false">
  207 + <a-descriptions bordered :column="2">
  208 + <a-descriptions-item label="配送单ID">{{ result.deliveryOrderId }}</a-descriptions-item>
  209 + <a-descriptions-item label="平台订单号">{{ result.orderNo }}</a-descriptions-item>
  210 + <a-descriptions-item label="外部订单号">{{ result.outOrderNo }}</a-descriptions-item>
  211 + <a-descriptions-item label="状态">{{ statusMap[result.status] || result.status }}</a-descriptions-item>
  212 + <a-descriptions-item label="配送费">¥{{ result.totalFee }}</a-descriptions-item>
  213 + <a-descriptions-item label="配送距离">{{ result.distance }} km</a-descriptions-item>
  214 + <a-descriptions-item label="基础费">¥{{ result.moneyBasic }}</a-descriptions-item>
  215 + <a-descriptions-item label="超距费">¥{{ result.moneyDistance }}</a-descriptions-item>
  216 + <a-descriptions-item label="时段附加费">¥{{ result.moneyTime }}</a-descriptions-item>
  217 + <a-descriptions-item label="预计送达">{{ result.estimatedMinutes }} 分钟</a-descriptions-item>
  218 + </a-descriptions>
  219 + </a-card>
  220 + </div>
  221 +</template>
  222 +
  223 +<script setup lang="ts">
  224 +import { computed, onMounted, reactive, ref, watch } from 'vue'
  225 +import { message } from 'ant-design-vue'
  226 +import { cityApi, deliveryApi, openApi } from '@/api'
  227 +
  228 +type OpenAppItem = {
  229 + id: number
  230 + appName: string
  231 + appKey: string
  232 + cityId: number
  233 + status: number
  234 +}
  235 +
  236 +type DeliveryItem = {
  237 + name: string
  238 + quantity: number
  239 + spec: string
  240 + remark: string
  241 +}
  242 +
  243 +const appList = ref<OpenAppItem[]>([])
  244 +const cityList = ref<any[]>([])
  245 +const saving = ref(false)
  246 +const calcLoading = ref(false)
  247 +const feeResult = ref<any>(null)
  248 +const result = ref<any>(null)
  249 +
  250 +const statusMap: Record<number, string> = {
  251 + 2: '待接单',
  252 + 3: '已接单',
  253 + 4: '配送中',
  254 + 6: '已完成',
  255 + 10: '已取消',
  256 +}
  257 +
  258 +function createDefaultItem(): DeliveryItem {
  259 + return { name: '', quantity: 1, spec: '', remark: '' }
  260 +}
  261 +
  262 +function createDefaultForm() {
  263 + return {
  264 + appId: undefined as number | undefined,
  265 + outStoreId: '',
  266 + storeName: '',
  267 + storeAddr: '',
  268 + storeLng: '',
  269 + storeLat: '',
  270 + recipName: '',
  271 + recipPhone: '',
  272 + recipAddr: '',
  273 + recipLng: '',
  274 + recipLat: '',
  275 + outOrderNo: generateOutOrderNo(),
  276 + weight: 0,
  277 + serviceTime: 0,
  278 + callbackUrl: '',
  279 + remark: '',
  280 + itemRemark: '',
  281 + items: [createDefaultItem()],
  282 + }
  283 +}
  284 +
  285 +const form = reactive(createDefaultForm())
  286 +
  287 +const selectedApp = computed(() => appList.value.find(item => item.id === form.appId))
  288 +const selectedTenantName = computed(() => {
  289 + const city = cityList.value.find(item => item.id === selectedApp.value?.cityId)
  290 + return city?.name || ''
  291 +})
  292 +
  293 +function generateOutOrderNo() {
  294 + return `MOCK${Date.now()}`
  295 +}
  296 +
  297 +function resetForm() {
  298 + Object.assign(form, createDefaultForm())
  299 + feeResult.value = null
  300 + result.value = null
  301 +}
  302 +
  303 +function fillDemo() {
  304 + if (!form.appId && appList.value.length) {
  305 + form.appId = appList.value[0].id
  306 + }
  307 + Object.assign(form, {
  308 + ...form,
  309 + outOrderNo: generateOutOrderNo(),
  310 + outStoreId: '',
  311 + storeName: '测试门店',
  312 + storeAddr: '广州市天河区体育西路 100 号',
  313 + storeLng: '113.327823',
  314 + storeLat: '23.137466',
  315 + recipName: '测试用户',
  316 + recipPhone: '13800138000',
  317 + recipAddr: '广州市天河区珠江新城测试地址',
  318 + recipLng: '113.324590',
  319 + recipLat: '23.119295',
  320 + weight: 1,
  321 + serviceTime: 0,
  322 + callbackUrl: '',
  323 + remark: '后台模拟推单',
  324 + itemRemark: '少冰少辣',
  325 + })
  326 + form.items = [
  327 + { name: '招牌奶茶', quantity: 2, spec: '大杯', remark: '少冰' },
  328 + { name: '鸡腿饭', quantity: 1, spec: '标准', remark: '不要辣' },
  329 + ]
  330 + feeResult.value = null
  331 + result.value = null
  332 +}
  333 +
  334 +function addItem() {
  335 + form.items.push(createDefaultItem())
  336 +}
  337 +
  338 +function removeItem(index: number) {
  339 + form.items.splice(index, 1)
  340 + if (!form.items.length) {
  341 + form.items.push(createDefaultItem())
  342 + }
  343 +}
  344 +
  345 +function handleAppChange() {
  346 + feeResult.value = null
  347 + result.value = null
  348 +}
  349 +
  350 +function filterAppOption(input: string, option: any) {
  351 + return String(option.children).toLowerCase().includes(input.toLowerCase())
  352 +}
  353 +
  354 +function buildPayload() {
  355 + return {
  356 + appId: form.appId,
  357 + outStoreId: form.outStoreId || undefined,
  358 + storeName: form.storeName || undefined,
  359 + storeAddr: form.storeAddr || undefined,
  360 + storeLng: form.storeLng || undefined,
  361 + storeLat: form.storeLat || undefined,
  362 + recipName: form.recipName,
  363 + recipPhone: form.recipPhone,
  364 + recipAddr: form.recipAddr,
  365 + recipLng: form.recipLng,
  366 + recipLat: form.recipLat,
  367 + outOrderNo: form.outOrderNo,
  368 + weight: form.weight,
  369 + serviceTime: form.serviceTime,
  370 + callbackUrl: form.callbackUrl || undefined,
  371 + remark: form.remark || undefined,
  372 + itemRemark: form.itemRemark || undefined,
  373 + items: form.items
  374 + .filter(item => item.name.trim())
  375 + .map(item => ({
  376 + name: item.name.trim(),
  377 + quantity: item.quantity || 1,
  378 + spec: item.spec || undefined,
  379 + remark: item.remark || undefined,
  380 + })),
  381 + }
  382 +}
  383 +
  384 +function clearCalcResult() {
  385 + feeResult.value = null
  386 +}
  387 +
  388 +function buildCalcPayload() {
  389 + return {
  390 + cityId: selectedApp.value?.cityId,
  391 + orderType: 6,
  392 + startLng: form.storeLng,
  393 + startLat: form.storeLat,
  394 + endLng: form.recipLng,
  395 + endLat: form.recipLat,
  396 + weight: form.weight,
  397 + pieces: form.items.reduce((sum, item) => sum + (item.quantity || 0), 0),
  398 + serviceTime: form.serviceTime,
  399 + }
  400 +}
  401 +
  402 +async function loadApps() {
  403 + const res: any = await openApi.list()
  404 + appList.value = Array.isArray(res?.data) ? res.data.filter((item: OpenAppItem) => item.status === 1) : []
  405 +}
  406 +
  407 +async function loadCities() {
  408 + const res: any = await cityApi.openList()
  409 + cityList.value = Array.isArray(res?.data) ? res.data : []
  410 +}
  411 +
  412 +async function handleSubmit() {
  413 + if (!form.appId) {
  414 + message.error('请选择开放应用')
  415 + return
  416 + }
  417 + if (!form.outOrderNo || !form.recipName || !form.recipPhone || !form.recipAddr || !form.recipLng || !form.recipLat) {
  418 + message.error('请填写完整的收件人与订单信息')
  419 + return
  420 + }
  421 + if (!form.outStoreId && (!form.storeName || !form.storeLng || !form.storeLat)) {
  422 + message.error('未填写门店编号时,请补全发货门店名称和经纬度')
  423 + return
  424 + }
  425 +
  426 + saving.value = true
  427 + try {
  428 + const res: any = await openApi.mockDeliveryCreate(buildPayload())
  429 + result.value = res.data
  430 + message.success('模拟推单成功')
  431 + } finally {
  432 + saving.value = false
  433 + }
  434 +}
  435 +
  436 +async function handleCalcFee() {
  437 + if (!form.appId || !selectedApp.value?.cityId) {
  438 + message.error('请选择开放应用')
  439 + return
  440 + }
  441 + if (!form.storeLng || !form.storeLat || !form.recipLng || !form.recipLat) {
  442 + message.error('请填写完整的发货与收货经纬度')
  443 + return
  444 + }
  445 +
  446 + calcLoading.value = true
  447 + try {
  448 + const res: any = await deliveryApi.calc(buildCalcPayload())
  449 + feeResult.value = res.data
  450 + message.success('配送费计算完成')
  451 + } finally {
  452 + calcLoading.value = false
  453 + }
  454 +}
  455 +
  456 +onMounted(async () => {
  457 + await Promise.all([loadApps(), loadCities()])
  458 + if (appList.value.length) {
  459 + form.appId = appList.value[0].id
  460 + }
  461 +})
  462 +
  463 +watch(
  464 + () => [form.storeLng, form.storeLat, form.recipLng, form.recipLat, form.weight, form.serviceTime, form.outStoreId],
  465 + () => {
  466 + clearCalcResult()
  467 + }
  468 +)
  469 +</script>
  470 +
  471 +<style scoped>
  472 +.mock-delivery-page {
  473 + display: grid;
  474 + gap: 18px;
  475 +}
  476 +</style>
... ...