合同智能审查系统功能摘要(顶部代码包可直接下载运行)
本系统提供合同智能审查功能,主要分为两个界面:
上传页面:
支持拖拽或点击上传DOC/DOCX/PDF合同文件
可选择合同场景和审查立场(买方/卖方)
显示历史任务列表,可查看已完成任务
审查页面:
左侧显示合同PDF,支持分页、缩放、比例调整
右侧展示结构化审查结果,按风险等级分类(全部/重大/一般风险)
提供双向联动功能:点击风险点自动高亮对应条款,点击条款可定位风险点
每个风险点包含完整说明、分析和修改建议
技术实现:
使用pdfjs-dist渲染PDF文档
Vue3+TypeScript框架
支持风险点高亮(红框+背景色区分风险等级)
提供缩放、分页等PDF操作控件
系统特点:
风险点可视化定位
审查结果结构化展示
智能风险等级分类
修改建议直接参考
准备工作
1、安装pdfjs-dist
npm install pdfjs-dist2、vue3+ts项目框架准备
3、mock数据准备
1、将pdf源文件放置public下 (若有后台pdf的url地址可不用)
2、在views下创建模拟mock数据
Untitled-1.json
javascript
{
"llmTextreviewResult": {
"chatContents": [
{
"ruleId": "rule-wp4yai9sn7hmg3qq",
"ruleName": "主体信息条款",
"tags": [
{
"value": "甲方(委托方):\n 公司名称:鑫博腾飞信息有限公司\n 甲方地址:兴安盟路w座\n 统一社会信用代码:913738763133209796\n 签署人:刘欢(副总经理)\n 联系电话:14700651785\n 邮箱:jun34@yangpan.cn\n 银行账号:HFCK3203590341474\n 开户行:丹县银行支行\n乙方(受托方):\n 公司名称:问语(西安)智能科技有限公司\n 乙方地址:姚街W座\n 统一社会信用代码:925648865779276323\n 签署人:王秀荣(董事长)\n 联系电话:18910227275\n 邮箱:uliu@xiongtan.cn\n 银行账号:BSVO0351986034404\n 开户行:合肥县银行支行\n鉴于:\n1.甲方为合法注册并具备销售资质的实体,乙方有意购买甲方提供的产品/服务;2.双方根据《中华人民共和国民法典》及相关法律法规,经平等协商,达成协议",
"positions": [
{
"pageNum": 1,
"box": [90, 304, 247, 402]
},
{
"pageNum": 2,
"box": [90, 73, 427, 132]
}
]
},
{
"value": "|甲方(盖章):|913738763133209796|乙方(盖章):|925648865779276323||法定代表人:|刘欢|法定代表人:|王秀荣||授权代表:|鑫博腾飞信息有限公司|授权代表:|问语(西安)智能科技有限公司||日期:|2026年04月17日|日期:|2026年04月17日|",
"positions": [
{
"pageNum": 4,
"box": [84, 190, 442, 95]
}
]
}
],
"riskReviewResults": [
{
"originalContract": "甲方(委托方):\n 公司名称:鑫博腾飞信息有限公司\n 甲方地址:兴安盟路w座\n 统一社会信用代码:913738763133209796\n 签署人:刘欢(副总经理)\n 联系电话:14700651785\n 邮箱:jun34@yangpan.cn\n 银行账号:HFCK3203590341474\n 开户行:丹县银行支行\n乙方(受托方):\n 公司名称:问语(西安)智能科技有限公司\n 乙方地址:姚街W座\n 统一社会信用代码:925648865779276323\n 签署人:王秀荣(董事长)\n 联系电话:18910227275\n 邮箱:uliu@xiongtan.cn\n 银行账号:BSVO0351986034404\n 开户行:合肥县银行支行\n",
"riskPoint": "合同主体信息缺失",
"riskAnalysis": "甲方信息不完整,存在重大风险点:缺少法定代表人信息,建议补全。乙方信息不完整,存在重大风险点:缺少法定代表人信息,建议补全。",
"modifyExample": "甲方(委托方):\n 公司名称:鑫博腾飞信息有限公司\n 甲方地址:兴安盟路w座\n 统一社会信用代码:913738763133209796\n 法定代表人:(请根据实际情况填写,此处仅为示例)\n 签署人:刘欢(副总经理)\n 联系电话:14700651785\n 邮箱:jun34@yangpan.cn\n 银行账号:HFCK3203590341474\n 开户行:丹县银行支行\n乙方(受托方):\n 公司名称:问语(西安)智能科技有限公司\n 乙方地址:姚街W座\n 统一社会信用代码:925648865779276323\n 法定代表人:(请根据实际情况填写,此处仅为示例)\n 签署人:王秀荣(董事长)\n 联系电话:18910227275\n 邮箱:uliu@xiongtan.cn\n 银行账号:BSVO0351986034404\n 开户行:合肥县银行支行",
"positions": [
{
"pageNum": 1,
"box": [90, 304, 247, 402]
},
{
"pageNum": 2,
"box": [90, 73, 181, 68]
}
]
}
],
"predictResult": "```json\n{\"风险等级\": \"重大风险\", \"风险审查结果\": [{\"风险点\": \"合同主体信息缺失\", \"风险分析\": \"甲方信息不完整,存在重大风险点:缺少法定代表人信息,建议补全。乙方信息不完整,存在重大风险点:缺少法定代表人信息,建议补全。\", \"修改示例\": \"甲方(委托方):\\n 公司名称:鑫博腾飞信息有限公司\\n 甲方地址:兴安盟路w座\\n 统一社会信用代码:913738763133209796\\n 法定代表人:(请根据实际情况填写,此处仅为示例)\\n 签署人:刘欢(副总经理)\\n 联系电话:14700651785\\n 邮箱:jun34@yangpan.cn\\n 银行账号:HFCK3203590341474\\n 开户行:丹县银行支行\\n乙方(受托方):\\n 公司名称:问语(西安)智能科技有限公司\\n 乙方地址:姚街W座\\n 统一社会信用代码:925648865779276323\\n 法定代表人:(请根据实际情况填写,此处仅为示例)\\n 签署人:王秀荣(董事长)\\n 联系电话:18910227275\\n 邮箱:uliu@xiongtan.cn\\n 银行账号:BSVO0351986034404\\n 开户行:合肥县银行支行\"}]}\n```",
"markdownResult": "风险等级:\n 重大风险\n\n风险审查结果:\n [{\"风险点\": \"合同主体信息缺失\", \"风险分析\": \"甲方信息不完整,存在重大风险点:缺少法定代表人信息,建议补全。乙方信息不完整,存在重大风险点:缺少法定代表人信息,建议补全。\", \"修改示例\": \"甲方(委托方):\\n 公司名称:鑫博腾飞信息有限公司\\n 甲方地址:兴安盟路w座\\n 统一社会信用代码:913738763133209796\\n 法定代表人:(请根据实际情况填写,此处仅为示例)\\n 签署人:刘欢(副总经理)\\n 联系电话:14700651785\\n 邮箱:jun34@yangpan.cn\\n 银行账号:HFCK3203590341474\\n 开户行:丹县银行支行\\n乙方(受托方):\\n 公司名称:问语(西安)智能科技有限公司\\n 乙方地址:姚街W座\\n 统一社会信用代码:925648865779276323\\n 法定代表人:(请根据实际情况填写,此处仅为示例)\\n 签署人:王秀荣(董事长)\\n 联系电话:18910227275\\n 邮箱:uliu@xiongtan.cn\\n 银行账号:BSVO0351986034404\\n 开户行:合肥县银行支行\"}]\n\n",
"isShow": true,
"riskName": "重大风险"
},
{
"ruleId": "rule-yj45ue9ziu9ftu1j",
"ruleName": "标的物条款",
"tags": [
{
"value": "销售类合同-软件许可项目-\n2026049096\n",
"positions": [
{
"pageNum": 1,
"box": [151, 76, 309, 59]
}
]
}
],
"riskReviewResults": [
{
"originalContract": "销售类合同-软件许可项目-\n2026049096\n",
"riskPoint": "标的物条款缺失",
"riskAnalysis": "存在重大风险点:未约定标的物条款。",
"modifyExample": "建议卖方结合公司要求对该条款进行补充。",
"positions": [
{
"pageNum": 1,
"box": [151, 76, 309, 59]
}
]
}
],
"predictResult": "```json\n\n{\n\n\"风险等级\": \"重大风险\",\n\n\"风险审查结果\": [\n\n{\n\n\"风险点\": \"标的物条款缺失\",\n\n\"风险分析\": \"存在重大风险点:未约定标的物条款。\",\n\n\"修改示例\": \"建议卖方结合公司要求对该条款进行补充。\"\n\n}\n\n]\n\n}\n\n```",
"markdownResult": "风险等级:\n 重大风险\n\n风险审查结果:\n [\n\n{\n\n\"风险点\": \"标的物条款缺失\",\n\n\"风险分析\": \"存在重大风险点:未约定标的物条款。\",\n\n\"修改示例\": \"建议卖方结合公司要求对该条款进行补充。\"\n\n}\n\n]\n\n",
"isShow": true,
"riskName": "重大风险"
},
{
"ruleId": "rule-75p067ek4a8f1unh",
"ruleName": "价款与结算条款",
"tags": [
{
"value": "合同金额:9,531.45元(大写:玖仟伍佰叁拾壹元肆角伍分)\n税率:9%\n税额:857.83元\n发票类型:增值税普通发票\n双方约定付款计划如下:\n\n|期数|标的|金额(元)|比例|付款触发条件||1|设备采购|2,859.44|30%|合同签订后3-5个工作日内支付||2|安装调试|3,812.58|40%|设备到货安装后支付||3|验收维护|2,859.44|30%|项目验收合格后支付|\n三、付款计划\n\n备注:以上付款计划为双方协商确定,具体支付以实际发生为准。\n四、双方权利与义务\n\n甲方权利与义务:\n按时足额支付服务费用。",
"positions": [
{
"pageNum": 2,
"box": [84, 256, 442, 375]
}
]
},
{
"value": "甲方银行账户信息:\n 开户行:丹县银行支行\n 账户名:鑫博腾飞信息有限公司\n 账号:HFCK3203590341474\n乙方银行账户信息:\n 开户行:合肥县银行支行\n 账户名:问语(西安)智能科技有限公司\n 账号:BSVO0351986034404",
"positions": [
{
"pageNum": 3,
"box": [90, 593, 187, 124]
},
{
"pageNum": 4,
"box": [90, 73, 235, 68]
}
]
}
],
"riskReviewResults": [
{
"originalContract": "合同金额:9,531.45元(大写:玖仟伍佰叁拾壹元肆角伍分)\n税率:9%\n税额:857.83元\n发票类型:增值税普通发票\n|",
"riskPoint": "缺失标的物价款信息",
"riskAnalysis": "合同文本中虽列明了合同金额、税率和税额,但未明确价款是否含税及支付币种。这可能导致双方对最终支付总额的理解产生分歧,例如合同金额是含税价还是不含税价不清晰,影响结算准确性。建议明确价款是否含税及支付币种。",
"modifyExample": "合同金额:9,531.45元(大写:玖仟伍佰叁拾壹元肆角伍分),此价格为含税总价。支付币种为人民币。税率:9%,税额:857.83元。发票类型:增值税普通发票。",
"positions": [
{
"pageNum": 2,
"box": [90, 256, 328, 95]
}
]
},
{
"originalContract": " 账户名:问语(西安)智能科技有限公司\n 账号:BSVO0351986034404",
"riskPoint": "缺失收款方信息",
"riskAnalysis": "合同文本提供了双方的银行账户信息,但缺失了收款方(即卖方)的开票信息,如公司名称、纳税人识别号等。这可能导致卖方无法及时、准确地开具发票,影响付款流程和税务合规。建议补充卖方的完整开票信息。",
"modifyExample": "乙方(卖方)开票信息:名称:问语(西安)智能科技有限公司;纳税人识别号:【请填写卖方纳税人识别号】;地址、电话:【请填写卖方注册地址及电话】;开户行及账号:合肥县银行支行、BSVO0351986034404。(以上信息需根据卖方实际情况填写完整)",
"positions": [
{
"pageNum": 4,
"box": [90, 101, 235, 40]
}
]
},
{
"originalContract": "|期数|标的|金额(元)|比例|付款触发条件||1|设备采购|2,859.44|30%|合同签订后3-5个工作日内支付||2|安装调试|3,812.58|40%|设备到货安装后支付||3|验收维护|2,859.44|30%|项目验收合格后支付|\n",
"riskPoint": "无预付款或预付款比例过低",
"riskAnalysis": "根据付款计划,第一笔款项(设备采购款)的支付条件是"合同签订后3-5个工作日内支付",比例为合同总额的30%。虽然此笔款项在交付前支付,但根据审查标准,预付款比例低于合同金额的20%即构成风险。当前30%的比例已高于20%的阈值,因此本风险点不成立。但需注意,该笔款项的支付条件为"合同签订后",而非"交付前",其性质可能被解释为定金或首付款而非严格意义上的"交付前预付款"。从保护卖方现金流的角度,此约定对卖方有利,不构成重大风险。",
"modifyExample": "(此风险点不成立,无需修改。第一笔付款比例30%已满足预付款比例要求。)",
"positions": [
{
"pageNum": 2,
"box": [84, 428, 442, 80]
}
]
}
],
"predictResult": "```json\n{\"风险等级\": \"重大风险\", \"风险审查结果\": [{\"风险点\": \"缺失标的物价款信息\", \"风险分析\": \"合同文本中虽列明了合同金额、税率和税额,但未明确价款是否含税及支付币种。这可能导致双方对最终支付总额的理解产生分歧,例如合同金额是含税价还是不含税价不清晰,影响结算准确性。建议明确价款是否含税及支付币种。\", \"修改示例\": \"合同金额:9,531.45元(大写:玖仟伍佰叁拾壹元肆角伍分),此价格为含税总价。支付币种为人民币。税率:9%,税额:857.83元。发票类型:增值税普通发票。\"}, {\"风险点\": \"缺失收款方信息\", \"风险分析\": \"合同文本提供了双方的银行账户信息,但缺失了收款方(即卖方)的开票信息,如公司名称、纳税人识别号等。这可能导致卖方无法及时、准确地开具发票,影响付款流程和税务合规。建议补充卖方的完整开票信息。\", \"修改示例\": \"乙方(卖方)开票信息:名称:问语(西安)智能科技有限公司;纳税人识别号:【请填写卖方纳税人识别号】;地址、电话:【请填写卖方注册地址及电话】;开户行及账号:合肥县银行支行、BSVO0351986034404。(以上信息需根据卖方实际情况填写完整)\"}, {\"风险点\": \"无预付款或预付款比例过低\", \"风险分析\": \"根据付款计划,第一笔款项(设备采购款)的支付条件是"合同签订后3-5个工作日内支付",比例为合同总额的30%。虽然此笔款项在交付前支付,但根据审查标准,预付款比例低于合同金额的20%即构成风险。当前30%的比例已高于20%的阈值,因此本风险点不成立。但需注意,该笔款项的支付条件为"合同签订后",而非"交付前",其性质可能被解释为定金或首付款而非严格意义上的"交付前预付款"。从保护卖方现金流的角度,此约定对卖方有利,不构成重大风险。\", \"修改示例\": \"(此风险点不成立,无需修改。第一笔付款比例30%已满足预付款比例要求。)\"}]}\n```",
"markdownResult": "风险等级:\n 重大风险\n\n风险审查结果:\n [{\"风险点\": \"缺失标的物价款信息\", \"风险分析\": \"合同文本中虽列明了合同金额、税率和税额,但未明确价款是否含税及支付币种。这可能导致双方对最终支付总额的理解产生分歧,例如合同金额是含税价还是不含税价不清晰,影响结算准确性。建议明确价款是否含税及支付币种。\", \"修改示例\": \"合同金额:9,531.45元(大写:玖仟伍佰叁拾壹元肆角伍分),此价格为含税总价。支付币种为人民币。税率:9%,税额:857.83元。发票类型:增值税普通发票。\"}, {\"风险点\": \"缺失收款方信息\", \"风险分析\": \"合同文本提供了双方的银行账户信息,但缺失了收款方(即卖方)的开票信息,如公司名称、纳税人识别号等。这可能导致卖方无法及时、准确地开具发票,影响付款流程和税务合规。建议补充卖方的完整开票信息。\", \"修改示例\": \"乙方(卖方)开票信息:名称:问语(西安)智能科技有限公司;纳税人识别号:【请填写卖方纳税人识别号】;地址、电话:【请填写卖方注册地址及电话】;开户行及账号:合肥县银行支行、BSVO0351986034404。(以上信息需根据卖方实际情况填写完整)\"}, {\"风险点\": \"无预付款或预付款比例过低\", \"风险分析\": \"根据付款计划,第一笔款项(设备采购款)的支付条件是"合同签订后3-5个工作日内支付",比例为合同总额的30%。虽然此笔款项在交付前支付,但根据审查标准,预付款比例低于合同金额的20%即构成风险。当前30%的比例已高于20%的阈值,因此本风险点不成立。但需注意,该笔款项的支付条件为"合同签订后",而非"交付前",其性质可能被解释为定金或首付款而非严格意义上的"交付前预付款"。从保护卖方现金流的角度,此约定对卖方有利,不构成重大风险。\", \"修改示例\": \"(此风险点不成立,无需修改。第一笔付款比例30%已满足预付款比例要求。)\"}]\n\n",
"isShow": true,
"riskName": "重大风险"
},
{
"ruleId": "rule-hmgymkvy3ysgd8gq",
"ruleName": "质量保证和质量要求条款",
"tags": [
{
"value": "按约定时间、标准提供服务。",
"positions": [
{
"pageNum": 3,
"box": [90, 91, 157, 12]
}
]
}
],
"riskReviewResults": [
{
"originalContract": "按约定时间、标准提供服务。",
"riskPoint": "未明确界定货物的质量标准",
"riskAnalysis": "合同文本仅约定"按约定时间、标准提供服务",但未明确约定具体执行何种质量标准(如国家标准、行业标准、企业标准),也未将该标准作为附件或明确其具体内容。此约定过于模糊,在合同履行中极易就"标准"的具体内容产生争议,对卖方(乙方)构成重大履约风险。建议明确约定所依据的具体标准名称、代码或详细技术要求。",
"modifyExample": "质量标准:乙方提供的服务应符合 [请在此处填写具体的国家标准名称及代码,例如:GB/T XXXXX-XXXX] 的规定,并满足双方确认的技术附件(附件X)中所列明的全部要求。",
"positions": [
{
"pageNum": 3,
"box": [90, 91, 157, 12]
}
]
}
],
"predictResult": "```json\n{\"风险等级\": \"重大风险\", \"风险审查结果\": [{\"风险点\": \"未明确界定货物的质量标准\", \"风险分析\": \"合同文本仅约定"按约定时间、标准提供服务",但未明确约定具体执行何种质量标准(如国家标准、行业标准、企业标准),也未将该标准作为附件或明确其具体内容。此约定过于模糊,在合同履行中极易就"标准"的具体内容产生争议,对卖方(乙方)构成重大履约风险。建议明确约定所依据的具体标准名称、代码或详细技术要求。\", \"修改示例\": \"质量标准:乙方提供的服务应符合 [请在此处填写具体的国家标准名称及代码,例如:GB/T XXXXX-XXXX] 的规定,并满足双方确认的技术附件(附件X)中所列明的全部要求。\"}]}\n```",
"markdownResult": "风险等级:\n 重大风险\n\n风险审查结果:\n [{\"风险点\": \"未明确界定货物的质量标准\", \"风险分析\": \"合同文本仅约定"按约定时间、标准提供服务",但未明确约定具体执行何种质量标准(如国家标准、行业标准、企业标准),也未将该标准作为附件或明确其具体内容。此约定过于模糊,在合同履行中极易就"标准"的具体内容产生争议,对卖方(乙方)构成重大履约风险。建议明确约定所依据的具体标准名称、代码或详细技术要求。\", \"修改示例\": \"质量标准:乙方提供的服务应符合 [请在此处填写具体的国家标准名称及代码,例如:GB/T XXXXX-XXXX] 的规定,并满足双方确认的技术附件(附件X)中所列明的全部要求。\"}]\n\n",
"isShow": true,
"riskName": "重大风险"
},
{
"ruleId": "rule-7y9ezpkf8290jenu",
"ruleName": "交付条款",
"tags": [
{
"value": "交付日期:2026年06月16日 09:06",
"positions": [
{
"pageNum": 1,
"box": [90, 241, 196, 12]
}
]
}
],
"riskReviewResults": [
{
"originalContract": "交付日期:2026年06月16日 09:06",
"riskPoint": "交付必备要素缺失或者不明确",
"riskAnalysis": "合同文本仅约定了交付日期,但缺失了交付地点、交付方式、到货确认方式等必备要素。这会导致交付过程无法明确执行,容易引发争议,对卖方存在重大履约风险。建议补充完整交付地点、交付方式和到货确认方式。",
"modifyExample": "交付日期:2026年06月16日 09:06;交付地点:[请填写具体交付地点,如买方指定仓库];交付方式:[请填写具体交付方式,如卖方送货上门];到货确认方式:买方在收到货物后[填写具体时限,如24小时]内进行数量及外观验收,并签署收货凭证。",
"positions": [
{
"pageNum": 1,
"box": [90, 241, 196, 12]
}
]
},
{
"originalContract": "交付日期:2026年06月16日 09:06",
"riskPoint": "缺失联系人信息",
"riskAnalysis": "合同文本未约定买卖双方的交付联系人及其有效联系方式(如姓名、电话、邮箱)。缺少联系人信息将导致交付沟通不畅,无法有效协调交付事宜,存在重大履约风险。建议明确双方联系人信息。",
"modifyExample": "买方交付联系人:姓名:[填写姓名],电话:[填写电话],邮箱:[填写邮箱];卖方交付联系人:姓名:[填写姓名],电话:[填写电话],邮箱:[填写邮箱]。双方如需变更联系人信息,应提前[填写具体时限,如3个工作日]书面通知对方。",
"positions": [
{
"pageNum": 1,
"box": [90, 241, 196, 12]
}
]
},
{
"originalContract": "交付日期:2026年06月16日 09:06",
"riskPoint": "未明确运输费用承担方",
"riskAnalysis": "合同文本未约定交付相关的运输费用由哪一方承担。费用承担不明确可能导致卖方在履约过程中产生意外成本或与买方产生争议。建议明确运输费用的承担方。",
"modifyExample": "本合同项下货物交付所产生的运输费用由[请明确填写"买方"或"卖方"]承担。",
"positions": [
{
"pageNum": 1,
"box": [90, 241, 196, 12]
}
]
}
],
"predictResult": "```json\n{\"风险等级\": \"重大风险\", \"风险审查结果\": [{\"风险点\": \"交付必备要素缺失或者不明确\", \"风险分析\": \"合同文本仅约定了交付日期,但缺失了交付地点、交付方式、到货确认方式等必备要素。这会导致交付过程无法明确执行,容易引发争议,对卖方存在重大履约风险。建议补充完整交付地点、交付方式和到货确认方式。\", \"修改示例\": \"交付日期:2026年06月16日 09:06;交付地点:[请填写具体交付地点,如买方指定仓库];交付方式:[请填写具体交付方式,如卖方送货上门];到货确认方式:买方在收到货物后[填写具体时限,如24小时]内进行数量及外观验收,并签署收货凭证。\"}, {\"风险点\": \"缺失联系人信息\", \"风险分析\": \"合同文本未约定买卖双方的交付联系人及其有效联系方式(如姓名、电话、邮箱)。缺少联系人信息将导致交付沟通不畅,无法有效协调交付事宜,存在重大履约风险。建议明确双方联系人信息。\", \"修改示例\": \"买方交付联系人:姓名:[填写姓名],电话:[填写电话],邮箱:[填写邮箱];卖方交付联系人:姓名:[填写姓名],电话:[填写电话],邮箱:[填写邮箱]。双方如需变更联系人信息,应提前[填写具体时限,如3个工作日]书面通知对方。\"}, {\"风险点\": \"未明确运输费用承担方\", \"风险分析\": \"合同文本未约定交付相关的运输费用由哪一方承担。费用承担不明确可能导致卖方在履约过程中产生意外成本或与买方产生争议。建议明确运输费用的承担方。\", \"修改示例\": \"本合同项下货物交付所产生的运输费用由[请明确填写"买方"或"卖方"]承担。\"}]}\n```",
"markdownResult": "风险等级:\n 重大风险\n\n风险审查结果:\n [{\"风险点\": \"交付必备要素缺失或者不明确\", \"风险分析\": \"合同文本仅约定了交付日期,但缺失了交付地点、交付方式、到货确认方式等必备要素。这会导致交付过程无法明确执行,容易引发争议,对卖方存在重大履约风险。建议补充完整交付地点、交付方式和到货确认方式。\", \"修改示例\": \"交付日期:2026年06月16日 09:06;交付地点:[请填写具体交付地点,如买方指定仓库];交付方式:[请填写具体交付方式,如卖方送货上门];到货确认方式:买方在收到货物后[填写具体时限,如24小时]内进行数量及外观验收,并签署收货凭证。\"}, {\"风险点\": \"缺失联系人信息\", \"风险分析\": \"合同文本未约定买卖双方的交付联系人及其有效联系方式(如姓名、电话、邮箱)。缺少联系人信息将导致交付沟通不畅,无法有效协调交付事宜,存在重大履约风险。建议明确双方联系人信息。\", \"修改示例\": \"买方交付联系人:姓名:[填写姓名],电话:[填写电话],邮箱:[填写邮箱];卖方交付联系人:姓名:[填写姓名],电话:[填写电话],邮箱:[填写邮箱]。双方如需变更联系人信息,应提前[填写具体时限,如3个工作日]书面通知对方。\"}, {\"风险点\": \"未明确运输费用承担方\", \"风险分析\": \"合同文本未约定交付相关的运输费用由哪一方承担。费用承担不明确可能导致卖方在履约过程中产生意外成本或与买方产生争议。建议明确运输费用的承担方。\", \"修改示例\": \"本合同项下货物交付所产生的运输费用由[请明确填写"买方"或"卖方"]承担。\"}]\n\n",
"isShow": true,
"riskName": "重大风险"
},
{
"ruleId": "rule-b5d1vyf692a2jh6j",
"ruleName": "验收条款",
"tags": [
{
"value": "销售类合同-软件许可项目-\n2026049096\n",
"positions": [
{
"pageNum": 1,
"box": [151, 76, 309, 59]
}
]
}
],
"riskReviewResults": [
{
"originalContract": "销售类合同-软件许可项目-\n2026049096\n",
"riskPoint": "验收条款缺失",
"riskAnalysis": "存在重大风险点:未约定验收条款。",
"modifyExample": "建议卖方结合公司要求对该条款进行补充。",
"positions": [
{
"pageNum": 1,
"box": [151, 76, 309, 59]
}
]
}
],
"predictResult": "```json\n\n{\n\n\"风险等级\": \"重大风险\",\n\n\"风险审查结果\": [\n\n{\n\n\"风险点\": \"验收条款缺失\",\n\n\"风险分析\": \"存在重大风险点:未约定验收条款。\",\n\n\"修改示例\": \"建议卖方结合公司要求对该条款进行补充。\"\n\n}\n\n]\n\n}\n\n```",
"markdownResult": "风险等级:\n 重大风险\n\n风险审查结果:\n [\n\n{\n\n\"风险点\": \"验收条款缺失\",\n\n\"风险分析\": \"存在重大风险点:未约定验收条款。\",\n\n\"修改示例\": \"建议卖方结合公司要求对该条款进行补充。\"\n\n}\n\n]\n\n",
"isShow": true,
"riskName": "重大风险"
},
{
"ruleId": "rule-mdnps9brksegix8n",
"ruleName": "包装条款",
"tags": [
{
"value": "销售类合同-软件许可项目-\n2026049096\n",
"positions": [
{
"pageNum": 1,
"box": [151, 76, 309, 59]
}
]
}
],
"riskReviewResults": [
{
"originalContract": "销售类合同-软件许可项目-\n2026049096\n",
"riskPoint": "包装条款缺失",
"riskAnalysis": "存在重大风险点:未约定包装条款。",
"modifyExample": "建议卖方结合公司要求对该条款进行补充。",
"positions": [
{
"pageNum": 1,
"box": [151, 76, 309, 59]
}
]
}
],
"predictResult": "```json\n\n{\n\n\"风险等级\": \"重大风险\",\n\n\"风险审查结果\": [\n\n{\n\n\"风险点\": \"包装条款缺失\",\n\n\"风险分析\": \"存在重大风险点:未约定包装条款。\",\n\n\"修改示例\": \"建议卖方结合公司要求对该条款进行补充。\"\n\n}\n\n]\n\n}\n\n```",
"markdownResult": "风险等级:\n 重大风险\n\n风险审查结果:\n [\n\n{\n\n\"风险点\": \"包装条款缺失\",\n\n\"风险分析\": \"存在重大风险点:未约定包装条款。\",\n\n\"修改示例\": \"建议卖方结合公司要求对该条款进行补充。\"\n\n}\n\n]\n\n",
"isShow": true,
"riskName": "重大风险"
},
{
"ruleId": "rule-3y92ekez6xi5ff9j",
"ruleName": "标的物风险转移条款",
"tags": [
{
"value": "销售类合同-软件许可项目-\n2026049096\n",
"positions": [
{
"pageNum": 1,
"box": [151, 76, 309, 59]
}
]
}
],
"riskReviewResults": [
{
"originalContract": "销售类合同-软件许可项目-\n2026049096\n",
"riskPoint": "标的物风险转移条款缺失",
"riskAnalysis": "存在重大风险点:未约定标的物风险转移条款。",
"modifyExample": "建议卖方结合公司要求对该条款进行补充。",
"positions": [
{
"pageNum": 1,
"box": [151, 76, 309, 59]
}
]
}
],
"predictResult": "```json\n\n{\n\n\"风险等级\": \"重大风险\",\n\n\"风险审查结果\": [\n\n{\n\n\"风险点\": \"标的物风险转移条款缺失\",\n\n\"风险分析\": \"存在重大风险点:未约定标的物风险转移条款。\",\n\n\"修改示例\": \"建议卖方结合公司要求对该条款进行补充。\"\n\n}\n\n]\n\n}\n\n```",
"markdownResult": "风险等级:\n 重大风险\n\n风险审查结果:\n [\n\n{\n\n\"风险点\": \"标的物风险转移条款缺失\",\n\n\"风险分析\": \"存在重大风险点:未约定标的物风险转移条款。\",\n\n\"修改示例\": \"建议卖方结合公司要求对该条款进行补充。\"\n\n}\n\n]\n\n",
"isShow": true,
"riskName": "重大风险"
},
{
"ruleId": "rule-1m5ss4sjhg0fhqzx",
"ruleName": "保密条款",
"tags": [
{
"value": "保证服务合法合规,对知悉的商业秘密保密。",
"positions": [
{
"pageNum": 3,
"box": [90, 109, 241, 12]
}
]
},
{
"value": "双方应对合同内容及合作中知悉的商业秘密严格保密,未经书面同意不得向第三方披露。",
"positions": [
{
"pageNum": 3,
"box": [90, 288, 433, 30]
}
]
}
],
"riskReviewResults": [
{
"originalContract": "",
"riskPoint": "",
"riskAnalysis": "该保密条款约定了双方的保密义务,未出现审查标准中列举的重大风险点。但条款较为简略,未明确保密信息的范围、保密期限、违约责任等具体内容,可能在实际履行中产生争议。建议卖方结合具体业务情况,细化保密信息的定义、保密期限、例外情形及违约责任,以增强条款的可操作性和风险可控性。",
"modifyExample": "",
"positions": []
}
],
"predictResult": "```json\n{\"风险等级\": \"一般风险\", \"风险审查结果\": [{\"风险分析\": \"该保密条款约定了双方的保密义务,未出现审查标准中列举的重大风险点。但条款较为简略,未明确保密信息的范围、保密期限、违约责任等具体内容,可能在实际履行中产生争议。建议卖方结合具体业务情况,细化保密信息的定义、保密期限、例外情形及违约责任,以增强条款的可操作性和风险可控性。\"}]}\n```",
"markdownResult": "风险等级:\n 一般风险\n\n风险审查结果:\n [{\"风险分析\": \"该保密条款约定了双方的保密义务,未出现审查标准中列举的重大风险点。但条款较为简略,未明确保密信息的范围、保密期限、违约责任等具体内容,可能在实际履行中产生争议。建议卖方结合具体业务情况,细化保密信息的定义、保密期限、例外情形及违约责任,以增强条款的可操作性和风险可控性。\"}]\n\n",
"isShow": true,
"riskName": "一般风险"
},
{
"ruleId": "rule-aa5i3ngz8n1kqtk6",
"ruleName": "知识产权条款",
"tags": [
{
"value": "销售类合同-软件许可项目-\n2026049096\n",
"positions": [
{
"pageNum": 1,
"box": [151, 76, 309, 59]
}
]
}
],
"riskReviewResults": [
{
"originalContract": "销售类合同-软件许可项目-\n2026049096\n",
"riskPoint": "知识产权条款缺失",
"riskAnalysis": "存在重大风险点:未约定知识产权条款。",
"modifyExample": "建议卖方结合公司要求对该条款进行补充。",
"positions": [
{
"pageNum": 1,
"box": [151, 76, 309, 59]
}
]
}
],
"predictResult": "```json\n\n{\n\n\"风险等级\": \"重大风险\",\n\n\"风险审查结果\": [\n\n{\n\n\"风险点\": \"知识产权条款缺失\",\n\n\"风险分析\": \"存在重大风险点:未约定知识产权条款。\",\n\n\"修改示例\": \"建议卖方结合公司要求对该条款进行补充。\"\n\n}\n\n]\n\n}\n\n```",
"markdownResult": "风险等级:\n 重大风险\n\n风险审查结果:\n [\n\n{\n\n\"风险点\": \"知识产权条款缺失\",\n\n\"风险分析\": \"存在重大风险点:未约定知识产权条款。\",\n\n\"修改示例\": \"建议卖方结合公司要求对该条款进行补充。\"\n\n}\n\n]\n\n",
"isShow": true,
"riskName": "重大风险"
},
{
"ruleId": "rule-a69q4g6agvzwj6st",
"ruleName": "违约责任条款",
"tags": [
{
"value": "1.任何一方违约,应向对方承担违约责任,包括赔偿损失。\n2.若乙方服务未达标,甲方可书面通知整改;三次整改未果,甲方有权解除合同并索赔。",
"positions": [
{
"pageNum": 3,
"box": [90, 190, 433, 48]
}
]
}
],
"riskReviewResults": [
{
"originalContract": "1.任何一方违约,应向对方承担违约责任,包括赔偿损失。\n",
"riskPoint": "卖方赔偿范围过大",
"riskAnalysis": "合同第1条约定任何一方违约需赔偿损失,但未明确损失范围,可能导致卖方需承担间接损失等过大的赔偿责任,存在重大风险。建议明确赔偿范围,将损失限定为直接损失。",
"modifyExample": "1.任何一方违约,应向对方承担违约责任,赔偿因此造成的直接损失。",
"positions": [
{
"pageNum": 3,
"box": [90, 190, 313, 12]
}
]
},
{
"originalContract": "2.若乙方服务未达标,甲方可书面通知整改;三次整改未果,甲方有权解除合同并索赔。",
"riskPoint": "违约后买方单方拒绝履行或解除权",
"riskAnalysis": "合同第2条约定乙方(卖方)服务未达标经三次整改未果,甲方(买方)有权解除合同并索赔。该条款赋予买方在卖方违约情况下的单方解除权,可能导致卖方在轻微违约或非根本违约时即面临合同解除和索赔的风险,对卖方不利。建议增加解除合同的限制条件,如明确为重大违约或根本违约,或设置合理的补救期。",
"modifyExample": "2.若乙方服务未达标,甲方可书面通知整改;若乙方在收到通知后30日内经三次整改仍未达到合同约定标准,构成根本违约的,甲方有权解除合同并要求乙方赔偿因此造成的直接损失。",
"positions": [
{
"pageNum": 3,
"box": [90, 208, 433, 30]
}
]
}
],
"predictResult": "```json\n{\"风险等级\": \"重大风险\", \"风险审查结果\": [{\"风险点\": \"卖方赔偿范围过大\", \"风险分析\": \"合同第1条约定任何一方违约需赔偿损失,但未明确损失范围,可能导致卖方需承担间接损失等过大的赔偿责任,存在重大风险。建议明确赔偿范围,将损失限定为直接损失。\", \"修改示例\": \"1.任何一方违约,应向对方承担违约责任,赔偿因此造成的直接损失。\"}, {\"风险点\": \"违约后买方单方拒绝履行或解除权\", \"风险分析\": \"合同第2条约定乙方(卖方)服务未达标经三次整改未果,甲方(买方)有权解除合同并索赔。该条款赋予买方在卖方违约情况下的单方解除权,可能导致卖方在轻微违约或非根本违约时即面临合同解除和索赔的风险,对卖方不利。建议增加解除合同的限制条件,如明确为重大违约或根本违约,或设置合理的补救期。\", \"修改示例\": \"2.若乙方服务未达标,甲方可书面通知整改;若乙方在收到通知后30日内经三次整改仍未达到合同约定标准,构成根本违约的,甲方有权解除合同并要求乙方赔偿因此造成的直接损失。\"}]}\n```",
"markdownResult": "风险等级:\n 重大风险\n\n风险审查结果:\n [{\"风险点\": \"卖方赔偿范围过大\", \"风险分析\": \"合同第1条约定任何一方违约需赔偿损失,但未明确损失范围,可能导致卖方需承担间接损失等过大的赔偿责任,存在重大风险。建议明确赔偿范围,将损失限定为直接损失。\", \"修改示例\": \"1.任何一方违约,应向对方承担违约责任,赔偿因此造成的直接损失。\"}, {\"风险点\": \"违约后买方单方拒绝履行或解除权\", \"风险分析\": \"合同第2条约定乙方(卖方)服务未达标经三次整改未果,甲方(买方)有权解除合同并索赔。该条款赋予买方在卖方违约情况下的单方解除权,可能导致卖方在轻微违约或非根本违约时即面临合同解除和索赔的风险,对卖方不利。建议增加解除合同的限制条件,如明确为重大违约或根本违约,或设置合理的补救期。\", \"修改示例\": \"2.若乙方服务未达标,甲方可书面通知整改;若乙方在收到通知后30日内经三次整改仍未达到合同约定标准,构成根本违约的,甲方有权解除合同并要求乙方赔偿因此造成的直接损失。\"}]\n\n",
"isShow": true,
"riskName": "重大风险"
},
{
"ruleId": "rule-ky0mv2aiy82kc6xe",
"ruleName": "争议解决条款",
"tags": [
{
"value": "争议应首先通过友好协商解决;协商不成,可提交甲方所在地人民法院诉讼",
"positions": [
{
"pageNum": 3,
"box": [90, 369, 397, 12]
}
]
}
],
"riskReviewResults": [
{
"originalContract": "争议应首先通过友好协商解决;协商不成,可提交甲方所在地人民法院诉讼",
"riskPoint": "买方所在地法院管辖",
"riskAnalysis": "约定争议解决法院在买方(甲方)所在地,且买方和卖方(乙方)不是同一地区,对卖方存在重大风险,可能增加卖方参与诉讼的成本和不便。建议修改为卖方所在地法院管辖,或约定一个对双方相对公平的管辖法院(如合同履行地、被告所在地)。",
"modifyExample": "争议应首先通过友好协商解决;协商不成,任何一方均有权向乙方所在地有管辖权的人民法院提起诉讼。",
"positions": [
{
"pageNum": 3,
"box": [90, 369, 397, 12]
}
]
}
],
"predictResult": "```json\n{\"风险等级\": \"重大风险\", \"风险审查结果\": [{\"风险点\": \"买方所在地法院管辖\", \"风险分析\": \"约定争议解决法院在买方(甲方)所在地,且买方和卖方(乙方)不是同一地区,对卖方存在重大风险,可能增加卖方参与诉讼的成本和不便。建议修改为卖方所在地法院管辖,或约定一个对双方相对公平的管辖法院(如合同履行地、被告所在地)。\", \"修改示例\": \"争议应首先通过友好协商解决;协商不成,任何一方均有权向乙方所在地有管辖权的人民法院提起诉讼。\"}]}\n```",
"markdownResult": "风险等级:\n 重大风险\n\n风险审查结果:\n [{\"风险点\": \"买方所在地法院管辖\", \"风险分析\": \"约定争议解决法院在买方(甲方)所在地,且买方和卖方(乙方)不是同一地区,对卖方存在重大风险,可能增加卖方参与诉讼的成本和不便。建议修改为卖方所在地法院管辖,或约定一个对双方相对公平的管辖法院(如合同履行地、被告所在地)。\", \"修改示例\": \"争议应首先通过友好协商解决;协商不成,任何一方均有权向乙方所在地有管辖权的人民法院提起诉讼。\"}]\n\n",
"isShow": true,
"riskName": "重大风险"
},
{
"ruleId": "rule-e2ut4n6c0tirbi2h",
"ruleName": "合同生效与有效期条款",
"tags": [
{
"value": "合同编号:WYAIKJ202604HT1804SWYY\n签署日期:2026年04月17日\n合同有效期:2026年04月17日至2027年04月17日\n交付日期:2026年06月16日 09:06",
"positions": [
{
"pageNum": 1,
"box": [90, 157, 286, 96]
}
]
},
{
"value": "本合同一式两份,甲乙双方各执一份,具有同等法律效力。\n未尽事宜,双方协商解决",
"positions": [
{
"pageNum": 3,
"box": [90, 513, 313, 29]
}
]
}
],
"riskReviewResults": [
{
"originalContract": "本合同一式两份,甲乙双方各执一份,具有同等法律效力。\n未尽事宜,双方协商解决",
"riskPoint": "未约定合同有效期",
"riskAnalysis": "合同文本中虽然标注了'合同有效期:2026年04月17日至2027年04月17日',但该表述位于合同首部信息区,并非正式的合同条款。正式的合同条款部分(即'本合同一式两份...')未明确约定合同的有效期或终止时间,存在合同期限约定不明、可能被解释为长期有效的重大法律风险。建议在合同正文条款中明确约定有效期。",
"modifyExample": "本合同一式两份,甲乙双方各执一份,具有同等法律效力。本合同有效期自2026年04月17日起至2027年04月17日止。未尽事宜,双方协商解决。",
"positions": [
{
"pageNum": 3,
"box": [90, 513, 313, 29]
}
]
}
],
"predictResult": "```json\n{\"风险等级\": \"重大风险\", \"风险审查结果\": [{\"风险点\": \"未约定合同有效期\", \"风险分析\": \"合同文本中虽然标注了'合同有效期:2026年04月17日至2027年04月17日',但该表述位于合同首部信息区,并非正式的合同条款。正式的合同条款部分(即'本合同一式两份...')未明确约定合同的有效期或终止时间,存在合同期限约定不明、可能被解释为长期有效的重大法律风险。建议在合同正文条款中明确约定有效期。\", \"修改示例\": \"本合同一式两份,甲乙双方各执一份,具有同等法律效力。本合同有效期自2026年04月17日起至2027年04月17日止。未尽事宜,双方协商解决。\"}]}\n```",
"markdownResult": "风险等级:\n 重大风险\n\n风险审查结果:\n [{\"风险点\": \"未约定合同有效期\", \"风险分析\": \"合同文本中虽然标注了'合同有效期:2026年04月17日至2027年04月17日',但该表述位于合同首部信息区,并非正式的合同条款。正式的合同条款部分(即'本合同一式两份...')未明确约定合同的有效期或终止时间,存在合同期限约定不明、可能被解释为长期有效的重大法律风险。建议在合同正文条款中明确约定有效期。\", \"修改示例\": \"本合同一式两份,甲乙双方各执一份,具有同等法律效力。本合同有效期自2026年04月17日起至2027年04月17日止。未尽事宜,双方协商解决。\"}]\n\n",
"isShow": true,
"riskName": "重大风险"
},
{
"ruleId": "rule-arbdcc05hsz73csk",
"ruleName": "合同解除与终止条款",
"tags": [
{
"value": "2.若乙方服务未达标,甲方可书面通知整改;三次整改未果,甲方有权解除合同并索赔。",
"positions": [
{
"pageNum": 3,
"box": [90, 208, 433, 30]
}
]
},
{
"value": "变更或解除需双方书面同意并签署文件",
"positions": [
{
"pageNum": 3,
"box": [90, 432, 205, 12]
}
]
}
],
"riskReviewResults": [
{
"originalContract": "",
"riskPoint": "",
"riskAnalysis": "该条款约定了甲方(买方)在乙方(卖方)服务未达标且经三次整改未果的情况下,有权解除合同并索赔。此解除权并非任意解除权,而是基于卖方违约行为且给予整改机会后的救济措施,属于附条件的解除权,对卖方有一定保护。但条款未明确合同解除后的责任承担(如已提供服务的处理、费用结算等),可能引发后续争议。建议卖方补充约定合同解除后的善后处理条款,例如明确已提供服务的费用结算方式、资料交接等,以降低不确定性风险。",
"modifyExample": "",
"positions": []
}
],
"predictResult": "```json\n{\"风险等级\": \"一般风险\", \"风险审查结果\": [{\"风险分析\": \"该条款约定了甲方(买方)在乙方(卖方)服务未达标且经三次整改未果的情况下,有权解除合同并索赔。此解除权并非任意解除权,而是基于卖方违约行为且给予整改机会后的救济措施,属于附条件的解除权,对卖方有一定保护。但条款未明确合同解除后的责任承担(如已提供服务的处理、费用结算等),可能引发后续争议。建议卖方补充约定合同解除后的善后处理条款,例如明确已提供服务的费用结算方式、资料交接等,以降低不确定性风险。\"}]}\n```",
"markdownResult": "风险等级:\n 一般风险\n\n风险审查结果:\n [{\"风险分析\": \"该条款约定了甲方(买方)在乙方(卖方)服务未达标且经三次整改未果的情况下,有权解除合同并索赔。此解除权并非任意解除权,而是基于卖方违约行为且给予整改机会后的救济措施,属于附条件的解除权,对卖方有一定保护。但条款未明确合同解除后的责任承担(如已提供服务的处理、费用结算等),可能引发后续争议。建议卖方补充约定合同解除后的善后处理条款,例如明确已提供服务的费用结算方式、资料交接等,以降低不确定性风险。\"}]\n\n",
"isShow": true,
"riskName": "一般风险"
},
{
"ruleId": "rule-uicutkiym7r5txfq",
"ruleName": "通知条款",
"tags": [
{
"value": "销售类合同-软件许可项目-\n2026049096\n",
"positions": [
{
"pageNum": 1,
"box": [151, 76, 309, 59]
}
]
}
],
"riskReviewResults": [
{
"originalContract": "销售类合同-软件许可项目-\n2026049096\n",
"riskPoint": "通知条款缺失",
"riskAnalysis": "存在重大风险点:未约定通知条款。",
"modifyExample": "建议卖方结合公司要求对该条款进行补充。",
"positions": [
{
"pageNum": 1,
"box": [151, 76, 309, 59]
}
]
}
],
"predictResult": "```json\n\n{\n\n\"风险等级\": \"重大风险\",\n\n\"风险审查结果\": [\n\n{\n\n\"风险点\": \"通知条款缺失\",\n\n\"风险分析\": \"存在重大风险点:未约定通知条款。\",\n\n\"修改示例\": \"建议卖方结合公司要求对该条款进行补充。\"\n\n}\n\n]\n\n}\n\n```",
"markdownResult": "风险等级:\n 重大风险\n\n风险审查结果:\n [\n\n{\n\n\"风险点\": \"通知条款缺失\",\n\n\"风险分析\": \"存在重大风险点:未约定通知条款。\",\n\n\"修改示例\": \"建议卖方结合公司要求对该条款进行补充。\"\n\n}\n\n]\n\n",
"isShow": true,
"riskName": "重大风险"
},
{
"ruleId": "rule-qhty76shuhsze1wt",
"ruleName": "合同变更条款",
"tags": [
{
"value": "变更或解除需双方书面同意并签署文件",
"positions": [
{
"pageNum": 3,
"box": [90, 432, 205, 12]
}
]
},
{
"value": "未尽事宜,双方协商解决",
"positions": [
{
"pageNum": 3,
"box": [90, 530, 133, 12]
}
]
}
],
"riskReviewResults": [
{
"originalContract": "",
"riskPoint": "",
"riskAnalysis": "合同变更条款仅约定变更或解除需双方书面同意并签署文件,未明确约定变更的具体形式(如书面补充协议、邮件确认等),也未明确变更的提出、协商、确认等具体流程,可能导致未来变更时因程序不明确产生争议。建议卖方在合同中明确约定变更的具体形式和流程,例如:'任何对本合同的变更或解除,均需由一方提出书面请求,经双方协商一致后,以书面补充协议形式签署并加盖公章后生效。'",
"modifyExample": "",
"positions": []
}
],
"predictResult": "```json\n{\"风险等级\": \"一般风险\", \"风险审查结果\": [{\"风险分析\": \"合同变更条款仅约定变更或解除需双方书面同意并签署文件,未明确约定变更的具体形式(如书面补充协议、邮件确认等),也未明确变更的提出、协商、确认等具体流程,可能导致未来变更时因程序不明确产生争议。建议卖方在合同中明确约定变更的具体形式和流程,例如:'任何对本合同的变更或解除,均需由一方提出书面请求,经双方协商一致后,以书面补充协议形式签署并加盖公章后生效。'\"}]}\n```",
"markdownResult": "风险等级:\n 一般风险\n\n风险审查结果:\n [{\"风险分析\": \"合同变更条款仅约定变更或解除需双方书面同意并签署文件,未明确约定变更的具体形式(如书面补充协议、邮件确认等),也未明确变更的提出、协商、确认等具体流程,可能导致未来变更时因程序不明确产生争议。建议卖方在合同中明确约定变更的具体形式和流程,例如:'任何对本合同的变更或解除,均需由一方提出书面请求,经双方协商一致后,以书面补充协议形式签署并加盖公章后生效。'\"}]\n\n",
"isShow": true,
"riskName": "一般风险"
},
{
"ruleId": "rule-vs3pr8u9s2fcitfb",
"ruleName": "售后维保条款",
"tags": [
{
"value": "销售类合同-软件许可项目-\n2026049096\n",
"positions": [
{
"pageNum": 1,
"box": [151, 76, 309, 59]
}
]
}
],
"riskReviewResults": [
{
"originalContract": "销售类合同-软件许可项目-\n2026049096\n",
"riskPoint": "售后维保条款缺失",
"riskAnalysis": "存在重大风险点:未约定售后维保条款。",
"modifyExample": "建议卖方结合公司要求对该条款进行补充。",
"positions": [
{
"pageNum": 1,
"box": [151, 76, 309, 59]
}
]
}
],
"predictResult": "```json\n\n{\n\n\"风险等级\": \"重大风险\",\n\n\"风险审查结果\": [\n\n{\n\n\"风险点\": \"售后维保条款缺失\",\n\n\"风险分析\": \"存在重大风险点:未约定售后维保条款。\",\n\n\"修改示例\": \"建议卖方结合公司要求对该条款进行补充。\"\n\n}\n\n]\n\n}\n\n```",
"markdownResult": "风险等级:\n 重大风险\n\n风险审查结果:\n [\n\n{\n\n\"风险点\": \"售后维保条款缺失\",\n\n\"风险分析\": \"存在重大风险点:未约定售后维保条款。\",\n\n\"修改示例\": \"建议卖方结合公司要求对该条款进行补充。\"\n\n}\n\n]\n\n",
"isShow": true,
"riskName": "重大风险"
}
]
}
}
3、创建PDFViewer.vue
javascript
<template>
<div class="pdf-viewer" ref="scrollContainerRef">
<div class="pdf-pages">
<div
v-for="page in pages"
:key="page.pageNum"
class="pdf-page-container"
>
<canvas
:id="`pdf-canvas-${page.pageNum}`"
class="pdf-canvas"
:style="{
width: `${page.displayWidth}px`,
height: `${page.displayHeight}px`
}"
></canvas>
<div
:id="`highlight-layer-${page.pageNum}`"
class="highlight-layer"
:style="{
width: `${page.displayWidth}px`,
height: `${page.displayHeight}px`
}"
></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
import * as PDFJS from 'pdfjs-dist'
import workerUrl from "pdfjs-dist/build/pdf.worker.mjs?url"
PDFJS.GlobalWorkerOptions.workerSrc = workerUrl
interface HighlightItem {
id: string
type: 'background' | 'redbox'
pageNum: number
box: number[]
riskId: string
riskName?: string
riskLevel?: string // 新增
displayIndex?: number // 新增
}
const props = defineProps<{
pdfUrl: string
highlights: HighlightItem[]
currentHighlightIds: string[]
}>()
const emit = defineEmits<{
pageRendered: [pageNum: number]
pageChange: [pageNum: number, totalPages: number]
}>()
const scrollContainerRef = ref<HTMLElement>()
const zoom = ref(1)
let pdfDoc: any = null
const pages = ref<{
pageNum: number
originalWidth: number
originalHeight: number
displayWidth: number
displayHeight: number
}[]>([])
const highlightLayers = new Map<number, HTMLElement>()
const currentPage = ref(1)
const totalPages = ref(0)
let initialZoomDone = false
const setZoom = async (level: number) => {
if (!pdfDoc) return
zoom.value = Math.min(Math.max(level, 0.5), 2)
await renderAllPages()
emit('pageChange', currentPage.value, totalPages.value)
}
const getZoom = () => zoom.value
const resetZoom = async () => {
if (pages.value.length > 0 && scrollContainerRef.value) {
const containerWidth = scrollContainerRef.value.clientWidth - 40
const firstPage = pages.value[0]
if (firstPage && firstPage.originalWidth) {
let fitZoom = containerWidth / firstPage.originalWidth
fitZoom = Math.min(Math.max(fitZoom, 0.6), 1)
await setZoom(fitZoom)
} else {
await setZoom(1)
}
} else {
await setZoom(1)
}
}
const renderAllPages = async () => {
if (!pdfDoc) return
for (let i = 1; i <= totalPages.value; i++) {
await renderPage(i)
}
}
const renderPage = async (pageNum: number) => {
const page = await pdfDoc.getPage(pageNum)
const originalViewport = page.getViewport({ scale: 1 })
const originalWidth = originalViewport.width
const originalHeight = originalViewport.height
const renderScale = zoom.value
const renderViewport = page.getViewport({ scale: renderScale })
const canvas = document.getElementById(`pdf-canvas-${pageNum}`) as HTMLCanvasElement
if (!canvas) return
canvas.width = renderViewport.width
canvas.height = renderViewport.height
const context = canvas.getContext('2d')
await page.render({ canvasContext: context!, viewport: renderViewport }).promise
const displayWidth = originalWidth * zoom.value
const displayHeight = originalHeight * zoom.value
canvas.style.width = `${displayWidth}px`
canvas.style.height = `${displayHeight}px`
let pageInfo = pages.value.find(p => p.pageNum === pageNum)
if (!pageInfo) {
pageInfo = { pageNum, originalWidth, originalHeight, displayWidth, displayHeight }
pages.value.push(pageInfo)
} else {
pageInfo.originalWidth = originalWidth
pageInfo.originalHeight = originalHeight
pageInfo.displayWidth = displayWidth
pageInfo.displayHeight = displayHeight
}
let layerDiv = highlightLayers.get(pageNum)
if (!layerDiv) {
layerDiv = document.getElementById(`highlight-layer-${pageNum}`)
if (!layerDiv) {
layerDiv = document.createElement('div')
layerDiv.id = `highlight-layer-${pageNum}`
layerDiv.className = 'highlight-layer'
canvas.parentElement?.appendChild(layerDiv)
}
highlightLayers.set(pageNum, layerDiv)
}
layerDiv.style.width = `${displayWidth}px`
layerDiv.style.height = `${displayHeight}px`
drawHighlights(pageNum)
emit('pageRendered', pageNum)
}
// 修改后的 drawHighlights:背景高亮不受 currentHighlightIds 限制
const drawHighlights = (pageNum: number) => {
const layer = highlightLayers.get(pageNum)
if (!layer) return
layer.innerHTML = ''
const pageInfo = pages.value.find(p => p.pageNum === pageNum)
if (!pageInfo || pageInfo.originalWidth === 0 || pageInfo.displayWidth === 0) return
const scaleX = pageInfo.displayWidth / pageInfo.originalWidth
const scaleY = pageInfo.displayHeight / pageInfo.originalHeight
let pageHighlights = props.highlights.filter(h => h.pageNum === pageNum)
// 只对 redbox 类型进行 currentHighlightIds 过滤,background 始终显示
if (props.currentHighlightIds.length > 0) {
pageHighlights = pageHighlights.filter(h => {
if (h.type === 'background') return true
return props.currentHighlightIds.includes(h.id)
})
}
pageHighlights.forEach(highlight => {
const box = highlight.box
if (!box || box.length !== 4) return
// 坐标格式:[x, y, width, height](左上角原点)
const left = box[0] * scaleX
const top = box[1] * scaleY
const width = box[2] * scaleX
const height = box[3] * scaleY
const div = document.createElement('div')
div.style.position = 'absolute'
div.style.left = `${left}px`
div.style.top = `${top}px`
div.style.width = `${width}px`
div.style.height = `${height}px`
div.style.pointerEvents = 'none'
if (highlight.type === 'background') {
// 根据风险等级设置不同背景色
const isMajor = highlight.riskLevel === '重大风险'
console.log('风险等级:', highlight.riskLevel)
div.style.backgroundColor = isMajor ? 'rgba(243,62,62,.1)' : 'rgba(255,216,0,.15)'
div.style.border = isMajor ? 'none' : '1px solid rgba(231,196,0,.8)'
} else {
div.style.border = '1px solid #f33e3e'
// 添加风险点标签
const label = document.createElement('div')
const displayIndex = highlight.displayIndex
if (displayIndex !== undefined && displayIndex !== null) {
label.textContent = `风险点${displayIndex}`
} else {
label.textContent = highlight.riskName || '风险点'
}
label.style.position = 'absolute'
label.style.top = '-1px' // 相对于红框顶部向上偏移
label.style.left = '0px'
label.style.backgroundColor = '#f33e3e' // 红色背景
label.style.color = 'white'
label.style.fontSize = '12px'
label.style.padding = '0px 8px'
label.style.borderRadius = '4px 0 0 4px'
label.style.whiteSpace = 'nowrap'
label.style.zIndex = '100'
label.style.pointerEvents = 'none'
label.style.transform = 'translateX(-100%)'
label.className = 'highlight-label'
div.appendChild(label)
}
if (highlight.riskName) div.title = highlight.riskName
layer.appendChild(div)
})
}
const loadPdf = async () => {
if (!props.pdfUrl || typeof props.pdfUrl !== 'string' || props.pdfUrl.trim() === '') {
console.error('PDF URL is invalid or empty:', props.pdfUrl)
pages.value = []
return
}
try {
const response = await fetch(props.pdfUrl)
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`)
const arrayBuffer = await response.arrayBuffer()
const doc = await PDFJS.getDocument({ data: arrayBuffer }).promise
pdfDoc = doc
totalPages.value = pdfDoc.numPages
emit('pageChange', currentPage.value, totalPages.value)
pages.value = []
for (let i = 1; i <= pdfDoc.numPages; i++) {
pages.value.push({ pageNum: i, originalWidth: 0, originalHeight: 0, displayWidth: 0, displayHeight: 0 })
}
await nextTick()
zoom.value = 1
await renderAllPages()
if (scrollContainerRef.value && pages.value.length > 0 && !initialZoomDone) {
const containerWidth = scrollContainerRef.value.clientWidth - 40
const firstPage = pages.value[0]
if (firstPage && firstPage.originalWidth) {
let fitZoom = containerWidth / firstPage.originalWidth
fitZoom = Math.min(Math.max(fitZoom, 0.6), 1)
zoom.value = fitZoom
await renderAllPages()
initialZoomDone = true
}
}
} catch (error) {
console.error('PDF加载失败:', error)
}
}
const updateCurrentPage = () => {
if (!scrollContainerRef.value) return
const containers = document.querySelectorAll('.pdf-page-container')
let visiblePage = 1
const scrollTop = scrollContainerRef.value.scrollTop
let minDistance = Infinity
for (let i = 0; i < containers.length; i++) {
const rect = containers[i].getBoundingClientRect()
const containerRect = scrollContainerRef.value.getBoundingClientRect()
const elementTop = rect.top - containerRect.top + scrollTop
const distance = Math.abs(elementTop - scrollTop)
if (distance < minDistance) {
minDistance = distance
visiblePage = i + 1
}
}
if (visiblePage !== currentPage.value) {
currentPage.value = visiblePage
emit('pageChange', currentPage.value, totalPages.value)
}
}
const scrollToHighlight = (pageNum: number, box: number[]) => {
if (!scrollContainerRef.value) return
const targetCanvas = document.getElementById(`pdf-canvas-${pageNum}`) as HTMLCanvasElement
if (!targetCanvas) return
const targetPageContainer = targetCanvas.parentElement as HTMLElement
if (!targetPageContainer) return
const pageInfo = pages.value.find(p => p.pageNum === pageNum)
if (!pageInfo) return
const scaleY = pageInfo.displayHeight / pageInfo.originalHeight
const boxTop = box[1] * scaleY
const containerRect = scrollContainerRef.value.getBoundingClientRect()
const targetRect = targetPageContainer.getBoundingClientRect()
let offset = targetRect.top - containerRect.top + scrollContainerRef.value.scrollTop + boxTop
offset = Math.max(offset - 100, 0)
scrollContainerRef.value.scrollTo({ top: offset, behavior: 'smooth' })
// const layer = highlightLayers.get(pageNum)
// if (layer) {
// const divs = layer.querySelectorAll('div')
// divs.forEach(div => {
// div.style.transition = 'all 0.3s'
// div.style.boxShadow = '0 0 0 2px #ff0000'
// setTimeout(() => { div.style.boxShadow = '' }, 1000)
// })
// }
setTimeout(() => updateCurrentPage(), 300)
}
const goToPage = (pageNum: number) => {
if (!scrollContainerRef.value) return
if (pageNum < 1) pageNum = 1
if (pageNum > totalPages.value) pageNum = totalPages.value
const targetCanvas = document.getElementById(`pdf-canvas-${pageNum}`)
if (!targetCanvas) return
const targetPageContainer = targetCanvas.parentElement as HTMLElement
if (!targetPageContainer) return
const containerRect = scrollContainerRef.value.getBoundingClientRect()
const targetRect = targetPageContainer.getBoundingClientRect()
let offset = targetRect.top - containerRect.top + scrollContainerRef.value.scrollTop
offset = Math.max(offset - 100, 0)
scrollContainerRef.value.scrollTo({ top: offset, behavior: 'smooth' })
setTimeout(() => updateCurrentPage(), 300)
}
const scrollToTop = () => {
if (scrollContainerRef.value) {
scrollContainerRef.value.scrollTo({ top: 0, behavior: 'smooth' });
}
};
const onScroll = () => updateCurrentPage()
onMounted(() => {
scrollContainerRef.value?.addEventListener('scroll', onScroll)
})
onUnmounted(() => {
scrollContainerRef.value?.removeEventListener('scroll', onScroll)
})
watch(() => [props.highlights, props.currentHighlightIds], () => {
for (const page of pages.value) drawHighlights(page.pageNum)
}, { deep: true })
watch(() => props.pdfUrl, () => {
if (props.pdfUrl) {
initialZoomDone = false
loadPdf()
}
}, { immediate: true })
defineExpose({
setZoom,
getZoom,
resetZoom,
scrollToHighlight,
goToPage,
currentPage,
totalPages,
scrollToTop,
})
</script>
<style scoped>
.pdf-viewer {
width: 100%;
height: 100%;
overflow: auto;
background: #e9ecef;
position: relative;
}
.pdf-pages {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
padding: 20px;
}
.pdf-page-container {
position: relative;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
background: white;
display: inline-block;
}
.pdf-canvas {
display: block;
}
.highlight-layer {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
}
.highlight-layer div {
pointer-events: auto;
cursor: pointer;
}
.highlight-layer div:hover {
opacity: 0.7;
}
</style>
4、创建ContractReview.vue
javascript
<template>
<div class="contract-review-system">
<!-- 上传页面 -->
<div v-if="showUploadPage" class="upload-page">
<div class="upload-container">
<div class="task-list-panel">
<div class="panel-header">全部</div>
<div class="task-items">
<div
v-for="task in taskList"
:key="task.id"
class="task-item"
:class="{ active: currentTaskId === task.id }"
@click="handleTaskClick(task)"
>
<div class="task-name">{{ task.docName }}</div>
<div class="task-time">{{ task.createdAt }}</div>
<div class="task-status" :class="task.status">
{{ task.status === "success" ? "完成" : "失败" }}
</div>
</div>
</div>
</div>
<div class="upload-panel">
<div class="upload-area">
<el-upload
class="upload-demo"
drag
:before-upload="handleBeforeUpload"
:show-file-list="false"
accept=".doc,.docx,.pdf"
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">拖拽或<em>点击上传</em></div>
<template #tip>
<div class="el-upload__tip">
文档格式:DOC/DOCX/PDF,单次最多上传10份,单份大小10M内
</div>
</template>
</el-upload>
</div>
<div class="config-area">
<div class="config-item">
<span class="label">合同场景</span>
<el-select v-model="contractScene" placeholder="请选择">
<el-option label="买卖合同" value="买卖合同" />
</el-select>
</div>
<div class="config-item">
<span class="label">审查立场</span>
<el-select v-model="reviewStance" placeholder="请选择">
<el-option label="卖方" value="seller" />
<el-option label="买方" value="buyer" />
</el-select>
</div>
</div>
<div class="action-buttons">
<el-button
type="primary"
size="large"
class="start-btn"
@click="startReview"
>开始合同审查</el-button
>
<el-link type="primary" :underline="false" class="review-list-link"
>审查清单管理</el-link
>
</div>
<div class="quota-info">
额度剩余 <span class="quota-num">189</span>
<el-link type="primary" :underline="false">购买</el-link>
</div>
<div class="api-tip">
如需集成接口调用,可使用合同审查API服务。如需合作咨询,请联系我们
</div>
</div>
</div>
</div>
<!-- 详情页面 -->
<div v-else class="detail-page">
<div class="detail-container">
<!-- 左侧任务列表 -->
<div class="left-sidebar">
<div class="new-task-btn">
<el-button type="primary" plain @click="createNewTask"
>新建任务</el-button
>
</div>
<div class="task-list-title">全部</div>
<div class="task-list">
<div
v-for="task in taskList"
:key="task.id"
class="task-item"
:class="{ active: currentTaskId === task.id }"
@click="handleTaskClick(task)"
>
<div class="task-name">{{ task.docName }}</div>
<div class="task-time">{{ task.createdAt }}</div>
<div class="task-status" :class="task.status">
{{ task.status === "success" ? "完成" : "失败" }}
</div>
</div>
</div>
</div>
<!-- 中间PDF预览区 -->
<div class="pdf-preview-area">
<div class="toolbar">
<div class="highlight-mode">
<span>高亮模式:</span>
<el-radio-group v-model="highlightMode" size="small">
<el-radio-button label="all">高亮全部风险点</el-radio-button>
<el-radio-button label="current"
>仅高亮当前选中的风险点</el-radio-button
>
</el-radio-group>
</div>
<div class="pdf-controls">
<el-button size="small" @click="zoomOut">缩小</el-button>
<span class="zoom-level">{{ Math.round(zoomLevel * 100) }}%</span>
<el-button size="small" @click="zoomIn">放大</el-button>
<el-button size="small" @click="resetZoom">重置</el-button>
</div>
<div class="page-controls">
<el-button
size="small"
@click="prevPage"
:disabled="currentPage <= 1"
>上一页</el-button
>
<span class="page-info"
>{{ currentPage }} / {{ totalPages }}</span
>
<el-button
size="small"
@click="nextPage"
:disabled="currentPage >= totalPages"
>下一页</el-button
>
</div>
</div>
<div class="pdf-viewer-wrapper">
<PDFViewer
ref="pdfViewerRef"
:pdf-url="pdfUrl"
:highlights="displayHighlights"
:current-highlight-ids="
highlightMode === 'current' ? currentHighlightIds : []
"
@page-rendered="handlePageRendered"
@page-change="handlePageChange"
/>
</div>
</div>
<!-- 右侧审查结果区 -->
<div class="risk-panel">
<div class="risk-tabs">
<el-radio-group
v-model="riskFilter"
size="small"
@change="handleFilterChange"
>
<el-radio-button label="all"
>全部风险 ({{ totalRiskCount }})</el-radio-button
>
<el-radio-button label="major"
>重大风险 ({{ majorRiskCount }})</el-radio-button
>
<el-radio-button label="minor"
>一般风险 ({{ minorRiskCount }})</el-radio-button
>
</el-radio-group>
</div>
<div class="risk-groups">
<el-collapse
v-model="activeGroupNames"
accordion
@change="handleCollapseChange"
>
<el-collapse-item
v-for="group in filteredGroups"
:key="group.ruleId"
:name="group.ruleId"
>
<template #title>
<div class="group-title">
<span class="group-name">{{ group.ruleName }}</span>
<div class="group-actions">
<el-tag
:type="
group.groupRiskLevel === '重大风险'
? 'danger'
: 'warning'
"
size="small"
>
{{
group.groupRiskLevel === "重大风险" ? "重大" : "一般"
}}
</el-tag>
<div class="tag-pager" v-if="group.tagGroups.length > 1">
<el-button
size="small"
text
@click.stop.prevent="changeGroupTag(group, -1)"
>
<el-icon><ArrowLeft /></el-icon>
</el-button>
<span class="tag-index"
>{{ (activeTagIndices[group.ruleId] ?? 0) + 1 }} /
{{ group.tagGroups.length }}</span
>
<el-button
size="small"
text
@click.stop.prevent="changeGroupTag(group, 1)"
>
<el-icon><ArrowRight /></el-icon>
</el-button>
</div>
</div>
</div>
</template>
<!-- 风险点按钮 -->
<div class="risk-buttons" v-if="group.risks.length > 0">
<div
v-for="risk in group.risks"
:key="risk.id"
class="risk-button"
:class="{ active: selectedRiskId === risk.id }"
@click="selectRisk(risk)"
>
<span class="risk-button-name"
>风险点{{ risk.displayIndex }}</span
>
</div>
</div>
<!-- 当前选中风险点的详情 -->
<div
class="risk-detail"
v-if="selectedRisk && selectedRisk.ruleId === group.ruleId"
>
<div class="detail-section" v-if="selectedRisk.riskPoint">
<div class="label">风险说明</div>
<div class="text">{{ selectedRisk.riskPoint }}</div>
</div>
<div class="detail-section" v-if="selectedRisk.riskAnalysis">
<div class="label">风险分析</div>
<div class="text">{{ selectedRisk.riskAnalysis }}</div>
</div>
<div class="detail-section" v-if="selectedRisk.modifyExample">
<div class="label">修改示例</div>
<div class="text">{{ selectedRisk.modifyExample }}</div>
</div>
</div>
<!-- 静态风险点(无位置) -->
<div
class="static-risks"
v-if="group.staticRisks && group.staticRisks.length"
>
<div
v-for="sRisk in group.staticRisks"
:key="sRisk.id"
class="static-risk-item"
>
<div class="detail-section" v-if="sRisk.riskPoint">
<div class="label">风险说明</div>
<div class="text">{{ sRisk.riskPoint }}</div>
</div>
<div class="detail-section" v-if="sRisk.riskAnalysis">
<div class="label">风险分析</div>
<div class="text">{{ sRisk.riskAnalysis }}</div>
</div>
<div class="detail-section" v-if="sRisk.modifyExample">
<div class="label">修改示例</div>
<div class="text">{{ sRisk.modifyExample }}</div>
</div>
</div>
</div>
</el-collapse-item>
</el-collapse>
</div>
<div class="export-footer">
<el-button type="primary" plain size="small" @click="exportResult"
>导出结果</el-button
>
<div class="quota-info-bottom">
额度剩余 <span class="quota-num">189</span>
<el-link type="primary" :underline="false">购买</el-link>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { ElMessage } from "element-plus";
import { UploadFilled, ArrowLeft, ArrowRight } from "@element-plus/icons-vue";
import PDFViewer from "./PDFViewer.vue";
import jsonData from "./Untitled-1.json";
// 页面状态
const showUploadPage = ref(true);
const contractScene = ref("买卖合同");
const reviewStance = ref("seller");
const currentTaskId = ref("");
const pdfUrl = ref("");
const pdfViewerRef = ref<any>(null);
// 高亮模式
const highlightMode = ref<"all" | "current">("current");
const selectedRiskId = ref<string>("");
const zoomLevel = computed(() => pdfViewerRef.value?.getZoom() || 1);
// 当前需要高亮背景的分组 ID(优先级高于 activeGroupNames)
const currentHighlightGroupId = ref<string | null>(null);
// 右侧筛选
const riskFilter = ref<"all" | "major" | "minor">("all");
const activeGroupNames = ref<string[]>([]); // 默认收起所有折叠面板
// 分页
const currentPage = ref(1);
const totalPages = ref(0);
interface TaskItem {
id: string;
docName: string;
createdAt: string;
status: string;
pdfURL: string;
reviewData: any;
}
const taskList = ref<TaskItem[]>([
{
id: "textreview-task-9sbt0rfd53pzyq0w",
docName: "集团总部-销售合同-2026-04-17T09_09_01.docx",
createdAt: "2026-06-10",
status: "success",
pdfURL: "/test.pdf",
reviewData: jsonData,
},
{
id: "textreview-task-9sbt0rfd53pzyq01",
docName: "测试1-2026-04-17T09_09_01.docx",
createdAt: "2026-06-10",
status: "success",
pdfURL: "/test1.pdf",
reviewData: jsonData,
},
]);
interface HighlightItem {
id: string;
type: "background" | "redbox";
pageNum: number;
box: number[];
riskId: string;
riskName?: string;
riskLevel?: string;
displayIndex?: number; // 新增
}
// 红框高亮(所有风险点的红框,固定)
const redHighlights = ref<HighlightItem[]>([]);
interface RiskDetail {
id: string;
displayIndex: number;
ruleId: string;
ruleName: string;
riskPoint: string;
riskAnalysis: string;
modifyExample: string;
riskLevel: string;
positions: any[];
}
interface TagGroup {
index: number;
value: string;
positions: { pageNum: number; box: number[] }[];
}
interface RiskGroup {
ruleId: string;
ruleName: string;
groupRiskLevel: string;
risks: RiskDetail[];
staticRisks: RiskDetail[];
tagGroups: TagGroup[];
currentTagIndex: number;
}
const riskGroups = ref<RiskGroup[]>([]);
const selectedRisk = ref<RiskDetail | null>(null);
// 记录每个分组当前选中的 tag 索引
const activeTagIndices = ref<Record<string, number>>({});
// 计算属性
const totalRiskCount = computed(() =>
riskGroups.value.reduce((sum, g) => sum + g.risks.length, 0),
);
const majorRiskCount = computed(() =>
riskGroups.value.reduce(
(sum, g) => sum + g.risks.filter((r) => r.riskLevel === "重大风险").length,
0,
),
);
const minorRiskCount = computed(() =>
riskGroups.value.reduce(
(sum, g) => sum + g.risks.filter((r) => r.riskLevel === "一般风险").length,
0,
),
);
const filteredGroups = computed(() => {
if (riskFilter.value === "all") return riskGroups.value;
return riskGroups.value
.map((group) => ({
...group,
risks: group.risks.filter((risk) =>
riskFilter.value === "major"
? risk.riskLevel === "重大风险"
: risk.riskLevel === "一般风险",
),
}))
.filter((group) => group.risks.length > 0);
});
const currentHighlightIds = computed(() => {
if (selectedRiskId.value) {
return redHighlights.value
.filter((h) => h.riskId === selectedRiskId.value)
.map((h) => h.id);
}
return [];
});
// 动态背景高亮:根据高亮模式
const dynamicBackgroundHighlights = computed<HighlightItem[]>(() => {
const bgList: HighlightItem[] = [];
if (highlightMode.value === "all") {
// 全部模式:遍历所有分组,每个分组的所有 tag 的所有 position 都高亮
for (const group of riskGroups.value) {
for (let tagIdx = 0; tagIdx < group.tagGroups.length; tagIdx++) {
const tagGroup = group.tagGroups[tagIdx];
if (!tagGroup || !tagGroup.positions.length) continue;
tagGroup.positions.forEach((pos, posIdx) => {
bgList.push({
id: `bg-${group.ruleId}-${tagIdx}-${posIdx}`,
type: "background",
pageNum: pos.pageNum,
box: pos.box,
riskId: `group-${group.ruleId}`,
riskName: group.ruleName,
riskLevel: group.groupRiskLevel,
});
});
}
}
} else {
// current 模式:优先使用 currentHighlightGroupId,否则使用已展开的分组
let targetGroupIds: string[] = [];
if (currentHighlightGroupId.value) {
targetGroupIds = [currentHighlightGroupId.value];
} else {
targetGroupIds = activeGroupNames.value;
}
for (const group of riskGroups.value) {
if (!targetGroupIds.includes(group.ruleId)) continue;
const tagIndex = activeTagIndices.value[group.ruleId] ?? 0;
const tagGroup = group.tagGroups[tagIndex];
if (!tagGroup || !tagGroup.positions.length) continue;
tagGroup.positions.forEach((pos, idx) => {
bgList.push({
id: `bg-${group.ruleId}-${tagIndex}-${idx}`,
type: "background",
pageNum: pos.pageNum,
box: pos.box,
riskId: `group-${group.ruleId}`,
riskName: group.ruleName,
riskLevel: group.groupRiskLevel,
});
});
}
}
return bgList;
});
// 红框高亮:根据高亮模式和当前选中的风险点
const displayRedHighlights = computed<HighlightItem[]>(() => {
if (highlightMode.value === "current") {
if (!selectedRiskId.value) return [];
return redHighlights.value.filter(
(h) => h.type === "redbox" && h.riskId === selectedRiskId.value,
);
} else {
return redHighlights.value.filter((h) => h.type === "redbox");
}
});
// 最终传给 PDFViewer 的高亮列表
const displayHighlights = computed(() => {
return [...displayRedHighlights.value, ...dynamicBackgroundHighlights.value];
});
// 解析数据(仅生成红框)
const parseGroupsAndHighlights = (data: any) => {
// 确保每次解析前都重置(防止调用时未重置干净)
activeTagIndices.value = {};
selectedRisk.value = null;
selectedRiskId.value = null;
const highlights: HighlightItem[] = [];
const groups: RiskGroup[] = [];
const chatContents = data.llmTextreviewResult?.chatContents || [];
chatContents.forEach((rule: any) => {
const ruleId = rule.ruleId;
const ruleName = rule.ruleName;
const ruleRiskLevel =
rule.riskName ||
(rule.predictResult?.includes("重大风险") ? "重大风险" : "一般风险");
const risksInGroup: RiskDetail[] = [];
const staticRisksInGroup: RiskDetail[] = [];
let groupRiskCounter = 0;
const tagGroups: TagGroup[] = [];
if (rule.tags && rule.tags.length) {
rule.tags.forEach((tag: any, idx: number) => {
const positions = tag.positions || [];
tagGroups.push({
index: idx,
value: tag.value || "",
positions: positions,
});
});
}
if (rule.riskReviewResults && rule.riskReviewResults.length) {
rule.riskReviewResults.forEach((risk: any, riskIdx: number) => {
const hasPositions = risk.positions && risk.positions.length > 0;
const hasContent =
(risk.riskAnalysis && risk.riskAnalysis.trim()) ||
(risk.modifyExample && risk.modifyExample.trim());
const riskId = `${ruleId}-${riskIdx}`;
let riskPoint = risk.riskPoint || "";
if (!riskPoint && risk.predictResult) {
try {
const parsed = JSON.parse(risk.predictResult);
if (parsed.风险审查结果?.[0]?.风险点)
riskPoint = parsed.风险审查结果[0].风险点;
} catch (e) {}
}
const riskAnalysis = risk.riskAnalysis || "";
const modifyExample = risk.modifyExample || "";
const riskLevel = ruleRiskLevel;
const riskDetail: RiskDetail = {
id: riskId,
displayIndex: 0,
ruleId,
ruleName,
riskPoint: riskPoint || ruleName,
riskAnalysis,
modifyExample,
riskLevel,
positions: risk.positions || [],
};
if (hasPositions) {
groupRiskCounter++;
riskDetail.displayIndex = groupRiskCounter;
risksInGroup.push(riskDetail);
risk.positions.forEach((pos: any, posIdx: number) => {
highlights.push({
id: `red-${riskId}-${posIdx}`,
type: "redbox",
pageNum: pos.pageNum,
box: pos.box,
riskId: riskId,
riskName: riskPoint,
displayIndex: riskDetail.displayIndex, // 添加这一行
});
});
} else if (hasContent) {
staticRisksInGroup.push(riskDetail);
}
});
}
if (
tagGroups.length > 0 ||
risksInGroup.length > 0 ||
staticRisksInGroup.length > 0
) {
const hasMajor = risksInGroup.some((r) => r.riskLevel === "重大风险");
groups.push({
ruleId,
ruleName,
groupRiskLevel: hasMajor ? "重大风险" : "一般风险",
risks: risksInGroup,
staticRisks: staticRisksInGroup,
tagGroups: tagGroups,
currentTagIndex: 0,
});
}
});
redHighlights.value = highlights;
riskGroups.value = groups;
// 初始化 activeTagIndices
const newTagIndices: Record<string, number> = {};
groups.forEach((g) => {
newTagIndices[g.ruleId] = 0;
});
activeTagIndices.value = newTagIndices;
// 默认不选中任何风险点,避免默认高亮
selectedRisk.value = null;
selectedRiskId.value = "";
};
// 选中风险点(滚动并高亮,并智能切换 tag 分页)
const selectRisk = (risk: RiskDetail) => {
selectedRiskId.value = risk.id;
selectedRisk.value = risk;
currentHighlightGroupId.value = risk.ruleId; // 新增
const group = riskGroups.value.find((g) => g.ruleId === risk.ruleId);
if (
group &&
activeGroupNames.value.includes(group.ruleId) &&
group.tagGroups.length > 0
) {
// 收集风险点的所有页码
const riskPageNums = new Set(risk.positions.map((p) => p.pageNum));
let matchedTagIndex = -1;
// 遍历所有 tag,找到第一个页码有交集的 tag
for (let i = 0; i < group.tagGroups.length; i++) {
const tag = group.tagGroups[i];
const tagPageNums = new Set(tag.positions.map((p) => p.pageNum));
for (const page of riskPageNums) {
if (tagPageNums.has(page)) {
matchedTagIndex = i;
break;
}
}
if (matchedTagIndex !== -1) break;
}
if (matchedTagIndex !== -1) {
// 如果找到匹配的 tag 且不是当前 tag,则切换过去
const currentTagIndex = activeTagIndices.value[group.ruleId] ?? 0;
if (matchedTagIndex !== currentTagIndex) {
activeTagIndices.value[group.ruleId] = matchedTagIndex;
group.currentTagIndex = matchedTagIndex;
}
// 如果已经是当前 tag,不做改变
} else {
// 没有页码匹配,重置为第一页
activeTagIndices.value[group.ruleId] = 0;
group.currentTagIndex = 0;
}
}
// 滚动到风险点的第一个位置
if (risk.positions && risk.positions.length > 0 && pdfViewerRef.value) {
const firstPos = risk.positions[0];
pdfViewerRef.value.scrollToHighlight(firstPos.pageNum, firstPos.box);
}
};
// 加载任务数据
const loadTaskData = (task: TaskItem) => {
// ========== 重置所有状态 ==========
activeGroupNames.value = [];
selectedRisk.value = null;
selectedRiskId.value = "";
currentHighlightGroupId.value = null;
activeTagIndices.value = {};
// ==================================
pdfUrl.value = task.pdfURL;
if (task.reviewData) {
parseGroupsAndHighlights(task.reviewData);
} else {
redHighlights.value = [];
riskGroups.value = [];
selectedRisk.value = null;
selectedRiskId.value = "";
}
// 等待 PDF 重新加载后滚动到顶部
setTimeout(() => {
pdfViewerRef.value?.scrollToTop();
}, 300); // 延迟确保 PDF 已开始渲染
};
// 切换分组 tag
const changeGroupTag = (group: RiskGroup, delta: number) => {
if (!group.tagGroups.length) return;
// 设置当前高亮分组为此分组
currentHighlightGroupId.value = group.ruleId;
const current = activeTagIndices.value[group.ruleId] ?? 0;
let newIndex = current + delta;
if (newIndex < 0) newIndex = group.tagGroups.length - 1;
if (newIndex >= group.tagGroups.length) newIndex = 0;
activeTagIndices.value[group.ruleId] = newIndex;
group.currentTagIndex = newIndex;
const tagGroup = group.tagGroups[newIndex];
if (tagGroup && tagGroup.positions.length && pdfViewerRef.value) {
const firstPos = tagGroup.positions[0];
pdfViewerRef.value.scrollToHighlight(firstPos.pageNum, firstPos.box);
}
};
// 折叠面板展开/收起时
const handleCollapseChange = (activeGroupId: string | string[]) => {
// 如果没有展开任何面板(全部收起)
if (
!activeGroupId ||
(Array.isArray(activeGroupId) && activeGroupId.length === 0)
) {
selectedRisk.value = null;
selectedRiskId.value = "";
return;
}
const groupId = Array.isArray(activeGroupId)
? activeGroupId[0]
: activeGroupId;
const group = riskGroups.value.find((g) => g.ruleId === groupId);
if (!group) return;
currentHighlightGroupId.value = groupId;
// 重置该分组的 tag 索引为 0(第 1 页)
if (activeTagIndices.value[groupId] !== 0) {
activeTagIndices.value[groupId] = 0;
group.currentTagIndex = 0;
}
// 1. 如果分组有风险点(有具体位置),选中第一个风险点并滚动
if (group.risks.length > 0) {
// 如果当前选中的风险点不属于这个分组,则选中该分组第一个风险点
if (!selectedRisk.value || selectedRisk.value.ruleId !== groupId) {
selectRisk(group.risks[0]);
}
}
// 2. 否则(没有风险点,只有 tags/背景),则滚动到第一个 tag 的位置
else if (
group.tagGroups.length > 0 &&
group.tagGroups[0].positions.length > 0
) {
const firstTag = group.tagGroups[0];
const firstPos = firstTag.positions[0];
if (pdfViewerRef.value) {
pdfViewerRef.value.scrollToHighlight(firstPos.pageNum, firstPos.box);
}
}
};
const handleFilterChange = () => {
if (
filteredGroups.value.length > 0 &&
filteredGroups.value[0].risks.length > 0
) {
// 只改变选中,不自动展开面板
const firstRisk = filteredGroups.value[0].risks[0];
selectedRisk.value = firstRisk;
selectedRiskId.value = firstRisk.id;
} else {
selectedRisk.value = null;
selectedRiskId.value = "";
}
};
// Mock 数据初始化
const initMockData = () => {
if (taskList.value.length) {
currentTaskId.value = taskList.value[0].id;
loadTaskData(taskList.value[0]);
}
};
// 事件处理
/**
* 处理任务点击事件
*
* @param task - 被点击的任务项
*/
const handleTaskClick = (task: TaskItem) => {
if (currentTaskId.value === task.id) return;
currentTaskId.value = task.id;
highlightMode.value = 'current'
loadTaskData(task);
};
const createNewTask = () => {
// ========== 重置所有状态 ==========
activeGroupNames.value = [];
selectedRisk.value = null;
selectedRiskId.value = "";
currentHighlightGroupId.value = null;
activeTagIndices.value = {};
pdfUrl.value = ""; // 清空 PDF 地址
redHighlights.value = []; // 清空红框数据
riskGroups.value = []; // 清空风险分组
// ==================================
showUploadPage.value = true; // 显示上传页面
};
const handleBeforeUpload = (file: File) => {
ElMessage.success(`已选择文件: ${file.name}`);
return false;
};
const startReview = () => {
if (taskList.value.length) {
showUploadPage.value = false;
loadTaskData(taskList.value[0]);
} else {
ElMessage.warning("请先上传合同文件");
}
};
const zoomIn = () => {
if (pdfViewerRef.value) {
const current = pdfViewerRef.value.getZoom();
const newZoom = Math.min(current + 0.1, 2);
pdfViewerRef.value.setZoom(newZoom);
}
};
const zoomOut = () => {
if (pdfViewerRef.value) {
const current = pdfViewerRef.value.getZoom();
const newZoom = Math.max(current - 0.1, 0.5);
pdfViewerRef.value.setZoom(newZoom);
}
};
const resetZoom = () => {
if (pdfViewerRef.value) {
pdfViewerRef.value.resetZoom();
}
};
const prevPage = () => {
if (pdfViewerRef.value && currentPage.value > 1) {
pdfViewerRef.value.goToPage(currentPage.value - 1);
}
};
const nextPage = () => {
if (pdfViewerRef.value && currentPage.value < totalPages.value) {
pdfViewerRef.value.goToPage(currentPage.value + 1);
}
};
const handlePageChange = (page: number, total: number) => {
currentPage.value = page;
totalPages.value = total;
};
const handlePageRendered = (pageNum: number) => {};
const exportResult = () => ElMessage.success("导出功能开发中");
onMounted(() => {
initMockData();
});
</script>
<style lang="scss" scoped>
.contract-review-system {
width: 100%;
height: 100%;
background: #f5f7fa;
.upload-page {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
.upload-container {
width: 1200px;
height: 600px;
background: white;
border-radius: 8px;
display: flex;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
.task-list-panel {
width: 280px;
border-right: 1px solid #e4e7ed;
padding: 20px;
.panel-header {
font-size: 16px;
font-weight: 500;
margin-bottom: 16px;
color: #303133;
}
.task-items {
.task-item {
padding: 12px;
margin-bottom: 8px;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
background: #fafafa;
&:hover {
background: #f0f2f5;
}
&.active {
background: #ecf5ff;
border-left: 3px solid #409eff;
}
.task-name {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-time {
font-size: 12px;
color: #909399;
margin-bottom: 4px;
}
.task-status {
font-size: 12px;
color: #67c23a;
&.failed {
color: #f56c6c;
}
}
}
}
}
.upload-panel {
flex: 1;
padding: 40px;
display: flex;
flex-direction: column;
align-items: center;
.upload-area {
width: 100%;
margin-bottom: 30px;
}
.config-area {
width: 100%;
margin-bottom: 20px;
.config-item {
display: flex;
align-items: center;
margin-bottom: 16px;
.label {
width: 80px;
font-size: 14px;
color: #606266;
}
.el-select {
width: 200px;
}
}
}
.action-buttons {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 20px;
.start-btn {
width: 160px;
}
.review-list-link {
font-size: 14px;
}
}
.quota-info {
font-size: 14px;
color: #606266;
.quota-num {
color: #409eff;
font-weight: 500;
margin: 0 4px;
}
}
.api-tip {
margin-top: 20px;
font-size: 12px;
color: #909399;
text-align: center;
}
}
}
}
.detail-page {
height: 100%;
.detail-container {
height: 100%;
display: flex;
background: #f5f7fa;
.left-sidebar {
width: 256px;
background: white;
display: flex;
flex-direction: column;
.new-task-btn {
padding: 16px;
border-bottom: 1px solid #e4e7ed;
text-align: center;
}
.task-list-title {
padding: 16px 16px 8px;
font-size: 14px;
font-weight: 500;
color: #606266;
}
.task-list {
flex: 1;
overflow-y: auto;
padding: 0 8px;
.task-item {
padding: 12px;
margin-bottom: 8px;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
&:hover {
background: #f0f2f5;
}
&.active {
background: #ecf5ff;
}
.task-name {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-time {
font-size: 12px;
color: #909399;
margin-bottom: 4px;
}
.task-status {
font-size: 12px;
color: #67c23a;
}
}
}
}
.pdf-preview-area {
flex: 1;
padding: 0 16px;
display: flex;
flex-direction: column;
background: #f0f2f5;
.toolbar {
background: white;
display: flex;
justify-content: space-between;
align-items: center;
height: 56px;
.highlight-mode {
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
}
.pdf-controls {
display: flex;
align-items: center;
gap: 8px;
.zoom-level {
min-width: 50px;
text-align: center;
}
}
.page-controls {
display: flex;
align-items: center;
gap: 8px;
margin-left: 16px;
}
}
.pdf-viewer-wrapper {
flex: 1;
overflow: auto;
background: #e9ecef;
padding: 0 16px 16px 0;
}
}
.risk-panel {
width: 400px;
background: white;
border-left: 1px solid #e4e7ed;
display: flex;
flex-direction: column;
.risk-tabs {
padding: 16px;
border-bottom: 1px solid #e4e7ed;
text-align: center;
}
.risk-groups {
flex: 1;
overflow-y: auto;
padding: 12px;
:deep(.el-collapse-item__header) {
font-weight: 500;
background: #fafafa;
border-radius: 4px;
margin-bottom: 8px;
padding: 0 12px;
}
.group-title {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
.group-name {
font-size: 14px;
font-weight: 500;
color: #303133;
}
.group-actions {
display: flex;
align-items: center;
gap: 12px;
.tag-pager {
display: flex;
align-items: center;
gap: 4px;
.tag-index {
font-size: 12px;
color: #606266;
min-width: 40px;
text-align: center;
}
}
}
}
.risk-buttons {
display: flex;
gap: 8px;
padding: 8px 0;
.risk-button {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 12px;
background: #f5f7fa;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #e9ecef;
}
&.active {
background: #ecf5ff;
border-left: 3px solid #409eff;
}
.risk-button-name {
font-size: 14px;
color: #303133;
}
}
}
.risk-detail {
.detail-section {
.label {
font-size: 18px;
font-weight: bold;
}
}
}
.static-risks {
.static-risk-item {
.detail-section {
.label {
font-size: 18px;
font-weight: bold;
}
}
}
}
}
.export-footer {
padding: 16px;
border-top: 1px solid #e4e7ed;
display: flex;
justify-content: space-between;
align-items: center;
.quota-info-bottom {
font-size: 13px;
color: #606266;
.quota-num {
color: #409eff;
font-weight: 500;
margin: 0 4px;
}
}
}
}
}
}
}
</style>
将上面代码放置框架内可直接运行