feat(tender): 企业注册与 h5 对齐(一站式表单 + 证件上传 + OCR 回填);修复登录/注册代理路由 404

- 新增 app/components/HjcEnterpriseForm.vue:与 h5 EnterpriseForm 行为一致的共享表单,
  字段集 / 必填规则 / OCR 回填 / 提示文案逐条对齐
- register.vue:一站式注册,一次性提交企业 + 经办人 + 授权 + 证件材料;
  企业联系电话、企业地址选填,其余(含企业邮箱)必填,4 类证件必传
- tender/qualification.vue:改用共享组件,补「经办人身份证号」,
  以真实文件上传 + OCR 取代原「证件 URL 文本框」;
  审核中(0)/已通过(1) 只读,驳回(2)/未提交(-1) 可编辑重提
- 新增 server/api/tender/upload.post.ts(multipart 原样转发 → /hjc/auth/upload,匿名,
  登录前后均可用)与 server/api/tender/ocr/recognize.post.ts(→ /hjc/ocr/recognize)
- 修正 datetime-local(YYYY-MM-DDTHH:mm)与后端 yyyy-MM-dd HH:mm:ss 的格式转换
- fix: server/api/tender/auth/{login,register}.post.ts 实际路由为 /api/tender/auth/*,
  而客户端调用 /api/tender/{login,register},导致登录与注册此前均 404;
  已按文件注释与同级接口约定移至扁平路径 server/api/tender/{login,register}.post.ts
- register / login / enterprise-save 三个代理改为原样透传 ApiResult{code,message,data},
  useHjcAuth 与资质页按 code===0 判定,避免后端校验失败信息被吞掉
This commit is contained in:
2026-09-10 13:12:21 +08:00
parent 0f3fa3c050
commit 5e7372d0c7
9 changed files with 521 additions and 139 deletions
+292
View File
@@ -0,0 +1,292 @@
<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.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']
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 类证件必传 */
function validate(): boolean {
const required: Array<[string, string]> = [
['name', '企业名称'],
['creditCode', '纳税人识别号'],
['contactEmail', '企业邮箱'],
['agentName', '经办人姓名'],
['idCardNo', '经办人身份证号'],
['agentEmail', '经办人邮箱'],
['agentPhone', '经办人手机号'],
['authorizeExpire', '授权到期时间']
]
if (props.showPassword) required.unshift(['password', '登录密码'])
for (const [key, label] of required) {
if (!form.value[key]) {
uniToast(`请填写${label}`)
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,
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 || ''
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 messageSSR 下不执行) */
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>