Files
hjc-web/app/components/HjcEnterpriseForm.vue
T
weicw-dshsa bd1842f08a fix(review): 企业表单删除 snake_case 死回退
与 hjc-h5 资质页同一处评审发现:`fill()` 里的 5 个基本户/发票类型字段曾写成
`data.bankAccountName ?? data.bank_account_name ?? ''` 的双键回退。

核实(不猜):后端只会下发 camelCase——hjc-java 的 JacksonConfig 无命名策略、
application.yml 的 spring.jackson 只配 time-zone/date-format、`/api/hjc/enterprise/my`
回的是实体属性名;而本仓的 `server/api/tender/enterprise.get.ts` 是**原样透传**
那个 JSON,不做任何键名变换。所以 snake 那一半永远取不到,是死代码。

只删这 5 个字段的蛇形回退(形态与 hjc-h5 侧保持一致),未动本仓其它改动。
守卫钉在 hjc-java 的 HjcEnterpriseJsonKeysTest(出参键 camelCase +
无人配置 property-naming-strategy)。

门禁:npx eslint . --quiet 通过(无输出);npm run build —— '✨ Build complete!'。
(worktree 里 .nuxt 残缺会先报 TSConfckParseError,已用 npm run postinstall 修复,
属环境问题不是代码问题。)
2026-10-08 13:49:16 +08:00

