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
+58 -71
View File
@@ -7,7 +7,12 @@
<div v-if="!loggedIn" class="py-20 text-center">
<p class="text-gray-500">请先登录后管理企业资质</p>
<NuxtLink to="/login?redirect=/tender/qualification" class="mt-4 inline-block rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700">去登录</NuxtLink>
<NuxtLink
to="/login?redirect=/tender/qualification"
class="mt-4 inline-block rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700"
>
去登录
</NuxtLink>
</div>
<div v-else class="mx-auto max-w-2xl rounded-lg border border-gray-200 p-6">
@@ -20,24 +25,18 @@
</div>
<form class="space-y-4" @submit.prevent="submit">
<Field label="企业名称"><input v-model="form.name" class="field" /></Field>
<Field label="纳税人识别号"><input v-model="form.creditCode" class="field" /></Field>
<Field label="企业联系电话"><input v-model="form.contactPhone" class="field" /></Field>
<Field label="企业邮箱"><input v-model="form.contactEmail" type="email" class="field" /></Field>
<Field label="企业地址"><input v-model="form.address" class="field" /></Field>
<Field label="经办人姓名"><input v-model="form.agentName" class="field" /></Field>
<Field label="经办人邮箱"><input v-model="form.agentEmail" type="email" class="field" /></Field>
<Field label="经办人手机号"><input v-model="form.agentPhone" class="field" /></Field>
<Field label="授权到期时间"><input v-model="form.authorizeExpire" type="datetime-local" class="field" /></Field>
<HjcEnterpriseForm ref="entForm" v-model:form="form" v-model:materials="materials" :readonly="readonly" />
<div class="space-y-3">
<Field label="经办人身份证正面 URL"><input v-model="materials.idcard_front" class="field" placeholder="/upload/xxx.jpg" /></Field>
<Field label="经办人身份证反面 URL"><input v-model="materials.idcard_back" class="field" placeholder="/upload/xxx.jpg" /></Field>
<Field label="授权委托书 URL"><input v-model="materials.handbook" class="field" placeholder="/upload/xxx.pdf" /></Field>
<Field label="营业执照 URL"><input v-model="materials.license" class="field" placeholder="/upload/xxx.jpg" /></Field>
</div>
<p v-if="readonly" class="text-center text-xs text-gray-500">
资质审核中 / 已通过时不可修改被驳回后可修改并重新提交
</p>
<button type="submit" class="w-full rounded-md bg-blue-600 py-3 text-white font-medium hover:bg-blue-700 disabled:opacity-50" :disabled="submitting">
<button
v-if="!readonly"
type="submit"
class="w-full rounded-md bg-blue-600 py-3 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
:disabled="submitting"
>
{{ submitting ? '提交中...' : '提交审核' }}
</button>
<p v-if="error" class="text-center text-sm text-red-500">{{ error }}</p>
@@ -48,95 +47,83 @@
</template>
<script setup lang="ts">
import { defineComponent, h } from 'vue'
const Field = defineComponent({
props: { label: String },
setup(props, { slots }) {
return () => h('div', { class: 'space-y-1' }, [
h('label', { class: 'block text-sm text-gray-600' }, props.label),
slots.default?.()
])
}
})
const { isLoggedIn } = useHjcAuth()
const loggedIn = ref(false)
const authStatus = ref(0)
const authStatus = ref(-1)
const rejectReason = ref('')
const submitting = ref(false)
const error = ref('')
const tip = ref('')
const entForm = ref<any>(null)
const form = reactive({
name: '', creditCode: '', contactPhone: '', contactEmail: '', address: '',
agentName: '', agentEmail: '', agentPhone: '', authorizeExpire: ''
const form = ref<Record<string, any>>({
name: '',
creditCode: '',
contactPhone: '',
contactEmail: '',
address: '',
agentName: '',
idCardNo: '',
agentEmail: '',
agentPhone: '',
authorizeExpire: ''
})
const materials = reactive({ idcard_front: '', idcard_back: '', handbook: '', license: '' })
const authStatusText = computed(() => ({ 0: '待审核', 1: '已通过', 2: '已驳回' } as any)[authStatus.value] ?? '待审核')
const materials = ref<any[]>([
{ materialType: 'idcard_front', label: '身份证人像面', materialName: '身份证人像面', fileUrl: '' },
{ materialType: 'idcard_back', label: '身份证国徽面', materialName: '身份证国徽面', fileUrl: '' },
{ materialType: 'handbook', label: '授权委托书', materialName: '授权委托书', fileUrl: '' },
{ materialType: 'license', label: '营业执照', materialName: '营业执照', fileUrl: '' }
])
const authStatusText = computed(
() => ({ 0: '待审核', 1: '已通过', 2: '已驳回' } as Record<number, string>)[authStatus.value] ?? '未提交资质'
)
const authStatusClass = computed(() => {
if (authStatus.value === 1) return 'bg-green-100 text-green-700'
if (authStatus.value === 2) return 'bg-red-100 text-red-600'
return 'bg-amber-100 text-amber-700'
})
/** 审核中(0)/已通过(1) 只读;驳回(2)/未提交(-1) 可编辑重提(与 h5 一致) */
const readonly = computed(() => authStatus.value === 0 || authStatus.value === 1)
async function load() {
const res: any = await $fetch('/api/tender/enterprise')
if (res) {
authStatus.value = res.authStatus ?? 0
authStatus.value = res.authStatus ?? -1
rejectReason.value = res.rejectReason || ''
form.name = res.name || ''
form.creditCode = res.creditCode || ''
form.contactPhone = res.contactPhone || ''
form.contactEmail = res.contactEmail || ''
form.address = res.address || ''
form.agentName = res.agentName || ''
form.agentEmail = res.agentEmail || ''
form.agentPhone = res.agentPhone || ''
form.authorizeExpire = res.authorizeExpire ? res.authorizeExpire.slice(0, 16) : ''
const ms: any[] = res.materials || []
for (const m of ms) {
if (m.materialType && (materials as any)[m.materialType] === '') {
(materials as any)[m.materialType] = m.fileUrl || ''
}
}
entForm.value?.fill(res)
}
}
async function submit() {
error.value = ''
tip.value = ''
if (!entForm.value?.validate()) return
submitting.value = true
const materialList = Object.entries(materials)
.filter(([, url]) => url)
.map(([type, url]) => ({ materialType: type, materialName: type, fileUrl: url }))
try {
await $fetch('/api/tender/enterprise/save', {
const res: any = await $fetch('/api/tender/enterprise/save', {
method: 'POST',
body: { ...form, materials: materialList }
body: entForm.value.buildPayload()
})
tip.value = '提交成功,等待平台审核'
if (res?.code !== 0) {
error.value = res?.message || '提交失败'
return
}
tip.value = res?.message || '提交成功,等待平台审核'
await load()
} catch (e: any) {
error.value = e?.data?.message || e?.message || '提交失败'
error.value = e?.data?.statusMessage || e?.data?.message || e?.message || '提交失败'
} finally {
submitting.value = false
}
}
loggedIn.value = isLoggedIn()
if (loggedIn.value) {
await load()
}
onMounted(async () => {
loggedIn.value = isLoggedIn()
if (loggedIn.value) {
await load()
}
})
</script>
<style scoped>
.field {
width: 100%;
border-radius: 6px;
border: 1px solid #d1d5db;
padding: 8px 16px;
font-size: 14px;
}
</style>