xsh_1997 vor 1 Woche
Ursprung
Commit
2939989c0e

+ 166 - 0
doc/店铺后台/配送设置/配送设置前端技术方案.md

@@ -0,0 +1,166 @@
1
+# 配送设置 — 前端技术方案
2
+
3
+> **依据:** 《配送设置功能需求.md》v1.1、《配送设置技术方案.md》v1.3  
4
+> **前端规范:** `doc/前端设计/前端设计.md`  
5
+> **范围:** 仅 **ruoyi-ui 商家端** 当前店铺 **发货信息** 查询与保存(Upsert);**不含** 运费模版、订单发货、平台维护。  
6
+> **实现状态:** 页面与 API 封装已落地,待菜单配置及 `/agri/seller/delivery` 联调。
7
+
8
+---
9
+
10
+## 1. 技术栈与写法约定
11
+
12
+| 项 | 说明 |
13
+|----|------|
14
+| 框架 | Vue 2 + Element UI |
15
+| 请求 | `@/utils/request` + `sellerShopHeaders()` 携带 **`X-Shop-Id`** |
16
+| 参考页面 | `agri/seller/shop/index.vue`(单店设置、上下文加载) |
17
+| 省市区 | 复用 `mixins/regionCascader.js` + `GET /agri/region/tree`(经 `getRegionCascaderTree` 规范化) |
18
+| 布局 | 单页 **`el-card` + 表单**(无检索区、无列表) |
19
+| 店铺切换 | **仅 Navbar**;业务页禁止展示店铺选择器 |
20
+
21
+---
22
+
23
+## 2. 文件清单
24
+
25
+| 类型 | 路径 | 说明 |
26
+|------|------|------|
27
+| 设置页 | `ruoyi-ui/src/views/agri/seller/delivery/index.vue` | 发货信息表单、保存 |
28
+| 配送 API | `ruoyi-ui/src/api/agri/seller/delivery.js` | GET / PUT |
29
+| 省市区 | `api/agri/region.js` + `utils/region.js` + `mixins/regionCascader.js` | 级联选择与落库 |
30
+| 店铺上下文 | `api/agri/seller/context.js` + `utils/sellerShop.js` | X-Shop-Id |
31
+
32
+**组件 name(keep-alive):** `AgriSellerDelivery`
33
+
34
+---
35
+
36
+## 3. 菜单与路由
37
+
38
+| 菜单名称 | 组件路径 | 路由 path(建议) | 权限标识 |
39
+|----------|----------|-------------------|----------|
40
+| 配送设置 | `agri/seller/delivery/index` | `seller/delivery` | `agri:seller:delivery:query` |
41
+
42
+**上级菜单:** 店铺经营管理端 → **物流相关**(与 **运费模版** 并列)
43
+
44
+| 按钮权限 | 标识 | 说明 |
45
+|----------|------|------|
46
+| 查询 | `agri:seller:delivery:query` | 进入页面、GET 回显 |
47
+| 保存 | `agri:seller:delivery:edit` | 「保存」按钮与 PUT |
48
+
49
+---
50
+
51
+## 4. 页面结构
52
+
53
+```text
54
+配送设置(每店一份 · 单页表单)
55
+└── 发货信息设置(el-card)
56
+    ├── 引导提示(未配置时 el-alert)
57
+    ├── 发货人 shipperName *
58
+    ├── 发货人联系方式 shipperMobile *(11 位手机)
59
+    ├── 发货地区 shipRegionCascader *(省/市/区县级联 → region.regionCode)
60
+    ├── 发货详细地址 detailAddress *
61
+    ├── 最近更新 updateTime(只读,有数据时展示)
62
+    └── 保存(Upsert,无删除)
63
+```
64
+
65
+**不提供:** 列表、删除、多仓库、页内店铺选择、运费规则表单。
66
+
67
+---
68
+
69
+## 5. 店铺上下文(X-Shop-Id)
70
+
71
+| 步骤 | 说明 |
72
+|------|------|
73
+| `created` | `GET /agri/seller/context` → `setSellerShopContext` |
74
+| GET / PUT | `sellerShopHeaders()` |
75
+| Navbar 切店 | `location.reload()` 整页刷新 |
76
+
77
+---
78
+
79
+## 6. API 封装
80
+
81
+**模块:** `ruoyi-ui/src/api/agri/seller/delivery.js`  
82
+**Base URL:** `/agri/seller/delivery`
83
+
84
+| 前端方法 | HTTP | 路径 | 用途 |
85
+|----------|------|------|------|
86
+| `getSellerDeliverySetting()` | GET | `/` | 查询当前店设置;无数据时 `data` 为 `null` |
87
+| `saveSellerDeliverySetting(data)` | PUT | `/` | 新增或覆盖保存 |
88
+
89
+### 6.1 查询响应(ShopDeliverySettingVO)
90
+
91
+| 字段 | 说明 |
92
+|------|------|
93
+| settingId | 有值表示已配置 |
94
+| shipperName / shipperMobile | 发货人信息 |
95
+| region.regionCode | 区县级 `biz_region.code`;用于级联回显 |
96
+| region.provinceName / cityName / districtName | 名称快照(展示用) |
97
+| detailAddress | 详细地址 |
98
+| updateTime | 最近更新时间 |
99
+
100
+### 6.2 保存 Body(ShopDeliverySaveDTO)
101
+
102
+```json
103
+{
104
+  "shipperName": "张三",
105
+  "shipperMobile": "13800138000",
106
+  "region": { "regionCode": "540102" },
107
+  "detailAddress": "某某街道 1 号"
108
+}
109
+```
110
+
111
+| 字段 | 前端校验 |
112
+|------|----------|
113
+| shipperName | 必填;≤64 |
114
+| shipperMobile | 必填;`/^1[3-9]\d{9}$/` |
115
+| region.regionCode | 级联须选至 **区县**(`parseRegionSelection`) |
116
+| detailAddress | 必填;≤256 |
117
+
118
+> 名称快照(`region_name` / `city_name`)由 **后端** 据 `regionCode` 写入;前端 **仅传 regionCode**。
119
+
120
+---
121
+
122
+## 7. 省市区级联
123
+
124
+| 项 | 说明 |
125
+|----|------|
126
+| 组件 | `el-cascader` + `REGION_CASCADER_PROPS`(`value: code`) |
127
+| 表单字段 | `shipRegionCascader`(路径)、`shipRegionCode`(落库)、`shipRegionName`(本地展示,不传后端) |
128
+| 变更 | `@change` → `applyRegionSelection(codes, 'ship')` |
129
+| 回显 | GET 后 `region.regionCode` → `syncRegionCascaderFromForm` |
130
+| 约束 | 仅 **type=3 区县** 可选;与商户注册地址同一套 normalize 逻辑 |
131
+
132
+---
133
+
134
+## 8. 交互要点
135
+
136
+| 场景 | 行为 |
137
+|------|------|
138
+| 首次进入 | `data=null` → 空表单 + 蓝色引导文案 |
139
+| 已有配置 | 全字段回显 + 展示 `updateTime` |
140
+| 保存成功 | Toast「保存成功」→ 重新 GET 刷新 |
141
+| 校验失败 | 字段级提示;不提交 |
142
+| 无编辑权限 | 隐藏「保存」按钮(`v-hasPermi`) |
143
+| 与运费模版 | **独立菜单/页面**;本页不含运费字段 |
144
+
145
+---
146
+
147
+## 9. 联调检查清单
148
+
149
+- [ ] 菜单组件路径 `agri/seller/delivery/index` 及按钮权限
150
+- [ ] Navbar 切换店铺后表单加载对应店数据
151
+- [ ] 首次 PUT 创建;再次 PUT 覆盖
152
+- [ ] 省市区未选全时前端拦截
153
+- [ ] 保存后 C 端商品详情 `logistics.shipCity` 有值(后端 Facade)
154
+- [ ] 未选店铺时后端返回「请先选择当前店铺」
155
+
156
+---
157
+
158
+## 10. 修订记录
159
+
160
+| 版本 | 说明 |
161
+|------|------|
162
+| v1.0 | 首版:单页发货信息表单 + 省市区级联 + Upsert API |
163
+
164
+---
165
+
166
+*文档版本:v1.0 · 关联《配送设置功能需求.md》v1.1、《配送设置技术方案.md》v1.3*

