Files
hjc-web/app/components/HjcEnterpriseForm.vue
weicw1996 5e7372d0c7 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 判定,避免后端校验失败信息被吞掉
2026-09-10 13:12:21 +08:00

293 lines
10 KiB
Vue
Raw Permalink 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.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>