Commit 054f8469e8efa8b2eed1f9e744d8a3cf322dbef5

Authored by 杨刚
1 parent fdbd231e

init

src/api/index.ts
@@ -8,9 +8,23 @@ export const cityApi = { @@ -8,9 +8,23 @@ export const cityApi = {
8 edit: (data: any) => request.put('/api/platform/city/edit', data), 8 edit: (data: any) => request.put('/api/platform/city/edit', data),
9 setStatus: (cityId: number, status: number) => 9 setStatus: (cityId: number, status: number) =>
10 request.post('/api/platform/city/setStatus', null, { params: { cityId, status } }), 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,8 +159,8 @@ function handleLogout() {
159 min-height: 100vh; 159 min-height: 100vh;
160 display: grid; 160 display: grid;
161 grid-template-columns: 280px minmax(0, 1fr); 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 .soft-sider, 166 .soft-sider,
@@ -173,10 +173,10 @@ function handleLogout() { @@ -173,10 +173,10 @@ function handleLogout() {
173 173
174 .soft-sider { 174 .soft-sider {
175 position: sticky; 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 display: flex; 180 display: flex;
181 flex-direction: column; 181 flex-direction: column;
182 overflow: hidden; 182 overflow: hidden;
@@ -205,9 +205,9 @@ function handleLogout() { @@ -205,9 +205,9 @@ function handleLogout() {
205 205
206 .sider-toggle { 206 .sider-toggle {
207 align-self: flex-end; 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 border: none; 211 border: none;
212 background: rgba(246, 242, 255, 0.9); 212 background: rgba(246, 242, 255, 0.9);
213 color: #7f6de5; 213 color: #7f6de5;
@@ -218,14 +218,14 @@ function handleLogout() { @@ -218,14 +218,14 @@ function handleLogout() {
218 .brand-block { 218 .brand-block {
219 display: flex; 219 display: flex;
220 align-items: center; 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 .brand-mark { 225 .brand-mark {
226 - width: 52px;  
227 - height: 52px;  
228 - border-radius: 18px; 226 + width: 44px;
  227 + height: 44px;
  228 + border-radius: 14px;
229 background: linear-gradient(145deg, #8c7cf0, #e6b5dc); 229 background: linear-gradient(145deg, #8c7cf0, #e6b5dc);
230 color: white; 230 color: white;
231 font-family: 'Outfit', sans-serif; 231 font-family: 'Outfit', sans-serif;
@@ -240,6 +240,17 @@ function handleLogout() { @@ -240,6 +240,17 @@ function handleLogout() {
240 flex-direction: column; 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 .brand-copy strong, 254 .brand-copy strong,
244 .profile-copy strong, 255 .profile-copy strong,
245 .hero-copy h2, 256 .hero-copy h2,
@@ -264,11 +275,11 @@ h1 { @@ -264,11 +275,11 @@ h1 {
264 } 275 }
265 276
266 .sider-foot { 277 .sider-foot {
267 - margin-top: 14px; 278 + margin-top: 10px;
268 flex-shrink: 0; 279 flex-shrink: 0;
269 - border-radius: 24px; 280 + border-radius: 18px;
270 background: linear-gradient(180deg, rgba(245, 241, 255, 0.85), rgba(255, 247, 250, 0.92)); 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 .soft-chip { 285 .soft-chip {
@@ -290,8 +301,8 @@ h1 { @@ -290,8 +301,8 @@ h1 {
290 } 301 }
291 302
292 .soft-topbar { 303 .soft-topbar {
293 - border-radius: 30px;  
294 - padding: 18px 24px; 304 + border-radius: 22px;
  305 + padding: 12px 16px;
295 display: flex; 306 display: flex;
296 align-items: center; 307 align-items: center;
297 justify-content: space-between; 308 justify-content: space-between;
@@ -300,21 +311,22 @@ h1 { @@ -300,21 +311,22 @@ h1 {
300 311
301 .soft-topbar h1 { 312 .soft-topbar h1 {
302 margin: 4px 0 0; 313 margin: 4px 0 0;
303 - font-size: 30px; 314 + font-size: 22px;
  315 + line-height: 1.3;
304 } 316 }
305 317
306 .eyebrow { 318 .eyebrow {
307 margin: 0; 319 margin: 0;
308 text-transform: uppercase; 320 text-transform: uppercase;
309 letter-spacing: 0.12em; 321 letter-spacing: 0.12em;
310 - font-size: 11px; 322 + font-size: 10px;
311 font-weight: 700; 323 font-weight: 700;
312 } 324 }
313 325
314 .topbar-actions { 326 .topbar-actions {
315 display: flex; 327 display: flex;
316 align-items: center; 328 align-items: center;
317 - gap: 14px; 329 + gap: 10px;
318 } 330 }
319 331
320 .date-pill, 332 .date-pill,
@@ -325,7 +337,8 @@ h1 { @@ -325,7 +337,8 @@ h1 {
325 border-radius: 999px; 337 border-radius: 999px;
326 background: rgba(255, 255, 255, 0.82); 338 background: rgba(255, 255, 255, 0.82);
327 border: 1px solid rgba(194, 184, 237, 0.38); 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 .profile-button { 344 .profile-button {
@@ -334,14 +347,15 @@ h1 { @@ -334,14 +347,15 @@ h1 {
334 } 347 }
335 348
336 .profile-avatar { 349 .profile-avatar {
337 - width: 38px;  
338 - height: 38px;  
339 - border-radius: 14px; 350 + width: 34px;
  351 + height: 34px;
  352 + border-radius: 12px;
340 display: grid; 353 display: grid;
341 place-items: center; 354 place-items: center;
342 background: linear-gradient(135deg, #a48ef4, #f1bfd8); 355 background: linear-gradient(135deg, #a48ef4, #f1bfd8);
343 color: white; 356 color: white;
344 font-weight: 700; 357 font-weight: 700;
  358 + font-size: 13px;
345 } 359 }
346 360
347 .profile-copy { 361 .profile-copy {
@@ -350,6 +364,26 @@ h1 { @@ -350,6 +364,26 @@ h1 {
350 text-align: left; 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 @media (max-width: 960px) { 387 @media (max-width: 960px) {
354 .layout-shell { 388 .layout-shell {
355 grid-template-columns: 1fr; 389 grid-template-columns: 1fr;
@@ -378,7 +412,7 @@ h1 { @@ -378,7 +412,7 @@ h1 {
378 } 412 }
379 413
380 .soft-topbar h1 { 414 .soft-topbar h1 {
381 - font-size: 26px; 415 + font-size: 20px;
382 } 416 }
383 417
384 .hero-copy h2 { 418 .hero-copy h2 {
src/style.css
@@ -30,6 +30,10 @@ @@ -30,6 +30,10 @@
30 --radius-sm: 14px; 30 --radius-sm: 14px;
31 --font-display: 'Outfit', 'Avenir Next', 'Segoe UI', sans-serif; 31 --font-display: 'Outfit', 'Avenir Next', 'Segoe UI', sans-serif;
32 --font-body: 'Plus Jakarta Sans', 'Segoe UI', sans-serif; 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 color: var(--text-main); 37 color: var(--text-main);
34 font-family: var(--font-body); 38 font-family: var(--font-body);
35 line-height: 1.5; 39 line-height: 1.5;
@@ -55,6 +59,7 @@ body { @@ -55,6 +59,7 @@ body {
55 margin: 0; 59 margin: 0;
56 background: var(--app-bg); 60 background: var(--app-bg);
57 color: var(--text-main); 61 color: var(--text-main);
  62 + font-size: var(--font-size-base);
58 } 63 }
59 64
60 a { 65 a {
@@ -69,6 +74,9 @@ a { @@ -69,6 +74,9 @@ a {
69 border-radius: 999px; 74 border-radius: 999px;
70 font-weight: 600; 75 font-weight: 600;
71 box-shadow: none; 76 box-shadow: none;
  77 + font-size: var(--font-size-md);
  78 + height: 32px;
  79 + padding-inline: 14px;
72 } 80 }
73 81
74 .ant-btn-primary { 82 .ant-btn-primary {
@@ -97,18 +105,19 @@ a { @@ -97,18 +105,19 @@ a {
97 105
98 .ant-card .ant-card-head { 106 .ant-card .ant-card-head {
99 border-bottom: 1px solid rgba(188, 180, 230, 0.18); 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 .ant-card .ant-card-head-title { 112 .ant-card .ant-card-head-title {
104 font-family: var(--font-display); 113 font-family: var(--font-display);
105 - font-size: 1.1rem; 114 + font-size: var(--font-size-lg);
106 color: var(--text-dark); 115 color: var(--text-dark);
107 font-weight: 700; 116 font-weight: 700;
108 } 117 }
109 118
110 .ant-card .ant-card-body { 119 .ant-card .ant-card-body {
111 - padding: 24px; 120 + padding: 18px 20px;
112 } 121 }
113 122
114 .ant-table-wrapper .ant-table { 123 .ant-table-wrapper .ant-table {
@@ -126,11 +135,17 @@ a { @@ -126,11 +135,17 @@ a {
126 color: var(--text-dark); 135 color: var(--text-dark);
127 border-bottom: none; 136 border-bottom: none;
128 font-weight: 700; 137 font-weight: 700;
  138 + font-size: var(--font-size-md);
  139 + padding-top: 12px;
  140 + padding-bottom: 12px;
129 } 141 }
130 142
131 .ant-table-wrapper .ant-table-tbody > tr > td { 143 .ant-table-wrapper .ant-table-tbody > tr > td {
132 border-bottom: 1px solid rgba(226, 220, 247, 0.72); 144 border-bottom: 1px solid rgba(226, 220, 247, 0.72);
133 background: rgba(255, 255, 255, 0.48); 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 .ant-table-wrapper .ant-table-tbody > tr:hover > td { 151 .ant-table-wrapper .ant-table-tbody > tr:hover > td {
@@ -148,6 +163,30 @@ a { @@ -148,6 +163,30 @@ a {
148 border-color: rgba(189, 180, 234, 0.4) !important; 163 border-color: rgba(189, 180, 234, 0.4) !important;
149 background: rgba(255, 255, 255, 0.78) !important; 164 background: rgba(255, 255, 255, 0.78) !important;
150 box-shadow: none !important; 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 .ant-input:focus, 192 .ant-input:focus,
@@ -161,7 +200,7 @@ a { @@ -161,7 +200,7 @@ a {
161 200
162 .ant-modal .ant-modal-content, 201 .ant-modal .ant-modal-content,
163 .ant-dropdown .ant-dropdown-menu { 202 .ant-dropdown .ant-dropdown-menu {
164 - border-radius: 28px; 203 + border-radius: 22px;
165 border: 1px solid rgba(228, 223, 247, 0.7); 204 border: 1px solid rgba(228, 223, 247, 0.7);
166 background: rgba(255, 255, 255, 0.92); 205 background: rgba(255, 255, 255, 0.92);
167 backdrop-filter: blur(24px); 206 backdrop-filter: blur(24px);
@@ -171,12 +210,22 @@ a { @@ -171,12 +210,22 @@ a {
171 .ant-modal .ant-modal-header { 210 .ant-modal .ant-modal-header {
172 background: transparent; 211 background: transparent;
173 border-bottom: 1px solid rgba(189, 180, 234, 0.18); 212 border-bottom: 1px solid rgba(189, 180, 234, 0.18);
  213 + padding: 18px 20px 12px;
174 } 214 }
175 215
176 .ant-modal .ant-modal-title { 216 .ant-modal .ant-modal-title {
177 font-family: var(--font-display); 217 font-family: var(--font-display);
178 color: var(--text-dark); 218 color: var(--text-dark);
179 font-weight: 700; 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 .ant-tag { 231 .ant-tag {
@@ -184,18 +233,32 @@ a { @@ -184,18 +233,32 @@ a {
184 border: none; 233 border: none;
185 padding-inline: 10px; 234 padding-inline: 10px;
186 font-weight: 600; 235 font-weight: 600;
  236 + font-size: var(--font-size-sm);
187 } 237 }
188 238
189 .ant-menu { 239 .ant-menu {
190 background: transparent !important; 240 background: transparent !important;
  241 + font-size: var(--font-size-md);
191 } 242 }
192 243
193 .ant-menu-item, 244 .ant-menu-item,
194 .ant-menu-submenu-title { 245 .ant-menu-submenu-title {
195 - border-radius: 18px !important; 246 + border-radius: 14px !important;
196 margin-inline: 8px !important; 247 margin-inline: 8px !important;
197 - margin-block: 6px !important; 248 + margin-block: 4px !important;
198 width: calc(100% - 16px) !important; 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 .ant-menu-light .ant-menu-item-selected, 264 .ant-menu-light .ant-menu-item-selected,
@@ -223,7 +286,7 @@ a { @@ -223,7 +286,7 @@ a {
223 } 286 }
224 287
225 .soft-page-shell { 288 .soft-page-shell {
226 - padding: 24px 24px 28px; 289 + padding: 18px 18px 22px;
227 } 290 }
228 291
229 .soft-section-card { 292 .soft-section-card {
src/views/city/CityList.vue
@@ -4,8 +4,7 @@ @@ -4,8 +4,7 @@
4 <template #extra> 4 <template #extra>
5 <a-button type="primary" @click="openAdd">新增</a-button> 5 <a-button type="primary" @click="openAdd">新增</a-button>
6 </template> 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 <template #bodyCell="{ column, record }"> 8 <template #bodyCell="{ column, record }">
10 <template v-if="column.key === 'status'"> 9 <template v-if="column.key === 'status'">
11 <a-tag :color="record.status === 1 ? 'green' : 'default'"> 10 <a-tag :color="record.status === 1 ? 'green' : 'default'">
@@ -17,9 +16,7 @@ @@ -17,9 +16,7 @@
17 <a @click="openEdit(record)">编辑</a> 16 <a @click="openEdit(record)">编辑</a>
18 <a @click="openConfig(record)">配送费配置</a> 17 <a @click="openConfig(record)">配送费配置</a>
19 <a @click="openLevelConfig(record)">骑手等级</a> 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 <a>{{ record.status === 1 ? '关闭' : '开通' }}</a> 20 <a>{{ record.status === 1 ? '关闭' : '开通' }}</a>
24 </a-popconfirm> 21 </a-popconfirm>
25 </a-space> 22 </a-space>
@@ -28,9 +25,7 @@ @@ -28,9 +25,7 @@
28 </a-table> 25 </a-table>
29 </a-card> 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 <a-form :model="form" layout="vertical"> 29 <a-form :model="form" layout="vertical">
35 <a-form-item label="名称"> 30 <a-form-item label="名称">
36 <a-input v-model:value="form.name" placeholder="如:华东一区 / 某租户名" /> 31 <a-input v-model:value="form.name" placeholder="如:华东一区 / 某租户名" />
@@ -47,161 +42,331 @@ @@ -47,161 +42,331 @@
47 </a-form> 42 </a-form>
48 </a-modal> 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 <a-row :gutter="16"> 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 </a-form-item> 99 </a-form-item>
119 </a-col> 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 <a-select-option :value="1">启用</a-select-option> 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 </a-select> 106 </a-select>
176 </a-form-item> 107 </a-form-item>
177 </a-col> 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 </a-form-item> 112 </a-form-item>
182 </a-col> 113 </a-col>
183 </a-row> 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 </a-form-item> 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 </a-modal> 370 </a-modal>
206 371
207 <a-modal v-model:open="levelVisible" :title="`骑手等级配置 - ${levelCityName}`" width="900px" :footer="null"> 372 <a-modal v-model:open="levelVisible" :title="`骑手等级配置 - ${levelCityName}`" width="900px" :footer="null">
@@ -234,8 +399,7 @@ @@ -234,8 +399,7 @@
234 </a-table> 399 </a-table>
235 </a-modal> 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 <a-form :model="levelForm" layout="vertical"> 403 <a-form :model="levelForm" layout="vertical">
240 <a-form-item label="等级编号"> 404 <a-form-item label="等级编号">
241 <a-input-number v-model:value="levelForm.levelId" :min="1" style="width:100%" /> 405 <a-input-number v-model:value="levelForm.levelId" :min="1" style="width:100%" />
@@ -295,7 +459,7 @@ @@ -295,7 +459,7 @@
295 </template> 459 </template>
296 460
297 <script setup lang="ts"> 461 <script setup lang="ts">
298 -import { ref, reactive, onMounted } from 'vue' 462 +import { onMounted, reactive, ref } from 'vue'
299 import { message } from 'ant-design-vue' 463 import { message } from 'ant-design-vue'
300 import { cityApi, riderLevelApi } from '@/api' 464 import { cityApi, riderLevelApi } from '@/api'
301 465
@@ -306,6 +470,18 @@ type TimePeriodForm = { @@ -306,6 +470,18 @@ type TimePeriodForm = {
306 money: number | null 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 const loading = ref(false) 485 const loading = ref(false)
310 const saving = ref(false) 486 const saving = ref(false)
311 const list = ref<any[]>([]) 487 const list = ref<any[]>([])
@@ -315,11 +491,32 @@ const levelVisible = ref(false) @@ -315,11 +491,32 @@ const levelVisible = ref(false)
315 const levelEditVisible = ref(false) 491 const levelEditVisible = ref(false)
316 const editingId = ref<number | null>(null) 492 const editingId = ref<number | null>(null)
317 const currentCityId = ref<number>(0) 493 const currentCityId = ref<number>(0)
  494 +const currentCityName = ref('')
318 const levelCityId = ref<number>(0) 495 const levelCityId = ref<number>(0)
319 const levelCityName = ref('') 496 const levelCityName = ref('')
320 const form = reactive({ name: '', areaCode: '', rate: 0, listOrder: 0 }) 497 const form = reactive({ name: '', areaCode: '', rate: 0, listOrder: 0 })
  498 +
321 const config = ref<any>(null) 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 const timePeriods = ref<TimePeriodForm[]>([]) 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 const levelLoading = ref(false) 520 const levelLoading = ref(false)
324 const levelSaving = ref(false) 521 const levelSaving = ref(false)
325 const levelList = ref<any[]>([]) 522 const levelList = ref<any[]>([])
@@ -367,7 +564,7 @@ async function loadList() { @@ -367,7 +564,7 @@ async function loadList() {
367 loading.value = true 564 loading.value = true
368 try { 565 try {
369 const res: any = await cityApi.tree() 566 const res: any = await cityApi.tree()
370 - list.value = res.data // 现在直接是一级列表 567 + list.value = res.data
371 } finally { 568 } finally {
372 loading.value = false 569 loading.value = false
373 } 570 }
@@ -385,13 +582,6 @@ function openEdit(record: any) { @@ -385,13 +582,6 @@ function openEdit(record: any) {
385 modalVisible.value = true 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 async function handleSave() { 585 async function handleSave() {
396 saving.value = true 586 saving.value = true
397 try { 587 try {
@@ -416,34 +606,198 @@ async function toggleStatus(record: any) { @@ -416,34 +606,198 @@ async function toggleStatus(record: any) {
416 606
417 async function openConfig(record: any) { 607 async function openConfig(record: any) {
418 currentCityId.value = record.id 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 timePeriods.value = (config.value.type6.times || []).map((period: any) => ({ 657 timePeriods.value = (config.value.type6.times || []).map((period: any) => ({
422 startText: minuteToText(period.start), 658 startText: minuteToText(period.start),
423 endText: minuteToText(period.end), 659 endText: minuteToText(period.end),
424 isOpen: period.isOpen === 0 ? 0 : 1, 660 isOpen: period.isOpen === 0 ? 0 : 1,
425 money: period.money ?? 0, 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 try { 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 } catch (err: any) { 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 return 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 try { 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 } finally { 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 async function loadLevels() { 803 async function loadLevels() {
@@ -537,18 +891,28 @@ function formatLevelRule(record: any) { @@ -537,18 +891,28 @@ function formatLevelRule(record: any) {
537 891
538 function createDefaultType6() { 892 function createDefaultType6() {
539 return { 893 return {
540 - feeMode: 1, 894 + minFee: 0,
  895 + baseSwitch: 0,
  896 + baseFee: 0,
  897 + feeMode: 2,
541 fixMoney: 0, 898 fixMoney: 0,
542 distanceSwitch: 1, 899 distanceSwitch: 1,
543 distanceBasic: 3, 900 distanceBasic: 3,
544 distanceBasicMoney: 4, 901 distanceBasicMoney: 4,
545 distanceMoreMoney: 1.5, 902 distanceMoreMoney: 1.5,
546 distanceType: 1, 903 distanceType: 1,
547 - weightSwitch: 0, 904 + distanceSteps: [],
  905 + weightSwitch: 1,
  906 + weightFirst: 5,
  907 + weightFirstFee: 0,
  908 + weightUnitFee: 1,
  909 + weightCapFee: 30,
548 weightBasic: 0, 910 weightBasic: 0,
549 weightBasicMoney: 0, 911 weightBasicMoney: 0,
550 weightMoreMoney: 0, 912 weightMoreMoney: 0,
551 weightType: 1, 913 weightType: 1,
  914 + pieceSwitch: 0,
  915 + pieceRules: [],
552 times: [], 916 times: [],
553 } 917 }
554 } 918 }
@@ -561,6 +925,8 @@ function normalizeConfig(raw: any) { @@ -561,6 +925,8 @@ function normalizeConfig(raw: any) {
561 type: [6], 925 type: [6],
562 type6: { 926 type6: {
563 ...type6, 927 ...type6,
  928 + distanceSteps: Array.isArray(type6.distanceSteps) ? type6.distanceSteps : [],
  929 + pieceRules: Array.isArray(type6.pieceRules) ? type6.pieceRules : [],
564 times: Array.isArray(type6.times) ? type6.times : [], 930 times: Array.isArray(type6.times) ? type6.times : [],
565 }, 931 },
566 distanceBasic: next.distanceBasic ?? 3, 932 distanceBasic: next.distanceBasic ?? 3,
@@ -571,30 +937,76 @@ function normalizeConfig(raw: any) { @@ -571,30 +937,76 @@ function normalizeConfig(raw: any) {
571 } 937 }
572 938
573 function addTimePeriod() { 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 function removeTimePeriod(index: number) { 943 function removeTimePeriod(index: number) {
583 timePeriods.value.splice(index, 1) 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 function buildTimesPayload() { 1000 function buildTimesPayload() {
587 return timePeriods.value 1001 return timePeriods.value
588 .map((period, index) => { 1002 .map((period, index) => {
589 const hasContent = period.startText || period.endText || period.money 1003 const hasContent = period.startText || period.endText || period.money
590 if (!hasContent) return null 1004 if (!hasContent) return null
591 -  
592 const start = textToMinute(period.startText, `第${index + 1}条时段开始时间格式错误`) 1005 const start = textToMinute(period.startText, `第${index + 1}条时段开始时间格式错误`)
593 const end = textToMinute(period.endText, `第${index + 1}条时段结束时间格式错误`) 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 return { 1010 return {
599 start, 1011 start,
600 end, 1012 end,
@@ -603,7 +1015,6 @@ function buildTimesPayload() { @@ -603,7 +1015,6 @@ function buildTimesPayload() {
603 } 1015 }
604 }) 1016 })
605 .filter(Boolean) 1017 .filter(Boolean)
606 - .sort((a: any, b: any) => a.start - b.start)  
607 } 1018 }
608 1019
609 function textToMinute(text: string, errorMessage: string) { 1020 function textToMinute(text: string, errorMessage: string) {
@@ -621,5 +1032,130 @@ function minuteToText(value: number | null | undefined) { @@ -621,5 +1032,130 @@ function minuteToText(value: number | null | undefined) {
621 return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}` 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 onMounted(loadList) 1039 onMounted(loadList)
625 </script> 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,15 +65,15 @@ function go(path: string) {
65 <style scoped> 65 <style scoped>
66 .dashboard-home { 66 .dashboard-home {
67 display: grid; 67 display: grid;
68 - gap: 22px; 68 + gap: 16px;
69 } 69 }
70 70
71 .hero-card { 71 .hero-card {
72 display: grid; 72 display: grid;
73 grid-template-columns: minmax(0, 1.2fr) minmax(260px, 0.9fr); 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 background: 77 background:
78 linear-gradient(160deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.62)), 78 linear-gradient(160deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.62)),
79 linear-gradient(135deg, rgba(198, 185, 255, 0.72), rgba(255, 217, 236, 0.78)); 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,7 +83,7 @@ function go(path: string) {
83 83
84 .hero-pill { 84 .hero-pill {
85 display: inline-flex; 85 display: inline-flex;
86 - padding: 8px 14px; 86 + padding: 6px 12px;
87 border-radius: 999px; 87 border-radius: 999px;
88 background: rgba(255, 255, 255, 0.76); 88 background: rgba(255, 255, 255, 0.76);
89 color: #6f5fe2; 89 color: #6f5fe2;
@@ -94,9 +94,9 @@ function go(path: string) { @@ -94,9 +94,9 @@ function go(path: string) {
94 } 94 }
95 95
96 .hero-copy h2 { 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 font-family: 'Outfit', sans-serif; 100 font-family: 'Outfit', sans-serif;
101 color: #2f2946; 101 color: #2f2946;
102 } 102 }
@@ -110,16 +110,16 @@ function go(path: string) { @@ -110,16 +110,16 @@ function go(path: string) {
110 110
111 .hero-grid { 111 .hero-grid {
112 display: flex; 112 display: flex;
113 - gap: 14px; 113 + gap: 12px;
114 flex-wrap: wrap; 114 flex-wrap: wrap;
115 - margin-top: 24px; 115 + margin-top: 18px;
116 } 116 }
117 117
118 .hero-metric { 118 .hero-metric {
119 min-width: 190px; 119 min-width: 190px;
120 - border-radius: 22px; 120 + border-radius: 18px;
121 background: rgba(255, 255, 255, 0.74); 121 background: rgba(255, 255, 255, 0.74);
122 - padding: 14px 16px; 122 + padding: 12px 14px;
123 } 123 }
124 124
125 .hero-metric span { 125 .hero-metric span {
@@ -141,7 +141,7 @@ function go(path: string) { @@ -141,7 +141,7 @@ function go(path: string) {
141 display: flex; 141 display: flex;
142 align-items: flex-end; 142 align-items: flex-end;
143 justify-content: center; 143 justify-content: center;
144 - min-height: 260px; 144 + min-height: 220px;
145 } 145 }
146 146
147 .hero-visual img { 147 .hero-visual img {
@@ -175,21 +175,21 @@ function go(path: string) { @@ -175,21 +175,21 @@ function go(path: string) {
175 .content-grid { 175 .content-grid {
176 display: grid; 176 display: grid;
177 grid-template-columns: 1.2fr 0.8fr; 177 grid-template-columns: 1.2fr 0.8fr;
178 - gap: 18px; 178 + gap: 14px;
179 } 179 }
180 180
181 .quick-links { 181 .quick-links {
182 display: grid; 182 display: grid;
183 grid-template-columns: repeat(2, minmax(0, 1fr)); 183 grid-template-columns: repeat(2, minmax(0, 1fr));
184 - gap: 14px; 184 + gap: 12px;
185 } 185 }
186 186
187 .quick-link { 187 .quick-link {
188 text-align: left; 188 text-align: left;
189 border: 1px solid rgba(202, 193, 240, 0.34); 189 border: 1px solid rgba(202, 193, 240, 0.34);
190 background: rgba(255, 255, 255, 0.78); 190 background: rgba(255, 255, 255, 0.78);
191 - border-radius: 24px;  
192 - padding: 18px; 191 + border-radius: 18px;
  192 + padding: 14px;
193 cursor: pointer; 193 cursor: pointer;
194 } 194 }
195 195
@@ -210,7 +210,7 @@ function go(path: string) { @@ -210,7 +210,7 @@ function go(path: string) {
210 } 210 }
211 211
212 .hero-copy h2 { 212 .hero-copy h2 {
213 - font-size: 28px; 213 + font-size: 24px;
214 } 214 }
215 } 215 }
216 </style> 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>