+ 21 - 0
ruoyi-ui/src/api/agri/seller/delivery.js

@@ -0,0 +1,21 @@
1
+import request from '@/utils/request'
2
+import { sellerShopHeaders } from '@/utils/sellerShop'
3
+
4
+// 查询当前店铺配送设置
5
+export function getSellerDeliverySetting() {
6
+  return request({
7
+    url: '/agri/seller/delivery',
8
+    method: 'get',
9
+    headers: sellerShopHeaders()
10
+  })
11
+}
12
+
13
+// 保存当前店铺配送设置(Upsert)
14
+export function saveSellerDeliverySetting(data) {
15
+  return request({
16
+    url: '/agri/seller/delivery',
17
+    method: 'put',
18
+    data: data,
19
+    headers: sellerShopHeaders()
20
+  })
21
+}

+ 222 - 0
ruoyi-ui/src/views/agri/seller/delivery/index.vue

@@ -0,0 +1,222 @@
1
+<template>
2
+  <div class="app-container">
3
+    <el-card shadow="never" class="setting-card" v-loading="loading">
4
+      <div slot="header" class="card-header">
5
+        <span class="card-title">发货信息设置</span>
6
+      </div>
7
+
8
+      <el-alert
9
+        v-if="!hasSetting"
10
+        title="请完善发货信息,便于买家在商品详情了解发货地(展示发货城市)。"
11
+        type="info"
12
+        :closable="false"
13
+        show-icon
14
+        class="tip-alert"
15
+      />
16
+
17
+      <el-form ref="form" :model="form" :rules="rules" label-width="130px" class="delivery-form">
18
+        <el-form-item label="发货人" prop="shipperName">
19
+          <el-input v-model="form.shipperName" placeholder="请输入发货人姓名" maxlength="64" show-word-limit style="max-width: 480px" />
20
+        </el-form-item>
21
+        <el-form-item label="发货人联系方式" prop="shipperMobile">
22
+          <el-input v-model="form.shipperMobile" placeholder="请输入11位手机号" maxlength="11" style="max-width: 480px" />
23
+        </el-form-item>
24
+        <el-form-item label="发货地区" prop="shipRegionCascader">
25
+          <el-cascader
26
+            v-model="form.shipRegionCascader"
27
+            :options="regionTree"
28
+            :props="regionCascaderProps"
29
+            clearable
30
+            filterable
31
+            placeholder="请选择省 / 市 / 区县"
32
+            style="width: 480px"
33
+            @change="handleShipRegionChange"
34
+          />
35
+        </el-form-item>
36
+        <el-form-item label="发货详细地址" prop="detailAddress">
37
+          <el-input
38
+            v-model="form.detailAddress"
39
+            type="textarea"
40
+            :rows="3"
41
+            placeholder="请输入街道、门牌号等详细地址"
42
+            maxlength="256"
43
+            show-word-limit
44
+            style="max-width: 640px"
45
+          />
46
+        </el-form-item>
47
+        <el-form-item v-if="updateTime" label="最近更新">
48
+          <span class="update-time">{{ parseTime(updateTime) }}</span>
49
+        </el-form-item>
50
+        <el-form-item>
51
+          <el-button type="primary" @click="submitForm" v-hasPermi="['agri:seller:delivery:edit']">保 存</el-button>
52
+        </el-form-item>
53
+      </el-form>
54
+    </el-card>
55
+  </div>
56
+</template>
57
+
58
+<script>
59
+import { getSellerDeliverySetting, saveSellerDeliverySetting } from "@/api/agri/seller/delivery"
60
+import { getSellerContext } from "@/api/agri/seller/context"
61
+import { setSellerShopContext } from "@/utils/sellerShop"
62
+import regionCascaderMixin from "@/mixins/regionCascader"
63
+
64
+export default {
65
+  name: "AgriSellerDelivery",
66
+  mixins: [regionCascaderMixin],
67
+  data() {
68
+    const validateMobile = (rule, value, callback) => {
69
+      const text = value ? String(value).trim() : ""
70
+      if (!text) {
71
+        callback(new Error("请填写发货人联系方式"))
72
+        return
73
+      }
74
+      if (!/^1[3-9]\d{9}$/.test(text)) {
75
+        callback(new Error("请填写正确的发货人联系方式"))
76
+        return
77
+      }
78
+      callback()
79
+    }
80
+    const validateRegion = (rule, value, callback) => {
81
+      if (!this.form.shipRegionCode) {
82
+        callback(new Error("请选择完整的发货地区"))
83
+        return
84
+      }
85
+      callback()
86
+    }
87
+    return {
88
+      loading: false,
89
+      hasSetting: false,
90
+      updateTime: null,
91
+      form: {
92
+        shipperName: "",
93
+        shipperMobile: "",
94
+        shipRegionCascader: [],
95
+        shipRegionCode: undefined,
96
+        shipRegionName: undefined,
97
+        detailAddress: ""
98
+      },
99
+      rules: {
100
+        shipperName: [{ required: true, message: "请填写发货人", trigger: "blur" }],
101
+        shipperMobile: [{ validator: validateMobile, trigger: "blur" }],
102
+        shipRegionCascader: [{ validator: validateRegion, trigger: "change" }],
103
+        detailAddress: [{ required: true, message: "请填写发货详细地址", trigger: "blur" }]
104
+      }
105
+    }
106
+  },
107
+  created() {
108
+    this.initPage()
109
+  },
110
+  methods: {
111
+    initPage() {
112
+      this.loadShopContext().then(() => {
113
+        this.loadSetting()
114
+      }).catch(() => {
115
+        this.loadSetting()
116
+      })
117
+    },
118
+    loadShopContext() {
119
+      return getSellerContext().then(response => {
120
+        const data = response.data || {}
121
+        if (data.shopId != null) {
122
+          setSellerShopContext(data.shopId, data.shopName)
123
+        }
124
+      })
125
+    },
126
+    loadSetting() {
127
+      this.loading = true
128
+      Promise.all([this.loadRegionTree(), getSellerDeliverySetting()]).then(([, response]) => {
129
+        const data = response.data
130
+        this.applySettingData(data)
131
+        this.loading = false
132
+      }).catch(() => {
133
+        this.loading = false
134
+      })
135
+    },
136
+    applySettingData(data) {
137
+      if (!data || !data.settingId) {
138
+        this.hasSetting = false
139
+        this.updateTime = null
140
+        this.resetFormData()
141
+        return
142
+      }
143
+      this.hasSetting = true
144
+      this.updateTime = data.updateTime
145
+      this.form = {
146
+        shipperName: data.shipperName || "",
147
+        shipperMobile: data.shipperMobile || "",
148
+        shipRegionCascader: [],
149
+        shipRegionCode: data.region ? data.region.regionCode : undefined,
150
+        shipRegionName: this.formatRegionName(data.region),
151
+        detailAddress: data.detailAddress || ""
152
+      }
153
+      this.syncRegionCascaderFromForm([
154
+        { codeKey: "shipRegionCode", cascaderKey: "shipRegionCascader" }
155
+      ])
156
+    },
157
+    formatRegionName(region) {
158
+      if (!region) {
159
+        return undefined
160
+      }
161
+      const parts = [region.provinceName, region.cityName, region.districtName].filter(Boolean)
162
+      return parts.length ? parts.join("/") : undefined
163
+    },
164
+    handleShipRegionChange(codes) {
165
+      this.applyRegionSelection(codes, "ship")
166
+      this.$refs.form && this.$refs.form.validateField("shipRegionCascader")
167
+    },
168
+    resetFormData() {
169
+      this.form = {
170
+        shipperName: "",
171
+        shipperMobile: "",
172
+        shipRegionCascader: [],
173
+        shipRegionCode: undefined,
174
+        shipRegionName: undefined,
175
+        detailAddress: ""
176
+      }
177
+      this.resetForm("form")
178
+    },
179
+    submitForm() {
180
+      this.$refs.form.validate(valid => {
181
+        if (!valid) {
182
+          return
183
+        }
184
+        const payload = {
185
+          shipperName: (this.form.shipperName || "").trim(),
186
+          shipperMobile: (this.form.shipperMobile || "").trim(),
187
+          region: {
188
+            regionCode: this.form.shipRegionCode
189
+          },
190
+          detailAddress: (this.form.detailAddress || "").trim()
191
+        }
192
+        saveSellerDeliverySetting(payload).then(() => {
193
+          this.$modal.msgSuccess("保存成功")
194
+          this.loadSetting()
195
+        })
196
+      })
197
+    }
198
+  }
199
+}
200
+</script>
201
+
202
+<style scoped lang="scss">
203
+.card-header {
204
+  display: flex;
205
+  align-items: center;
206
+  justify-content: space-between;
207
+}
208
+.card-title {
209
+  font-weight: 600;
210
+  font-size: 15px;
211
+}
212
+.tip-alert {
213
+  margin-bottom: 20px;
214
+}
215
+.delivery-form {
216
+  max-width: 820px;
217
+}
218
+.update-time {
219
+  color: #909399;
220
+  font-size: 13px;
221
+}
222
+</style>