24 KiB
个人开户确认二次确认弹窗 + 邀请扫码办理 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 在"新增个人开户申请"页面(PersonalOpenForm.vue)的"确认"按钮点击后新增二次确认弹窗,弹窗提供"保存"(等同现有提交逻辑)/"取消"(仅关闭弹窗)/"邀请扫码办理"(提交后在弹窗内展示携带 applicationNo 的二维码)三个选项。
Architecture: 不改动接口契约,仍复用现有 createPersonalOpenDraftApi(/api/wallet-management/personal-opening/create-draft)。把原 handleSubmit 拆成 validateBeforeSubmit(前置校验)/buildSubmitPayload(公共 payload 构造)/submitCreateDraft(纯提交,返回 applicationNo)三个可复用函数,新增确认弹窗的两态 UI(confirm/qrcode)与对应状态、事件处理函数,二维码用新增依赖 qrcode.vue 渲染,二维码 URL 域名/路径通过新增环境变量配置。
Tech Stack: Vue 3 <script setup> + Ant Design Vue 4 (a-modal/a-space/a-button) + qrcode.vue(新增依赖) + Vite 环境变量。
Global Constraints
- 项目为纯 JavaScript(无 TypeScript),不引入任何
.ts文件。 - 不写测试用例、不写 CHANGELOG(项目 AGENTS.md 明确要求),因此本计划中"验证"步骤统一使用
npm run build做语法/引用检查,以及手动通过npm run dev走一遍页面交互流程,不是传统单测 TDD 循环——这是对 writing-plans 默认 TDD 步骤模板的必要调整,原因是本项目未配置任何测试框架/脚本。 - 日期时间字段格式、trace header 等约定与本次改动无关,不涉及。
- 仅修改
src/views/wallet/personal-open/PersonalOpenForm.vue、.env.development、.env.production、package.json/package-lock.json(新增依赖),不改动其他文件。 isEdit(编辑)与isDetail(查看)模式的现有行为必须保持不变,只有isCreate(新增)模式下"确认"按钮的行为发生变化。
Task 1: 新增 qrcode.vue 依赖与二维码 H5 地址环境变量
Files:
- Modify:
package.json(新增 dependency,通过npm install自动写入) - Modify:
package-lock.json(npm install自动更新) - Modify:
.env.development - Modify:
.env.production
Interfaces:
-
Consumes: 无
-
Produces:
qrcode.vue包在<script setup>中可通过import QrcodeVue from 'qrcode.vue'引入,模板内用<qrcode-vue :value="..." :size="200" />渲染;import.meta.env.VITE_H5_QRCODE_BASE_URL/import.meta.env.VITE_H5_QRCODE_PATH可在任意前端代码中读取。 -
Step 1: 安装 qrcode.vue 依赖
Run: npm install qrcode.vue@3.10.0
Expected: 命令成功退出(exit code 0),package.json 的 dependencies 中出现 "qrcode.vue": "^3.10.0",package-lock.json 有对应更新。
- Step 2: 校验依赖已正确写入 package.json
Run: Select-String -Path package.json -Pattern "qrcode.vue"
Expected: 输出包含一行 "qrcode.vue": "^3.10.0",(或 npm 实际写入的版本范围)。
- Step 3: 在
.env.development末尾新增两行环境变量
现有 .env.development 内容:
VITE_API_BASE_URL=http://localhost:8888
改为:
VITE_API_BASE_URL=http://localhost:8888
VITE_H5_QRCODE_BASE_URL=
VITE_H5_QRCODE_PATH=/mobile/personal-open
- Step 4: 在
.env.production末尾新增两行环境变量
现有 .env.production 内容:
VITE_API_BASE_URL=
改为:
VITE_API_BASE_URL=
VITE_H5_QRCODE_BASE_URL=
VITE_H5_QRCODE_PATH=/mobile/personal-open
- Step 5: 提交
git add package.json package-lock.json .env.development .env.production
git commit -m "chore: 新增qrcode.vue依赖及个人开户扫码H5地址环境变量"
Task 2: 拆分 PersonalOpenForm.vue 的提交逻辑为可复用函数
Files:
- Modify:
src/views/wallet/personal-open/PersonalOpenForm.vue:440-519(删除原handleSubmit,替换为下列新函数) - Modify:
src/views/wallet/personal-open/PersonalOpenForm.vue:32-37(编辑模式"保存"按钮改绑handleEditSubmit)
Interfaces:
-
Consumes: 组件内已有的
form(reactive)、channelNo/agreementChecked/idCardFrontFileNo/idCardBackFileNo/facePhotoFileNo/idCardFrontUploading/idCardBackUploading/facePhotoUploading/identityMismatch/formRef/submitting/submittedApplicationNo(均为既有 ref/reactive,不改变其定义)、createPersonalOpenDraftApi/updatePersonalOpenApplicationApi(既有 import)。 -
Produces: 供 Task 3/Task 4 使用的函数签名:
async function validateBeforeSubmit(): Promise<boolean>—— 校验全部通过返回true,否则弹出对应错误提示并返回false。function buildSubmitPayload(): { payload: object, fileList: Array<{fileNo: string, fileType: string}> }async function submitCreateDraft(): Promise<string|null>—— 调用create-draft,成功时把res.data.data.applicationNo写入submittedApplicationNo.value并返回该值,失败(code !== 200)返回null。async function handleEditSubmit(): Promise<void>—— 编辑模式"保存"按钮绑定的函数,行为等价于原handleSubmit的else分支。
-
Step 1: 用 Edit 工具删除原
handleSubmit整个函数(第 440-519 行),替换为下列 4 个函数
原内容(440-519 行,将被完整替换):
async function handleSubmit() {
if (isCreate.value && !agreementChecked.value) {
showErrorModal('请先阅读并勾选同意用户支付服务协议与隐私政策')
return
}
if (!channelNo.value) {
showErrorModal('请先选择渠道归属')
return
}
if (!form.customerId) {
showErrorModal('请先选择客户')
return
}
if (idCardFrontUploading.value || idCardBackUploading.value || facePhotoUploading.value) {
showErrorModal('证件照片正在上传中,请稍候')
return
}
// 编辑模式下若未重新上传,保留 loadDetail 回填的原 fileNo 即可提交,故只在创建时强制校验必须已上传
if (isCreate.value && (!idCardFrontFileNo.value || !idCardBackFileNo.value || !facePhotoFileNo.value)) {
showErrorModal('请上传身份证人像面、国徽面和正面免冠照')
return
}
if (identityMismatch.value) {
showErrorModal('身份证信息与所选客户信息不一致,请核实后重新上传身份证或重新选择客户')
return
}
try {
await formRef.value.validate()
} catch {
return
}
submitting.value = true
try {
const fileList = [
{ fileNo: idCardFrontFileNo.value, fileType: '01' },
{ fileNo: idCardBackFileNo.value, fileType: '02' },
{ fileNo: facePhotoFileNo.value, fileType: '04' }
]
const payload = {
accountProperty: '1',
channelNo: channelNo.value,
customerId: form.customerId,
customerName: form.customerName,
certificateType: form.certificateType,
certificateNumber: form.certificateNumber,
certificateEffectiveDate: form.certificateEffectiveDate,
certificateExpiryDate: form.certificateExpiryDate,
issuingAuthority: form.issuingAuthority,
certificateAddress: form.certificateAddress,
gender: form.gender,
ethnic: form.ethnic,
occupation: form.occupationType,
jobNote: form.occupationType === 'OTHER' ? form.jobNote : undefined,
mobilePhone: form.mobilePhone,
bankCardNumber: form.bankCardNumber
}
if (isCreate.value) {
const res = await createPersonalOpenDraftApi({ ...payload, fileList })
if (res.data.code === 200) {
submittedApplicationNo.value = res.data.data.applicationNo
message.success('申请已提交')
}
} else {
// 已知缺口:personal-opening/update 接口尚未提供,前端按 applicationNo 作为定位字段假设实现,
// 详见《缺失的后端接口.md》Part 2 新增条目;证件照片参数名与 create-draft 不同,后端要求为
// accountOpenApplicationFileBtoList
const res = await updatePersonalOpenApplicationApi({
applicationNo: route.query.applicationNo,
...payload,
accountOpenApplicationFileBtoList: fileList
})
if (res.data.code === 200) {
message.success('修改成功')
goBack()
}
}
} finally {
submitting.value = false
}
}
新内容(替换为):
// 提交前的全部前置校验:协议勾选/渠道/客户/证件照片上传状态/身份证信息一致性/表单必填项。
// 供"确认"按钮预检(create 模式,通过后弹二次确认弹窗)与编辑模式"保存"按钮共用。
async function validateBeforeSubmit() {
if (isCreate.value && !agreementChecked.value) {
showErrorModal('请先阅读并勾选同意用户支付服务协议与隐私政策')
return false
}
if (!channelNo.value) {
showErrorModal('请先选择渠道归属')
return false
}
if (!form.customerId) {
showErrorModal('请先选择客户')
return false
}
if (idCardFrontUploading.value || idCardBackUploading.value || facePhotoUploading.value) {
showErrorModal('证件照片正在上传中,请稍候')
return false
}
// 编辑模式下若未重新上传,保留 loadDetail 回填的原 fileNo 即可提交,故只在创建时强制校验必须已上传
if (isCreate.value && (!idCardFrontFileNo.value || !idCardBackFileNo.value || !facePhotoFileNo.value)) {
showErrorModal('请上传身份证人像面、国徽面和正面免冠照')
return false
}
if (identityMismatch.value) {
showErrorModal('身份证信息与所选客户信息不一致,请核实后重新上传身份证或重新选择客户')
return false
}
try {
await formRef.value.validate()
return true
} catch {
return false
}
}
// 构造提交给 create-draft/update 的公共 payload 与证件文件列表,create/edit 提交内容一致,仅
// 文件字段名不同(update 接口要求 accountOpenApplicationFileBtoList,由调用方自行拼接)。
function buildSubmitPayload() {
const fileList = [
{ fileNo: idCardFrontFileNo.value, fileType: '01' },
{ fileNo: idCardBackFileNo.value, fileType: '02' },
{ fileNo: facePhotoFileNo.value, fileType: '04' }
]
const payload = {
accountProperty: '1',
channelNo: channelNo.value,
customerId: form.customerId,
customerName: form.customerName,
certificateType: form.certificateType,
certificateNumber: form.certificateNumber,
certificateEffectiveDate: form.certificateEffectiveDate,
certificateExpiryDate: form.certificateExpiryDate,
issuingAuthority: form.issuingAuthority,
certificateAddress: form.certificateAddress,
gender: form.gender,
ethnic: form.ethnic,
occupation: form.occupationType,
jobNote: form.occupationType === 'OTHER' ? form.jobNote : undefined,
mobilePhone: form.mobilePhone,
bankCardNumber: form.bankCardNumber
}
return { payload, fileList }
}
// 提交个人开户草稿(create-draft),成功时记录 applicationNo 并返回,失败返回 null。
// 失败时的错误提示由 src/api/request.js 的响应拦截器统一弹出,这里不重复提示。
async function submitCreateDraft() {
const { payload, fileList } = buildSubmitPayload()
const res = await createPersonalOpenDraftApi({ ...payload, fileList })
if (res.data.code === 200) {
submittedApplicationNo.value = res.data.data.applicationNo
return res.data.data.applicationNo
}
return null
}
// 编辑模式"保存"按钮:校验通过后调用 update 接口,行为与改造前的 handleSubmit 编辑分支完全一致。
async function handleEditSubmit() {
const valid = await validateBeforeSubmit()
if (!valid) return
submitting.value = true
try {
const { payload, fileList } = buildSubmitPayload()
// 已知缺口:personal-opening/update 接口尚未提供,前端按 applicationNo 作为定位字段假设实现,
// 详见《缺失的后端接口.md》Part 2 新增条目;证件照片参数名与 create-draft 不同,后端要求为
// accountOpenApplicationFileBtoList
const res = await updatePersonalOpenApplicationApi({
applicationNo: route.query.applicationNo,
...payload,
accountOpenApplicationFileBtoList: fileList
})
if (res.data.code === 200) {
message.success('修改成功')
goBack()
}
} finally {
submitting.value = false
}
}
- Step 2: 更新编辑模式"保存"按钮绑定
第 32-37 行原内容:
<template v-else-if="isEdit" #extra>
<a-space>
<a-button type="primary" :loading="submitting" @click="handleSubmit">保存</a-button>
<a-button @click="goBack">取消</a-button>
</a-space>
</template>
改为:
<template v-else-if="isEdit" #extra>
<a-space>
<a-button type="primary" :loading="submitting" @click="handleEditSubmit">保存</a-button>
<a-button @click="goBack">取消</a-button>
</a-space>
</template>
- Step 3: 运行 build 校验无语法/引用错误
Run: npm run build
Expected: 命令成功退出(exit code 0),输出中不包含 handleSubmit is not defined 或其他 Rollup/Vite 报错。(此时"确认"按钮仍绑定旧的 handleSubmit,尚未替换,预期此步骤会因为第 28 行仍引用已删除的 handleSubmit 而报错——这是预期的失败,用于确认 Task 3 必须紧接着修复该引用)
- Step 4: 提交(允许 build 暂时失败,因为"确认"按钮引用将在 Task 3 修复;若不希望有中间失败态,可将本 Task 与 Task 3 合并为一次提交)
git add src/views/wallet/personal-open/PersonalOpenForm.vue
git commit -m "refactor: 拆分个人开户提交逻辑为validateBeforeSubmit/buildSubmitPayload/submitCreateDraft"
Task 3: 新增二次确认弹窗(确认态:保存/取消/邀请扫码办理)
Files:
- Modify:
src/views/wallet/personal-open/PersonalOpenForm.vue(template 新增a-modal、第 28 行按钮改绑、script 新增状态与函数、style 新增样式)
Interfaces:
-
Consumes: Task 2 产出的
validateBeforeSubmit()、submitCreateDraft()。 -
Produces:
const confirmModalVisible = ref(false)const confirmModalStep = ref('confirm')('confirm' | 'qrcode',Task 4 会用到)const modalSaving = ref(false)const modalInviting = ref(false)(Task 4 会用到)async function handleConfirmButtonClick(): Promise<void>async function handleModalSave(): Promise<void>function handleModalCancel(): void
-
Step 1: 修改第 28 行"确认"按钮绑定
原内容:
<a-button type="primary" :disabled="!agreementChecked" :loading="submitting" @click="handleSubmit">确认</a-button>
改为:
<a-button type="primary" :disabled="!agreementChecked" @click="handleConfirmButtonClick">确认</a-button>
- Step 2: 在
<PersonalCustomerPicker ref="pickerRef" @select="handleSelectCustomer" />之前新增确认弹窗
第 178 行原内容:
<PersonalCustomerPicker ref="pickerRef" @select="handleSelectCustomer" />
改为(新增 a-modal,置于其之前):
<a-modal
v-model:open="confirmModalVisible"
title="提示"
:footer="null"
:mask-closable="false"
@cancel="handleModalCancel"
>
<template v-if="confirmModalStep === 'confirm'">
<p>是否确认开户?</p>
<div class="confirm-modal-actions">
<a-space>
<a-button type="primary" :loading="modalSaving" :disabled="modalInviting" @click="handleModalSave">保存</a-button>
<a-button :disabled="modalSaving || modalInviting" @click="handleModalCancel">取消</a-button>
<a-button :loading="modalInviting" :disabled="modalSaving" @click="handleModalInvite">邀请扫码办理</a-button>
</a-space>
</div>
</template>
<template v-else>
<div class="qrcode-panel">
<qrcode-vue :value="qrCodeUrl" :size="200" level="M" />
<p class="qrcode-application-no">申请编号:{{ qrApplicationNo }}</p>
<a-button @click="handleModalCancel">关闭</a-button>
</div>
</template>
</a-modal>
<PersonalCustomerPicker ref="pickerRef" @select="handleSelectCustomer" />
(注:qrcode-vue 组件、qrCodeUrl/qrApplicationNo/handleModalInvite 在 Task 4 中定义,本 Task 先把模板结构和"确认态"三按钮跑通即可;Task 3 完成时 confirmModalStep 永远是 'confirm',不会走到 v-else 分支,不影响本 Task 的可测试性。)
- Step 3: 在 script setup 中新增弹窗相关状态与函数
在 const submitting = ref(false) 与 const submittedApplicationNo = ref('') 之间(约第 235-237 行)插入以下新状态:
原内容:
const submitting = ref(false)
// 仅 create 模式下"提交成功后原地展示结果面板"使用,与 edit/detail 模式读取的 route.query.applicationNo 区分
const submittedApplicationNo = ref('')
改为:
const submitting = ref(false)
// 仅 create 模式下"提交成功后原地展示结果面板"使用,与 edit/detail 模式读取的 route.query.applicationNo 区分
const submittedApplicationNo = ref('')
// "确认"按钮触发的二次确认弹窗:confirm=文案+三按钮态,qrcode=邀请扫码办理成功后的二维码展示态
const confirmModalVisible = ref(false)
const confirmModalStep = ref('confirm')
const modalSaving = ref(false)
const modalInviting = ref(false)
const qrCodeUrl = ref('')
const qrApplicationNo = ref('')
在文件末尾 function goBack() { ... } 之前(即原 handleQueryProgress 函数之后)新增以下函数:
// "确认"按钮点击:校验通过后打开二次确认弹窗,不直接提交
async function handleConfirmButtonClick() {
const valid = await validateBeforeSubmit()
if (!valid) return
confirmModalStep.value = 'confirm'
confirmModalVisible.value = true
}
// 弹窗内"保存":与原有提交逻辑一致,成功后关闭弹窗并展示"已提交"结果面板
async function handleModalSave() {
modalSaving.value = true
try {
const applicationNo = await submitCreateDraft()
if (applicationNo) {
message.success('申请已提交')
confirmModalVisible.value = false
}
} finally {
modalSaving.value = false
}
}
// 弹窗内"取消"/关闭图标/邀请扫码办理成功后的"关闭"按钮:统一只关闭弹窗,不发请求、不跳转
function handleModalCancel() {
confirmModalVisible.value = false
}
- Step 4: 在
<style scoped>末尾新增弹窗样式
原文件末尾:
.id-card-hint {
color: #ff4d4f;
font-size: 12px;
margin-top: 8px;
}
改为:
.id-card-hint {
color: #ff4d4f;
font-size: 12px;
margin-top: 8px;
}
.confirm-modal-actions {
margin-top: 24px;
text-align: right;
}
.qrcode-panel {
text-align: center;
}
.qrcode-application-no {
margin: 12px 0 8px;
color: #333;
}
- Step 5: 运行 build 校验
Run: npm run build
Expected: 命令成功退出(exit code 0)。若报错提示 qrCodeUrl/qrApplicationNo/handleModalInvite/qrcode-vue 未定义,属预期(将在 Task 4 补齐),若报错内容是其他(如 handleConfirmButtonClick/handleModalSave/handleModalCancel 未定义),需回头检查 Step 3 是否插入到了 <script setup> 块内正确位置。
- Step 6: 提交
git add src/views/wallet/personal-open/PersonalOpenForm.vue
git commit -m "feat: 个人开户确认按钮新增二次确认弹窗(保存/取消)"
Task 4: 邀请扫码办理 —— 生成并展示二维码
Files:
- Modify:
src/views/wallet/personal-open/PersonalOpenForm.vue(新增qrcode.vue引入、buildQrCodeUrl、handleModalInvite)
Interfaces:
-
Consumes: Task 1 的
qrcode.vue依赖与VITE_H5_QRCODE_BASE_URL/VITE_H5_QRCODE_PATH环境变量;Task 3 的confirmModalStep/modalInviting/qrCodeUrl/qrApplicationNo/submitCreateDraft。 -
Produces:
function buildQrCodeUrl(applicationNo: string): string、async function handleModalInvite(): Promise<void>(模板中"邀请扫码办理"按钮已在 Task 3 绑定好@click="handleModalInvite",本 Task 只需把函数定义补上)。 -
Step 1: 在
<script setup>顶部 import 区新增qrcode.vue引入
原第 182-186 行:
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message, Modal } from 'ant-design-vue'
import dayjs from 'dayjs'
import ChannelSelect from '@/components/ChannelSelect.vue'
改为:
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message, Modal } from 'ant-design-vue'
import dayjs from 'dayjs'
import QrcodeVue from 'qrcode.vue'
import ChannelSelect from '@/components/ChannelSelect.vue'
(<script setup> 下,导入的组件变量 QrcodeVue 会被编译器自动注册,模板里用 kebab-case <qrcode-vue> 即可解析到它,无需额外 components: {} 声明。)
- Step 2: 在
handleConfirmButtonClick之前新增buildQrCodeUrl函数,并补齐handleModalInvite
在 Task 3 新增的 handleModalSave 函数之后、handleModalCancel 函数之前插入:
// 拼接扫码 H5 地址:域名/路径来自环境变量,域名未配置(生产环境尚未确定)时用占位域名兜底,
// 保证二维码始终可以生成,不阻断柜员操作;域名确定后只需修改 .env.production,无需改代码。
function buildQrCodeUrl(applicationNo) {
const base = import.meta.env.VITE_H5_QRCODE_BASE_URL || 'https://example.com'
const path = import.meta.env.VITE_H5_QRCODE_PATH || '/mobile/personal-open'
return `${base}${path}?applicationNo=${applicationNo}`
}
// 弹窗内"邀请扫码办理":与"保存"调用同一个提交接口,成功后不关闭弹窗,切换为二维码展示态
async function handleModalInvite() {
modalInviting.value = true
try {
const applicationNo = await submitCreateDraft()
if (applicationNo) {
qrApplicationNo.value = applicationNo
qrCodeUrl.value = buildQrCodeUrl(applicationNo)
confirmModalStep.value = 'qrcode'
}
} finally {
modalInviting.value = false
}
}
完成后,handleModalSave/buildQrCodeUrl/handleModalInvite/handleModalCancel 四个函数应按此顺序相邻排列在 handleConfirmButtonClick 之后。
- Step 3: 运行 build 校验
Run: npm run build
Expected: 命令成功退出(exit code 0),无未定义变量/组件报错。
- Step 4: 启动开发服务器做端到端人工验证
Run: npm run dev
打开浏览器访问个人开户新增页(/wallet/personal-open 列表页点"新增" 或直接访问对应路由),依次验证:
- 不勾选协议/不选渠道/不选客户时点"确认" —— 应维持原有报错提示,不弹出二次确认弹窗。
- 补全信息、上传身份证正反面+免冠照并勾选协议后点"确认" —— 应弹出"提示"弹窗,文案"是否确认开户?",三个按钮"保存"/"取消"/"邀请扫码办理"。
- 点"取消" —— 弹窗关闭,表单数据保留,可再次点"确认"重新弹出弹窗。
- 再次点"确认"后点"保存" —— 弹窗关闭,页面切换为"开户申请已提交"结果面板,展示真实申请编号。
- (需要另一条全新数据,重新填一遍表单)点"确认"后点"邀请扫码办理" —— 弹窗不关闭,切换为二维码图片 + "申请编号:xxx" 文字 + "关闭"按钮;二维码图片用手机扫码 App 或任意在线二维码解码工具验证其内容为
https://example.com/mobile/personal-open?applicationNo=<刚生成的申请编号>(因.env.development中VITE_H5_QRCODE_BASE_URL留空,应回退到占位域名https://example.com)。 - 点二维码态的"关闭"按钮 —— 弹窗关闭,页面已处于"开户申请已提交"结果面板(与第4步效果一致)。
确认以上 6 点全部符合预期后,按 Ctrl+C 停止 npm run dev 进程。
- Step 5: 提交
git add src/views/wallet/personal-open/PersonalOpenForm.vue
git commit -m "feat: 个人开户邀请扫码办理生成并展示二维码"
Self-Review Notes
- Spec coverage:设计文档中"触发时机与校验"→Task 2/3;"弹窗结构""三个按钮行为"→Task 3/4;"技术实现"(依赖、环境变量、代码拆分)→Task 1/2;端到端验证覆盖设计文档全部 6 步交互流程→Task 4 Step 4。
- Placeholder scan:所有代码块均为完整可运行代码,无 TODO/TBD。
- Type/命名一致性:
validateBeforeSubmit/buildSubmitPayload/submitCreateDraft在 Task 2 定义、Task 3/4 中的调用方与签名一致;confirmModalStep/modalSaving/modalInviting/qrCodeUrl/qrApplicationNo在 Task 3 声明,Task 4 沿用同名变量,未出现命名漂移。