From a6cdf8642e21596563819abc666690f9daf4c795 Mon Sep 17 00:00:00 2001
From: ingkele <598792849@qq.com>
Date: Fri, 23 Oct 2026 19:07:19 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20AI=E5=AE=A1=E6=9F=A5=E6=8A=A5=E5=91=8A?=
=?UTF-8?q?=E6=94=AF=E6=8C=81=E4=B8=8B=E8=BD=BDWord=20+=20=E6=88=91?=
=?UTF-8?q?=E7=9A=84=E6=8A=A5=E5=91=8A=E6=94=AF=E6=8C=81=E9=87=8D=E6=96=B0?=
=?UTF-8?q?=E5=AE=A1=E6=9F=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 云函数jjxReviewTimer完成审查后自动将HTML报告转换为Word文档并上传
- 新增buildDocx.js纯正则解析HTML报告生成A4公文版式docx(三级数据提取兼容结构漂移)
- 兼容智能体插件直接返回docx链接(jjx-docx-link标记协议)
- router/jjxDoReview同步支持Word生成(try降级不影响主流程)
- 前端报告列表新增下载Word/重新审查按钮
- 修复上传/查询取真实uid(vk.getVuex而非页面本地data)
---
pages/ai/reports.vue | 73 +-
pages/ai/review.vue | 1 +
.../jjxReviewTimer/buildDocx.js | 922 ++++++++++++++++++
.../cloudfunctions/jjxReviewTimer/index.js | 43 +
.../jjxReviewTimer/local-test.js | 336 +++++++
.../jjxReviewTimer/package.json | 5 +-
.../router/service/client/ai/pub/buildDocx.js | 922 ++++++++++++++++++
.../service/client/ai/pub/jjxDoReview.js | 55 ++
8 files changed, 2355 insertions(+), 2 deletions(-)
create mode 100644 uniCloud-tcb/cloudfunctions/jjxReviewTimer/buildDocx.js
create mode 100644 uniCloud-tcb/cloudfunctions/jjxReviewTimer/local-test.js
create mode 100644 uniCloud-tcb/cloudfunctions/router/service/client/ai/pub/buildDocx.js
diff --git a/pages/ai/reports.vue b/pages/ai/reports.vue
index c151068..377ee29 100644
--- a/pages/ai/reports.vue
+++ b/pages/ai/reports.vue
@@ -31,8 +31,11 @@
暂无审查结果
' let html = text @@ -238,6 +295,20 @@ export default { color: #fff; } +.action-btn-primary { + background: #a92924; + color: #fff; +} + +.action-btn-primary:active { + background: #8c1f1b; +} + +.action-btn-disabled { + color: #bbb; + border-color: #ddd; +} + .summary-popup { padding: 30rpx; max-height: 70vh; diff --git a/pages/ai/review.vue b/pages/ai/review.vue index 0e9a328..1404234 100644 --- a/pages/ai/review.vue +++ b/pages/ai/review.vue @@ -122,6 +122,7 @@ export default { file_base64: base64, file_name: fileName, file_size: fileSize, + uid: vk.getVuex('$user.userInfo._id') || '', upload_only: true } }) diff --git a/uniCloud-tcb/cloudfunctions/jjxReviewTimer/buildDocx.js b/uniCloud-tcb/cloudfunctions/jjxReviewTimer/buildDocx.js new file mode 100644 index 0000000..f69dcc4 --- /dev/null +++ b/uniCloud-tcb/cloudfunctions/jjxReviewTimer/buildDocx.js @@ -0,0 +1,922 @@ +'use strict'; + +/** + * 合同审查报告 → Word(.docx) 生成器 + * + * 数据来源:智能体在 HTML 报告末尾输出的结构化 JSON + * + * + * 排版目标:正式法律文书风格,A4 纵向,可直接打印装订 + */ + +const { + Document, + Packer, + Paragraph, + TextRun, + Table, + TableRow, + TableCell, + WidthType, + AlignmentType, + BorderStyle, + ShadingType, + TableLayoutType, + Footer, + Header, + PageNumber, + VerticalAlign, + ExternalHyperlink +} = require('docx'); + +const FONT = 'Microsoft YaHei'; +const FONT_SONG = 'SimSun'; + +const C = { + primary: '1A5276', + danger: 'E74C3C', + success: '27AE60', + warning: 'F39C12', + text: '222222', + gray: '777777', + lightBg: 'F5F7F9', + pinkBg: 'FDF2F2', + border: 'D0D7DE' +}; + +const PAGE_BORDER = { + top: { style: BorderStyle.SINGLE, size: 4, color: C.border }, + bottom: { style: BorderStyle.SINGLE, size: 4, color: C.border }, + left: { style: BorderStyle.SINGLE, size: 4, color: C.border }, + right: { style: BorderStyle.SINGLE, size: 4, color: C.border }, + insideHorizontal: { style: BorderStyle.SINGLE, size: 4, color: C.border }, + insideVertical: { style: BorderStyle.SINGLE, size: 4, color: C.border } +}; + +// ---------- 基础元件 ---------- + +function run(text, o) { + o = o || {}; + const opt = { + text: String(text === null || text === undefined ? '' : text), + font: o.font || FONT, + size: o.size || 21, + bold: !!o.bold, + italics: !!o.italics, + color: o.color || C.text + }; + if (o.underline) opt.underline = {}; + return new TextRun(opt); +} + +function para(children, o) { + o = o || {}; + const opt = { + children: Array.isArray(children) ? children : [children], + spacing: { + before: o.before === undefined ? 60 : o.before, + after: o.after === undefined ? 60 : o.after, + line: o.line || 320 + } + }; + if (o.alignment) opt.alignment = o.alignment; + if (o.indent) opt.indent = o.indent; + if (o.border) opt.border = o.border; + if (o.shading) opt.shading = { type: ShadingType.CLEAR, fill: o.shading, color: 'auto' }; + return new Paragraph(opt); +} + +function cell(children, o) { + o = o || {}; + const opt = { + children: Array.isArray(children) ? children : [children], + verticalAlign: VerticalAlign.CENTER, + margins: { top: 60, bottom: 60, left: 110, right: 110 } + }; + if (o.shading) opt.shading = { type: ShadingType.CLEAR, fill: o.shading, color: 'auto' }; + if (o.width) opt.width = { size: o.width, type: WidthType.PERCENTAGE }; + if (o.columnSpan) opt.columnSpan = o.columnSpan; + return new TableCell(opt); +} + +function table(rows, widths) { + return new Table({ + rows: rows, + width: { size: 100, type: WidthType.PERCENTAGE }, + columnWidths: widths, + layout: TableLayoutType.AUTOFIT, + borders: PAGE_BORDER + }); +} + +function sectionTitle(no, text) { + return para([run(no + '、' + text, { size: 28, bold: true, color: C.primary })], { + before: 320, + after: 140, + border: { bottom: { style: BorderStyle.SINGLE, size: 8, color: C.primary, space: 4 } } + }); +} + +function pick(obj, keys, dft) { + if (!obj) return dft; + for (let i = 0; i < keys.length; i++) { + const v = obj[keys[i]]; + if (v !== undefined && v !== null && v !== '') return v; + } + return dft; +} + +function asArray(v) { + if (!v) return []; + return Array.isArray(v) ? v : [v]; +} + +// ---------- 风险条目 ---------- + +function riskBlock(item, color, levelText) { + const clause = pick(item, ['clause', 'no', '条款', 'clauseNo'], ''); + const issue = pick(item, ['issue', 'risk', 'problem', '描述', '问题'], ''); + const suggest = pick(item, ['suggestion', 'fix', 'advice', '建议', '修改建议'], ''); + const basis = pick(item, ['legal_basis', 'legalBasis', 'basis', '法律依据'], ''); + const blocks = []; + + blocks.push(para([ + run('[' + levelText + ']', { bold: true, color: color, size: 21 }), + run(clause ? ' 第 ' + clause + ' 条' : ' 合同条款', { bold: true, color: color, size: 21 }) + ], { before: 140, after: 40 })); + + blocks.push(para([ + run('问题:', { bold: true, size: 21 }), + run(issue, { size: 21 }) + ], { after: 40, indent: { left: 240 } })); + + if (suggest) { + blocks.push(para([ + run('建议:', { bold: true, size: 21 }), + run(suggest, { size: 21 }) + ], { after: 40, indent: { left: 240 } })); + } + + if (basis) { + blocks.push(para([ + run('依据:', { bold: true, size: 21 }), + run(basis, { size: 21, color: C.gray }) + ], { after: 80, indent: { left: 240 } })); + } + + return blocks; +} + +function riskSection(items, color, levelText, emptyText) { + const list = asArray(items); + if (!list.length) { + return [para([run(emptyText || '无', { size: 21, color: C.gray })], { after: 80 })]; + } + let out = []; + list.forEach(function (it) { + out = out.concat(riskBlock(it, color, levelText)); + }); + return out; +} + +// ---------- 主函数 ---------- + +async function buildDocx(input) { + const data = input.data || {}; + const fileName = input.fileName || '合同审查报告'; + const contractName = pick(data, ['contract_name', 'contractName', '协议名称', 'name'], fileName.replace(/\.[^.]+$/, '')); + const reportTitle = pick(data, ['report_title', 'reportTitle', '报告标题'], '合同审查报告'); + const reviewDate = pick(data, ['review_date', 'reviewDate', '审核日期'], new Date().toLocaleDateString('zh-CN')); + const summary = pick(data, ['summary', '统计'], {}) || {}; + const conclusion = pick(data, ['conclusion', '审核结论', 'summary_text'], ''); + const highRisks = pick(data, ['high_risks', 'highRisks', '高风险'], []); + const mediumRisks = pick(data, ['medium_risks', 'mediumRisks', '中风险'], []); + const lowRisks = pick(data, ['low_risks', 'lowRisks', '低风险'], []); + const suggestions = pick(data, ['suggestions', '优化建议', 'advice_table'], []); + const templates = pick(data, ['clause_templates', 'clauseTemplates', '补充条款'], []); + const legalBases = pick(data, ['legal_bases', 'legalBases', '法律依据'], []); + const cases = pick(data, ['cases', '案例'], []); + const disclaimer = pick(data, ['disclaimer', '免责声明'], ''); + + const nCompliant = pick(summary, ['compliant', '合规'], 0); + const nPartial = pick(summary, ['partial', '部分合规'], 0); + const nHigh = pick(summary, ['high', '高风险'], asArray(highRisks).length); + const nMedium = pick(summary, ['medium', '中风险'], asArray(mediumRisks).length); + + const children = []; + + // ===== 抬头 ===== + children.push(para([run(reportTitle, { size: 44, bold: true, color: C.primary })], { + alignment: AlignmentType.CENTER, + before: 200, + after: 120 + })); + children.push(para([run(contractName, { size: 26, color: C.gray })], { + alignment: AlignmentType.CENTER, + after: 60 + })); + children.push(para([run('审核日期:' + reviewDate + ' 出具机构:金久信签约宝', { size: 20, color: C.gray })], { + alignment: AlignmentType.CENTER, + after: 240 + })); + + // ===== 目录 ===== + children.push(sectionTitle('一', '报告目录')); + const tocItems = [ + '二、审核结论', + '三、需处理问题(高风险 / 中风险)', + '四、优化建议汇总表', + '五、补充条款模板', + '六、法律依据', + '七、相关案例', + '八、免责声明' + ]; + tocItems.forEach(function (item) { + children.push(para([run(item, { size: 21 })], { after: 30, indent: { left: 240 } })); + }); + + // ===== 审核结论 ===== + children.push(sectionTitle('二', '审核结论')); + + const statRow = new TableRow({ + children: [ + cell([para([run(String(nCompliant), { size: 40, bold: true, color: C.success })], { alignment: AlignmentType.CENTER, before: 40, after: 20 }), + para([run('合规条款', { size: 18, color: C.gray })], { alignment: AlignmentType.CENTER, after: 40 })], + { shading: 'F0F9EB', width: 25 }), + cell([para([run(String(nPartial), { size: 40, bold: true, color: C.warning })], { alignment: AlignmentType.CENTER, before: 40, after: 20 }), + para([run('部分合规', { size: 18, color: C.gray })], { alignment: AlignmentType.CENTER, after: 40 })], + { shading: 'FDF6EC', width: 25 }), + cell([para([run(String(nHigh), { size: 40, bold: true, color: C.danger })], { alignment: AlignmentType.CENTER, before: 40, after: 20 }), + para([run('高风险', { size: 18, color: C.gray })], { alignment: AlignmentType.CENTER, after: 40 })], + { shading: 'FEF0F0', width: 25 }), + cell([para([run(String(nMedium), { size: 40, bold: true, color: C.warning })], { alignment: AlignmentType.CENTER, before: 40, after: 20 }), + para([run('中风险', { size: 18, color: C.gray })], { alignment: AlignmentType.CENTER, after: 40 })], + { shading: 'FDF6EC', width: 25 }) + ] + }); + children.push(table([statRow], [2000, 2000, 2000, 2000])); + + if (conclusion) { + children.push(para([run('审核结论', { size: 21, bold: true, color: C.primary })], { before: 200, after: 80 })); + String(conclusion).split(/\n+/).forEach(function (line) { + if (line.trim()) { + children.push(para([run(line.trim(), { size: 21 })], { after: 60, indent: { left: 240 } })); + } + }); + } + + // ===== 需处理问题 ===== + children.push(sectionTitle('三', '需处理问题')); + + children.push(para([run('(一)高风险问题', { size: 24, bold: true, color: C.danger })], { before: 140, after: 80 })); + riskSection(highRisks, C.danger, '高风险', '本次审查未发现高风险问题。').forEach(function (p) { children.push(p); }); + + children.push(para([run('(二)中风险问题', { size: 24, bold: true, color: C.warning })], { before: 200, after: 80 })); + riskSection(mediumRisks, C.warning, '中风险', '本次审查未发现中风险问题。').forEach(function (p) { children.push(p); }); + + if (asArray(lowRisks).length) { + children.push(para([run('(三)低风险问题', { size: 24, bold: true, color: C.gray })], { before: 200, after: 80 })); + riskSection(lowRisks, C.gray, '低风险', '无').forEach(function (p) { children.push(p); }); + } + + // ===== 优化建议汇总表 ===== + children.push(sectionTitle('四', '优化建议汇总表')); + const sugList = asArray(suggestions); + if (!sugList.length) { + children.push(para([run('无', { size: 21, color: C.gray })])); + } else { + const headRow = new TableRow({ + tableHeader: true, + children: [ + cell([para([run('优先级', { bold: true, color: 'FFFFFF', size: 20 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 12 }), + cell([para([run('条款编号', { bold: true, color: 'FFFFFF', size: 20 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 15 }), + cell([para([run('问题描述', { bold: true, color: 'FFFFFF', size: 20 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 38 }), + cell([para([run('建议方案', { bold: true, color: 'FFFFFF', size: 20 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 35 }) + ] + }); + const rows = [headRow]; + sugList.forEach(function (s, i) { + const lv = String(pick(s, ['priority', 'level', '优先级'], 'M')).toUpperCase(); + const lvBg = lv === 'H' ? C.danger : (lv === 'L' ? '999999' : C.warning); + rows.push(new TableRow({ + children: [ + cell([para([run(lv, { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: lvBg, width: 12 }), + cell([para([run(pick(s, ['clause', 'no', '条款编号'], '-'), { size: 19 })], { alignment: AlignmentType.CENTER })], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 15 }), + cell([para([run(pick(s, ['issue', 'problem', '问题描述', '描述'], '-'), { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 38 }), + cell([para([run(pick(s, ['solution', 'suggestion', '建议方案', '建议'], '-'), { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 35 }) + ] + })); + }); + children.push(table(rows, [1200, 1500, 3800, 3500])); + } + + // ===== 补充条款模板 ===== + children.push(sectionTitle('五', '补充条款模板')); + const tplList = asArray(templates); + if (!tplList.length) { + children.push(para([run('无', { size: 21, color: C.gray })])); + } else { + tplList.forEach(function (t, i) { + const title = pick(t, ['title', 'name', '条款名称'], '补充条款 ' + (i + 1)); + const content = pick(t, ['content', 'text', '条文', '内容'], ''); + children.push(para([run(String(i + 1) + '. ' + title, { size: 21, bold: true, color: C.primary })], { before: 140, after: 60 })); + String(content).split(/\n+/).forEach(function (line) { + if (line.trim()) { + children.push(para([run(line.trim(), { size: 20, font: FONT_SONG })], { + after: 50, + indent: { left: 240 }, + shading: C.lightBg, + border: { left: { style: BorderStyle.SINGLE, size: 12, color: C.primary, space: 6 } } + })); + } + }); + }); + } + + // ===== 法律依据 ===== + children.push(sectionTitle('六', '法律依据')); + const lawList = asArray(legalBases); + if (!lawList.length) { + children.push(para([run('本报告未引用具体法条,相关意见为实务建议,具体法律适用需律师人工核实。', { size: 20, color: C.gray })])); + } else { + lawList.forEach(function (l, i) { + const law = pick(l, ['law', 'name', '法律名称'], ''); + const article = pick(l, ['article', 'no', '条款编号'], ''); + const content = pick(l, ['content', 'text', '引用内容', '内容'], ''); + children.push(para([ + run(String(i + 1) + '. ', { size: 20, bold: true }), + run(law + (article ? ' ' + article : ''), { size: 20, bold: true, color: C.primary }), + run(content ? ':' + content : '', { size: 20 }) + ], { after: 60, indent: { left: 120 } })); + }); + } + + // ===== 相关案例 ===== + children.push(sectionTitle('七', '相关案例')); + const caseList = asArray(cases); + if (!caseList.length) { + children.push(para([run('暂未检索到公开案例,建议人工核实。', { size: 20, color: C.gray })])); + } else { + const caseHead = new TableRow({ + tableHeader: true, + children: [ + cell([para([run('序号', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 8 }), + cell([para([run('案件名称·案由', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 26 }), + cell([para([run('法院·机构', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 14 }), + cell([para([run('年份', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 8 }), + cell([para([run('判决要点', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 28 }), + cell([para([run('与风险点关联', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 16 }) + ] + }); + const caseRows = [caseHead]; + caseList.forEach(function (c, i) { + const name = pick(c, ['name', 'title', '案件名称'], '-'); + const url = pick(c, ['url', 'link', '链接'], ''); + const court = pick(c, ['court', 'org', '法院'], '-'); + const year = pick(c, ['year', '年份'], '-'); + const result = pick(c, ['result', 'point', '判决要点', '要点'], '-'); + const relation = pick(c, ['relation', '关联', '与本次风险点关联'], '-'); + const nameCellChildren = [para([run(name, { size: 19, bold: true })], { after: url ? 20 : 0 })]; + if (url) { + const host = url.replace(/^https?:\/\//i, '').split('/')[0]; + nameCellChildren.push(para([ + new ExternalHyperlink({ + link: url, + children: [run('来源:' + host + ' ↗', { size: 16, color: '2E74B5', underline: true })] + }) + ], {})); + } + caseRows.push(new TableRow({ + children: [ + cell([para([run(String(i + 1), { size: 19 })], { alignment: AlignmentType.CENTER })], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 8 }), + cell(nameCellChildren, { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 26 }), + cell([para([run(court, { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 14 }), + cell([para([run(year, { size: 19 })], { alignment: AlignmentType.CENTER })], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 8 }), + cell([para([run(result, { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 28 }), + cell([para([run(relation, { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 16 }) + ] + })); + }); + children.push(table(caseRows, [700, 2300, 1300, 700, 2500, 1500])); + } + + // ===== 免责声明 ===== + children.push(sectionTitle('八', '免责声明')); + const disText = disclaimer || '本报告由人工智能根据用户上传的合同文本自动生成,仅供内部风险排查参考,不构成正式法律意见,亦不能替代执业律师的专业审查。涉及重大交易、争议解决或诉讼事项,请务必委托执业律师复核。报告中的法律法规引用以官方公布文本为准,案例信息来源于公开检索结果,具体适用请人工核实。'; + String(disText).split(/\n+/).forEach(function (line) { + if (line.trim()) { + children.push(para([run(line.trim(), { size: 19, color: C.gray })], { + after: 50, + shading: C.pinkBg, + indent: { left: 120, right: 120 }, + border: { left: { style: BorderStyle.SINGLE, size: 12, color: C.danger, space: 6 } } + })); + } + }); + + // ===== 文档 ===== + const doc = new Document({ + creator: '金久信签约宝', + title: contractName + ' - 合同审查报告', + description: 'AI 合同审查报告', + styles: { + default: { + document: { + run: { font: FONT, size: 21, color: C.text } + } + } + }, + sections: [{ + properties: { + page: { + margin: { top: 1134, right: 1134, bottom: 1134, left: 1134 } + } + }, + headers: { + default: new Header({ + children: [ + para([ + run('金久信签约宝 · AI 合同审查报告', { size: 18, color: C.gray }), + run(' ' + contractName, { size: 18, color: C.gray }) + ], { alignment: AlignmentType.RIGHT, after: 0 }) + ] + }) + }, + footers: { + default: new Footer({ + children: [ + new Paragraph({ + alignment: AlignmentType.CENTER, + children: [ + new TextRun({ text: '第 ', font: FONT, size: 18, color: C.gray }), + new TextRun({ children: [PageNumber.CURRENT], font: FONT, size: 18, color: C.gray }), + new TextRun({ text: ' 页 / 共 ', font: FONT, size: 18, color: C.gray }), + new TextRun({ children: [PageNumber.TOTAL_PAGES], font: FONT, size: 18, color: C.gray }), + new TextRun({ text: ' 页', font: FONT, size: 18, color: C.gray }) + ] + }) + ] + }) + }, + children: children + }] + }); + + return await Packer.toBuffer(doc); +} + +// ---------- HTML 解析(生产数据兜底通道) ---------- +// 智能体实际返回的是纯 HTML 报告,未必带 jjx-report-data 数据块, +// 这里按区块结构把 HTML 还原成结构化数据,无需修改智能体提示词。 + +function decodeEntities(s) { + return String(s || '') + .replace(/ /g, ' ') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'|'/g, "'") + .replace(/&/g, '&') + .replace(/(\d+);/g, function (m, d) { return String.fromCharCode(Number(d)); }) + .replace(/([0-9a-fA-F]+);/g, function (m, h) { return String.fromCharCode(parseInt(h, 16)); }); +} + +function stripTags(html) { + return decodeEntities( + String(html || '') + .replace(/