日期:2026-10-21。范围:m/crm/customer 客户主表、联系人、开票信息、跟进、商机直接操作,以及移动产品表单的关键规则。只读审计;本报告之外未修改业务代码,未打开浏览器、调用业务 API、写入数据库、运行全量 tsc 或更新 graph。报告里的 writes=1 是生产保存函数调用到 mock 写入函数,不等于真实数据库写入成功。
确认四组移动端校验遗漏。C23 联系人空手机号真实保存由主任务已发现;本审计独立复现其保存函数,并确认手机格式、邮箱格式同样遗漏。客户主表、客户跟进方式和商机数字也存在同源问题。开票信息和产品共享表单未发现同类遗漏。
根因:CustomerFields.vue 仅把 field.required 绑定到 van-field 的必填标记,没有 van-form/rules 校验;安装版本 Vant 的 Field.mjs:368 生成 input 时没有原生 required 属性。SSR 实测 <van-field required> 的 input 也没有 required。因此即使主客户表单使用 native-type="submit",必填仍不会自动拦截。ContactPanel.vue:186、InvoicePanel.vue:164、FollowPanel.vue 则直接 @click="save",必须在保存入口覆盖规则。
数字也不能依赖原生浏览器:Vant field/utils.mjs 将 type="number" 转成 type="text" inputmode="decimal";CustomerFields.vue 没有使用 Field.minValue/maxValue,保存时的 Number() 只转换值。
| 范围 | Web 权威规则 | 移动保存入口与缺口 | 已执行的生产函数复现 | 最小修复 |
|---|---|---|---|---|
| 联系人 C23 扩展 | crm/customer/components/ContactFormDialog.vue:129:姓名必填;mobile 必填及 /^1[3-9]\d{9}$/;email 可空,非空需 type: 'email' |
m/crm/customer/ContactPanel.vue:402 save() 只验姓名;新增/编辑共用此函数 |
姓名有效时 mobile 空、mobile=123、email=bad 均 writes=1,error='';姓名空的对照用例 writes=0 |
在设置 acting、构建 payload 前校验 mobile 必填及格式、email 非空格式;新增与编辑同时覆盖。保留空邮箱和固定电话自由格式 |
| 客户主表 | crm/customer/CustomerFormDialog.vue:410:customerName、shortName、customerType、source、parkArea、province、city、district 必填;mobile 可空,非空需相同手机号正则;email 可空,非空需 email 格式;名称查重 |
m/crm/customer/form.vue:127 save() 只有附件和新增开票条件检查,然后园区字典/名称查重/写入 |
分别清空上述 8 字段,每例都 checks=1,writes=1;mobile=123、email=a@b 同样调用写入 |
在字典 ensure、查重和写入前校验所有既有必填元数据与手机/邮箱规则,新增编辑共用。客户已创建后补存开票信息分支维持原语义,不再重复创建客户 |
| 客户跟进 | crm/customer/FollowFormDialog.vue:131:followType、followContent 必填;nextTime 可空 |
m/crm/customer/FollowPanel.vue:243 save() 仅验内容。CustomerFields.openSelect() 永远提供 value='' 的第一项,默认 phone 可被清空 |
followType=''、有效内容时 writes=1,error='';payload 保留空 followType |
新增一条跟进方式必填拦截;不要改已修复的 C14 `nextTime |
| 商机编辑与推进阶段 | crm/opportunity/components/OpportunityForm.vue:68/108:winRate 和 stagePercent 控件范围 0–100;winRate precision=2。ChangeStageDialog.vue:38/42 两个 slider 范围 0–100,step=5 |
m/crm/customer/OpportunityActions.vue:233 save() 只验必填、名称至少2字、阶段不重复。progress 的 minValue/maxValue 未生效 |
edit/stage 两模式,stagePercent=-1/101、winRate=-1/101 全部 writes=1;0/0 正常提交;edit.winRate=12.345 未按 Web 精度处理 |
仅移动模块为两数字添加有限数及范围检查,并在 CustomerFields 将 minValue/maxValue 传给 Vant 的 min/max;保存入口仍需兜底。精度应按模式对齐:编辑赢单率2位、推进阶段整数。不要把 step=5 误解为必须是5的倍数 |
客户邮箱说明:其原生 <form> 的 input type=email 会拦截部分格式,但保存函数本身没有对应规则;a@b 是浏览器 email 输入通常允许、Web async-validator type=email 明确拒绝的差异。这里将其标为生产函数层缺口,未宣称已经浏览器实测。联系人保存按钮在表单外直接调用函数,email='bad' 完全没有原生表单提交这一层。
邮箱实现注意:现有 utils/form/validator.ts:215 validateEmail() 的域名后缀允许零个点,因此也接受 a@b,不能直接复用后宣称与 Web 一致。本审计通过 Element Plus 实际依赖的 async-validator 验证:空字符串通过、bad 拒绝、a@b 拒绝、a@example.com 通过。采用与该规则一致的邮件验证实现,并用这些边界值及常见合法邮箱作对照。
精度说明:安装版本 Element Plus slider/src/slider2.mjs:92 根据 min/max/step 得出 precision=0,并把它传给输入框。输入框没有 step-strictly,所以不能新增“5的倍数”限制。推进阶段是否在 UI 输入完成时取整、还是保存时明确提示,应保持 Web 的结果语义;编辑阶段进度未设置 precision,不要额外强制整数。
| 范围 | 结果 |
|---|---|
| 开票信息 | InvoicePanel.vue:334 save() 已明确校验单位名称。Web InvoiceInfo.vue:325 rules 也仅要求单位名称;税号、银行、账号、电话/传真没有强制格式。新增客户的选填开票资料已经实现“填任一项后单位名称必填”,与 Web CustomerFormDialog.vue:645 一致。不增加税号位数、银行账号数字、电话格式要求 |
| 客户日期、跟进日期、商机预计成交日期 | Web 对客户 nextTime、客户跟进 nextTime、预计成交日期没有前后大小/不得过去等规则。移动使用只读日期选择器,未发现需要新增的业务日期约束。商机跟进 followTime 为必填,OpportunityActions.fields 和保存检查已覆盖;不新增“下次必须晚于本次”等规则 |
| 商机其他必填/长度 | 编辑的商机名称、客户、阶段、负责人和名称至少2字已有保存检查;名称及长文本 maxlength 已传递给 Vant。推进阶段 stage、stagePercent、winRate、changeReason 已有必填检查;变更原因为500字上限。商机跟进方式、时间、内容均有必填检查。移动编辑目前比 Web 多标记 stagePercent/winRate 必填,这是既有差异,不是本次遗漏,勿再扩大 |
| 客户公海原因 | CustomerPoolSheet.submit() 已在请求前检查选中 ID 和 trim 后的非空原因;maxlength=500。无同类缺口 |
| 产品共享表单 | 移动 m/crm/product/form.vue 使用同一个 components/business/product-form-dialog/index.vue,仅 variant=page。产品名称必填、非空条码数字校验在 formRules:313,submitForm:452 在请求前执行 validate。税率0–100/整数,库存与预警值>=0控件约束同 Web。不要为移动改动其共享校验逻辑 |
| 产品 API schema 备注 | backend/app/schemas/crm/product.py:218/255 stock/lowStock 是 int,但现有共享库存控件没有 precision=0。这是 Web 与移动共用的已有 schema 差异;本次没有真实请求复现,不作为移动专属缺陷,不建议借此修改共享 Web |
修复后用原生产函数+mock API 回归:非法输入应零次写入并保留草稿/弹层;合法输入继续走原数据转换、事件、刷新链路。联系人新增/编辑各覆盖空手机、错误手机、错误邮箱、空邮箱合法;客户逐个必填缺失、可空手机/邮箱、非空格式错误、查重失败、开票补存重试;客户跟进清空方式但内容有效、C14/C17原回归;商机edit/stage边界-1/0/100/101、有限数、对应精度、同阶段拦截;商机跟进既有必填及可空日期。
UI 回归只使用主任务专用测试数据:联系人修改为合法手机后保存;客户空必填应在前端展示具体字段提示;清空跟进方式不写入;商机仅安全测试数据,不操作真实业务商机。产品与开票只需已有正向/必填回归。
以下脚本在 web-admin 目录执行,使用 @vue/compiler-sfc 和 TypeScript AST 抽取当前生产函数,未复制业务判断实现。仅网络/导航/弹窗用桩,model.ts、保存函数、商机字段元数据按生产代码运行。输出为本报告上述结果;修复后非法用例应从 writes=1 变为0。
node --input-type=module <<'NODE'
import fs from 'node:fs'
import ts from 'typescript'
import { parse } from '@vue/compiler-sfc'
import { ref, computed, createSSRApp, h } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { Field } from 'vant'
const base = 'src/views/m/crm/customer/'
const model = {}
new Function('exports', ts.transpile(fs.readFileSync(base + 'model.ts', 'utf8'), {
target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS
}))(model)
function extract(file, names, bindings) {
const { descriptor } = parse(fs.readFileSync(file, 'utf8'))
const ast = ts.createSourceFile(file, descriptor.scriptSetup.content, ts.ScriptTarget.Latest, true)
const source = ast.statements.filter(n => ts.isFunctionDeclaration(n)
? names.includes(n.name?.text)
: ts.isVariableStatement(n) && names.includes(n.declarationList.declarations[0].name.getText(ast)))
.map(n => n.getText(ast)).join('\n')
return new Function(...Object.keys(bindings), ts.transpile(source, {
target: ts.ScriptTarget.ES2022
}) + '\nreturn { ' + names.join(',') + ' }')(...Object.values(bindings))
}
const html = await renderToString(createSSRApp({
render: () => h(Field, { modelValue: '', label: '必填字段', required: true, type: 'text' })
}))
console.log('Vant required input:', html.match(/<input[^>]*>/)?.[0])
async function contact(row) {
const writes = [], saveError = ref('')
const s = extract(base + 'ContactPanel.vue', ['save'], {
acting: ref(false), form: ref(row), saveError, contactFields: model.contactFields,
props: { customerId: 'fixture' }, fetchCreateContact: async d => writes.push(d),
fetchUpdateContact: async d => writes.push(d), editing: ref(true), emit: () => {}, load: async () => {}
})
await s.save()
return { writes: writes.length, error: saveError.value }
}
for (const row of [
{ contactName: '测试' }, { contactName: '测试', mobile: '123' },
{ contactName: '测试', mobile: '13800138000', email: 'bad' },
{ contactName: '测试', mobile: '13800138000', email: 'a@example.com' },
{ contactName: '', mobile: '13800138000' }
]) console.log('contact', row, await contact(row))
async function customer(patch) {
const writes = [], checks = [], error = ref('')
const form = model.customerForm({ customerName: '测试客户', shortName: '测试', customerType: 'type',
source: 'source', parkArea: 'park', province: '省', city: '市', district: '区', ...patch })
const s = extract(base + 'form.vue', ['save'], {
saving: ref(false), error, uploads: new Map(), invoice: ref({}), id: ref(''),
state: { customerId: '' }, form: ref(form),
dict: { getOptions: () => [{ label: 'park', value: 'park' }], ensure: async () => ({ value: 'park' }) },
showCrmConfirmDialog: async () => {}, unwrapData: model.unwrapData,
fetchCheckDuplicate: async d => { checks.push(d); return { isDuplicate: false } },
saveCustomerAndInvoice: model.saveCustomerAndInvoice,
fetchCreateCustomer: async d => { writes.push(d); return { id: 'fixture' } },
fetchUpdateCustomer: async d => writes.push(d), addInvoiceInfo: async () => {},
showSuccessToast: () => {}, router: { replace: async () => {} }, CUSTOMER_PATH: model.CUSTOMER_PATH
})
await s.save()
return { writes: writes.length, checks: checks.length, error: error.value }
}
for (const key of model.customerGroups.flatMap(g => g.fields).filter(f => f.required).map(f => f.key))
console.log('customer.empty.' + key, await customer({ [key]: '' }))
for (const [key, value] of [['mobile', '123'], ['email', 'a@b']])
console.log('customer.invalid.' + key, await customer({ [key]: value }))
const followWrites = [], followError = ref('')
const follow = extract(base + 'FollowPanel.vue', ['save'], {
acting: ref(false), upload: ref(null), saveError: followError,
form: ref({ followType: '', followContent: '测试内容', nextTime: '' }), contacts: ref([]),
fetchCreateCustomerFollow: async d => followWrites.push(d), props: { customerId: 'fixture' },
apiTime: model.apiTime, editing: ref(true), emit: () => {}, load: async () => {}
})
await follow.save()
console.log('follow.emptyType', { writes: followWrites.length, error: followError.value })
async function opportunity(modeName, patch) {
const writes = [], error = ref(''), mode = ref(modeName)
const form = ref({ opportunityName: '测试商机', customerId: 'fixture', stage: 'quote',
stagePercent: 60, winRate: 65, ownerUserId: 'owner', changeReason: '测试原因', ...patch })
const s = extract(base + 'OpportunityActions.vue', ['progress', 'fields', 'save'], {
ref, computed, mode, form, busy: ref(false), error, upload: ref(null), customers: ref([]),
dictOptions: () => [], OPPORTUNITY_TYPE_FALLBACK: {}, OPPORTUNITY_SOURCE_FALLBACK: {},
props: { opportunity: { id: 'fixture', stage: 'initial' } },
changeStage: async d => writes.push(d), updateOpportunity: async d => writes.push(d),
apiTime: model.apiTime, addOpportunityFollow: async d => writes.push(d),
updateOpportunityFollow: async d => writes.push(d), emit: () => {}
})
await s.save()
return { writes: writes.length, error: error.value,
stagePercent: writes[0]?.stagePercent, winRate: writes[0]?.winRate }
}
for (const mode of ['stage', 'edit']) for (const patch of [
{ stagePercent: -1 }, { stagePercent: 101 }, { winRate: -1 }, { winRate: 101 },
{ winRate: 12.345 }, { stagePercent: 0, winRate: 0 }
]) console.log('opportunity.' + mode, patch, await opportunity(mode, patch))
NODE