hookehuyr

fix(plan): 修复表单提交数据为空问题并添加金额格式化

修复问题:
- 修复表单提交时数据为空的问题(submit 中立即重置导致)
- 添加金额字段格式化显示(分 → 元),便于调试查看

代码改动:
- 移除 submit() 中的 resetForm() 调用,让父组件先处理数据
- 新增 formatAmounts() 函数转换金额单位(分 → 元)
- 优化日志输出:同时显示格式化数据(元)和原始数据(分)
- 表单重置逻辑统一由 close() 函数处理

文档新增:
- docs/接口联调注意事项.md - 完整的 API 联调指南
  - 数据单位规范(金额字段单位为"分")
  - 接口联调流程和注意事项
  - 金额转换工具函数
  - 常见问题和解决方案

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
...@@ -10,6 +10,7 @@ declare module 'vue' { ...@@ -10,6 +10,7 @@ declare module 'vue' {
10 AgePicker: typeof import('./src/components/PlanFields/AgePicker.vue')['default'] 10 AgePicker: typeof import('./src/components/PlanFields/AgePicker.vue')['default']
11 AgePickerGlobal: typeof import('./src/components/PlanFields/AgePickerGlobal.vue')['default'] 11 AgePickerGlobal: typeof import('./src/components/PlanFields/AgePickerGlobal.vue')['default']
12 AmountInput: typeof import('./src/components/PlanFields/AmountInput.vue')['default'] 12 AmountInput: typeof import('./src/components/PlanFields/AmountInput.vue')['default']
13 + AmountKeyboard: typeof import('./src/components/PlanFields/AmountKeyboard.vue')['default']
13 CriticalIllnessTemplate: typeof import('./src/components/PlanTemplates/CriticalIllnessTemplate.vue')['default'] 14 CriticalIllnessTemplate: typeof import('./src/components/PlanTemplates/CriticalIllnessTemplate.vue')['default']
14 DatePicker: typeof import('./src/components/PlanFields/DatePicker.vue')['default'] 15 DatePicker: typeof import('./src/components/PlanFields/DatePicker.vue')['default']
15 DatePickerGlobal: typeof import('./src/components/PlanFields/DatePickerGlobal.vue')['default'] 16 DatePickerGlobal: typeof import('./src/components/PlanFields/DatePickerGlobal.vue')['default']
...@@ -28,6 +29,7 @@ declare module 'vue' { ...@@ -28,6 +29,7 @@ declare module 'vue' {
28 NutDatePicker: typeof import('@nutui/nutui-taro')['DatePicker'] 29 NutDatePicker: typeof import('@nutui/nutui-taro')['DatePicker']
29 NutEmpty: typeof import('@nutui/nutui-taro')['Empty'] 30 NutEmpty: typeof import('@nutui/nutui-taro')['Empty']
30 NutInput: typeof import('@nutui/nutui-taro')['Input'] 31 NutInput: typeof import('@nutui/nutui-taro')['Input']
32 + NutNumberKeyboard: typeof import('@nutui/nutui-taro')['NumberKeyboard']
31 NutPicker: typeof import('@nutui/nutui-taro')['Picker'] 33 NutPicker: typeof import('@nutui/nutui-taro')['Picker']
32 NutPopup: typeof import('@nutui/nutui-taro')['Popup'] 34 NutPopup: typeof import('@nutui/nutui-taro')['Popup']
33 NutRadio: typeof import('@nutui/nutui-taro')['Radio'] 35 NutRadio: typeof import('@nutui/nutui-taro')['Radio']
...@@ -38,7 +40,7 @@ declare module 'vue' { ...@@ -38,7 +40,7 @@ declare module 'vue' {
38 PdfPreview: typeof import('./src/components/PdfPreview.vue')['default'] 40 PdfPreview: typeof import('./src/components/PdfPreview.vue')['default']
39 Picker: typeof import('./src/components/time-picker-data/picker.vue')['default'] 41 Picker: typeof import('./src/components/time-picker-data/picker.vue')['default']
40 PlanFormContainer: typeof import('./src/components/PlanFormContainer.vue')['default'] 42 PlanFormContainer: typeof import('./src/components/PlanFormContainer.vue')['default']
41 - PlanPopup: typeof import('./src/components/PlanPopup/index.vue')['default'] 43 + PlanPopup: typeof import('./src/components/PlanSchemes/PlanPopup.vue')['default']
42 PlanPopupNew: typeof import('./src/components/PlanPopupNew.vue')['default'] 44 PlanPopupNew: typeof import('./src/components/PlanPopupNew.vue')['default']
43 PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default'] 45 PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default']
44 ProductCard: typeof import('./src/components/ProductCard.vue')['default'] 46 ProductCard: typeof import('./src/components/ProductCard.vue')['default']
......
...@@ -13,6 +13,14 @@ export default { ...@@ -13,6 +13,14 @@ export default {
13 quiet: false, 13 quiet: false,
14 stats: true 14 stats: true
15 }, 15 },
16 - mini: {}, 16 + mini: {
17 - h5: {} 17 + // 开启 vConsole 调试工具
18 + debug: true
19 + },
20 + h5: {
21 + // H5 端也可以使用 eruda(另一种调试工具)
22 + devServer: {
23 + port: 10086
24 + }
25 + }
18 } 26 }
......
1 +# 接口联调注意事项
2 +
3 +> **文档用途**:记录计划书表单接口联调的关键注意事项和规范
4 +>
5 +> **最后更新**:2026-02-09
6 +> **维护者**:Claude Code
7 +
8 +## 📋 目录
9 +
10 +- [数据单位规范](#数据单位规范)
11 +- [接口联调流程](#接口联调流程)
12 +- [关键注意事项](#关键注意事项)
13 +- [调试技巧](#调试技巧)
14 +- [常见问题](#常见问题)
15 +
16 +---
17 +
18 +## 数据单位规范
19 +
20 +### ⚠️ 金额字段单位:分(非元)
21 +
22 +**重要**:所有金额字段内部存储单位都是 **分**,不是元!
23 +
24 +| 字段名 | 类型 | 单位 | 示例 | 显示 |
25 +|--------|------|------|------|------|
26 +| `coverage` | Number | 分 | `10000` | `100.00` 元 |
27 +| `premium` | Number | 分 | `5000` | `50.00` 元 |
28 +| `amount` | Number | 分 | `1000` | `10.00` 元 |
29 +| `total_amount` | Number | 分 | `15000` | `150.00` 元 |
30 +
31 +**转换公式**
32 +```javascript
33 +// 元 → 分(提交给后端)
34 +const cents = Math.round(yuan * 100)
35 +
36 +// 分 → 元(前端显示)
37 +const yuan = (cents / 100).toFixed(2)
38 +```
39 +
40 +**为什么使用"分"?**
41 +- ✅ 避免浮点数精度问题(`0.1 + 0.2 !== 0.3`
42 +- ✅ 整数运算精确可靠
43 +- ✅ 金融行业标准做法
44 +
45 +---
46 +
47 +## 接口联调流程
48 +
49 +### 1. 提交计划书表单
50 +
51 +**接口路径**`/srv/?a=submit_plan`(待确认)
52 +
53 +**请求参数**
54 +```javascript
55 +{
56 + product_id: 1, // 产品ID
57 + form_sn: 'life-insurance-wiop3e', // 表单模版标识
58 + form_data: { // 表单数据
59 + coverage: 10000, // 保额(分)← 注意单位
60 + gender: 'male',
61 + age: 30,
62 + birthday: '1994-01-01',
63 + smoker: false,
64 + payment_period: 20,
65 + // ... 其他字段
66 + }
67 +}
68 +```
69 +
70 +**响应格式**
71 +```javascript
72 +{
73 + code: 1, // 1 表示成功
74 + data: {
75 + plan_id: 123, // 计划书ID
76 + status: 'processing', // 状态:processing(生成中) | generated(已完成)
77 + download_url: '' // 下载链接(生成完成后才有)
78 + },
79 + msg: '提交成功'
80 +}
81 +```
82 +
83 +### 2. 查询计划书状态
84 +
85 +**接口路径**`/srv/?a=get_plan_status`
86 +
87 +**请求参数**
88 +```javascript
89 +{
90 + plan_id: 123 // 计划书ID
91 +}
92 +```
93 +
94 +**响应格式**
95 +```javascript
96 +{
97 + code: 1,
98 + data: {
99 + plan_id: 123,
100 + status: 'generated', // processing | generated
101 + download_url: 'https://...' // PDF 下载链接
102 + },
103 + msg: ''
104 +}
105 +```
106 +
107 +---
108 +
109 +## 关键注意事项
110 +
111 +### ⚠️ 1. 金额字段单位必须是"分"
112 +
113 +**错误示例**
114 +```javascript
115 +// ❌ 错误:直接发送"元"
116 +{
117 + coverage: 100.00 // 后端会解析错误或精度丢失
118 +}
119 +```
120 +
121 +**正确示例**
122 +```javascript
123 +// ✅ 正确:发送"分"
124 +{
125 + coverage: 10000 // 后端接收后再除以100
126 +}
127 +```
128 +
129 +### ⚠️ 2. 检查响应码
130 +
131 +**必须检查 `res.code === 1`**
132 +```javascript
133 +const res = await submitPlanAPI(params)
134 +
135 +if (res.code === 1) {
136 + // 成功
137 + console.log('提交成功:', res.data)
138 +} else {
139 + // 失败
140 + Taro.showToast({
141 + title: res.msg || '提交失败',
142 + icon: 'none'
143 + })
144 +}
145 +```
146 +
147 +### ⚠️ 3. 错误处理
148 +
149 +**所有 API 调用必须有 `try-catch`**
150 +```javascript
151 +try {
152 + const res = await submitPlanAPI(params)
153 +
154 + if (res.code === 1) {
155 + Taro.showToast({ title: '提交成功', icon: 'success' })
156 + } else {
157 + Taro.showToast({ title: res.msg || '提交失败', icon: 'none' })
158 + }
159 +} catch (err) {
160 + console.error('[SubmitPlan] 提交失败:', err)
161 + Taro.showToast({
162 + title: '网络异常,请重试',
163 + icon: 'none'
164 + })
165 +}
166 +```
167 +
168 +### ⚠️ 4. 加载状态
169 +
170 +**提交时显示 loading**
171 +```javascript
172 +const loading = ref(false)
173 +
174 +const submit = async () => {
175 + loading.value = true
176 +
177 + try {
178 + await submitPlanAPI(params)
179 + } finally {
180 + loading.value = false
181 + }
182 +}
183 +```
184 +
185 +---
186 +
187 +## 调试技巧
188 +
189 +### 1. 打印请求数据
190 +
191 +**提交前打印完整数据**(已实现):
192 +```javascript
193 +console.log('[PlanFormContainer] 提交计划书:', {
194 + product_id: props.product.id,
195 + product_name: props.product.product_name,
196 + form_sn: props.product.form_sn,
197 + form_data: formattedData // ← 格式化后的数据(元)
198 +})
199 +
200 +console.log('[PlanFormContainer] 原始数据(分):', formData.value)
201 +```
202 +
203 +**打印效果**
204 +```javascript
205 +// 格式化后(便于查看)
206 +form_data: {
207 + coverage: '100.00',
208 + gender: 'male',
209 + age: 30
210 +}
211 +
212 +// 原始数据(实际发送)
213 +form_data: {
214 + coverage: 10000,
215 + gender: 'male',
216 + age: 30
217 +}
218 +```
219 +
220 +### 2. 网络请求拦截器
221 +
222 +**检查 `src/utils/request.js` 中的拦截器配置**
223 +- ✅ 请求拦截器已自动注入 sessionid
224 +- ✅ 响应拦截器已处理 401 自动刷新
225 +- ✅ 超时配置:5 秒
226 +
227 +**查看请求日志**
228 +```javascript
229 +// request.js 中的日志
230 +console.log('[Request] URL:', url)
231 +console.log('[Request] Data:', params)
232 +console.log('[Response] Data:', res)
233 +```
234 +
235 +### 3. 开发者工具
236 +
237 +**微信开发者工具**
238 +1. 点击"调试器" → "Network"
239 +2. 找到对应的请求(`submit_plan`
240 +3. 查看"Headers"和"Payload"
241 +4. 确认金额字段是整数(分)
242 +
243 +---
244 +
245 +## 常见问题
246 +
247 +### Q1: 后端接收到的金额是 `0.00`?
248 +
249 +**原因**:前端发送的值是 `100.00`(浮点数),但后端期望的是整数(分)。
250 +
251 +**解决方案**:确保发送的是整数(分):
252 +```javascript
253 +// ❌ 错误
254 +form_data: {
255 + coverage: 100.00 // 浮点数
256 +}
257 +
258 +// ✅ 正确
259 +form_data: {
260 + coverage: 10000 // 整数
261 +}
262 +```
263 +
264 +### Q2: 后端返回的金额如何显示?
265 +
266 +**后端返回的金额单位应该是"分"**
267 +```javascript
268 +// 后端返回
269 +{
270 + code: 1,
271 + data: {
272 + coverage: 10000 // 分
273 + }
274 +}
275 +
276 +// 前端显示
277 +const displayYuan = (res.data.coverage / 100).toFixed(2)
278 +// displayYuan = '100.00'
279 +```
280 +
281 +### Q3: 如何验证金额是否正确?
282 +
283 +**验证方法**
284 +```javascript
285 +// 输入 100.00 元
286 +console.log('输入值:', '100.00')
287 +
288 +// 存储的值(分)
289 +console.log('存储值:', 10000)
290 +
291 +// 显示的值(元)
292 +console.log('显示值:', (10000 / 100).toFixed(2)) // '100.00'
293 +```
294 +
295 +---
296 +
297 +## 接口联调清单
298 +
299 +### 提交前检查
300 +
301 +- [ ] 后端接口地址已配置(`src/utils/config.js`
302 +- [ ] 请求路径正确(`/srv/?a=xxx`
303 +- [ ] 请求参数格式已确认
304 +- [ ] 响应格式已确认(`{ code, data, msg }`
305 +- [ ] 金额字段单位已确认(分)
306 +
307 +### 提交时验证
308 +
309 +- [ ] 金额字段是整数(分)
310 +- [ ] 检查 `res.code === 1`
311 +- [ ] 错误提示用户友好
312 +- [ ] Loading 状态正确显示
313 +
314 +### 提交后处理
315 +
316 +- [ ] 成功后跳转到计划书列表页
317 +- [ ] 失败后停留在表单页
318 +- [ ] 网络异常有重试机制
319 +- [ ] 轮询状态(如果是生成中的计划书)
320 +
321 +---
322 +
323 +## 接口定义示例
324 +
325 +### 1. 提交计划书
326 +
327 +**定义位置**`src/api/index.js`
328 +
329 +```javascript
330 +/**
331 + * 提交计划书表单
332 + *
333 + * @param {Object} params - 请求参数
334 + * @param {number} params.product_id - 产品ID
335 + * @param {string} params.form_sn - 表单模版标识
336 + * @param {Object} params.form_data - 表单数据
337 + * @returns {Promise<{code: number, data: Object, msg: string}>}
338 + *
339 + * @example
340 + * const res = await submitPlanAPI({
341 + * product_id: 1,
342 + * form_sn: 'life-insurance-wiop3e',
343 + * form_data: {
344 + * coverage: 10000, // 保额(分)
345 + * gender: 'male',
346 + * age: 30
347 + * }
348 + * })
349 + */
350 +export const submitPlanAPI = (params) => {
351 + return buildApiUrl('submit_plan', params)
352 +}
353 +```
354 +
355 +### 2. 查询计划书状态
356 +
357 +```javascript
358 +/**
359 + * 查询计划书生成状态
360 + *
361 + * @param {Object} params - 请求参数
362 + * @param {number} params.plan_id - 计划书ID
363 + * @returns {Promise<{code: number, data: Object, msg: string}>}
364 + */
365 +export const getPlanStatusAPI = (params) => {
366 + return buildApiUrl('get_plan_status', params)
367 +}
368 +```
369 +
370 +---
371 +
372 +## 快速参考
373 +
374 +### 金额转换工具函数
375 +
376 +**位置**`src/utils/amount.js`(建议创建)
377 +
378 +```javascript
379 +/**
380 + * 元转分
381 + * @param {number|string} yuan - 元
382 + * @returns {number} 分
383 + */
384 +export const yuanToCents = (yuan) => {
385 + return Math.round(Number(yuan) * 100)
386 +}
387 +
388 +/**
389 + * 分转元
390 + * @param {number} cents - 分
391 + * @returns {string} 元(带2位小数)
392 + */
393 +export const centsToYuan = (cents) => {
394 + return (cents / 100).toFixed(2)
395 +}
396 +
397 +/**
398 + * 格式化金额显示
399 + * @param {number} cents - 分
400 + * @returns {string} 格式化后的金额(如:"10,000.00")
401 + */
402 +export const formatAmount = (cents) => {
403 + const yuan = (cents / 100).toFixed(2)
404 + const parts = yuan.split('.')
405 + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')
406 + return parts.join('.')
407 +}
408 +```
409 +
410 +### 使用示例
411 +
412 +```javascript
413 +import { yuanToCents, centsToYuan, formatAmount } from '@/utils/amount'
414 +
415 +// 用户输入
416 +const input = '100.00'
417 +const cents = yuanToCents(input) // 10000
418 +
419 +// 存储
420 +formData.value.coverage = cents
421 +
422 +// 显示
423 +const display = formatAmount(cents) // '10,000.00' 或 '100.00'
424 +```
425 +
426 +---
427 +
428 +## 维护日志
429 +
430 +- **2026-02-09**:创建文档,记录金额字段单位规范和联调注意事项
431 +
432 +---
433 +
434 +## 相关文档
435 +
436 +- [项目 CLAUDE.md](../CLAUDE.md) - 项目开发规范
437 +- [API 集成日志](../api-integration-log.md) - API 联调记录
438 +- [变更日志](../CHANGELOG.md) - 版本更新历史
...@@ -57,4 +57,6 @@ export default { ...@@ -57,4 +57,6 @@ export default {
57 navigationBarTitleText: '西园寺预约', 57 navigationBarTitleText: '西园寺预约',
58 navigationBarTextStyle: 'black', 58 navigationBarTextStyle: 'black',
59 }, 59 },
60 + // 开发环境开启调试模式
61 + ...(process.env.NODE_ENV === 'development' ? { debug: true } : {}),
60 } 62 }
......
...@@ -217,8 +217,35 @@ const close = async () => { ...@@ -217,8 +217,35 @@ const close = async () => {
217 } 217 }
218 218
219 /** 219 /**
220 + * 格式化金额数据(分 → 元)
221 + * @description 将金额从"分"转换为"元"(带2位小数),便于调试和查看
222 + * @param {Object} data - 表单数据
223 + * @returns {Object} 格式化后的表单数据
224 + * @example
225 + * formatAmounts({ coverage: 10000 }) // 返回 { coverage: '100.00' }
226 + */
227 +const formatAmounts = (data) => {
228 + const formatted = { ...data }
229 +
230 + // 金额字段列表(单位:分)
231 + const amountFields = ['coverage', 'premium', 'amount', 'total_amount']
232 +
233 + for (const field of amountFields) {
234 + if (formatted[field] !== null && formatted[field] !== undefined) {
235 + // 分 → 元,保留2位小数
236 + formatted[field] = (formatted[field] / 100).toFixed(2)
237 + }
238 + }
239 +
240 + return formatted
241 +}
242 +
243 +/**
220 * 提交表单 244 * 提交表单
221 * @description 将表单数据和产品信息一起提交 245 * @description 将表单数据和产品信息一起提交
246 + *
247 + * ⚠️ 注意:接口未完成,当前仅为前端测试
248 + * TODO: 后端接口准备就绪后,需要调用 submitPlanAPI 提交表单数据
222 */ 249 */
223 const submit = async () => { 250 const submit = async () => {
224 if (!props.product) { 251 if (!props.product) {
...@@ -234,25 +261,27 @@ const submit = async () => { ...@@ -234,25 +261,27 @@ const submit = async () => {
234 } 261 }
235 } 262 }
236 263
264 + // 格式化金额数据(便于调试查看)
265 + const formattedData = formatAmounts(formData.value)
266 +
237 console.log('[PlanFormContainer] 提交计划书:', { 267 console.log('[PlanFormContainer] 提交计划书:', {
238 product_id: props.product.id, 268 product_id: props.product.id,
239 product_name: props.product.product_name, 269 product_name: props.product.product_name,
240 form_sn: props.product.form_sn, 270 form_sn: props.product.form_sn,
241 - form_data: formData.value 271 + form_data: formattedData // ← 打印格式化后的数据(元)
242 }) 272 })
243 273
244 - // 发送提交事件 274 + console.log('[PlanFormContainer] 原始数据(分):', formData.value)
275 +
276 + // 发送提交事件(携带原始表单数据给父组件,单位:分)
245 emit('submit', { 277 emit('submit', {
246 product_id: props.product.id, 278 product_id: props.product.id,
247 form_sn: props.product.form_sn, 279 form_sn: props.product.form_sn,
248 - form_data: formData.value 280 + form_data: formData.value // ← 发送原始数据(分),接口需要
249 }) 281 })
250 282
251 - // ⚠️ 等待父组件处理提交事件(可能需要关闭弹窗) 283 + // ✅ 不在这里重置表单,让父组件先处理数据
252 - await nextTick() 284 + // 重置逻辑交给 close() 函数处理(关闭弹窗时自动清空)
253 -
254 - // 提交成功后重置表单,避免下次打开时保留旧数据
255 - resetForm()
256 } 285 }
257 286
258 /** 287 /**
......