修复bug
This commit is contained in:
@@ -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` 五种企业状态。企业名称、法定名称和联系人信息支持真实保存;营业执照上传使用文件服务,仅接受 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 模式先清空本地记录,请求失败不会回退模拟成功。收发货单位通过 `/merchant/v1/partners/candidates` 一次读取当前租户启用候选;后端主数据类型 `CUSTOMER / SUPPLIER / BOTH` 不等同于订单角色,同一候选可用于发货方或收货方并分别保存快照。订单写请求以发货方 ID 填充后端强制校验的主往来单位 `customerId`。快递信息仍由平台维护、客户只读。订单取消不属于本轮本地一期验收,客户端不暴露取消写操作。订单产品状态仍按已确认的七状态执行,不设置“已驳回”;若兼容旧接口数据收到 `REJECTED`,只按待审核展示且不开放编辑。
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
export type LegalDocumentKey = 'terms' | 'privacy'
|
||||
|
||||
export interface LegalSection {
|
||||
title: string
|
||||
paragraphs?: string[]
|
||||
items?: string[]
|
||||
}
|
||||
|
||||
export interface LegalDocument {
|
||||
title: string
|
||||
version: string
|
||||
updatedAt: string
|
||||
effectiveAt: string
|
||||
introduction: string[]
|
||||
sections: LegalSection[]
|
||||
}
|
||||
|
||||
const terms: LegalDocument = {
|
||||
title: '柏悦物流用户服务协议',
|
||||
version: '1.0',
|
||||
updatedAt: '2026年8月3日',
|
||||
effectiveAt: '2026年8月3日',
|
||||
introduction: [
|
||||
'欢迎使用柏悦物流客户工作台。本协议是您与柏悦物流之间关于注册、登录和使用客户工作台及相关物流服务所订立的协议。',
|
||||
'请您在注册前完整阅读并充分理解本协议,尤其是有关责任范围、违约处理、争议解决等可能对您权益产生重大影响的条款。您勾选同意、完成注册或实际使用服务,即表示您已阅读、理解并同意接受本协议。',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
title: '一、协议范围与定义',
|
||||
items: [
|
||||
'“柏悦物流”或“我们”是指运营柏悦物流客户工作台并在具体订单、报价单、运输单证或服务合同中载明的实际服务主体。',
|
||||
'“平台”是指柏悦物流向企业客户提供的客户工作台及相关网页、系统和功能。',
|
||||
'“用户”或“您”包括注册企业、企业管理员及经企业授权使用平台的成员。企业管理员在平台内进行的授权、下单和管理行为,视为企业行为。',
|
||||
'本协议规范平台账号及通用服务。具体物流服务还可能适用报价单、订单、运单、委托书、服务规则或双方另行签署的合同;约定不一致时,以双方就具体服务作出的特别约定为准,但法律另有强制性规定的除外。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '二、账号注册与管理',
|
||||
items: [
|
||||
'您应具备签订和履行本协议所需的民事权利能力及行为能力,并保证已获得所在企业的合法授权。',
|
||||
'您应提供真实、准确、完整、有效的企业及联系人信息,并在信息发生变化时及时更新。柏悦物流有权依法或基于风险控制需要核验营业执照、法定代表人、联系人及其他资质信息。',
|
||||
'您应妥善保管账号、密码及验证信息,不得出租、出借、转让或与无权人员共享。通过您账号完成的操作原则上视为您的行为;发现账号被盗用、冒用或存在安全风险时,应立即修改密码并联系平台客服。',
|
||||
'企业管理员应根据成员实际职责分配权限,并及时停用离职、调岗或不再需要访问平台的成员账号。因企业内部授权管理不当造成的损失,由企业依法承担相应责任。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '三、平台及物流服务',
|
||||
items: [
|
||||
'平台可用于维护企业资料和成员权限、创建及提交物流订单、查看订单和履约信息,以及使用柏悦物流后续开放的其他功能。具体功能以平台实际提供为准。',
|
||||
'您提交的订单为物流服务需求。服务范围、线路、时效、价格、计费重量、附加费用、清关要求及赔偿规则,以柏悦物流确认的订单、报价、合同和适用服务规则为准。平台展示的预计时效不构成绝对到达时间承诺。',
|
||||
'柏悦物流可自行履行服务,也可根据履约需要委托仓库、承运人、报关服务商、目的地服务商及其他合格合作方完成全部或部分服务。',
|
||||
'因实际重量、体积、品类、地址、海关查验、税费或其他订单信息变化导致费用调整的,柏悦物流将依据适用计费规则与实际服务情况结算。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '四、您的陈述、保证与义务',
|
||||
items: [
|
||||
'您应如实、完整填写发货人、收货人、货物名称、数量、重量、尺寸、价值、原产地、用途及监管属性等信息,并按要求提供真实有效的发票、装箱单、合同、授权文件和清关资料。',
|
||||
'您保证对交付运输的货物享有合法处分权,货物来源和用途合法,包装符合运输、仓储和目的地国家或地区的要求。',
|
||||
'您不得通过平台寄递或安排运输法律法规、国际公约、海关、航空安全规则或柏悦物流服务规则禁止或限制且未如实申报的物品,包括但不限于危险品、违禁品、侵权商品及来源不明物品。',
|
||||
'您向平台提供联系人、发货人、收货人及其他第三方的个人信息前,应确保具有合法处理依据,并已向相关人员履行必要告知义务。您不得提供与履约无关的信息。',
|
||||
'您不得以任何方式干扰平台运行、绕过权限控制、探测系统漏洞、传播恶意程序、批量抓取数据,或利用平台从事违法、侵权、欺诈及损害他人合法权益的活动。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '五、费用与结算',
|
||||
items: [
|
||||
'您应按双方确认的价格、账期和方式支付运费、仓储费、操作费、关税税费、附加费及其他已实际发生的费用。',
|
||||
'如您对账单或费用有异议,应在双方约定期限内提出并提供相关依据;无争议部分仍应按期支付。逾期付款的责任以双方合同、订单或适用规则为准。',
|
||||
'第三方收取的关税、税款、查验费、港杂费及目的地费用,除双方另有书面约定外,由依法或依交易安排应承担该费用的一方支付。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '六、知识产权与平台数据',
|
||||
items: [
|
||||
'平台的软件、页面设计、商标、标识、文字、图片及相关技术成果的知识产权归柏悦物流或合法权利人所有。未经书面许可,您不得复制、改编、反向工程、出售或用于本协议目的之外的用途。',
|
||||
'您保留对依法上传内容的相应权利,并授权柏悦物流在提供服务、履行法定义务、处理争议和保障安全所必要的范围内使用相关内容。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '七、服务变更、中断与终止',
|
||||
items: [
|
||||
'柏悦物流可基于业务安排、系统维护、安全风险或法律法规要求调整平台功能,并通过平台公告、站内消息或其他合理方式通知您。',
|
||||
'因维护升级、网络故障、第三方服务异常、自然灾害、政府行为、战争、罢工、公共卫生事件或其他不可合理控制的原因造成服务中断或延迟的,柏悦物流将尽合理努力恢复服务并降低影响。',
|
||||
'如您严重违反法律法规、本协议或服务规则,提供虚假资料,寄递违禁品,危害平台安全,或逾期履行主要付款义务,柏悦物流可视情采取限制功能、暂停服务、终止账号等措施,并保留依法追究责任的权利。',
|
||||
'账号终止不影响终止前已产生的订单、费用、保密义务、赔偿责任及争议解决条款。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '八、责任范围',
|
||||
items: [
|
||||
'柏悦物流将按照法律法规、双方合同和行业合理标准提供服务。因柏悦物流原因造成货物灭失、损毁或延误的,责任范围依适用法律、具体服务合同、运单条款及保价或保险约定确定。依法不得限制或免除的责任,不受本条限制。',
|
||||
'因您提供的信息或单证不真实、不准确、不完整,包装不当,货物自身性质或合理损耗,收发货方原因,海关及其他主管机关行为,或您违反本协议导致的损失及第三方索赔,由责任方依法承担。',
|
||||
'平台中的查询结果、预计时效和节点信息可能因承运人或其他合作方回传存在合理延迟,最终履约状态以实际运输和签收记录为准。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '九、协议更新与通知',
|
||||
items: [
|
||||
'柏悦物流可根据法律法规、监管要求或业务变化更新本协议。涉及您重大权益的更新,我们将通过平台显著提示、站内消息或其他合理方式通知,并依法在必要时重新取得您的同意。',
|
||||
'通知发送至您在平台预留的联系方式、账号消息中心或平台公告区域,即视为已采用合理方式送达。请及时维护有效联系方式并关注相关通知。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '十、适用法律与争议解决',
|
||||
paragraphs: [
|
||||
'本协议的订立、履行、解释及争议解决适用中华人民共和国大陆地区法律。因本协议产生的争议,双方应先友好协商;协商不成的,按双方另行签署的合同约定处理。没有特别约定的,可向依法有管辖权的人民法院提起诉讼。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '十一、联系我们',
|
||||
paragraphs: [
|
||||
'如您对本协议或服务有疑问、意见或投诉,可通过平台公示的客服渠道联系柏悦物流。我们会在核验您的身份和请求后尽快处理。',
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const privacy: LegalDocument = {
|
||||
title: '柏悦物流隐私政策',
|
||||
version: '1.0',
|
||||
updatedAt: '2026年8月3日',
|
||||
effectiveAt: '2026年8月3日',
|
||||
introduction: [
|
||||
'柏悦物流重视对个人信息的保护。本政策说明我们在运营客户工作台、提供物流及相关服务时,如何收集、使用、存储、提供和保护个人信息,以及您如何行使个人信息权利。',
|
||||
'本平台主要面向企业客户。企业向我们提供其员工、联系人、发货人、收货人或其他个人的信息时,应确保具有合法处理依据并已履行必要的告知义务。',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
title: '一、适用范围',
|
||||
items: [
|
||||
'本政策适用于柏悦物流客户工作台及通过该平台提供的企业账号管理、头程订单、物流履约、客户服务等功能。',
|
||||
'具体服务由合作方独立提供并由其自行决定个人信息处理目的和方式的,相关合作方的隐私政策同时适用。我们会在合理可行范围内提示您关注。',
|
||||
'本政策中的“柏悦物流”或“我们”是指平台及具体订单、合同或平台公示信息中载明的实际运营和服务主体。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '二、我们如何收集和使用个人信息',
|
||||
items: [
|
||||
'注册与登录:为创建和管理企业账号、验证身份及保障账号安全,我们会处理登录用户名、密码的安全校验信息、企业名称、联系人姓名、法定代表人姓名、手机号、联系邮箱、国家或地区和企业地址。',
|
||||
'企业认证与权限管理:为核验企业资质、开通下单资格和管理企业成员,我们可能处理营业执照、统一社会信用代码、法定代表人及联系人信息、企业资质文件、成员姓名、联系方式、角色和权限信息。',
|
||||
'物流下单与履约:为接收订单并完成收货、仓储、运输、报关清关、派送、查询和售后,我们可能处理订单号,发货人和收货人的姓名、公司、电话、邮箱、地址,货物名称、数量、重量、尺寸、价值、原产地、品牌、包装及申报信息,以及发票、装箱单、合同、委托书和依法需要的清关资料。部分清关资料可能包含身份证件等敏感个人信息,我们会在具体业务需要时另行提示,并在取得相应授权或具备其他合法依据后处理。',
|
||||
'费用与对账:为计费、结算、开票和处理争议,我们可能处理订单费用、账单、付款状态、发票抬头及税务信息。柏悦当前平台功能不要求您直接提交银行卡密码或支付验证码。',
|
||||
'客户服务:当您咨询、投诉或申请售后时,我们可能处理您的账号及联系方式、咨询内容、沟通记录、相关订单和您主动提交的证明材料,以核实身份、定位问题和处理请求。',
|
||||
'安全与运维:为保障账号、交易和系统安全,排查故障并防范欺诈、攻击和越权访问,我们可能自动记录登录时间、操作日志、IP 地址、浏览器类型、设备与系统基础信息、异常和错误日志。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '三、处理个人信息的依据与原则',
|
||||
items: [
|
||||
'我们遵循合法、正当、必要、诚信和目的明确原则,仅处理实现相关功能所必需的信息。',
|
||||
'我们会根据具体情形,基于取得您的同意、订立或履行合同所必需、履行法定义务、应对突发公共卫生事件或紧急保护自然人人身财产安全,以及法律允许的其他依据处理个人信息。',
|
||||
'如某项信息并非基本功能所必需,您可以选择不提供;但这可能导致对应的可选功能无法使用。对于履行合同或法定义务所必需的信息,拒绝提供可能导致我们无法提供相关服务。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '四、Cookie 与同类技术',
|
||||
paragraphs: [
|
||||
'平台可能使用 Cookie、本地存储或类似技术维持登录状态、记住必要设置、保障安全并改善系统稳定性。我们不会将这些技术用于与平台服务无关的目的。您可通过浏览器设置管理 Cookie,但禁用必要 Cookie 可能导致登录或部分功能无法正常使用。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '五、委托处理、共享、转让与公开披露',
|
||||
items: [
|
||||
'为完成物流服务,我们可能在必要范围内向仓库、承运人、报关或清关服务商、目的地派送方、信息技术服务商及其他履约合作方提供订单和联系人信息。我们会通过合同等方式要求其按照约定目的和适用法律处理信息。',
|
||||
'为履行法定义务或配合有权机关依法提出的要求,我们可能向海关、交通、邮政、税务、公安、司法或其他主管机关提供必要信息。',
|
||||
'除取得单独同意或法律法规另有规定外,我们不会向其他个人信息处理者提供您的个人信息,不会公开披露您的个人信息。发生合并、分立、资产转让、破产清算等导致个人信息转移的,我们将依法告知接收方信息,并要求其继续受本政策约束;处理目的或方式发生变化的,由接收方依法重新取得同意。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '六、跨境物流中的信息出境',
|
||||
paragraphs: [
|
||||
'当您委托我们提供跨境物流、境外仓储、清关或派送服务时,为履行订单所必需,我们可能需要向目的地国家或地区的关联服务主体、承运人、仓库、清关机构或派送方提供收发货人信息、地址、联系方式、货物和清关资料。我们将依据适用法律履行个人信息出境相关义务,并采取合同、权限控制、最小化提供等保护措施。您向我们提供第三方信息前,应向相关个人告知可能发生的跨境传输并确保具有合法依据。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '七、个人信息的存储',
|
||||
items: [
|
||||
'我们原则上将在中华人民共和国境内运营过程中收集和产生的个人信息存储在境内;因跨境履约确需向境外提供的,按本政策第六条处理。',
|
||||
'我们仅在实现处理目的所需的最短期限及法律法规要求的期限内保存信息。保存期限届满后,我们会依法删除或匿名化处理;法律规定的保存期限尚未届满,或删除从技术上难以实现的,我们将停止除存储和必要安全保护之外的处理。',
|
||||
'账号注销或企业终止合作后,部分订单、运单、结算、审计及争议处理信息仍可能按照法律规定或处理争议所需期限保留。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '八、我们如何保护个人信息',
|
||||
items: [
|
||||
'我们会根据个人信息的类型和风险,采取与业务相适应的访问控制、身份鉴别、权限隔离、日志审计、备份和安全管理等措施,尽力防止信息遭到未经授权的访问、泄露、篡改、丢失或滥用。',
|
||||
'我们限制员工和合作方仅在履行职责所需范围内访问信息,并通过制度、培训和合同要求其承担保密和安全义务。',
|
||||
'如发生或可能发生个人信息安全事件,我们将依法采取补救措施,并按规定向有关主管部门报告及向受影响个人告知。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '九、您的个人信息权利',
|
||||
items: [
|
||||
'在适用法律规定的范围内,您有权查阅、复制、更正、补充或删除您的个人信息,并有权撤回基于同意作出的授权、请求注销账号、要求解释个人信息处理规则。',
|
||||
'部分企业资料和成员信息可由有权限的用户在平台内查看或更新。对平台暂未提供自助入口的请求,您可通过平台公示的客服渠道提出。',
|
||||
'为保障信息安全,我们可能要求您验证身份并说明具体请求。我们将在法律规定期限内答复;对于法律规定的例外情形,我们可能无法响应,并会向您说明原因。',
|
||||
'撤回同意不影响撤回前基于同意已进行的处理。删除信息或注销账号可能导致相关功能不可用,但不影响依法应继续保存的信息及您在注销前应承担的责任。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '十、未成年人保护',
|
||||
paragraphs: [
|
||||
'本平台面向具备相应授权的企业用户,不以未满十四周岁的未成年人为服务对象。如我们发现未经监护人同意收集了儿童个人信息,将依法尽快删除或采取其他必要措施。请勿通过平台提交与物流履约无关的未成年人信息。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '十一、政策更新',
|
||||
paragraphs: [
|
||||
'我们可能根据法律法规、监管要求、服务或技术变化更新本政策。对于可能对您权益产生重大影响的变更,我们会通过平台显著提示、站内消息或其他合理方式通知,并在法律要求时重新取得您的同意。',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '十二、联系我们',
|
||||
paragraphs: [
|
||||
'如您对本政策、个人信息处理活动或权利请求有疑问、意见或投诉,可通过平台公示的客服渠道联系柏悦物流。我们将在核验您的身份后,按照适用法律规定的期限处理和答复。',
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const legalDocuments: Record<LegalDocumentKey, LegalDocument> = { terms, privacy }
|
||||
@@ -3,4 +3,6 @@ import type { RouteRecordRaw } from 'vue-router'
|
||||
export const authRoutes = [
|
||||
{ path: '/login', name: 'login', component: () => import('./views/LoginView.vue'), meta: { title: '登录' } },
|
||||
{ path: '/register', name: 'register', component: () => import('./views/RegisterView.vue'), meta: { title: '企业注册' } },
|
||||
{ path: '/legal/terms', name: 'terms', component: () => import('./views/LegalDocumentView.vue'), props: { documentKey: 'terms' }, meta: { title: '用户服务协议' } },
|
||||
{ path: '/legal/privacy', name: 'privacy', component: () => import('./views/LegalDocumentView.vue'), props: { documentKey: 'privacy' }, meta: { title: '隐私政策' } },
|
||||
] satisfies RouteRecordRaw[]
|
||||
|
||||
@@ -4,6 +4,9 @@ import test from 'node:test'
|
||||
|
||||
const loginSource = readFileSync(new URL('./LoginView.vue', import.meta.url), 'utf8')
|
||||
const registerSource = readFileSync(new URL('./RegisterView.vue', import.meta.url), 'utf8')
|
||||
const legalSource = readFileSync(new URL('./LegalDocumentView.vue', import.meta.url), 'utf8')
|
||||
const legalDocumentsSource = readFileSync(new URL('../legalDocuments.ts', import.meta.url), 'utf8')
|
||||
const authRoutesSource = readFileSync(new URL('../routes.ts', import.meta.url), 'utf8')
|
||||
const authStoreSource = readFileSync(new URL('../store/auth.ts', import.meta.url), 'utf8')
|
||||
const httpSource = readFileSync(new URL('../../../api/http.ts', import.meta.url), 'utf8')
|
||||
const sessionSource = readFileSync(new URL('../../../api/session.ts', import.meta.url), 'utf8')
|
||||
@@ -42,6 +45,17 @@ test('shows immediate login after registration and separates company review from
|
||||
assert.match(registerSource, /立即登录/)
|
||||
})
|
||||
|
||||
test('opens complete service and privacy documents from registration', () => {
|
||||
assert.match(registerSource, /to="\/legal\/terms" target="_blank"/)
|
||||
assert.match(registerSource, /to="\/legal\/privacy" target="_blank"/)
|
||||
assert.doesNotMatch(registerSource, /showDocument/)
|
||||
assert.match(authRoutesSource, /path: '\/legal\/terms'/)
|
||||
assert.match(authRoutesSource, /path: '\/legal\/privacy'/)
|
||||
assert.match(legalSource, /document\.sections/)
|
||||
assert.match(legalDocumentsSource, /跨境物流中的信息出境/)
|
||||
assert.match(legalDocumentsSource, /违禁品/)
|
||||
})
|
||||
|
||||
test('uses real auth endpoints and single-flight refresh handling', () => {
|
||||
assert.match(authApiSource, /\/auth\/login/)
|
||||
assert.match(authApiSource, /\/auth\/register/)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import baiyueLogoDark from '@/assets/brand/baiyue-logo-dark.png'
|
||||
import { legalDocuments, type LegalDocumentKey } from '@/modules/auth/legalDocuments'
|
||||
|
||||
const props = defineProps<{ documentKey: LegalDocumentKey }>()
|
||||
const document = computed(() => legalDocuments[props.documentKey])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="legal-page">
|
||||
<header class="legal-header">
|
||||
<RouterLink to="/register"><ArrowLeft :size="18" /> 返回企业注册</RouterLink>
|
||||
<RouterLink to="/login" class="auth-brand" aria-label="返回柏悦物流登录页">
|
||||
<img class="auth-brand-logo" :src="baiyueLogoDark" alt="柏悦物流" />
|
||||
</RouterLink>
|
||||
</header>
|
||||
|
||||
<main class="legal-main">
|
||||
<article class="legal-document">
|
||||
<header class="legal-title">
|
||||
<h1>{{ document.title }}</h1>
|
||||
<dl>
|
||||
<div><dt>版本</dt><dd>{{ document.version }}</dd></div>
|
||||
<div><dt>更新日期</dt><dd>{{ document.updatedAt }}</dd></div>
|
||||
<div><dt>生效日期</dt><dd>{{ document.effectiveAt }}</dd></div>
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
<section class="legal-introduction" aria-label="重要提示">
|
||||
<p v-for="paragraph in document.introduction" :key="paragraph">{{ paragraph }}</p>
|
||||
</section>
|
||||
|
||||
<section v-for="section in document.sections" :key="section.title" class="legal-section">
|
||||
<h2>{{ section.title }}</h2>
|
||||
<p v-for="paragraph in section.paragraphs" :key="paragraph">{{ paragraph }}</p>
|
||||
<ul v-if="section.items">
|
||||
<li v-for="item in section.items" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
</section>
|
||||
</article>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -87,9 +87,6 @@ async function submit() {
|
||||
}
|
||||
}
|
||||
|
||||
function showDocument(name: string) {
|
||||
ElMessage.info(`${name}将在正式接入前由业务方确认内容`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -116,7 +113,7 @@ function showDocument(name: string) {
|
||||
<el-form-item prop="agreement" class="agreement-item">
|
||||
<div class="agreement-control">
|
||||
<el-checkbox v-model="form.agreement"><span class="agreement-required" aria-hidden="true">*</span>我已阅读并同意</el-checkbox>
|
||||
<span><button type="button" @click="showDocument('《用户服务协议》')">《用户服务协议》</button> 和 <button type="button" @click="showDocument('《隐私政策》')">《隐私政策》</button></span>
|
||||
<span><RouterLink to="/legal/terms" target="_blank" rel="noopener">《用户服务协议》</RouterLink> 和 <RouterLink to="/legal/privacy" target="_blank" rel="noopener">《隐私政策》</RouterLink></span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<p class="register-security-note">注册成功后账号可立即登录;首次登录需上传营业执照并等待平台审核,审核通过后才能提交订单。</p>
|
||||
|
||||
@@ -26,6 +26,10 @@ test('supports tenant profile updates and authenticated qualification downloads'
|
||||
assert.match(source, /apiBlobRequest\(\{ method: 'GET', url \}\)/)
|
||||
assert.match(source, /url\.startsWith\('\/merchant\/v1\/files\/qualifications\/'\)/)
|
||||
assert.match(storeSource, /await updateTenantProfile/)
|
||||
assert.match(source, /url: '\/merchant\/v1\/iam\/tenant\/qualifications\/submit-review'/)
|
||||
assert.match(source, /data\.append\('qualificationType', qualificationType\)/)
|
||||
assert.match(source, /'SUPERSEDED'/)
|
||||
assert.match(storeSource, /normalizeQualificationStatus\(item\.status\)/)
|
||||
})
|
||||
|
||||
test('uses membershipId and full replacement arrays in authorization writes', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { apiBlobRequest, apiRequest } from '@/api/http'
|
||||
import type { MerchantTenantStatus } from '../contracts'
|
||||
export type QualificationStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
|
||||
export type QualificationStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'SUPERSEDED'
|
||||
export type MerchantQualificationType = 'BUSINESS_LICENSE' | 'LEGAL_PERSON_ID' | 'FREIGHT_FORWARDER_CERTIFICATE' | 'BANK_ACCOUNT_LICENSE'
|
||||
|
||||
export interface MerchantTenantProfile {
|
||||
id: number | string
|
||||
@@ -25,7 +26,7 @@ export interface UpdateMerchantTenantProfileRequest {
|
||||
export interface MerchantQualification {
|
||||
id: number | string
|
||||
tenantId: number | string
|
||||
qualificationType: 'BUSINESS_LICENSE'
|
||||
qualificationType: MerchantQualificationType
|
||||
certificateNo: string | null
|
||||
documentUrl: string
|
||||
status: QualificationStatus
|
||||
@@ -48,8 +49,8 @@ export interface MerchantPermissionResource {
|
||||
|
||||
export interface QualificationUploadResult {
|
||||
qualificationId: number | string
|
||||
reviewId: number | string
|
||||
tenantStatus: 'PENDING'
|
||||
reviewId: number | string | null
|
||||
tenantStatus: MerchantTenantStatus
|
||||
documentUrl: string
|
||||
}
|
||||
|
||||
@@ -142,13 +143,24 @@ export function updateMerchantPersonalProfile(payload: Pick<MerchantPersonalProf
|
||||
return apiRequest<MerchantPersonalProfile>({ method: 'PUT', url: '/merchant/v1/iam/profile', data: payload })
|
||||
}
|
||||
|
||||
export function uploadBusinessLicense(file: File, certificateNo?: string) {
|
||||
export function uploadMerchantQualification(
|
||||
file: File,
|
||||
qualificationType: MerchantQualificationType,
|
||||
certificateNo?: string,
|
||||
replace = false,
|
||||
) {
|
||||
const data = new FormData()
|
||||
data.append('file', file)
|
||||
data.append('qualificationType', qualificationType)
|
||||
if (certificateNo) data.append('certificateNo', certificateNo)
|
||||
data.append('replace', String(replace))
|
||||
return apiRequest<QualificationUploadResult>({ method: 'POST', url: '/merchant/v1/files/qualifications', data })
|
||||
}
|
||||
|
||||
export function submitTenantQualificationReview() {
|
||||
return apiRequest<MerchantTenantProfile>({ method: 'POST', url: '/merchant/v1/iam/tenant/qualifications/submit-review' })
|
||||
}
|
||||
|
||||
export function downloadQualificationImage(documentUrl: string) {
|
||||
const url = documentUrl.startsWith('/api/') ? documentUrl.slice(4) : documentUrl
|
||||
if (!url.startsWith('/merchant/v1/files/qualifications/')) throw new Error('资质文件地址无效')
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
TENANT_PERMISSION_GROUPS,
|
||||
type MerchantTenantStatus,
|
||||
} from '@/modules/tenant-management/contracts'
|
||||
import { normalizeQualificationStatus, type TenantQualificationStatus } from '@/modules/tenant-management/utils/qualificationStatus'
|
||||
|
||||
export type TenantRecordStatus = 'ACTIVE' | 'INACTIVE'
|
||||
|
||||
@@ -42,7 +43,7 @@ export interface TenantQualification {
|
||||
id: string
|
||||
type: string
|
||||
fileName: string
|
||||
status: 'VERIFIED' | 'PENDING' | 'REJECTED' | 'EXPIRED'
|
||||
status: TenantQualificationStatus
|
||||
expiresAt: string
|
||||
updatedAt: string
|
||||
documentUrl: string
|
||||
@@ -93,8 +94,6 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
|
||||
const activeMemberCount = computed(() => members.value.filter((member) => member.status === 'ACTIVE').length)
|
||||
|
||||
async function loadCompanyContext() {
|
||||
company.value = { ...emptyCompany }
|
||||
qualifications.value = []
|
||||
const [profile, qualificationRecords] = await Promise.all([
|
||||
getTenantProfile(),
|
||||
getTenantQualifications(),
|
||||
@@ -110,9 +109,14 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
|
||||
}
|
||||
qualifications.value = qualificationRecords.map((item) => ({
|
||||
id: String(item.id),
|
||||
type: item.qualificationType === 'BUSINESS_LICENSE' ? '营业执照' : item.qualificationType,
|
||||
fileName: item.documentUrl.split('/').pop() || '营业执照',
|
||||
status: item.status === 'APPROVED' ? 'VERIFIED' : item.status,
|
||||
type: ({
|
||||
BUSINESS_LICENSE: '营业执照',
|
||||
LEGAL_PERSON_ID: '法人身份证',
|
||||
FREIGHT_FORWARDER_CERTIFICATE: '货代资质证书',
|
||||
BANK_ACCOUNT_LICENSE: '银行开户许可证',
|
||||
} as const)[item.qualificationType],
|
||||
fileName: item.documentUrl.split('/').pop() || '资质文件',
|
||||
status: normalizeQualificationStatus(item.status),
|
||||
expiresAt: '-',
|
||||
updatedAt: '-',
|
||||
documentUrl: item.documentUrl,
|
||||
|
||||
@@ -2,13 +2,14 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { formatQualificationFileSize, QUALIFICATION_MAX_FILE_SIZE, validateQualificationFile } from './qualificationFile.ts'
|
||||
|
||||
test('accepts the backend-confirmed JPG and PNG formats up to 5MB', () => {
|
||||
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' }), '')
|
||||
assert.equal(validateQualificationFile({ name: '营业执照.PNG', size: 2048, type: 'image/png' }), '')
|
||||
assert.equal(validateQualificationFile({ name: '货代资质.pdf', size: 2048, type: 'application/pdf' }), '')
|
||||
})
|
||||
|
||||
test('rejects PDF, empty, mismatched, and oversized files', () => {
|
||||
assert.match(validateQualificationFile({ name: '营业执照.pdf', size: 1024, type: 'application/pdf' }), /格式不支持/)
|
||||
test('rejects unsupported, empty, mismatched, and oversized files', () => {
|
||||
assert.match(validateQualificationFile({ name: '营业执照.svg', size: 1024, type: 'image/svg+xml' }), /格式不支持/)
|
||||
assert.match(validateQualificationFile({ name: '伪装图片.png', size: 1024, type: 'text/plain' }), /内容类型/)
|
||||
assert.match(validateQualificationFile({ name: '空文件.png', size: 0, type: 'image/png' }), /内容为空/)
|
||||
assert.match(validateQualificationFile({ name: '超大文件.jpg', size: QUALIFICATION_MAX_FILE_SIZE + 1, type: 'image/jpeg' }), /超过 5MB/)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const QUALIFICATION_MAX_FILE_SIZE = 5 * 1024 * 1024
|
||||
|
||||
const allowedExtensions = new Set(['jpg', 'jpeg', 'png'])
|
||||
const allowedMimeTypes = new Set(['image/jpeg', 'image/png'])
|
||||
const allowedExtensions = new Set(['jpg', 'jpeg', 'png', 'pdf'])
|
||||
const allowedMimeTypes = new Set(['image/jpeg', 'image/png', 'application/pdf'])
|
||||
|
||||
export interface QualificationFileLike {
|
||||
name: string
|
||||
@@ -11,7 +11,7 @@ export interface QualificationFileLike {
|
||||
|
||||
export function validateQualificationFile(file: QualificationFileLike) {
|
||||
const extension = file.name.includes('.') ? file.name.split('.').pop()?.toLowerCase() ?? '' : ''
|
||||
if (!allowedExtensions.has(extension)) return '文件格式不支持,请选择 JPG 或 PNG 图片'
|
||||
if (!allowedExtensions.has(extension)) return '文件格式不支持,请选择 PDF、JPG 或 PNG 文件'
|
||||
if (file.type && !allowedMimeTypes.has(file.type)) return '文件内容类型与支持的资质格式不一致'
|
||||
if (file.size <= 0) return '文件内容为空,请重新选择'
|
||||
if (file.size > QUALIFICATION_MAX_FILE_SIZE) return '文件超过 5MB,请压缩后重新选择'
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { normalizeQualificationStatus, qualificationStatusMeta } from './qualificationStatus.ts'
|
||||
|
||||
test('maps backend qualification states without treating replaced files as expired', () => {
|
||||
assert.equal(normalizeQualificationStatus('APPROVED'), 'VERIFIED')
|
||||
assert.equal(normalizeQualificationStatus('SUPERSEDED'), 'SUPERSEDED')
|
||||
assert.deepEqual(qualificationStatusMeta('SUPERSEDED'), { label: '已替换', type: 'info' })
|
||||
})
|
||||
|
||||
test('keeps a safe expired fallback for unknown legacy states', () => {
|
||||
assert.equal(normalizeQualificationStatus('UNKNOWN'), 'EXPIRED')
|
||||
assert.deepEqual(qualificationStatusMeta('EXPIRED'), { label: '已过期', type: 'info' })
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
export type TenantQualificationStatus = 'VERIFIED' | 'PENDING' | 'REJECTED' | 'SUPERSEDED' | 'EXPIRED'
|
||||
|
||||
export function normalizeQualificationStatus(status: string): TenantQualificationStatus {
|
||||
if (status === 'APPROVED') return 'VERIFIED'
|
||||
if (status === 'PENDING' || status === 'REJECTED' || status === 'SUPERSEDED') return status
|
||||
return 'EXPIRED'
|
||||
}
|
||||
|
||||
export function qualificationStatusMeta(status: TenantQualificationStatus) {
|
||||
if (status === 'VERIFIED') return { label: '已验证', type: 'success' as const }
|
||||
if (status === 'PENDING') return { label: '待审核', type: 'warning' as const }
|
||||
if (status === 'REJECTED') return { label: '已驳回', type: 'danger' as const }
|
||||
if (status === 'SUPERSEDED') return { label: '已替换', type: 'info' as const }
|
||||
return { label: '已过期', type: 'info' as const }
|
||||
}
|
||||
@@ -7,17 +7,23 @@ import DataTable, { type DataTableColumn } from '@/components/common/DataTable.v
|
||||
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 { downloadQualificationImage, uploadBusinessLicense } from '@/modules/tenant-management/api/merchantIam'
|
||||
import { qualificationStatusMeta } from '@/modules/tenant-management/utils/qualificationStatus'
|
||||
import {
|
||||
downloadQualificationImage,
|
||||
submitTenantQualificationReview,
|
||||
uploadMerchantQualification,
|
||||
type MerchantQualificationType,
|
||||
} from '@/modules/tenant-management/api/merchantIam'
|
||||
import { ApiError } from '@/api/http'
|
||||
|
||||
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')
|
||||
@@ -26,6 +32,7 @@ const qualificationOpen = ref(false)
|
||||
const previewOpen = ref(false)
|
||||
const previewRecord = ref<TenantQualification | null>(null)
|
||||
const previewUrl = ref('')
|
||||
const previewKind = ref<'image' | 'pdf'>('image')
|
||||
const previewError = ref('')
|
||||
const previewLoading = ref(false)
|
||||
const profileFormRef = ref<FormInstance>()
|
||||
@@ -37,13 +44,31 @@ const selectedFileSize = ref(0)
|
||||
const selectedFileType = ref('')
|
||||
const qualificationFileError = ref('')
|
||||
const qualificationSubmitting = ref(false)
|
||||
const reviewSubmitting = ref(false)
|
||||
const profileSubmitting = ref(false)
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const draft = reactive<TenantCompanyProfile>({ ...store.company })
|
||||
const qualificationDraft = reactive({ type: '营业执照', fileName: '', certificateNo: '' })
|
||||
const qualificationTypes: Array<{ value: MerchantQualificationType; label: string }> = [
|
||||
{ value: 'BUSINESS_LICENSE', label: '营业执照' },
|
||||
{ value: 'LEGAL_PERSON_ID', label: '法人身份证' },
|
||||
{ value: 'FREIGHT_FORWARDER_CERTIFICATE', label: '货代资质证书' },
|
||||
{ value: 'BANK_ACCOUNT_LICENSE', label: '银行开户许可证' },
|
||||
]
|
||||
const qualificationDraft = reactive({ type: 'BUSINESS_LICENSE' as MerchantQualificationType, fileName: '', certificateNo: '' })
|
||||
const canUploadQualification = computed(() => ['PROFILE_REQUIRED', 'REJECTED'].includes(store.company.auditStatus))
|
||||
const uploadedQualificationTypes = computed(() => new Set(store.qualifications
|
||||
.filter((item) => item.status === 'PENDING')
|
||||
.map((item) => qualificationTypes.find((type) => type.label === item.type)?.value)
|
||||
.filter((type): type is MerchantQualificationType => Boolean(type))))
|
||||
const canSubmitQualificationReview = computed(() => canUploadQualification.value
|
||||
&& qualificationTypes.every((type) => uploadedQualificationTypes.value.has(type.value)))
|
||||
const qualificationReviewDisabledReason = computed(() => {
|
||||
if (!canUploadQualification.value) return qualificationUploadDisabledReason.value
|
||||
const missing = qualificationTypes.filter((type) => !uploadedQualificationTypes.value.has(type.value)).map((type) => type.label)
|
||||
return missing.length ? `请先上传:${missing.join('、')}` : ''
|
||||
})
|
||||
const qualificationUploadDisabledReason = computed(() => {
|
||||
if (store.company.auditStatus === 'PENDING') return '企业资料正在审核,暂不能再次提交资质文件。'
|
||||
if (store.company.auditStatus === 'ACTIVE') return '当前企业已审核通过,资质替换请联系平台管理员处理。'
|
||||
@@ -51,7 +76,7 @@ const qualificationUploadDisabledReason = computed(() => {
|
||||
return ''
|
||||
})
|
||||
const qualificationPanelHint = computed(() => canUploadQualification.value
|
||||
? '一期仅支持提交营业执照;提交后将进入平台审核。'
|
||||
? '请先上传四类必填资质;文件上传不会自动送审,全部齐全后再提交企业审核。'
|
||||
: qualificationUploadDisabledReason.value)
|
||||
const profileFormRules: FormRules<TenantCompanyProfile> = {
|
||||
companyName: [{ required: true, whitespace: true, message: '请输入公司名称', trigger: 'blur' }],
|
||||
@@ -61,7 +86,7 @@ const profileFormRules: FormRules<TenantCompanyProfile> = {
|
||||
contactEmail: [{ type: 'email', message: '请输入正确的联系邮箱', trigger: 'blur' }],
|
||||
}
|
||||
const qualificationFormRules: FormRules = {
|
||||
fileName: [{ required: true, message: '请选择营业执照图片', trigger: 'change' }],
|
||||
fileName: [{ required: true, message: '请选择资质文件', trigger: 'change' }],
|
||||
}
|
||||
const qualificationColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
|
||||
@@ -76,19 +101,24 @@ const pagedQualifications = computed(() => {
|
||||
return store.qualifications.slice(start, start + qualificationPageSize.value)
|
||||
})
|
||||
|
||||
async function loadCompanyContext() {
|
||||
loading.value = true
|
||||
async function refreshCompanyContext() {
|
||||
loadError.value = ''
|
||||
try {
|
||||
await store.loadCompanyContext()
|
||||
Object.assign(draft, store.company)
|
||||
return true
|
||||
} catch (error) {
|
||||
loadError.value = error instanceof Error ? error.message : '企业资料加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCompanyContext() {
|
||||
loading.value = true
|
||||
await refreshCompanyContext()
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onMounted(() => void loadCompanyContext())
|
||||
|
||||
function startEdit() {
|
||||
@@ -163,7 +193,7 @@ function removeSelectedFile() {
|
||||
|
||||
function resetQualificationDraft() {
|
||||
uploadRef.value?.clearFiles()
|
||||
qualificationDraft.type = '营业执照'
|
||||
qualificationDraft.type = 'BUSINESS_LICENSE'
|
||||
qualificationDraft.fileName = ''
|
||||
qualificationDraft.certificateNo = store.company.creditCode
|
||||
selectedFileSize.value = 0
|
||||
@@ -183,7 +213,7 @@ async function submitQualification() {
|
||||
}
|
||||
const valid = await qualificationFormRef.value?.validate().then(() => true).catch(() => false)
|
||||
if (!valid || !selectedFile.value) {
|
||||
qualificationFileError.value = '请选择营业执照图片'
|
||||
qualificationFileError.value = '请选择资质文件'
|
||||
return
|
||||
}
|
||||
if (qualificationFileError.value) {
|
||||
@@ -192,23 +222,54 @@ async function submitQualification() {
|
||||
}
|
||||
qualificationSubmitting.value = true
|
||||
try {
|
||||
await uploadBusinessLicense(selectedFile.value, qualificationDraft.certificateNo.trim() || undefined)
|
||||
await store.loadCompanyContext()
|
||||
qualificationOpen.value = false
|
||||
ElMessage.success('营业执照已提交审核')
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.code === 'A0409') {
|
||||
await loadCompanyContext()
|
||||
try {
|
||||
const existing = store.qualifications.find((item) => (
|
||||
item.type === qualificationTypes.find((type) => type.value === qualificationDraft.type)?.label
|
||||
&& item.status !== 'SUPERSEDED'
|
||||
))
|
||||
await uploadMerchantQualification(
|
||||
selectedFile.value,
|
||||
qualificationDraft.type,
|
||||
qualificationDraft.type === 'BUSINESS_LICENSE' ? qualificationDraft.certificateNo.trim() || undefined : undefined,
|
||||
Boolean(existing),
|
||||
)
|
||||
qualificationOpen.value = false
|
||||
ElMessage.warning('企业资料正在审核,不能再次提交资质文件')
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.code === 'A0409') {
|
||||
await loadCompanyContext()
|
||||
qualificationOpen.value = false
|
||||
ElMessage.warning('企业资料正在审核,不能再次提交资质文件')
|
||||
return
|
||||
}
|
||||
ElMessage.error(error instanceof Error ? error.message : '资质文件上传失败')
|
||||
return
|
||||
}
|
||||
ElMessage.error(error instanceof Error ? error.message : '营业执照上传失败')
|
||||
const refreshed = await refreshCompanyContext()
|
||||
if (refreshed) ElMessage.success('资质文件已上传,尚未提交审核')
|
||||
else ElMessage.warning('资质文件已上传,但最新状态刷新失败,请点击“重新加载”确认状态')
|
||||
} finally {
|
||||
qualificationSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitQualificationReview() {
|
||||
if (!canSubmitQualificationReview.value || reviewSubmitting.value) return
|
||||
reviewSubmitting.value = true
|
||||
try {
|
||||
try {
|
||||
await submitTenantQualificationReview()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '企业审核提交失败')
|
||||
return
|
||||
}
|
||||
const refreshed = await refreshCompanyContext()
|
||||
if (refreshed) ElMessage.success('企业资料已提交审核')
|
||||
else ElMessage.warning('企业资料已提交审核,但最新状态刷新失败,请点击“重新加载”确认状态')
|
||||
} finally {
|
||||
reviewSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function previewQualification(record: TenantQualification) {
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
previewRecord.value = record
|
||||
@@ -222,6 +283,7 @@ async function previewQualification(record: TenantQualification) {
|
||||
previewLoading.value = true
|
||||
try {
|
||||
const blob = await downloadQualificationImage(record.documentUrl)
|
||||
previewKind.value = blob.type === 'application/pdf' || record.fileName.toLowerCase().endsWith('.pdf') ? 'pdf' : 'image'
|
||||
previewUrl.value = URL.createObjectURL(blob)
|
||||
} catch (error) {
|
||||
previewError.value = error instanceof Error && error.message === '资质文件地址无效'
|
||||
@@ -236,13 +298,11 @@ async function previewQualification(record: TenantQualification) {
|
||||
function closeQualificationPreview() {
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = ''
|
||||
previewKind.value = 'image'
|
||||
previewError.value = ''
|
||||
previewRecord.value = null
|
||||
}
|
||||
|
||||
function qualificationStatus(status: string) {
|
||||
return status === 'VERIFIED' ? '已验证' : status === 'PENDING' ? '待审核' : status === 'REJECTED' ? '已驳回' : '已过期'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -287,9 +347,10 @@ function qualificationStatus(status: string) {
|
||||
<div class="client-card qualification-panel">
|
||||
<header>
|
||||
<div><h3>企业资质</h3><p>{{ qualificationPanelHint }}</p></div>
|
||||
<el-tooltip v-if="auth.hasPermission('company:qualification:upload')" :disabled="canUploadQualification" :content="qualificationUploadDisabledReason" placement="top">
|
||||
<span class="qualification-update-control"><el-button type="primary" :disabled="!canUploadQualification" @click="openQualificationDialog"><Upload :size="16" />更新资质</el-button></span>
|
||||
</el-tooltip>
|
||||
<div v-if="auth.hasPermission('company:qualification:upload')" class="qualification-panel-actions">
|
||||
<el-tooltip :disabled="canUploadQualification" :content="qualificationUploadDisabledReason" placement="top"><span class="qualification-update-control"><el-button :disabled="!canUploadQualification" @click="openQualificationDialog"><Upload :size="16" />上传资质</el-button></span></el-tooltip>
|
||||
<el-tooltip :disabled="canSubmitQualificationReview" :content="qualificationReviewDisabledReason" placement="top"><span class="qualification-update-control"><el-button type="primary" :loading="reviewSubmitting" :disabled="!canSubmitQualificationReview" @click="submitQualificationReview"><FileCheck2 :size="16" />提交企业审核</el-button></span></el-tooltip>
|
||||
</div>
|
||||
</header>
|
||||
<DataTable
|
||||
v-model:page="qualificationPage"
|
||||
@@ -303,11 +364,11 @@ function qualificationStatus(status: string) {
|
||||
row-key="id"
|
||||
>
|
||||
<template #actions="{ row }"><el-button link type="primary" @click="previewQualification(row as TenantQualification)">查看</el-button></template>
|
||||
<template #status="{ row }"><el-tag :type="row.status === 'VERIFIED' ? 'success' : row.status === 'PENDING' ? 'warning' : 'danger'" effect="light">{{ qualificationStatus(row.status) }}</el-tag></template>
|
||||
<template #status="{ row }"><el-tag :type="qualificationStatusMeta(row.status).type" effect="light">{{ qualificationStatusMeta(row.status).label }}</el-tag></template>
|
||||
</DataTable>
|
||||
<div class="mobile-qualification-list">
|
||||
<article v-for="record in store.qualifications" :key="record.id">
|
||||
<header><strong>{{ record.type }}</strong><el-tag :type="record.status === 'VERIFIED' ? 'success' : record.status === 'PENDING' ? 'warning' : 'danger'" effect="light">{{ qualificationStatus(record.status) }}</el-tag></header>
|
||||
<header><strong>{{ record.type }}</strong><el-tag :type="qualificationStatusMeta(record.status).type" effect="light">{{ qualificationStatusMeta(record.status).label }}</el-tag></header>
|
||||
<div class="qualification-file-name"><FileText :size="16" /><span>{{ record.fileName }}</span></div>
|
||||
<dl><div><dt>有效期</dt><dd>{{ record.expiresAt }}</dd></div><div><dt>更新时间</dt><dd>{{ record.updatedAt }}</dd></div></dl>
|
||||
<el-button link type="primary" @click="previewQualification(record)">查看文件信息</el-button>
|
||||
@@ -338,11 +399,11 @@ function qualificationStatus(status: string) {
|
||||
|
||||
<el-dialog v-model="qualificationOpen" title="更新资质文件" width="min(520px, 92vw)" @closed="resetQualificationDraft">
|
||||
<el-form ref="qualificationFormRef" :model="qualificationDraft" :rules="qualificationFormRules" label-position="top">
|
||||
<el-form-item label="文件类型"><el-input v-model="qualificationDraft.type" disabled /></el-form-item>
|
||||
<el-form-item label="文件类型" required><el-select v-model="qualificationDraft.type"><el-option v-for="type in qualificationTypes" :key="type.value" :label="type.label" :value="type.value" /></el-select></el-form-item>
|
||||
<el-form-item label="选择文件" prop="fileName" required :error="qualificationFileError">
|
||||
<el-upload ref="uploadRef" :auto-upload="false" :limit="1" :show-file-list="false" accept=".jpg,.jpeg,.png" @change="handleFileChange">
|
||||
<el-upload ref="uploadRef" :auto-upload="false" :limit="1" :show-file-list="false" accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" @change="handleFileChange">
|
||||
<el-button><Upload :size="16" />选择文件</el-button>
|
||||
<template #tip><div class="upload-tip">仅支持 JPG、PNG,单张图片不超过 5MB。</div></template>
|
||||
<template #tip><div class="upload-tip">支持 PDF、JPG、PNG,单个文件不超过 5MB。</div></template>
|
||||
</el-upload>
|
||||
<div v-if="qualificationDraft.fileName" class="selected-qualification-file" aria-live="polite">
|
||||
<FileText :size="20" />
|
||||
@@ -350,17 +411,18 @@ function qualificationStatus(status: string) {
|
||||
<el-button link type="danger" aria-label="移除所选资质文件" @click="removeSelectedFile"><Trash2 :size="16" />移除</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="统一社会信用代码"><el-input v-model="qualificationDraft.certificateNo" maxlength="18" placeholder="建议填写 18 位统一社会信用代码" /></el-form-item>
|
||||
<el-form-item v-if="qualificationDraft.type === 'BUSINESS_LICENSE'" label="统一社会信用代码"><el-input v-model="qualificationDraft.certificateNo" maxlength="18" placeholder="建议填写 18 位统一社会信用代码" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button :disabled="qualificationSubmitting" @click="qualificationOpen = false">取消</el-button><el-button type="primary" :loading="qualificationSubmitting" @click="submitQualification">提交审核</el-button></template>
|
||||
<template #footer><el-button :disabled="qualificationSubmitting" @click="qualificationOpen = false">取消</el-button><el-button type="primary" :loading="qualificationSubmitting" @click="submitQualification">保存资质文件</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="previewOpen" :title="`${previewRecord?.type || '资质文件'}详情`" width="min(560px, 92vw)" @closed="closeQualificationPreview">
|
||||
<div v-if="previewRecord" v-loading="previewLoading" class="document-preview">
|
||||
<img v-if="previewUrl" :src="previewUrl" :alt="`${previewRecord.type}预览`" class="qualification-preview-image" />
|
||||
<img v-if="previewUrl && previewKind === 'image'" :src="previewUrl" :alt="`${previewRecord.type}预览`" class="qualification-preview-image" />
|
||||
<iframe v-else-if="previewUrl && previewKind === 'pdf'" :src="previewUrl" :title="`${previewRecord.type} PDF 预览`" class="qualification-preview-frame" />
|
||||
<FileCheck2 :size="42" />
|
||||
<strong>{{ previewRecord.fileName }}</strong>
|
||||
<el-tag :type="previewRecord.status === 'VERIFIED' ? 'success' : previewRecord.status === 'PENDING' ? 'warning' : 'danger'" effect="light">{{ qualificationStatus(previewRecord.status) }}</el-tag>
|
||||
<el-tag :type="qualificationStatusMeta(previewRecord.status).type" effect="light">{{ qualificationStatusMeta(previewRecord.status).label }}</el-tag>
|
||||
<dl><div><dt>文件类型</dt><dd>{{ previewRecord.type }}</dd></div><div><dt>有效期</dt><dd>{{ previewRecord.expiresAt }}</dd></div><div><dt>更新时间</dt><dd>{{ previewRecord.updatedAt }}</dd></div></dl>
|
||||
<p v-if="previewError" class="document-preview-error" role="alert">{{ previewError }}</p>
|
||||
<span v-else>{{ previewUrl ? '文件通过当前商户会话临时加载,关闭弹窗后立即释放。' : '当前文件暂不可预览。' }}</span>
|
||||
@@ -382,6 +444,7 @@ function qualificationStatus(status: string) {
|
||||
.company-audit-notice span { color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||||
.company-audit-notice.is-approved { border-color: #bcd9d2; background: #eff8f5; color: #20745f; }
|
||||
.qualification-preview-image { display: block; max-width: 100%; max-height: 360px; margin: 0 auto 14px; object-fit: contain; }
|
||||
.qualification-preview-frame { display:block; width:100%; min-height:min(60vh,560px); margin:0 0 14px; border:0; background:#fff; }
|
||||
.company-audit-notice.is-pending { border-color: #e5d2a5; background: #fff9eb; color: #9a6a12; }
|
||||
.qualification-panel header p { margin: 6px 0 0; color: var(--muted); font-size: 12px; }
|
||||
.profile-tabs :deep(.el-tabs__header) { margin-bottom: 14px; padding: 0 16px; border: 1px solid var(--border); background: #fff; }
|
||||
@@ -399,6 +462,7 @@ function qualificationStatus(status: string) {
|
||||
.qualification-panel { padding: 0; overflow: hidden; }
|
||||
.qualification-panel > header { padding: 20px 22px; border-bottom: 1px solid var(--border); }
|
||||
.qualification-panel h3 { margin: 0; }
|
||||
.qualification-panel-actions { display:flex; flex-wrap:wrap; justify-content:flex-end; gap:8px; }
|
||||
.qualification-update-control { display: inline-flex; }
|
||||
.mobile-qualification-list { display: none; }
|
||||
.profile-form h3 { margin: 4px 0 18px; padding-bottom: 10px; border-bottom: 1px solid var(--border); font-size: 15px; }
|
||||
@@ -424,6 +488,8 @@ function qualificationStatus(status: string) {
|
||||
.profile-grid, .form-grid-two { grid-template-columns: 1fr; }
|
||||
.profile-grid > div:nth-child(odd) { border-right: 0; }
|
||||
.qualification-panel > header { align-items: flex-start; gap: 12px; }
|
||||
.qualification-panel-actions { display:grid; width:100%; grid-template-columns:1fr; }
|
||||
.qualification-panel-actions .qualification-update-control,.qualification-panel-actions :deep(.el-button) { width:100%; }
|
||||
.qualification-panel > header .el-button { flex: 0 0 auto; }
|
||||
.desktop-qualification-table { display: none; }
|
||||
.mobile-qualification-list { display: grid; gap: 10px; padding: 10px; }
|
||||
|
||||
@@ -39,7 +39,13 @@ test('implements company details, contact information and qualification workflow
|
||||
assert.match(profileSource, /prop="companyName" required><el-input v-model="draft\.companyName" placeholder="请输入公司名称"/)
|
||||
assert.match(profileSource, /prop="fileName" required :error="qualificationFileError"/)
|
||||
assert.match(profileSource, /const valid = await profileFormRef\.value\?\.validate\(\)/)
|
||||
assert.match(profileSource, /qualificationFileError\.value = '请选择营业执照图片'/)
|
||||
assert.match(profileSource, /qualificationFileError\.value = '请选择资质文件'/)
|
||||
assert.match(profileSource, /submitTenantQualificationReview/)
|
||||
assert.match(profileSource, /提交企业审核/)
|
||||
assert.match(profileSource, /资质文件已上传,尚未提交审核/)
|
||||
assert.match(profileSource, /资质文件已上传,但最新状态刷新失败/)
|
||||
assert.match(profileSource, /企业资料已提交审核,但最新状态刷新失败/)
|
||||
assert.match(profileSource, /qualificationStatusMeta/)
|
||||
assert.match(profileSource, /const canUploadQualification = computed/)
|
||||
assert.match(profileSource, /:disabled="!canUploadQualification"/)
|
||||
assert.match(profileSource, /error\.code === 'A0409'/)
|
||||
|
||||
Vendored
+4
@@ -4,3 +4,7 @@ html, body, #app { min-width: 320px; min-height: 100%; margin: 0; }
|
||||
body { min-height: 100vh; background: #f5f7fa; }
|
||||
button, input { font: inherit; }
|
||||
a { color: inherit; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
html, body, #app { min-width: 0; }
|
||||
}
|
||||
|
||||
+34
-3
@@ -36,9 +36,9 @@
|
||||
.agreement-control { display: flex; min-height: 44px; align-items: center; gap: 4px; flex-wrap: wrap; color: var(--muted); }
|
||||
.agreement-item .el-checkbox { min-height: 44px; white-space: normal; }
|
||||
.agreement-required { margin-right: 2px; color: var(--el-color-danger); }
|
||||
.agreement-item button { padding: 0; border: 0; background: transparent; color: var(--primary); cursor: pointer; }
|
||||
.agreement-item button:hover { text-decoration: underline; }
|
||||
.agreement-item button:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||||
.agreement-item a { color: var(--primary); text-decoration: none; }
|
||||
.agreement-item a:hover { text-decoration: underline; }
|
||||
.agreement-item a:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||||
.register-security-note { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||||
.register-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--border); }
|
||||
.registration-result { display: flex; min-height: 470px; align-items: center; justify-content: center; flex-direction: column; color: var(--primary); text-align: center; }
|
||||
@@ -54,6 +54,31 @@
|
||||
.registration-next-step strong { color: var(--navy); font-size: 13px; }
|
||||
.registration-result-actions { display: flex; justify-content: center; gap: 10px; margin-top: 28px; }
|
||||
|
||||
.legal-page { min-height: 100vh; background: #f5f7f8; }
|
||||
.legal-header { position: sticky; top: 0; z-index: 10; display: flex; height: 68px; align-items: center; justify-content: space-between; padding: 0 clamp(20px, 5vw, 72px); border-bottom: 1px solid var(--border); background: rgba(255, 255, 255, .96); }
|
||||
.legal-header > a:first-child { display: flex; min-height: 44px; align-items: center; gap: 6px; color: var(--muted); font-size: 13px; text-decoration: none; }
|
||||
.legal-header > a:first-child:hover { color: var(--primary); }
|
||||
.legal-header a:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
|
||||
.legal-main { width: min(860px, calc(100% - 32px)); margin: 36px auto 64px; }
|
||||
.legal-document { padding: clamp(28px, 5vw, 56px); border: 1px solid var(--border); background: #fff; color: #34464f; }
|
||||
.legal-title { padding-bottom: 26px; border-bottom: 1px solid var(--border); }
|
||||
.legal-title h1 { margin: 0 0 20px; color: var(--navy); font-size: clamp(28px, 4vw, 38px); line-height: 1.25; }
|
||||
.legal-title dl { display: flex; margin: 0; gap: 24px; flex-wrap: wrap; }
|
||||
.legal-title dl div { display: flex; gap: 6px; font-size: 12px; }
|
||||
.legal-title dt { color: var(--muted); }
|
||||
.legal-title dd { margin: 0; color: var(--navy); font-weight: 650; }
|
||||
.legal-introduction { margin: 28px 0 34px; padding: 18px 20px; background: #eef6f4; color: #29423e; }
|
||||
.legal-introduction p { margin: 0; font-size: 14px; line-height: 1.9; }
|
||||
.legal-introduction p + p { margin-top: 10px; }
|
||||
.legal-section { margin-top: 32px; }
|
||||
.legal-section h2 { margin: 0 0 12px; color: var(--navy); font-size: 18px; line-height: 1.5; }
|
||||
.legal-section p, .legal-section li { font-size: 14px; line-height: 1.9; }
|
||||
.legal-section p { margin: 0; }
|
||||
.legal-section p + p { margin-top: 10px; }
|
||||
.legal-section ul { margin: 0; padding-left: 1.35em; }
|
||||
.legal-section li + li { margin-top: 8px; }
|
||||
.legal-section li::marker { color: var(--primary); }
|
||||
|
||||
.client-dashboard { display: flex; flex-direction: column; gap: 18px; }
|
||||
.dashboard-load-error { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 14px 16px; border: 1px solid #f1d4a8; border-radius: 12px; background: #fff9ef; color: #9a5a09; }
|
||||
.dashboard-load-error > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||||
@@ -117,6 +142,12 @@
|
||||
.registration-result dl div:nth-child(odd) { border-right: 0; }
|
||||
.registration-result-actions { width: 100%; }
|
||||
.registration-result-actions .el-button { flex: 1; }
|
||||
.legal-header { height: 60px; padding: 0 16px; }
|
||||
.legal-header .auth-brand-logo { width: 120px; height: 48px; }
|
||||
.legal-main { margin: 16px auto 32px; }
|
||||
.legal-document { padding: 24px 20px; }
|
||||
.legal-title dl { flex-direction: column; gap: 6px; }
|
||||
.legal-introduction { margin: 22px 0 28px; padding: 16px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
Reference in New Issue
Block a user