diff --git a/README.md b/README.md
index 22d94ec..0154080 100644
--- a/README.md
+++ b/README.md
@@ -43,7 +43,7 @@ pnpm test
“我的信息”使用真实个人资料和修改密码接口,姓名、电话、邮箱按服务端约束维护;修改密码成功后清理旧会话并返回登录页。成员列表返回姓名、联系方式和 `roleIds`,新增、编辑、角色全量保存、启停和管理员重置密码均使用真实接口,不回退静态成功。角色页同时加载成员关系,直接打开或刷新后成员数仍按真实 `roleIds` 统计。角色列表返回 `permissionIds`,页面可回显并全量保存角色权限;角色编辑、启停、删除分别调用 `PUT /merchant/v1/iam/roles/{roleId}`、`PUT /merchant/v1/iam/roles/{roleId}/status` 和 `DELETE /merchant/v1/iam/roles/{roleId}`,内置角色及仍有关联成员的角色继续受保护。
-企业资料和资质列表使用真实租户接口,覆盖 `PROFILE_REQUIRED`、`PENDING`、`ACTIVE`、`REJECTED`、`DISABLED` 五种企业状态。企业名称、法定名称和联系人信息支持真实保存;营业执照、法人身份证、货代资质证书和银行开户许可证使用文件服务分别上传,接受 PDF、JPG、PNG 且不超过 5MB,四类齐全后显式提交审核,文件预览通过当前商户 Token 临时下载并在关闭弹窗后释放。
+企业资料和资质列表使用真实租户接口,覆盖 `PROFILE_REQUIRED`、`PENDING`、`ACTIVE`、`REJECTED`、`DISABLED` 五种企业状态。企业名称、法定名称和联系人信息支持真实保存;营业执照、法人身份证为必填资质,货代资质证书和银行开户许可证为选填资质,均使用文件服务分别上传,接受 PDF、JPG、PNG 且不超过 5MB;两项必填资质齐全后可显式提交审核,文件预览通过当前商户 Token 临时下载并在关闭弹窗后释放。
头程订单列表、详情、仓库查询、创建/编辑草稿和提交已经接入真实 API,并显式映射后端 `transportMode`、`containerMode` 和订单状态。创建与提交请求携带幂等键,`merchant:order:create`、`merchant:order:edit`、`merchant:order:submit` 分别映射到独立前端操作权限。页面补齐渠道、提货方式、报关模式、目的仓类型、包装类型等后端必填字段;API 模式先清空本地记录,请求失败不会回退模拟成功。FBA 和海外仓按公共仓类型筛选,选择后由仓库主数据生成目的仓字段,不再要求客户重复选择收货单位;私人仓才填写并按当前用户缓存收件资料。只有“我司提货”需要填写提货联系人和地址,其他送货方式不发送发货方快照,订单也不再依赖往来单位 `customerId`。快递信息仍由平台维护、客户只读。订单取消不属于本轮本地一期验收,客户端不暴露取消写操作。订单产品状态仍按已确认的七状态执行,不设置“已驳回”;若兼容旧接口数据收到 `REJECTED`,只按待审核展示且不开放编辑。
diff --git a/nginx/default.conf.template b/nginx/default.conf.template
index 581c6a5..9063add 100644
--- a/nginx/default.conf.template
+++ b/nginx/default.conf.template
@@ -13,6 +13,7 @@ server {
# 前端统一请求 /api,保持原始路径转发给 Gateway。
location /api/ {
+ client_max_body_size 6m;
proxy_pass ${API_UPSTREAM};
proxy_http_version 1.1;
proxy_set_header Host $host;
diff --git a/src/deployment/nginxConfig.test.mjs b/src/deployment/nginxConfig.test.mjs
new file mode 100644
index 0000000..7fc7ad7
--- /dev/null
+++ b/src/deployment/nginxConfig.test.mjs
@@ -0,0 +1,9 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import test from 'node:test'
+
+const config = readFileSync(new URL('../../nginx/default.conf.template', import.meta.url), 'utf8')
+
+test('allows qualification uploads with multipart overhead through the API proxy', () => {
+ assert.match(config, /location \/api\/ \{[\s\S]*?client_max_body_size 6m;/)
+})
diff --git a/src/modules/auth/views/AuthViews.test.mjs b/src/modules/auth/views/AuthViews.test.mjs
index 1a455dd..4b5f4d3 100644
--- a/src/modules/auth/views/AuthViews.test.mjs
+++ b/src/modules/auth/views/AuthViews.test.mjs
@@ -42,8 +42,9 @@ test('shows immediate login after registration and separates company review from
assert.match(registerSource, /企业账号注册成功/)
assert.match(registerSource, /无需等待注册审核/)
assert.match(registerSource, /提交订单前还需完成企业审核/)
- assert.match(registerSource, /营业执照、法人身份证、货代资质证书和银行开户许可证/)
- assert.match(registerSource, /一次补齐四类必填资质并提交审核/)
+ assert.match(registerSource, /营业执照和法人身份证/)
+ assert.match(registerSource, /货代资质证书、银行开户许可证可选填/)
+ assert.match(registerSource, /其他资质可选填/)
assert.match(registerSource, /立即登录/)
})
diff --git a/src/modules/auth/views/RegisterView.vue b/src/modules/auth/views/RegisterView.vue
index 94d68bb..1d63fc0 100644
--- a/src/modules/auth/views/RegisterView.vue
+++ b/src/modules/auth/views/RegisterView.vue
@@ -116,7 +116,7 @@ async function submit() {
《用户服务协议》 和 《隐私政策》
-
注册成功后账号可立即登录;首次登录需补齐营业执照、法人身份证、货代资质证书和银行开户许可证,提交平台审核通过后才能提交订单。
+ 注册成功后账号可立即登录;首次登录需上传营业执照和法人身份证,货代资质证书、银行开户许可证可选填。提交平台审核通过后才能提交订单。
取消注册企业账号
@@ -126,7 +126,7 @@ async function submit() {
企业账号注册成功
{{ form.username }} 现在可以直接登录客户工作台,无需等待注册审核。
- 企业名称
- {{ form.companyName }}
- 登录账号
- {{ form.username }}
- 注册时间
- {{ submittedAt }}
- 账号状态
- 可登录
- 提交订单前还需完成企业审核登录后请在“企业资料”一次补齐四类必填资质并提交审核。审核期间仍可维护资料、成员和订单草稿。
+ 提交订单前还需完成企业审核登录后请在“企业资料”上传营业执照和法人身份证并提交审核,其他资质可选填。审核期间仍可维护资料、成员和订单草稿。
立即登录
diff --git a/src/modules/tenant-management/utils/qualificationFile.test.mjs b/src/modules/tenant-management/utils/qualificationFile.test.mjs
index bc85c40..b145475 100644
--- a/src/modules/tenant-management/utils/qualificationFile.test.mjs
+++ b/src/modules/tenant-management/utils/qualificationFile.test.mjs
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict'
import test from 'node:test'
-import { formatQualificationFileSize, QUALIFICATION_MAX_FILE_SIZE, validateQualificationFile } from './qualificationFile.ts'
+import { formatQualificationFileSize, qualificationUploadErrorMessage, QUALIFICATION_MAX_FILE_SIZE, validateQualificationFile } from './qualificationFile.ts'
test('accepts the backend-confirmed PDF, JPG and PNG formats up to 5MB', () => {
assert.equal(validateQualificationFile({ name: '营业执照.jpeg', size: QUALIFICATION_MAX_FILE_SIZE, type: 'image/jpeg' }), '')
@@ -20,3 +20,9 @@ test('formats selected file sizes for review', () => {
assert.equal(formatQualificationFileSize(1536), '1.5 KB')
assert.equal(formatQualificationFileSize(2 * 1024 * 1024), '2.0 MB')
})
+
+test('turns gateway 413 responses into an actionable upload message', () => {
+ assert.match(qualificationUploadErrorMessage({ status: 413 }), /上传网关拒绝(413)/)
+ assert.match(qualificationUploadErrorMessage({ status: 413 }), /已小于 5MB.*检查网关上传限制/)
+ assert.equal(qualificationUploadErrorMessage(new Error('服务暂不可用')), '服务暂不可用')
+})
diff --git a/src/modules/tenant-management/utils/qualificationFile.ts b/src/modules/tenant-management/utils/qualificationFile.ts
index 65c1f70..a7a3916 100644
--- a/src/modules/tenant-management/utils/qualificationFile.ts
+++ b/src/modules/tenant-management/utils/qualificationFile.ts
@@ -23,3 +23,13 @@ export function formatQualificationFileSize(bytes: number) {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
+
+export function qualificationUploadErrorMessage(error: unknown) {
+ const status = typeof error === 'object' && error !== null && 'status' in error
+ ? (error as { status?: unknown }).status
+ : undefined
+ if (status === 413) {
+ return '文件被上传网关拒绝(413)。请压缩后重试;若文件已小于 5MB,请联系平台管理员检查网关上传限制'
+ }
+ return error instanceof Error ? error.message : '资质文件上传失败'
+}
diff --git a/src/modules/tenant-management/views/CompanyProfileView.vue b/src/modules/tenant-management/views/CompanyProfileView.vue
index 97374b0..bc84525 100644
--- a/src/modules/tenant-management/views/CompanyProfileView.vue
+++ b/src/modules/tenant-management/views/CompanyProfileView.vue
@@ -6,7 +6,7 @@ import { Building2, CheckCircle2, Clock3, FileCheck2, FileText, Pencil, Trash2,
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import { useTenantAdministrationStore, type TenantCompanyProfile, type TenantQualification } from '@/modules/tenant-management/store/tenantAdministration'
import { useAuthStore } from '@/modules/auth/public'
-import { formatQualificationFileSize, validateQualificationFile } from '@/modules/tenant-management/utils/qualificationFile'
+import { formatQualificationFileSize, qualificationUploadErrorMessage, validateQualificationFile } from '@/modules/tenant-management/utils/qualificationFile'
import { qualificationStatusMeta } from '@/modules/tenant-management/utils/qualificationStatus'
import {
downloadQualificationImage,
@@ -19,10 +19,10 @@ const store = useTenantAdministrationStore()
const auth = useAuthStore()
const companyApproved = computed(() => store.company.auditStatus === 'ACTIVE')
const auditStatusCopy = computed(() => ({
- PROFILE_REQUIRED: { title: '请完善企业资质', detail: '四类必填资质提交审核前不能提交头程订单。' },
+ PROFILE_REQUIRED: { title: '请完善企业资质', detail: '上传营业执照和法人身份证并提交审核后,才能提交头程订单。' },
PENDING: { title: '企业资料正在等待平台审核', detail: '审核期间仍可维护资料和保存订单草稿;审核通过前不能提交订单。' },
ACTIVE: { title: '企业资料已审核,下单资格已开通', detail: '您可以创建、保存并提交头程订单。' },
- REJECTED: { title: '企业资料审核未通过', detail: '请根据平台反馈替换对应资质,四类文件齐全后可重新提交审核。' },
+ REJECTED: { title: '企业资料审核未通过', detail: '请根据平台反馈替换对应资质,必填文件齐全后可重新提交审核。' },
DISABLED: { title: '企业账号已被平台停用', detail: '当前不能提交订单,请联系平台客服核实。' },
})[store.company.auditStatus])
const activeTab = ref('basic')
@@ -44,7 +44,7 @@ const loading = ref(false)
const loadError = ref('')
const draft = reactive({ ...store.company })
type QualificationUploadState = 'idle' | 'uploading' | 'uploaded' | 'error'
-type QualificationTypeDefinition = { value: MerchantQualificationType; label: string; description: string }
+type QualificationTypeDefinition = { value: MerchantQualificationType; label: string; description: string; required: boolean }
type QualificationDraftItem = QualificationTypeDefinition & {
file: File | null
fileName: string
@@ -57,11 +57,12 @@ type QualificationDraftItem = QualificationTypeDefinition & {
}
const qualificationTypes: QualificationTypeDefinition[] = [
- { value: 'BUSINESS_LICENSE', label: '营业执照', description: '上传清晰完整的营业执照文件。' },
- { value: 'LEGAL_PERSON_ID', label: '法人身份证', description: '正反面请合并为一个 PDF、JPG 或 PNG 文件。' },
- { value: 'FREIGHT_FORWARDER_CERTIFICATE', label: '货代资质证书', description: '上传当前有效的货代资质证明。' },
- { value: 'BANK_ACCOUNT_LICENSE', label: '银行开户许可证', description: '上传企业对公账户开户证明。' },
+ { value: 'BUSINESS_LICENSE', label: '营业执照', description: '上传清晰完整的营业执照文件。', required: true },
+ { value: 'LEGAL_PERSON_ID', label: '法人身份证', description: '正反面请合并为一个 PDF、JPG 或 PNG 文件。', required: true },
+ { value: 'FREIGHT_FORWARDER_CERTIFICATE', label: '货代资质证书', description: '如有,可上传当前有效的货代资质证明。', required: false },
+ { value: 'BANK_ACCOUNT_LICENSE', label: '银行开户许可证', description: '如有,可上传企业对公账户开户证明。', required: false },
]
+const requiredQualificationTypes = qualificationTypes.filter((type) => type.required)
const qualificationDrafts = reactive(qualificationTypes.map((type) => ({
...type,
file: null,
@@ -80,11 +81,11 @@ const normalizedQualificationCertificateNo = computed(() => qualificationCertifi
const businessLicenseFileSelected = computed(() => Boolean(
qualificationDrafts.find((item) => item.value === 'BUSINESS_LICENSE')?.file,
))
-const qualificationCreditCodeReady = computed(() => companyCreditCodeValid.value
- || (businessLicenseFileSelected.value && unifiedSocialCreditCodePattern.test(normalizedQualificationCertificateNo.value)))
+const qualificationCreditCodeReady = computed(() => !businessLicenseFileSelected.value
+ || companyCreditCodeValid.value
+ || unifiedSocialCreditCodePattern.test(normalizedQualificationCertificateNo.value))
const qualificationCreditCodeError = computed(() => {
if (qualificationCreditCodeReady.value) return ''
- if (!businessLicenseFileSelected.value) return '请重新选择营业执照,并填写18位统一社会信用代码'
return '请输入18位字母或数字组成的统一社会信用代码'
})
const canUploadQualification = computed(() => ['PROFILE_REQUIRED', 'REJECTED'].includes(store.company.auditStatus))
@@ -98,22 +99,12 @@ const qualificationUploadDisabledReason = computed(() => {
if (store.company.auditStatus === 'DISABLED') return '企业账号已停用,暂不能提交资质文件。'
return ''
})
-const missingQualificationTypes = computed(() => qualificationTypes
- .filter((type) => {
- const draftItem = qualificationDrafts.find((item) => item.value === type.value)
- return !uploadedQualificationTypes.value.has(type.value)
- && !draftItem?.file
- && draftItem?.uploadState !== 'uploaded'
- })
- .map((type) => type.label))
const selectedQualificationCount = computed(() => qualificationDrafts.filter((item) => Boolean(item.file)).length)
const canSaveQualificationBatch = computed(() => canUploadQualification.value
&& selectedQualificationCount.value > 0
- && missingQualificationTypes.value.length === 0
&& qualificationCreditCodeReady.value)
const qualificationSaveDisabledReason = computed(() => {
if (!canUploadQualification.value) return qualificationUploadDisabledReason.value
- if (missingQualificationTypes.value.length) return `还需选择:${missingQualificationTypes.value.join('、')}`
if (!qualificationCreditCodeReady.value) return qualificationCreditCodeError.value
if (!selectedQualificationCount.value) return '请选择需要新增或替换的资质文件'
return ''
@@ -123,15 +114,15 @@ const qualificationDialogHint = computed(() => qualificationSaveDisabledReason.v
: qualificationSaveDisabledReason.value)
const canSubmitQualificationReview = computed(() => canUploadQualification.value
&& companyCreditCodeValid.value
- && qualificationTypes.every((type) => uploadedQualificationTypes.value.has(type.value)))
+ && requiredQualificationTypes.every((type) => uploadedQualificationTypes.value.has(type.value)))
const qualificationReviewDisabledReason = computed(() => {
if (!canUploadQualification.value) return qualificationUploadDisabledReason.value
if (!companyCreditCodeValid.value) return '请先通过营业执照填写18位统一社会信用代码'
- const missing = qualificationTypes.filter((type) => !uploadedQualificationTypes.value.has(type.value)).map((type) => type.label)
+ const missing = requiredQualificationTypes.filter((type) => !uploadedQualificationTypes.value.has(type.value)).map((type) => type.label)
return missing.length ? `请先上传:${missing.join('、')}` : ''
})
const qualificationPanelHint = computed(() => canUploadQualification.value
- ? '一次选择四类必填资质;文件保存不会自动送审,全部齐全后再提交企业审核。'
+ ? '营业执照、法人身份证为必填;货代资质证书、银行开户许可证可选填。文件保存后需另行提交审核。'
: qualificationUploadDisabledReason.value)
const profileFormRules: FormRules = {
companyName: [{ required: true, whitespace: true, message: '请输入公司名称', trigger: 'blur' }],
@@ -243,14 +234,14 @@ function qualificationSlotLabel(item: QualificationDraftItem) {
const record = qualificationRecord(item.value)
if (record?.status === 'PENDING') return '已上传'
if (record?.status === 'REJECTED') return '需重新上传'
- return '待上传'
+ return item.required ? '待上传' : '选填'
}
function qualificationSlotTagType(item: QualificationDraftItem) {
if (item.file) return 'warning'
if (item.uploadState === 'uploaded' || qualificationRecord(item.value)?.status === 'PENDING') return 'success'
if (item.uploadState === 'error' || qualificationRecord(item.value)?.status === 'REJECTED') return 'danger'
- return 'warning'
+ return item.required ? 'warning' : 'info'
}
function handleQualificationFileChange(type: MerchantQualificationType, file: UploadFile) {
@@ -323,13 +314,6 @@ async function submitQualification() {
ElMessage.warning(qualificationUploadDisabledReason.value)
return
}
- if (missingQualificationTypes.value.length) {
- qualificationDrafts.forEach((item) => {
- if (!uploadedQualificationTypes.value.has(item.value) && !item.file) item.error = '请选择该项资质文件'
- })
- ElMessage.warning(`请补齐:${missingQualificationTypes.value.join('、')}`)
- return
- }
if (!qualificationCreditCodeReady.value) {
const businessLicense = qualificationDraftItem('BUSINESS_LICENSE')
if (businessLicense) businessLicense.error = qualificationCreditCodeError.value
@@ -366,7 +350,7 @@ async function submitQualification() {
item.uploadState = 'uploaded'
uploadedCount += 1
} catch (error) {
- const message = error instanceof Error ? error.message : '资质文件上传失败'
+ const message = qualificationUploadErrorMessage(error)
item.error = message
item.uploadState = 'error'
failures.push(`${item.label}:${message}`)
@@ -542,14 +526,14 @@ function closeQualificationPreview() {
@closed="resetQualificationDraft"
>
- 一次补齐四类必填资质
- 每类选择一个文件后统一保存;文件保存不会自动提交企业审核。
+ 必填资质与选填资料
+ 营业执照、法人身份证必填;其余两项可按需上传。文件保存不会自动提交企业审核。
-
{{ item.label }}
+
{{ item.label }}({{ item.required ? '必填' : '选填' }})
{{ item.description }}
{{ qualificationSlotLabel(item) }}
@@ -579,7 +563,7 @@ function closeQualificationPreview() {
{{ item.error }}
-
+
{{ qualificationDialogHint }}
diff --git a/src/modules/tenant-management/views/TenantAdministrationViews.test.mjs b/src/modules/tenant-management/views/TenantAdministrationViews.test.mjs
index 6a57884..2838b87 100644
--- a/src/modules/tenant-management/views/TenantAdministrationViews.test.mjs
+++ b/src/modules/tenant-management/views/TenantAdministrationViews.test.mjs
@@ -47,7 +47,7 @@ test('implements company details, contact information and qualification workflow
assert.match(profileSource, /:close-on-click-modal="!qualificationSubmitting"/)
assert.match(profileSource, /:close-on-press-escape="!qualificationSubmitting"/)
assert.match(profileSource, /:show-close="!qualificationSubmitting"/)
- assert.match(profileSource, /请重新选择营业执照,并填写18位统一社会信用代码/)
+ assert.match(profileSource, /const qualificationCreditCodeReady = computed\(\(\) => !businessLicenseFileSelected\.value/)
assert.match(profileSource, /companyCreditCodeValid\.value/)
assert.doesNotMatch(profileSource, / type\.required\)/)
+ assert.match(profileSource, /货代资质证书'.*required: false/)
+ assert.match(profileSource, /银行开户许可证'.*required: false/)
+ assert.match(profileSource, /requiredQualificationTypes\.every/)
+ assert.doesNotMatch(profileSource, /四类必填资质/)
+ assert.match(profileSource, /qualificationUploadErrorMessage\(error\)/)
assert.match(profileSource, /submitTenantQualificationReview/)
assert.match(profileSource, /提交企业审核/)
assert.match(profileSource, /已保存.*项资质文件,尚未提交审核/)