bd1842f08a
与 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 修复,
属环境问题不是代码问题。)
387 lines
14 KiB
Vue
387 lines
14 KiB
Vue
<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>
|