387 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="space-y-4">
<h3 class="border-l-4 border-blue-600 pl-2 text-sm font-semibold text-blue-600">企业信息</h3>
<div class="space-y-1">
<label class="block text-sm text-gray-600">企业名称(登录账号)</label>
<input v-model="form.name" class="field" :disabled="readonly" placeholder="请输入企业名称,将作为登录账号" />
</div>
<div v-if="showPassword" class="space-y-1">
<label class="block text-sm text-gray-600">登录密码</label>
<input v-model="form.password" type="password" class="field" :disabled="readonly" placeholder="请设置登录密码" />
</div>
<div class="space-y-1">
<label class="block text-sm text-gray-600">纳税人识别号</label>
<input v-model="form.creditCode" class="field" :disabled="readonly" placeholder="统一社会信用代码" />
</div>
<div class="space-y-1">
<label class="block text-sm text-gray-600">企业地址(选填)</label>
<input v-model="form.address" class="field" :disabled="readonly" placeholder="企业地址" />
</div>
<div class="space-y-1">
<label class="block text-sm text-gray-600">企业联系电话(选填)</label>
<input v-model="form.contactPhone" class="field" :disabled="readonly" placeholder="企业联系电话" />
</div>
<div class="space-y-1">
<label class="block text-sm text-gray-600">企业邮箱</label>
<input v-model="form.contactEmail" type="email" class="field" :disabled="readonly" placeholder="企业邮箱" />
</div>
<h3 class="border-l-4 border-blue-600 pl-2 pt-2 text-sm font-semibold text-blue-600">
基本户信息与发票类型
</h3>
<div class="space-y-1">
<label class="block text-sm text-gray-600">账户名称</label>
<input
v-model="form.bankAccountName"
class="field"
:disabled="readonly"
maxlength="255"
placeholder="基本户账户名称,可与上方企业名称不同"
/>
</div>
<div class="space-y-1">
<label class="block text-sm text-gray-600">账号</label>
<input
v-model="form.bankAccountNo"
class="field"
:disabled="readonly"
maxlength="32"
placeholder="基本户账号,8-32 位数字"
/>
</div>
<div class="space-y-1">
<label class="block text-sm text-gray-600">开户银行</label>
<input v-model="form.bankName" class="field" :disabled="readonly" maxlength="128" placeholder="如:中国工商银行" />
</div>
<div class="space-y-1">
<label class="block text-sm text-gray-600">开户支行</label>
<input
v-model="form.bankBranch"
class="field"
:disabled="readonly"
maxlength="128"
placeholder="如:南宁市民族大道支行"
/>
</div>
<div class="space-y-1">
<label class="block text-sm text-gray-600">发票类型</label>
<div class="flex flex-wrap items-center gap-6 pt-1">
<label
v-for="t in INVOICE_TYPES"
:key="t.value"
class="flex items-center gap-2 text-sm"
:class="readonly ? 'text-gray-400' : 'text-gray-700'"
>
<input v-model="form.invoiceType" type="radio" :value="t.value" :disabled="readonly" />
{{ t.label }}
</label>
</div>
</div>
<h3 class="border-l-4 border-blue-600 pl-2 pt-2 text-sm font-semibold text-blue-600">经办人信息</h3>
<div class="space-y-1">
<label class="block text-sm text-gray-600">经办人姓名</label>
<input v-model="form.agentName" class="field" :disabled="readonly" placeholder="经办人姓名" />
</div>
<div class="space-y-1">
<label class="block text-sm text-gray-600">经办人身份证号</label>
<input v-model="form.idCardNo" class="field" :disabled="readonly" placeholder="经办人身份证号" />
</div>
<div class="space-y-1">
<label class="block text-sm text-gray-600">经办人邮箱</label>
<input v-model="form.agentEmail" type="email" class="field" :disabled="readonly" placeholder="经办人邮箱" />
</div>
<div class="space-y-1">
<label class="block text-sm text-gray-600">经办人手机号</label>
<input v-model="form.agentPhone" class="field" :disabled="readonly" placeholder="经办人手机号" />
</div>
<div class="space-y-1">
<label class="block text-sm text-gray-600">授权到期时间</label>
<input v-model="form.authorizeExpire" type="datetime-local" class="field" :disabled="readonly" />
</div>
<h3 class="border-l-4 border-blue-600 pl-2 pt-2 text-sm font-semibold text-blue-600">
证件上传(单张不超过 5M)
</h3>
<div class="flex flex-wrap gap-6">
<div v-for="m in materials" :key="m.materialType" class="w-40 text-center">
<div class="mb-2 text-xs text-gray-600">{{ m.label }}</div>
<label
class="flex h-40 w-40 cursor-pointer items-center justify-center overflow-hidden rounded-md border border-dashed border-gray-300 bg-gray-50"
:class="{ 'cursor-not-allowed opacity-60': readonly }"
>
<input
type="file"
accept="image/*"
class="hidden"
:disabled="readonly"
@change="onPick(m, $event)"
/>
<img v-if="m.fileUrl" :src="m.fileUrl" class="h-full w-full object-cover" alt="" />
<span v-else class="text-3xl text-gray-400">+</span>
</label>
<button
v-if="m.fileUrl && !readonly"
type="button"
class="mt-1 text-xs text-red-500 hover:underline"
@click="removeImage(m)"
>
删除
</button>
</div>
</div>
<div v-if="ocrHints.license" class="rounded-md bg-amber-50 px-3 py-2 text-xs text-amber-700">
{{ ocrHints.license }}
</div>
<div v-if="ocrHints.idcard_front" class="rounded-md bg-amber-50 px-3 py-2 text-xs text-amber-700">
{{ ocrHints.idcard_front }}
</div>
</div>
</template>
<script setup lang="ts">
import { message } from 'ant-design-vue'
/**
* 企业信息 + 经办人 + 授权 + 证件上传(含身份证/营业执照 OCR 回填)共享表单。
* 注册页与「企业资质」页共用,与 h5 端 components/EnterpriseForm.vue 行为一致。
*/
const form = defineModel<Record<string, any>>('form', { required: true })
const materials = defineModel<any[]>('materials', { required: true })
const props = withDefaults(
defineProps<{
/** 是否显示登录密码字段(仅注册页) */
showPassword?: boolean
/** 只读(审核中/已通过时禁止编辑与上传) */
readonly?: boolean
}>(),
{ showPassword: false, readonly: false }
)
const ocrHints = reactive<Record<string, string>>({ license: '', idcard_front: '' })
/** 材料类型:仅身份证人像面与营业执照需要 OCR */
const OCR_TYPES = ['license', 'idcard_front']
/**
* 发票类型:单选、**无默认值**、必选。取值与后端 `hjc_invoice.invoice_type` 口径一致,
* 不要另起字符串字面量(与 h5 端 components/EnterpriseForm.vue 保持一致)。
*/
const INVOICE_TYPES = [
{ value: 'VAT_SPECIAL', label: '电子专票' },
{ value: 'VAT_NORMAL', label: '电子普票' }
]
/** 基本户账号:与后端 validateRegister 一致,仅做长度与字符校验(纯数字 8-32 位) */
const BANK_ACCOUNT_NO_RE = /^[0-9]{8,32}$/
async function onPick(m: any, e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || props.readonly) return
if (file.size > 5 * 1024 * 1024) {
uniToast('单张图片不超过 5M')
return
}
try {
const fd = new FormData()
fd.append('file', file)
const res: any = await $fetch('/api/tender/upload', {
method: 'POST',
body: fd
})
m.fileUrl = res?.url || ''
if (!m.fileUrl) {
uniToast('上传失败')
return
}
} catch (e: any) {
uniToast(e?.data?.statusMessage || e?.data?.message || '上传失败')
return
}
if (OCR_TYPES.includes(m.materialType)) {
await runOcr(m)
}
}
/** 营业执照 / 身份证人像面上传后自动 OCR,失败仅提示不阻断 */
async function runOcr(m: any) {
if (!m.fileUrl) return
try {
const r: any = await $fetch('/api/tender/ocr/recognize', {
method: 'POST',
body: { materialType: m.materialType, fileUrl: m.fileUrl }
})
backfill(m.materialType, r)
} catch (e: any) {
uniToast(e?.data?.statusMessage || e?.data?.message || '识别失败,请手动填写或重试')
}
}
function backfill(materialType: string, r: any) {
if (!r) return
const parts: string[] = []
if (materialType === 'license') {
fillIfBlank('creditCode', r.creditCode)
fillIfBlank('address', r.address)
if (r.enterpriseName) parts.push(`企业名称:${r.enterpriseName}`)
if (r.legalPerson) parts.push(`法定代表人:${r.legalPerson}`)
ocrHints.license = parts.length ? `营业执照识别(仅供核对,不覆盖登录账号):${parts.join(';')}` : ''
if (parts.length) uniToast('营业执照识别完成')
} else if (materialType === 'idcard_front') {
fillIfBlank('agentName', r.agentName)
fillIfBlank('idCardNo', r.idCardNo)
if (r.agentName) parts.push(`姓名:${r.agentName}`)
if (r.idCardNo) parts.push(`身份证号:${r.idCardNo}`)
ocrHints.idcard_front = parts.length ? `身份证识别:${parts.join(';')}` : ''
if (parts.length) uniToast('身份证识别完成')
}
}
/** 仅回填空字段,不覆盖用户已输入内容 */
function fillIfBlank(key: string, value?: string) {
if (value && !form.value[key]) {
form.value[key] = value
}
}
function removeImage(m: any) {
if (props.readonly) return
m.fileUrl = ''
if (ocrHints[m.materialType] !== undefined) ocrHints[m.materialType] = ''
}
/** 必填校验:企业联系电话、企业地址选填,其余必填(含基本户 4 项与发票类型);4 类证件必传 */
function validate(): boolean {
const f = form.value
const required: Array<[string, string]> = [
['name', '企业名称'],
['creditCode', '纳税人识别号'],
['contactEmail', '企业邮箱'],
['bankAccountName', '基本户账户名称'],
['bankAccountNo', '基本户账号'],
['bankName', '开户银行'],
['bankBranch', '开户支行'],
['agentName', '经办人姓名'],
['idCardNo', '经办人身份证号'],
['agentEmail', '经办人邮箱'],
['agentPhone', '经办人手机号'],
['authorizeExpire', '授权到期时间']
]
if (props.showPassword) required.unshift(['password', '登录密码'])
for (const [key, label] of required) {
if (!String(f[key] ?? '').trim()) {
uniToast(`请填写${label}`)
return false
}
}
// 账号:空值已在上面的必填循环里点名提示;这里只负责长度与字符
if (!BANK_ACCOUNT_NO_RE.test(String(f.bankAccountNo ?? '').trim())) {
uniToast('基本户账号只能为 8-32 位数字')
return false
}
// 发票类型无默认值,未选时提交前拦截
if (!INVOICE_TYPES.some((t) => t.value === f.invoiceType)) {
uniToast('请选择发票类型')
return false
}
if (materials.value.some((m: any) => !m.fileUrl)) {
uniToast('请上传全部证件')
return false
}
return true
}
/** datetime-local 取值为 `YYYY-MM-DDTHH:mm`,后端需要 `yyyy-MM-dd HH:mm:ss` */
function normalizeDateTime(v: string) {
if (!v) return v
const s = v.replace('T', ' ')
return s.length === 16 ? `${s}:00` : s
}
/** 提交用载荷(含基本户与发票类型、证件材料,授权到期时间已规范化为后端格式) */
function buildPayload() {
const f = form.value
return {
name: f.name,
creditCode: f.creditCode,
contactPhone: f.contactPhone,
contactEmail: f.contactEmail,
address: f.address,
bankAccountName: f.bankAccountName,
bankAccountNo: f.bankAccountNo,
bankName: f.bankName,
bankBranch: f.bankBranch,
invoiceType: f.invoiceType,
agentName: f.agentName,
agentEmail: f.agentEmail,
agentPhone: f.agentPhone,
idCardNo: f.idCardNo,
authorizeExpire: normalizeDateTime(f.authorizeExpire),
materials: materials.value.map((m: any) => ({
materialType: m.materialType,
materialName: m.materialName,
fileUrl: m.fileUrl
}))
}
}
/** 加载后端返回的企业资料 → 表单 / 证件 */
function fill(data: any) {
if (!data) return
const f = form.value
f.name = data.name || ''
f.creditCode = data.creditCode || ''
f.contactPhone = data.contactPhone || ''
f.contactEmail = data.contactEmail || ''
f.address = data.address || ''
// 后端出参就是 Java 属性名(camelCase):后端 Jackson 没有任何 snake_case 命名策略
// (`/api/tender/enterprise` 也只是原样透传 `/api/hjc/enterprise/my` 的 JSON),
// 所以曾经写的 `?? data.bank_account_name` 这类回退是**死代码**,已删除;
// hjc-h5 的资质页同步做了同样的删除。守卫见 hjc-java 的 HjcEnterpriseJsonKeysTest。
f.bankAccountName = data.bankAccountName ?? ''
f.bankAccountNo = data.bankAccountNo ?? ''
f.bankName = data.bankName ?? ''
f.bankBranch = data.bankBranch ?? ''
f.invoiceType = data.invoiceType ?? ''
f.agentName = data.agentName || ''
f.idCardNo = data.idCardNo || ''
f.agentEmail = data.agentEmail || ''
f.agentPhone = data.agentPhone || ''
// 后端 `yyyy-MM-dd HH:mm:ss` → datetime-local `yyyy-MM-ddTHH:mm`
f.authorizeExpire = data.authorizeExpire ? String(data.authorizeExpire).replace(' ', 'T').slice(0, 16) : ''
const ms: any[] = data.materials || []
for (const m of ms) {
const target = materials.value.find((x: any) => x.materialType === m.materialType)
if (target) target.fileUrl = m.fileUrl || ''
}
}
/** 轻量提示(ant-design-vue message,SSR 下不执行) */
function uniToast(msg: string) {
if (import.meta.client) message.warning(msg)
}
defineExpose({ validate, buildPayload, fill })
</script>
<style scoped>
.field {
width: 100%;
border-radius: 6px;
border: 1px solid #d1d5db;
padding: 8px 16px;
font-size: 14px;
}
.field:disabled {
background: #f3f4f6;
color: #6b7280;
}
</style>