Explorar el Código

aidify重命名和ai合同填写

ellisran hace 1 semana
padre
commit
f45bf4efde

+ 96 - 0
app/controller/contract_controller.js

@@ -11,6 +11,7 @@ const moment = require('moment');
 const sendToWormhole = require('stream-wormhole');
 const fs = require('fs');
 const path = require('path');
+const AiDify = require('../lib/ai_dify');
 module.exports = app => {
 
     class ContractController extends app.BaseController {
@@ -1021,6 +1022,101 @@ module.exports = app => {
                 ctx.redirect(`/sp/${ctx.subProject.id}/dashboard`);
             }
         }
+
+        async editAndAi(ctx) {
+            try {
+                const data = JSON.parse(ctx.request.body.data);
+                if (!data.id || !data.fid) throw '数据错误';
+                const contract = await ctx.service.contract.getDataById(data.id);
+                if (!contract) {
+                    throw '未找到合同相关数据';
+                }
+                const att = await ctx.service.contractAtt.getDataById(data.fid);
+                if (!att) {
+                    throw '未找到合同文件';
+                }
+                att.viewpath = ctx.helper.getPreviewPath(att.fileext, ctx.app.config.fujianOssPath + att.filepath);
+                const aiDify = new AiDify({ apiKey: ctx.difyInfo.apiKey.contract });
+                const transResult = await aiDify.contractTrans(att);
+                transResult.res = JSON.parse(transResult.res);
+                const responseData = {
+                    ...transResult,
+                    cid: contract.id,
+                    attached: false,
+                };
+                ctx.body = { err: 0, msg: '', data: responseData };
+            } catch (err) {
+                console.log(err);
+                this.log(err);
+                ctx.body = { err: 1, msg: err.toString(), data: null };
+            }
+        }
+
+        async uploadAndAi(ctx) {
+            const stream = await ctx.getFileStream();
+            try {
+                const hasExistingCid = !!stream.fields.cid;
+                const cid = stream.fields.cid || this.ctx.app.uuid.v4();
+                if (!cid) throw '参数有误';
+
+                const attachToContract = stream.fields.attachToContract === 'true' || stream.fields.attachToContract === '1';
+                const create_time = Date.parse(new Date()) / 1000;
+                const parsedPath = path.parse(stream.filename);
+                const headpath = ctx.contractOptions.spid
+                    ? `sp/contract/${ctx.contractOptions.spid}`
+                    : `app/public/upload/${ctx.contractOptions.tid}`;
+                const filepath = `${headpath}/contract/${cid}/fujian_${create_time}${parsedPath.ext}`;
+
+                await ctx.app.fujianOss.put(ctx.app.config.fujianOssFolder + filepath, stream);
+                const fileInfo = { filepath, name: stream.filename, ext: parsedPath.ext };
+                if (stream) await sendToWormhole(stream);
+
+                if (!fileInfo) {
+                    throw '请上传文件';
+                }
+
+                const newFile = {
+                    spid: ctx.contractOptions.spid || null,
+                    tid: ctx.contractOptions.tid || null,
+                    contract_type: ctx.contract_type,
+                    cid,
+                    uid: ctx.session.sessionUser.accountId,
+                    filename: fileInfo.name,
+                    fileext: fileInfo.ext,
+                    filesize: stream.fields.size,
+                    filepath: fileInfo.filepath,
+                    upload_time: new Date(),
+                };
+
+                // 写入数据库(只有这一条记录)
+                if (!hasExistingCid || attachToContract) {
+                    const fileResult = await ctx.service.contractAtt.saveFileMsgToDb([newFile]);
+                    newFile.id = fileResult.insertId;
+                    newFile.username = ctx.session.sessionUser.name;
+                }
+
+                // 重新查出该cid下的附件,按filepath精确匹配出刚上传的这一份(避免拿到该cid下其他历史文件)
+                newFile.viewpath = ctx.helper.getPreviewPath(newFile.fileext, ctx.app.config.fujianOssPath + newFile.filepath);
+                const aiDify = new AiDify({ apiKey: ctx.difyInfo.apiKey.contract });
+                const transResult = await aiDify.contractTrans(newFile);
+                transResult.res = JSON.parse(transResult.res);
+                // 如果不勾选"附加到合同"并且没有cid,则要删除oss文件
+                if (!attachToContract && hasExistingCid) {
+                    await ctx.app.fujianOss.delete(ctx.app.config.fujianOssFolder + newFile.filepath);
+                }
+                const responseData = {
+                    ...transResult,
+                    cid,
+                    attached: attachToContract,
+                };
+                if ((!hasExistingCid || attachToContract) && newFile.id) responseData.file = newFile;
+                ctx.body = { err: 0, msg: '', data: responseData };
+            } catch (err) {
+                stream && (await sendToWormhole(stream));
+                this.log(err);
+                ctx.body = { err: 1, msg: err.toString(), data: null };
+            }
+        }
     }
 
     return ContractController;

+ 9 - 9
app/controller/ledger_controller.js

@@ -1191,9 +1191,9 @@ module.exports = app => {
             if (!pos) throw '选择的计量单元不存在,请刷新页面后再试';
 
             const validColSet = data.template.col_set.filter(x => { return !x.expr; });
-            const AIInspect = require('../lib/ai_inspect');
-            const aiInspect = new AIInspect({ apiKey: ctx.difyInfo.apiKey.image2PosCalcDetail, baseUrl: ctx.difyInfo.baseUrl });
-            const transResult = await aiInspect.posCalcDetailRead(validColSet, data.billsName, data.keyword, data.files);
+            const AIDify = require('../lib/ai_dify');
+            const aiDify = new AIDify({ apiKey: ctx.difyInfo.apiKey.image2PosCalcDetail, baseUrl: ctx.difyInfo.baseUrl });
+            const transResult = await aiDify.posCalcDetailRead(validColSet, data.billsName, data.keyword, data.files);
             const rows = transResult.res.split('\n').filter(x => { return !!x; });
             rows.shift();
             const pcdData = rows.map(x => {
@@ -1213,9 +1213,9 @@ module.exports = app => {
         }
         async _aiComplieSteelPos(ctx, bills, data) {
             const validColSet = data.template.col_set.filter(x => { return !x.expr; });
-            const AIInspect = require('../lib/ai_inspect');
-            const aiInspect = new AIInspect({ apiKey: ctx.difyInfo.apiKey.image2Pos, baseUrl: ctx.difyInfo.baseUrl });
-            const transResult = await aiInspect.posCalcDetailRead(validColSet, data.billsName, data.keyword, data.files);
+            const AIDify = require('../lib/ai_dify');
+            const aiDify = new AIDify({ apiKey: ctx.difyInfo.apiKey.image2Pos, baseUrl: ctx.difyInfo.baseUrl });
+            const transResult = await aiDify.posCalcDetailRead(validColSet, data.billsName, data.keyword, data.files);
             const parts = transResult.res.split('\n==++==\n').filter(x => { return !!x; });
             const pos = [];
             const posInfoReg = new RegExp('{{.*}}', 'igm');
@@ -1256,9 +1256,9 @@ module.exports = app => {
         }
         async _aiComplieConcretePos(ctx, bills, data) {
             const validColSet = data.template.col_set.filter(x => { return !x.expr; });
-            const AIInspect = require('../lib/ai_inspect');
-            const aiInspect = new AIInspect({ apiKey: ctx.difyInfo.apiKey.image2PosQty, baseUrl: ctx.difyInfo.baseUrl });
-            const transResult = await aiInspect.posCalcDetailRead(validColSet, data.billsName, data.keyword, data.files);
+            const AIDify = require('../lib/ai_dify');
+            const aiDify = new AIDify({ apiKey: ctx.difyInfo.apiKey.image2PosQty, baseUrl: ctx.difyInfo.baseUrl });
+            const transResult = await aiDify.posCalcDetailRead(validColSet, data.billsName, data.keyword, data.files);
             const parts = transResult.res.split('\n==++==\n').filter(x => { return !!x; });
             const pos = [];
             for (const p of parts) {

+ 1 - 1
app/controller/profile_controller.js

@@ -430,7 +430,7 @@ module.exports = app => {
                         const account = JSON.parse(dskAccount.dsk_account);
                         const compilation = await dsk.getCompilation(data.mobile);
                         response.data = {};
-                        response.data.compilation = compilation;
+                        response.data.compilation = compilation && compilation.length > 0 ? ctx.helper._.filter(compilation, { type: 'highway' }) : [];
                         if (data.getProject) {
                             const compilationInfo = data.compilationId ? ctx.helper._.find(compilation, { ID: data.compilationId }) : null;
                             const compilationId = compilationInfo ? compilationInfo.ID : (compilation.length > 0 ? compilation[0].ID : null);

+ 7 - 7
app/controller/tender_controller.js

@@ -1113,7 +1113,7 @@ module.exports = app => {
                 await ctx.service.tenderInfo.saveTenderInfo(ctx.tender.id, { shenpi: postData });
                 let auditList = [];
                 let groupList = [];
-                const spType =  shenpiConst.sp_type[data.code]|| shenpiConst.cost_sp_type[data.code];
+                const spType = shenpiConst.sp_type[data.code] || shenpiConst.cost_sp_type[data.code];
                 if (data.status === shenpiConst.sp_status.gdspl) {
                     groupList = await ctx.service.shenpiGroup.getGroupList(ctx.tender.id, spType) || [];
                     if (groupList && groupList.length > 0) {
@@ -1860,9 +1860,9 @@ module.exports = app => {
                 }
 
                 const result = await ctx.service.tempFile.addFiles(ctx.tender.id, uploadfiles, user);
-                const AIInspect = require('../lib/ai_inspect');
-                const aiInspect = new AIInspect({ apiKey: ctx.difyInfo.apiKey.image2Excel, baseUrl: ctx.difyInfo.baseUrl });
-                const transResult = await aiInspect.imageTransExcel(result);
+                const AIDify = require('../lib/ai_dify');
+                const aiDify = new AIDify({ apiKey: ctx.difyInfo.apiKey.image2Excel, baseUrl: ctx.difyInfo.baseUrl });
+                const transResult = await aiDify.imageTransExcel(result);
                 ctx.body = { err: 0, msg: '', data: transResult.res };
             } catch (err) {
                 ctx.log(err);
@@ -1875,9 +1875,9 @@ module.exports = app => {
             try {
                 const data = JSON.parse(ctx.request.body.data);
                 if (data.name === undefined || data.unit === undefined) throw '参数错误';
-                const AIInspect = require('../lib/ai_inspect');
-                const aiInspect = new AIInspect({ apiKey: ctx.difyInfo.apiKey.billsIdentify, baseUrl: ctx.difyInfo.baseUrl });
-                const result = await aiInspect.billsIdentify(data.name, data.unit);
+                const AIDify = require('../lib/ai_dify');
+                const aiDify = new AIDify({ apiKey: ctx.difyInfo.apiKey.billsIdentify, baseUrl: ctx.difyInfo.baseUrl });
+                const result = await aiDify.billsIdentify(data.name, data.unit);
                 ctx.body = { err: 0, msg: '', data: result.res };
             } catch(err) {
                 ctx.log(err);

+ 2 - 4
app/controller/wap_controller.js

@@ -21,7 +21,7 @@ const path = require('path');
 const sendToWormhole = require('stream-wormhole');
 const moment = require('moment');
 const auditType = require('../const/audit').auditType;
-const AiInspect = require('../lib/ai_inspect');
+const AiDify = require('../lib/ai_dify');
 const uuid = require('node-uuid');
 const nlsToken = require('../lib/nls_token');
 const streamToArray = require('stream-to-array');
@@ -1436,9 +1436,7 @@ module.exports = app => {
                 if (!user_input) {
                     throw '参数有误';
                 }
-                const ai = new AiInspect({
-                    chatflowId: conversationId,
-                });
+                const ai = new AiDify({ apiKey: ctx.difyInfo.apiKey.inspection, chatflowId: conversationId });
                 const result = await ai.ask(user_input);
                 let answerText = result.answer || '';
                 let jsonPart = null;

+ 33 - 2
app/lib/ai_inspect.js

@@ -1,7 +1,9 @@
 'use strict';
 const axios = require('axios');
+const DOCUMENT_EXTENSIONS = ['pdf', 'doc', 'docx'];
+const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp'];
 
-class AiInspect {
+class AiDify {
     /**
      * 构造函数
      * @param {object} config
@@ -108,6 +110,35 @@ class AiInspect {
         };
         return await this.workflows(body);
     }
+
+    /**
+     * 根据文件名后缀判断 Dify 的 type 字段(image / document)
+     */
+    resolveDifyFileType(filename = '') {
+        const ext = filename ? filename.split('.').pop().toLowerCase() : '';
+        if (IMAGE_EXTENSIONS.includes(ext)) return 'image';
+        if (DOCUMENT_EXTENSIONS.includes(ext)) return 'document';
+        // 无法判断时默认按document处理,让Dify那边自行兜底
+        return 'document';
+    }
+
+    async contractTrans(file, userId = 'user_123') {
+        const body = {
+            inputs: { fileList: [] },
+            user: userId,
+            response_mode: 'blocking',
+        };
+        if (!file.viewpath) {
+            throw new Error('文件缺少viewpath字段');
+        }
+        const fileType = this.resolveDifyFileType(file.filename);
+        body.inputs.fileList.push({
+            type: fileType,
+            transfer_method: 'remote_url',
+            url: file.viewpath,
+        });
+        return await this.workflows(body);
+    }
 }
 
-module.exports = AiInspect;
+module.exports = AiDify;

+ 91 - 1
app/public/css/main.css

@@ -2350,6 +2350,69 @@ animation:shake 1s .2s ease both;}
     background: #ccc;
     border-radius: 3px;
 }
+/* ai按钮css */
+.ai-fill-btn {
+    background: linear-gradient(90deg, #FF9A56 0%, #FF6B9D 50%, #C86DD7 100%);
+    color: #fff;
+    border: none;
+    box-shadow: 0 2px 8px rgba(255, 107, 157, 0.4);
+    transition: all 0.2s ease;
+    padding: 2px 7px;
+}
+
+.ai-fill-btn:hover {
+    color: #fff;
+    transform: translateY(-1px);
+    box-shadow: 0 4px 12px rgba(255, 107, 157, 0.55);
+}
+
+.ai-fill-btn i {
+    margin-right: 4px;
+}
+
+.ai-upload-area {
+    border: 2px dashed #d9d9d9;
+    border-radius: 8px;
+    padding: 24px;
+    text-align: center;
+    cursor: pointer;
+    transition: border-color 0.2s;
+}
+.ai-upload-area:hover,
+.ai-upload-area.dragover {
+    border-color: #C86DD7;
+    background: #fdf6ff;
+}
+.ai-upload-result {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 8px 0;
+    font-size: 14px;
+}
+
+.ai-notice {
+    background: #fff8ec;
+    border: 1px solid #ffe6b8;
+    border-radius: 6px;
+    padding: 10px 14px;
+    font-size: 13px;
+}
+.ai-notice-title {
+    font-weight: 500;
+    color: #b8780a;
+    margin-bottom: 4px;
+}
+.ai-notice-list {
+    margin: 0;
+    padding-left: 18px;
+    color: #6b6b6b;
+}
+.ai-notice-list li { margin-bottom: 2px; }
+.ai-notice-divider {
+    border-top: 1px solid #ffe6b8;
+    margin: 8px 0;
+}
 .ai-btn {
     position: relative;
     border: 1px solid rgba(255,255,255,0.15);
@@ -2392,4 +2455,31 @@ animation:shake 1s .2s ease both;}
     box-shadow:
             0 0 8px rgba(0, 245, 255, 0.8),
             0 0 14px rgba(139, 92, 246, 0.6);
-}
+}
+
+.ai-section-label {
+    font-size: 13px;
+    font-weight: 500;
+    color: #555;
+    margin-bottom: 6px;
+}
+
+.ai-existing-files-list {
+    max-height: 160px;
+    overflow-y: auto;
+    border: 1px solid #eee;
+    border-radius: 6px;
+    padding: 4px 8px;
+}
+
+.ai-existing-file-item {
+    padding: 4px 0;
+    border-bottom: 1px solid #f5f5f5;
+}
+.ai-existing-file-item:last-child {
+    border-bottom: none;
+}
+.ai-existing-file-item label {
+    margin-bottom: 0;
+    cursor: pointer;
+}

+ 368 - 43
app/public/js/contract_detail.js

@@ -333,8 +333,10 @@ $(document).ready(function() {
             if ($('.bc-bar .nav li .active').attr('href') === '#htdetail') {
                 if (node.settle_code || (shenpi_status && node.status !== auditConst.status.uncheck && node.status !== auditConst.status.checkNo)) {
                     $('#edit_contract_btn').hide();
+                    $('#ai_edit_contract_btn').hide();
                 } else {
                     $('#edit_contract_btn').show();
+                    $('#ai_edit_contract_btn').show();
                 }
                 $('#save_contract_btn').hide();
                 $('#cancel_contract_btn').hide();
@@ -361,6 +363,7 @@ $(document).ready(function() {
                 }
             } else if ($('.bc-bar .nav li .active').attr('href') === '#htpay') {
                 $('#edit_contract_btn').hide();
+                $('#ai_edit_contract_btn').hide();
                 $('#save_contract_btn').hide();
                 $('#cancel_contract_btn').hide();
                 $('#add_contract_pay_btn').hide();
@@ -389,6 +392,7 @@ $(document).ready(function() {
                 $('a[href*="#cons-upfile"]').hide();
             } else if ($('.bc-bar .nav li .active').attr('href') === '#htfile') {
                 $('#edit_contract_btn').hide();
+                $('#ai_edit_contract_btn').hide();
                 $('#save_contract_btn').hide();
                 $('#cancel_contract_btn').hide();
                 $('#add_contract_pay_btn').hide();
@@ -396,6 +400,7 @@ $(document).ready(function() {
                 $('a[href*="#cons-upfile"]').show();
             } else if ($('.bc-bar .nav li .active').attr('href') === '#htsupplement') {
                 $('#edit_contract_btn').hide();
+                $('#ai_edit_contract_btn').hide();
                 $('#save_contract_btn').hide();
                 $('#cancel_contract_btn').hide();
                 $('#add_contract_pay_btn').hide();
@@ -498,6 +503,7 @@ $(document).ready(function() {
                     contractTreeSpreadObj.changeContractTab(node, true);
                 } else {
                     $('#edit_contract_btn').hide();
+                    $('#ai_edit_contract_btn').hide();
                     $('#save_contract_btn').hide();
                     $('#cancel_contract_btn').hide();
                     $('a[href*="#cons-upfile"]').hide();
@@ -524,6 +530,7 @@ $(document).ready(function() {
                 contractTreeSpreadObj.changeContractTab(node, true);
             } else {
                 $('#edit_contract_btn').hide();
+                $('#ai_edit_contract_btn').hide();
                 $('#save_contract_btn').hide();
                 $('#cancel_contract_btn').hide();
                 $('a[href*="#cons-unlock"]').hide();
@@ -676,6 +683,7 @@ $(document).ready(function() {
                         contractTreeSpreadObj.refreshTree(contractSheet, refreshNode);
                         // contractTreeSpreadObj.setContractPays(result.pays, node);
                         contractTreeSpreadObj.setContractListHtml('pay', node, result.pays);
+                        node.files = result.files;
                         contractTreeSpreadObj.setContractFiles(result.files, node.id);
                         // contractTreeSpreadObj.setContractSupplements(result.supplements, node.id);
                         contractTreeSpreadObj.setContractListHtml('supplement', node, result.supplements);
@@ -689,6 +697,7 @@ $(document).ready(function() {
                 $('#htfile-table').hide();
                 $('#htsupplement-table').hide();
                 $('#edit_contract_btn').hide();
+                $('#ai_edit_contract_btn').hide();
                 $('#save_contract_btn').hide();
                 $('#cancel_contract_btn').hide();
                 $('a[href*="#cons-unlock"]').hide();
@@ -1835,34 +1844,53 @@ $(document).ready(function() {
         const node = SpreadJsObj.getSelectObject(contractSheet);
         console.log(node);
         if (node && node.c_code && (node.uid === user_id || permission_edit_contract) && (!shenpi_status || (shenpi_status && node.status === auditConst.status.uncheck || node.status === auditConst.status.checkNo))) {
-            $('#edit_contract_btn').hide();
-            $('#save_contract_btn').show();
-            $('#cancel_contract_btn').show();
-            for (const c of contractDetail) {
-                if ($('#htdetail_' + c) && $('#htdetail_' + c).hasClass('change-input-td')) {
-                    if (c === 'sign_date') {
-                        $('#htdetail_' + c).html('<input type="date" class="form-control form-control-sm" value="' + moment(node[c]).format('YYYY-MM-DD') + '">');
-                        continue;
-                    } else if (c === 'remark') {
-                        $('#htdetail_' + c).html('<textarea class="form-control form-control-sm" rows="3">' + node[c] + '</textarea>');
-                        continue;
-                    } else if (c === 'tax') {
-                        $('#htdetail_' + c).html('<input type="number" min="0" max="100" step="1" class="form-control form-control-sm" value="' + (node[c] ? node[c] : '') + '">');
-                        continue;
-                    }
-                    $('#htdetail_' + c).html('<input type="text" class="form-control form-control-sm" value="' + node[c] + '">');
-                }
-            }
-            const attrInfo = node.attribute_json ? JSON.parse(node.attribute_json) : null;
-            for (const attr of attributeSet) {
-                if (attr.type === 'long_text') {
-                    $('#htdetail_' + attr.field).html('<textarea class="form-control form-control-sm" rows="3">' + (attrInfo && attrInfo[attr.field] ? attrInfo[attr.field] : '') + '</textarea>');
+            editContract(node);
+        }
+    });
+
+    $('#ai_edit_contract_btn').on('click', function () {
+        const node = SpreadJsObj.getSelectObject(contractSheet);
+        console.log(node);
+        if (node && node.c_code && (node.uid === user_id || permission_edit_contract) && (!shenpi_status || (shenpi_status && node.status === auditConst.status.uncheck || node.status === auditConst.status.checkNo))) {
+            // files只要pdf,word和图片格式的
+            const files = _.filter(node.files, function (f) {
+                const ext = f.filename.toLowerCase().split('.').splice(-1)[0];
+                return ['pdf', 'doc', 'docx', 'jpg', 'jpeg', 'png', 'webp'].includes(ext);
+            });
+            openAiRecognizeModal({ mode: 'edit', cid: node.id, existingFiles: files });
+            editContract(node);
+        }
+    });
+
+    function editContract(node) {
+        $('#edit_contract_btn').hide();
+        $('#ai_edit_contract_btn').show();
+        $('#save_contract_btn').show();
+        $('#cancel_contract_btn').show();
+        for (const c of contractDetail) {
+            if ($('#htdetail_' + c) && $('#htdetail_' + c).hasClass('change-input-td')) {
+                if (c === 'sign_date') {
+                    $('#htdetail_' + c).html('<input type="date" class="form-control form-control-sm" value="' + moment(node[c]).format('YYYY-MM-DD') + '">');
+                    continue;
+                } else if (c === 'remark') {
+                    $('#htdetail_' + c).html('<textarea class="form-control form-control-sm" rows="3">' + node[c] + '</textarea>');
+                    continue;
+                } else if (c === 'tax') {
+                    $('#htdetail_' + c).html('<input type="number" min="0" max="100" step="1" class="form-control form-control-sm" value="' + (node[c] ? node[c] : '') + '">');
                     continue;
                 }
-                $('#htdetail_' + attr.field).html('<input type="' + attr.type + '" class="form-control form-control-sm" value="' + (attrInfo && attrInfo[attr.field] ? attrInfo[attr.field] : '') + '">');
+                $('#htdetail_' + c).html('<input type="text" class="form-control form-control-sm" value="' + node[c] + '">');
             }
         }
-    });
+        const attrInfo = node.attribute_json ? JSON.parse(node.attribute_json) : null;
+        for (const attr of attributeSet) {
+            if (attr.type === 'long_text') {
+                $('#htdetail_' + attr.field).html('<textarea class="form-control form-control-sm" rows="3">' + (attrInfo && attrInfo[attr.field] ? attrInfo[attr.field] : '') + '</textarea>');
+                continue;
+            }
+            $('#htdetail_' + attr.field).html('<input type="' + attr.type + '" class="form-control form-control-sm" value="' + (attrInfo && attrInfo[attr.field] ? attrInfo[attr.field] : '') + '">');
+        }
+    }
 
     $('#save_contract_btn').on('click', function () {
         const node = SpreadJsObj.getSelectObject(contractSheet);
@@ -1943,8 +1971,10 @@ $(document).ready(function() {
             // 更新至服务器
             postData(window.location.pathname + '/update', {postType: 'update-contract', postData: data}, function (result) {
                 toastr.success('已编辑成功');
-                const refreshNode = contractTree.loadPostData(result);
+                const refreshNode = contractTree.loadPostData({ update: result.contract });
                 contractTreeSpreadObj.refreshTree(contractSheet, refreshNode);
+                node.files = result.files;
+                contractTreeSpreadObj.setContractFiles(result.files, node.id);
                 const newNode = SpreadJsObj.getSelectObject(contractSheet);
                 contractTreeSpreadObj.changeContractTab(newNode, true);
             });
@@ -1955,6 +1985,7 @@ $(document).ready(function() {
         const node = SpreadJsObj.getSelectObject(contractSheet);
         if (node && node.c_code && (node.uid === user_id || permission_edit_contract)) {
             $('#edit_contract_btn').show();
+            $('#ai_edit_contract_btn').show();
             $('#save_contract_btn').hide();
             $('#cancel_contract_btn').hide();
             for (const c of contractDetail) {
@@ -2001,6 +2032,7 @@ $(document).ready(function() {
                 })
                 postDataWithFile(`${thisUrl}/${contractConst.typeMap[contract_type]}/${node.id}/file/upload`, formData, function (result) {
                     contractTreeSpreadObj.setContractFiles(result, node.id);
+                    node.files = result;
                     $('#file-modal').val('');
                     $('#file-cancel').click()
                 });
@@ -2014,6 +2046,7 @@ $(document).ready(function() {
         deleteAfterHint(function () {
             postData(`${thisUrl}/${contractConst.typeMap[contract_type]}/${node.id}/file/delete`, { id: fid }, function (result) {
                 contractTreeSpreadObj.setContractFiles(result, node.id);
+                node.files = result;
             });
         }, '确认删除该文件?');
     });
@@ -2617,17 +2650,22 @@ $(document).ready(function() {
         });
     }
 
+    const edit_contract_input_key = ['c_code', 'name', 'tax', 'price', 'party_a', 'party_a_user', 'party_b', 'party_b_user', 'sign_date', 'address', 'entity', 'bank', 'bank_account'];
+    const signDate = $('#cons-add input[name="sign_date"]').datepicker().data('datepicker');
     if (permission_add) {
-        const signDate = $('#cons-add input[name="sign_date"]').datepicker().data('datepicker');
         $('#cons-add').on('show.bs.modal', function () {
-            $('#cons-add input[name="code"]').val('');
-            $('#cons-add input[name="name"]').val('');
-            $('#cons-add input[name="price"]').val('');
-            $('#cons-add input[name="party_a"]').val('');
-            $('#cons-add input[name="party_b"]').val('');
-            $('#cons-add input[name="sign_date"]').val('');
+            for (const key of edit_contract_input_key) {
+                $('#cons-add input[name="' + key + '"]').val('');
+            }
             signDate.selectDate('');
             $('#cons-add textarea[name="remark"]').val('');
+            // type选回第一个
+            $('#cons-add select[name="type"]').val($('#cons-add select[name="type"] option:first').val());
+            $('#contract-add-cid').val('');
+            $('#contract-add-fid').val('');
+            $('#contract-add-attach').prop('checked', false);
+            $('#ai-file-name').text('');
+            $('#ai-file-panel').hide();
         });
 
         $('#add-contract').click(function () {
@@ -2637,17 +2675,17 @@ $(document).ready(function() {
                 return;
             }
             console.log(node);
-            const data = {
-                code: $('#cons-add input[name="code"]').val(),
-                name: $('#cons-add input[name="name"]').val(),
-                price: $('#cons-add input[name="price"]').val(),
-                party_a: $('#cons-add input[name="party_a"]').val(),
-                party_b: $('#cons-add input[name="party_b"]').val(),
-                sign_date: $('#cons-add input[name="sign_date"]').val() || null,
-                remark: $('#cons-add textarea[name="remark"]').val(),
-                type: $('#cons-add select[name="type"]').val(),
-            }
-            if (!data.code) {
+            const data = {};
+            for (const key of edit_contract_input_key) {
+                data[key] = $('#cons-add input[name="'+ key +'"]').val();
+            }
+            const new_cid = $('#contract-add-cid').val();
+            data.cid = new_cid;
+            data.attach = $('#contract-add-attach').prop('checked');
+            data.fid = $('#contract-add-fid').val() ? parseInt($('#contract-add-fid').val()) : 0;
+            data.remark = $('#cons-add textarea[name="remark"]').val();
+            data.type = $('#cons-add select[name="type"]').val();
+            if (!data.c_code) {
                 toastr.error('请输入合同编号');
                 return;
             }
@@ -3905,6 +3943,293 @@ $(document).ready(function() {
             if (stdXmj) stdXmj.spread.refresh();
         }
     });
+
+    $('#aiFillBtn').on('click', function () {
+        openAiRecognizeModal({ mode: 'create' });
+    });
+
+    // 弹窗状态:mode区分新建/编辑,cid是合同id(编辑时已有,新建时上传成功后才拿到)
+    const aiModalState = {
+        mode: 'create',       // 'create' | 'edit'
+        cid: null,
+        existingFiles: [],     // 编辑模式下,该合同已有的文件列表 [{ id, filename }, ...]
+    };
+
+    let aiSelectedFile = null; // 用户本次上传选择的新文件
+
+    /**
+     * 打开AI识别弹窗的统一入口
+     * @param {Object} options
+     *   mode: 'create' | 'edit'
+     *   cid: 编辑模式下传入当前合同id;新建模式不传
+     *   existingFiles: 编辑模式下传入该合同已有的文件列表 [{ id, filename }],新建模式不传或传空数组
+     *
+     * 调用示例:
+     *   新建合同弹窗的AI按钮: openAiRecognizeModal({ mode: 'create' })
+     *   编辑合同弹窗的AI按钮: openAiRecognizeModal({ mode: 'edit', cid: contractId, existingFiles: attList })
+     */
+    function openAiRecognizeModal(options) {
+        options = options || {};
+        aiModalState.mode = options.mode || 'create';
+        aiModalState.cid = options.cid || null;
+        aiModalState.existingFiles = options.existingFiles || [];
+
+        resetAiModalUI();
+        renderExistingFilesList();
+        $('#ai-recognize-modal').modal('show');
+    }
+
+    function resetAiModalUI() {
+        // 重置文件上传状态
+        clearUploadedFileUI();
+
+        // 重置单选状态
+        $('input[name="aiFileSource"]').prop('checked', false);
+
+        // 重置勾选项(默认勾选,但实际是否显示由renderExistingFilesList后续逻辑控制)
+        $('#aiAttachToContract').prop('checked', true);
+        $('#aiAttachCheckArea').hide();
+
+        // 重置识别按钮状态
+        $('#aiStartRecognize').prop('disabled', true);
+        resetSubmitBtnUI($('#aiStartRecognize'));
+    }
+
+    function renderExistingFilesList() {
+        const hasExisting = aiModalState.mode === 'edit' && aiModalState.existingFiles.length > 0;
+
+        if (!hasExisting) {
+            $('#aiExistingFilesArea').hide();
+            $('#aiUploadSectionLabel').hide();
+            $('#aiExistingFilesList').empty();
+            return;
+        }
+
+        $('#aiUploadSectionLabel').show();
+        const $list = $('#aiExistingFilesList').empty();
+
+        aiModalState.existingFiles.forEach(function (file) {
+            const radioId = 'aiExistingFile_' + file.id;
+            const $item = $(`
+      <div class="ai-existing-file-item">
+        <div class="form-check">
+          <input type="radio" name="aiFileSource" class="form-check-input" id="${radioId}" value="${file.id}">
+          <label class="form-check-label" for="${radioId}">
+            <i class="fa-solid fa-file-lines"></i> ${file.filename}
+          </label>
+        </div>
+      </div>
+    `);
+            $list.append($item);
+        });
+
+        $('#aiExistingFilesArea').show();
+    }
+
+// 选中"已有文件"中的某一项
+    $(document).on('change', 'input[name="aiFileSource"]', function () {
+        const selectedValue = $(this).val();
+
+        if (selectedValue === 'upload') {
+            $('#aiAttachCheckArea').show(); // 新建、编辑场景下,只要是"上传新文件"都显示这个勾选
+            $('#aiStartRecognize').prop('disabled', !aiSelectedFile);
+        } else {
+            // 用户选中了某个已有文件,清空本次上传的新文件状态
+            clearUploadedFileUI();
+            $('#aiAttachCheckArea').hide(); // 已有文件本身已经在合同文件里,不需要"是否添加"这个勾选
+            $('#aiStartRecognize').prop('disabled', false);
+        }
+    });
+
+// 点击上传区域,触发文件选择
+    $('#aiUploadArea').on('click', function (e) {
+        // file input 冒泡上来的点击,不处理
+        if (e.target.id === 'aiFileInput') {
+            return;
+        }
+
+        $('#aiFileInput')[0].click();
+    });
+
+// 拖拽上传
+    $('#aiUploadArea').on('dragover', function (e) {
+        e.preventDefault();
+        $(this).addClass('dragover');
+    }).on('dragleave', function () {
+        $(this).removeClass('dragover');
+    }).on('drop', function (e) {
+        e.preventDefault();
+        $(this).removeClass('dragover');
+        const file = e.originalEvent.dataTransfer.files[0];
+        if (file) handleAiFileSelected(file);
+    });
+
+// 选择文件
+    $('#aiFileInput').on('change', function () {
+        const file = this.files[0];
+        if (file) handleAiFileSelected(file);
+    });
+
+    function handleAiFileSelected(file) {
+        aiSelectedFile = file;
+        $('#aiFileName').text(file.name);
+        $('#aiUploadPlaceholder').hide();
+        $('#aiUploadResult').show();
+        $('#aiAttachCheckArea').show(); // 新建场景下,选择文件时也要显示这个勾选框
+
+        // 选中"上传新文件"这个隐藏radio,与已有文件选项互斥
+        $('#aiSourceUpload').prop('checked', true).trigger('change');
+    }
+
+// 移除已选文件
+    $('#aiFileRemove').on('click', function (e) {
+        e.stopPropagation();
+        clearUploadedFileUI();
+        $('#aiStartRecognize').prop('disabled', true);
+    });
+
+    function clearUploadedFileUI() {
+        aiSelectedFile = null;
+        $('#aiFileInput').val('');
+        $('#aiUploadResult').hide();
+        $('#aiUploadPlaceholder').show();
+    }
+
+// 点击「开始识别」
+    $('#aiStartRecognize').on('click', function () {
+        const $btn = $(this);
+        const selectedSource = $('input[name="aiFileSource"]:checked').val();
+        const usingExisting = selectedSource && selectedSource !== 'upload';
+        const usingUpload = selectedSource === 'upload' && aiSelectedFile;
+
+        if (!usingExisting && !usingUpload) return;
+
+        $btn.prop('disabled', true);
+        $('#aiRecognizeBtnText').text('识别中...');
+        $('#aiRecognizeSpinner').show();
+
+        if (usingExisting) {
+            // 使用已有文件 -> 走 editAndAi
+            postData(window.location.pathname + '/ai/edit', { id: aiModalState.cid, fid: selectedSource }, function (res) {
+                handleAiRecognizeSuccess(res);
+                resetSubmitBtnUI($btn);
+            }, function (err) {
+                handleAiRecognizeError(err);
+            });
+        } else {
+            // 上传新文件 -> 走 uploadAndAi
+            const formData = new FormData();
+            if (aiSelectedFile === undefined) {
+                toastr.error('未选择上传文件!');
+                resetSubmitBtnUI($btn);
+                return false;
+            }
+            const filesize = aiSelectedFile.size;
+            if (filesize > 50 * 1024 * 1024) {
+                toastr.error('文件大小过大!');
+                resetSubmitBtnUI($btn);
+                return false;
+            }
+            const fileext = aiSelectedFile.name.toLowerCase().split('.').splice(-1)[0];
+            if (['pdf', 'doc', 'docx', 'jpg', 'jpeg', 'png', 'webp'].indexOf(fileext) === -1) {
+                toastr.error('只能上传PDF、Word、图片格式的合同文件!');
+                resetSubmitBtnUI($btn);
+                return false;
+            }
+            if (aiModalState.cid) {
+                formData.append('cid', aiModalState.cid);
+            }
+            formData.append('attachToContract', $('#aiAttachToContract').is(':checked') ? '1' : '0');
+            formData.append('size', filesize);
+            formData.append('file', aiSelectedFile);
+            // 新建模式不传uuid,后端会自动生成并在返回结果里带回cid
+            postDataWithFile(window.location.pathname + '/ai/upload', formData, function (res) {
+                handleAiRecognizeSuccess(res);
+                resetSubmitBtnUI($btn);
+            }, function (err) {
+                handleAiRecognizeError(err);
+            });
+        }
+    });
+
+    function resetSubmitBtnUI($btn) {
+        $btn.prop('disabled', false);
+        $('#aiRecognizeBtnText').text('开始识别');
+        $('#aiRecognizeSpinner').hide();
+    }
+
+    function handleAiRecognizeSuccess(res) {
+        const result = res;
+        const data = result.res || {};
+        if (aiModalState.mode === 'create') {
+            for (const key of edit_contract_input_key) {
+                $('#cons-add input[name="' + key + '"]').val(data[key] || '');
+            }
+            signDate.selectDate(data && data.sign_date ? new Date(moment(data.sign_date).format('YYYY-MM-DD')) : '');
+            $('#contract-add-cid').val(result.cid);
+            if (result.file) {
+                $('#contract-add-fid').val(result.file.id || '');
+                $('#contract-add-attach').prop('checked', result.attached || false);
+                $('#ai-file-name').text(result.file.filename);   // 例如:合同解析结果.md
+                $('#ai-file-panel').show();
+            } else {
+                $('#contract-add-fid').val('');
+                $('#contract-add-attach').prop('checked', false);
+                $('#ai-file-name').text('');
+                $('#ai-file-panel').hide();
+            }
+        } else {
+            const node = SpreadJsObj.getSelectObject(contractSheet);
+            if (node && node.c_code && (node.uid === user_id || permission_edit_contract) && (!shenpi_status || (shenpi_status && node.status === auditConst.status.uncheck || node.status === auditConst.status.checkNo))) {
+                $('#edit_contract_btn').hide();
+                $('#save_contract_btn').show();
+                $('#cancel_contract_btn').show();
+                for (const c of contractDetail) {
+                    if ($('#htdetail_' + c) && $('#htdetail_' + c).hasClass('change-input-td')) {
+                        if (c === 'sign_date') {
+                            $('#htdetail_' + c).html('<input type="date" class="form-control form-control-sm" value="' + moment(data[c]).format('YYYY-MM-DD') + '">');
+                            continue;
+                        } else if (c === 'remark') {
+                            $('#htdetail_' + c).html('<textarea class="form-control form-control-sm" rows="3">' + node[c] + '</textarea>');
+                            continue;
+                        } else if (c === 'tax') {
+                            $('#htdetail_' + c).html('<input type="number" min="0" max="100" step="1" class="form-control form-control-sm" value="' + (data[c] ? data[c] : '') + '">');
+                            continue;
+                        }
+                        $('#htdetail_' + c).html('<input type="text" class="form-control form-control-sm" value="' + data[c] + '">');
+                    }
+                }
+                const attrInfo = node.attribute_json ? JSON.parse(node.attribute_json) : null;
+                for (const attr of attributeSet) {
+                    if (attr.type === 'long_text') {
+                        $('#htdetail_' + attr.field).html('<textarea class="form-control form-control-sm" rows="3">' + (attrInfo && attrInfo[attr.field] ? attrInfo[attr.field] : '') + '</textarea>');
+                        continue;
+                    }
+                    $('#htdetail_' + attr.field).html('<input type="' + attr.type + '" class="form-control form-control-sm" value="' + (attrInfo && attrInfo[attr.field] ? attrInfo[attr.field] : '') + '">');
+                }
+            }
+            if (result.file) {
+                // 插入到files最前面
+                node.files.unshift(result.file);
+                contractTreeSpreadObj.setContractFiles(node.files, node.id);
+            }
+        }
+
+        $('#ai-recognize-modal').modal('hide');
+    }
+
+    function handleAiRecognizeError() {
+        toastr.error('识别失败,请重试或手动填写');
+    }
+
+// 弹窗关闭时重置状态(但不清空已回填的表单内容)
+    $('#ai-recognize-modal').on('hidden.bs.modal', function () {
+        clearUploadedFileUI();
+        $('input[name="aiFileSource"]').prop('checked', false);
+        $('#aiAttachCheckArea').hide();
+        $('#aiStartRecognize').prop('disabled', true);
+        resetSubmitBtnUI($('#aiStartRecognize'));
+    });
 });
 /**
  * 校验文件大小、格式

+ 8 - 0
app/router.js

@@ -342,6 +342,10 @@ module.exports = app => {
     app.post('/sp/:id/contract/tender/:tid/detail/:type/:cid/supplement/:csid/file/upload', sessionAuth, subProjectCheck, contractCheck, 'contractController.uploadFile');
     app.post('/sp/:id/contract/tender/:tid/detail/:type/:cid/supplement/:csid/file/delete', sessionAuth, subProjectCheck, contractCheck, 'contractController.deleteFile');
     app.get('/sp/:id/contract/tender/:tid/detail/:type/:cid/supplement/:csid/file/:fid/download', sessionAuth, subProjectCheck, contractCheck, 'contractController.downloadFile');
+    app.post('/sp/:id/contract/tender/:tid/detail/ai/upload', sessionAuth, subProjectCheck, contractCheck, 'contractController.uploadAndAi');
+    app.post('/sp/:id/contract/tender/:tid/detail/:type/ai/upload', sessionAuth, subProjectCheck, contractCheck, 'contractController.uploadAndAi');
+    app.post('/sp/:id/contract/tender/:tid/detail/ai/edit', sessionAuth, subProjectCheck, contractCheck, 'contractController.editAndAi');
+    app.post('/sp/:id/contract/tender/:tid/detail/:type/ai/edit', sessionAuth, subProjectCheck, contractCheck, 'contractController.editAndAi');
     // 项目合同管理
     app.get('/sp/:id/contract', sessionAuth, subProjectCheck, contractCheck, 'contractController.detail');
     app.post('/sp/:id/contract/audit/save', sessionAuth, subProjectCheck, contractCheck, 'contractController.auditSave');
@@ -369,6 +373,10 @@ module.exports = app => {
     app.post('/sp/:id/contract/detail/:type/:cid/supplement/:csid/file/upload', sessionAuth, subProjectCheck, contractCheck, 'contractController.uploadFile');
     app.post('/sp/:id/contract/detail/:type/:cid/supplement/:csid/file/delete', sessionAuth, subProjectCheck, contractCheck, 'contractController.deleteFile');
     app.get('/sp/:id/contract/detail/:type/:cid/supplement/:csid/file/:fid/download', sessionAuth, subProjectCheck, contractCheck, 'contractController.downloadFile');
+    app.post('/sp/:id/contract/detail/ai/upload', sessionAuth, subProjectCheck, contractCheck, 'contractController.uploadAndAi');
+    app.post('/sp/:id/contract/detail/:type/ai/upload', sessionAuth, subProjectCheck, contractCheck, 'contractController.uploadAndAi');
+    app.post('/sp/:id/contract/detail/ai/edit', sessionAuth, subProjectCheck, contractCheck, 'contractController.editAndAi');
+    app.post('/sp/:id/contract/detail/:type/ai/edit', sessionAuth, subProjectCheck, contractCheck, 'contractController.editAndAi');
 
 
     // 资料归集-列表

+ 34 - 12
app/service/contract.js

@@ -39,9 +39,10 @@ module.exports = app => {
 
         async add(options, node, data) {
             if (!options[this.setting.type] || !node || !data) throw '参数有误';
-            const insertId = this.uuid.v4();
+            const insertId = data.cid || this.uuid.v4();
             const transaction = await this.db.beginTransaction();
             try {
+                const add_key = ['c_code', 'name', 'tax', 'price', 'party_a', 'party_a_user', 'party_b', 'party_b_user', 'sign_date', 'address', 'entity', 'bank', 'bank_account'];
                 const maxId = await this.ctx.service.contractTree._getMaxLid(options);
                 const insertData = {
                     id: insertId,
@@ -53,18 +54,40 @@ module.exports = app => {
                     contract_pid: !node.c_code ? node.contract_id : node.contract_pid,
                     level: !node.c_code ? node.level + 1 : node.level,
                     is_leaf: 1,
-                    c_code: data.code,
-                    name: data.name,
-                    total_price: data.price ? parseFloat(data.price) : 0,
-                    price: data.price ? parseFloat(data.price) : 0,
-                    party_a: data.party_a,
-                    party_b: data.party_b,
-                    sign_date: data.sign_date,
-                    remark: data.remark,
-                    type: data.type,
+                    // c_code: data.c_code,
+                    // name: data.name,
+                    // total_price: data.price ? parseFloat(data.price) : 0,
+                    // price: data.price ? parseFloat(data.price) : 0,
+                    // party_a: data.party_a,
+                    // party_b: data.party_b,
+                    // sign_date: data.sign_date,
+                    // remark: data.remark,
+                    // type: data.type,
                     create_time: new Date(),
                     need_shenpi: options.spid ? this.ctx.subProject.page_show.openContractSubProjectShenpi : this.ctx.subProject.page_show.openContractTenderShenpi,
                 };
+                for (const key of add_key) {
+                    if (key === 'price') {
+                        insertData[key] = data[key] ? parseFloat(data[key]) : 0;
+                        insertData.total_price = data[key] ? parseFloat(data[key]) : 0;
+                        continue;
+                    } else if (key === 'tax') {
+                        insertData[key] = data[key] ? parseFloat(data[key]) : null;
+                        continue;
+                    } else if (key === 'sign_date') {
+                        insertData[key] = data[key] ? new Date(data[key]) : null;
+                        continue;
+                    }
+                    insertData[key] = data[key] || '';
+                }
+                // 删除attachments
+                if (!data.attach && data.fid) {
+                    const fileInfo = await this.ctx.service.contractAtt.getDataById(data.fid);
+                    if (fileInfo || Object.keys(fileInfo).length) {
+                        await this.ctx.app.fujianOss.delete(this.ctx.app.config.fujianOssFolder + fileInfo.filepath);
+                        await transaction.delete(this.ctx.service.contractAtt.tableName, { id: data.fid });
+                    }
+                }
                 insertData[this.setting.fullPath] = !node.c_code
                     ? node[this.setting.fullPath] + '-' + insertData[this.setting.kid]
                     : node[this.setting.fullPath].replace('-' + node[this.setting.kid], '-' + insertData[this.setting.kid]);
@@ -125,8 +148,7 @@ module.exports = app => {
                 await transaction.rollback();
                 throw err;
             }
-
-            return { update: await this.getDataById(ids) };
+            return { contract: await this.getDataById(ids), files: await this.ctx.service.contractAtt.getAtt(ids[0]) };
         }
 
         async getMaxOrder(options, pid, transaction) {

+ 1 - 0
app/view/contract/detail.ejs

@@ -95,6 +95,7 @@
                                 <a href="javascript:void(0);" id="cancel_contract_btn" style="display: none" class="btn btn-secondary btn-sm pull-right mr-2">取消</a>
                                 <a href="javascript:void(0);" id="save_contract_btn" style="display: none" class="btn btn-primary btn-sm pull-right mr-2">确定</a>
                                 <a href="javascript:void(0);" id="edit_contract_btn" style="display: none" class="btn btn-primary btn-sm pull-right mr-2">编辑合同</a>
+                                <a href="javascript:void(0);" id="ai_edit_contract_btn" style="display: none" class="btn btn-sm ai-fill-btn pull-right mr-2"><i class="fa fa-magic"></i> AI识别编辑合同</a>
                                 <a href="javascript:void(0);" id="add_contract_supplement_btn" style="display: none;" class="btn btn-primary btn-sm pull-right mr-2">新增补充合同</a>
                                 <% if (shenpi_status) { %>
                                     <span class="pull-right mr-2" id="shenpi_btn">

+ 209 - 28
app/view/contract/detail_modal.ejs

@@ -3,52 +3,162 @@
 <% if (ctx.session.sessionUser.is_admin || audit_permission.permission_add) { %>
 <!--新增合同-->
 <div class="modal fade" id="cons-add" data-backdrop="static">
-    <div class="modal-dialog" role="document">
+    <div class="modal-dialog modal-lg" role="document">
         <div class="modal-content">
             <div class="modal-header">
                 <h5 class="modal-title">新增合同</h5>
             </div>
             <div class="modal-body">
-                <div class="form-group">
-                    <label>合同编号<b class="text-danger">*</b></label>
-                    <input class="form-control form-control-sm" placeholder="请输入合同编号" type="text" name="code">
+                <div class="row">
+                    <div class="col-md-6">
+                        <div class="form-group">
+                            <label>合同编号<b class="text-danger">*</b></label>
+                            <input class="form-control form-control-sm" placeholder="请输入合同编号" type="text" name="c_code">
+                        </div>
+                    </div>
+                    <div class="col-md-6">
+                        <div class="form-group">
+                            <label>合同名称<b class="text-danger">*</b></label>
+                            <input class="form-control form-control-sm" placeholder="请输入合同名称" type="text" name="name">
+                        </div>
+                    </div>
                 </div>
-                <div class="form-group">
-                    <label>合同名称<b class="text-danger">*</b></label>
-                    <input class="form-control form-control-sm" placeholder="请输入合同名称" type="text" name="name">
+
+                <div class="row">
+                    <div class="col-md-6">
+                        <div class="form-group">
+                            <label>合同金额<b class="text-danger">*</b></label>
+                            <input class="form-control form-control-sm" placeholder="请输入合同金额" type="text" name="price">
+                            <div class="invalid-feedback">合同金额不能为0。</div>
+                        </div>
+                    </div>
+                    <div class="col-md-6">
+                        <div class="form-group">
+                            <label>税率(%)</label>
+                            <input class="form-control form-control-sm" placeholder="" type="text" name="tax">
+                        </div>
+                    </div>
                 </div>
-                <div class="form-group">
-                    <label>合同金额<b class="text-danger">*</b></label>
-                    <input class="form-control form-control-sm" placeholder="请输入合同金额" type="text" name="price">
-                    <div class="invalid-feedback">合同金额不能为0。</div>
+
+                <div class="row">
+                    <div class="col-md-6">
+                        <div class="form-group">
+                            <label>合同类型</label>
+                            <select class="form-control form-control-sm" name="type">
+                                <option value=""></option>
+                                <% for (const t of types) { %>
+                                    <option value="<%- t %>"><%- t %></option>
+                                <% } %>
+                            </select>
+                        </div>
+                    </div>
+                    <div class="col-md-6">
+                        <div class="form-group">
+                            <label>签订日期</label>
+                            <input class="datepicker-here form-control form-control-sm" name="sign_date" placeholder="点击选择时间" data-date-format="yyyy-MM-dd" data-language="zh" type="text" autocomplete="off">
+                        </div>
+                    </div>
                 </div>
-                <div class="form-group">
-                    <label>合同类型</label>
-                    <select class="form-control form-control-sm" name="type">
-                        <option value=""></option>
-                        <% for (const t of types) { %>
-                        <option value="<%- t %>"><%- t %></option>
-                        <% } %>
-                    </select>
+
+                <div class="row">
+                    <div class="col-md-6">
+                        <div class="form-group">
+                            <label>签订单位(甲方)</label>
+                            <input class="form-control form-control-sm" placeholder="" type="text" name="party_a">
+                        </div>
+                    </div>
+                    <div class="col-md-6">
+                        <div class="form-group">
+                            <label>甲方签约人</label>
+                            <input class="form-control form-control-sm" placeholder="" type="text" name="party_a_user">
+                        </div>
+                    </div>
                 </div>
-                <div class="form-group">
-                    <label>签订单位(甲方)</label>
-                    <input class="form-control form-control-sm" placeholder="请输入签订单位" type="text" name="party_a">
+
+                <div class="row">
+                    <div class="col-md-6">
+                        <div class="form-group">
+                            <label>签订单位(乙方)</label>
+                            <input class="form-control form-control-sm" placeholder="" type="text" name="party_b">
+                        </div>
+                    </div>
+                    <div class="col-md-6">
+                        <div class="form-group">
+                            <label>乙方签约人</label>
+                            <input class="form-control form-control-sm" placeholder="" type="text" name="party_b_user">
+                        </div>
+                    </div>
                 </div>
-                <div class="form-group">
-                    <label>签订单位(乙方)</label>
-                    <input class="form-control form-control-sm" placeholder="请输入签订单位" type="text" name="party_b">
+
+                <div class="row">
+                    <div class="col-md-6">
+                        <div class="form-group">
+                            <label>签约地点</label>
+                            <input class="form-control form-control-sm" placeholder="" type="text" name="address">
+                        </div>
+                    </div>
                 </div>
-                <div class="form-group">
-                    <label>签订日期</label>
-                    <input class="datepicker-here form-control form-control-sm" name="sign_date" placeholder="点击选择时间" data-date-format="yyyy-MM-dd" data-language="zh" type="text" autocomplete="off">
+
+                <div class="payee-fields-group">
+                    <hr class="my-2">
+                    <div class="row">
+                        <div class="col-md-4">
+                            <div class="form-group">
+                                <label>收款单位</label>
+                                <input class="form-control form-control-sm" placeholder="" type="text" name="entity">
+                            </div>
+                        </div>
+                        <div class="col-md-4">
+                            <div class="form-group">
+                                <label>收款开户行</label>
+                                <input class="form-control form-control-sm" placeholder="" type="text" name="bank">
+                            </div>
+                        </div>
+                        <div class="col-md-4">
+                            <div class="form-group">
+                                <label>收款账号</label>
+                                <input class="form-control form-control-sm" placeholder="" type="text" name="bank_account">
+                            </div>
+                        </div>
+                    </div>
                 </div>
+
                 <div class="form-group">
                     <label>备注</label>
                     <textarea class="form-control form-control-sm" name="remark" rows="3"></textarea>
                 </div>
+
+                <!-- AI识别文件 -->
+                <div id="ai-file-panel" class="border rounded p-2 mt-2" style="display:none;">
+                    <div class="d-flex justify-content-between align-items-center">
+                        <div>
+                            <label class="mb-1 font-weight-bold">
+                                <i class="fa fa-file-text-o text-primary"></i>
+                                AI识别文件
+                            </label>
+
+                            <div class="text-muted small">
+                                <i class="fa fa-file-o"></i>
+                                <span id="ai-file-name"></span>
+                            </div>
+                        </div>
+
+                        <div class="custom-control custom-checkbox">
+                            <input type="checkbox"
+                                   class="custom-control-input"
+                                   id="contract-add-attach"
+                                   checked>
+                            <label class="custom-control-label" for="contract-add-attach">
+                                添加至合同附件
+                            </label>
+                        </div>
+                    </div>
+                </div>
             </div>
             <div class="modal-footer">
+                <a href="javascript:void(0);" class="btn btn-sm ai-fill-btn" id="aiFillBtn"><i class="fa fa-magic"></i> AI识别填写</a>
+                <input type="hidden" id="contract-add-cid">
+                <input type="hidden" id="contract-add-fid">
                 <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>
                 <button type="button" class="btn btn-sm btn-primary" id="add-contract">确定</button>
             </div>
@@ -56,6 +166,77 @@
     </div>
 </div>
 <% } %>
+<div class="modal fade" id="ai-recognize-modal" data-backdrop="static">
+    <div class="modal-dialog modal-md" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title"><i class="fa-solid fa-wand-magic-sparkles text-warning"></i> AI识别填写</h5>
+                <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+                    <span aria-hidden="true">&times;</span>
+                </button>
+            </div>
+            <div class="modal-body">
+
+                <!-- 已有合同文件选择区(仅编辑模式且存在文件时显示) -->
+                <div class="ai-existing-files" id="aiExistingFilesArea" style="display:none;">
+                    <div class="ai-section-label">从已有合同文件(PDF、Word、图片格式)中选择一份识别</div>
+                    <div class="ai-existing-files-list" id="aiExistingFilesList"></div>
+                </div>
+
+                <div class="ai-section-label mt-2" id="aiUploadSectionLabel" style="display:none;">或上传新文件</div>
+
+                <!-- 上传区域 -->
+                <div class="ai-upload-area" id="aiUploadArea">
+                    <input type="radio" name="aiFileSource" id="aiSourceUpload" value="upload" style="display:none;">
+                    <input type="file" id="aiFileInput" accept=".pdf,.doc,.docx,.png,.jpg,.jpeg" style="display:none;">
+                    <div class="ai-upload-placeholder" id="aiUploadPlaceholder">
+                        <i class="fa-solid fa-cloud-arrow-up fa-2x text-muted mb-2"></i>
+                        <div>点击或拖拽上传合同文件</div>
+                        <div class="text-muted small">支持 PDF、Word、图片格式</div>
+                    </div>
+                    <div class="ai-upload-result" id="aiUploadResult" style="display:none;">
+                        <i class="fa-solid fa-file-lines text-primary"></i>
+                        <span id="aiFileName" class="ml-1"></span>
+                        <i class="fa-solid fa-circle-xmark text-danger ml-2" id="aiFileRemove" style="cursor:pointer;"></i>
+                    </div>
+                </div>
+
+                <!-- 注意事项 -->
+                <div class="ai-notice mt-3">
+                    <div class="ai-notice-title"><i class="fa-solid fa-circle-info"></i> 识别注意事项</div>
+                    <ul class="ai-notice-list">
+                        <li>识别结果由AI自动提取,可能存在误差,提交前请仔细核对各字段</li>
+                        <li>手写签名、模糊印章等内容识别准确率相对较低,建议重点核对甲乙方、金额、日期信息</li>
+                        <li>扫描件请尽量保证清晰、无遮挡,识别效果会更准确</li>
+                        <li>识别过程可能需要几秒到一分钟,请耐心等待,请勿重复提交</li>
+                    </ul>
+                    <div class="ai-notice-divider"></div>
+                    <div class="ai-notice-title"><i class="fa-solid fa-shield-halved"></i> 数据安全提示</div>
+                    <ul class="ai-notice-list">
+                        <li>上传的合同文件将发送至第三方AI大模型(千问,deepseek等)进行内容识别,请确认文件不含与本次识别无关的敏感信息</li>
+                        <li>请勿上传包含他人隐私信息(如身份证号、个人手机号等非必要信息)的非合同类文件</li>
+                    </ul>
+                </div>
+
+                <!-- 勾选项:仅在"上传新文件"且编辑模式下显示 -->
+                <div class="form-check mt-3" id="aiAttachCheckArea" style="display:none;">
+                    <input type="checkbox" class="form-check-input" id="aiAttachToContract" checked>
+                    <label class="form-check-label" for="aiAttachToContract">
+                        将此文件同时添加至该合同的合同文件中
+                    </label>
+                </div>
+
+            </div>
+            <div class="modal-footer">
+                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">取消</button>
+                <button type="button" class="btn btn-sm btn-primary" id="aiStartRecognize" disabled>
+                    <span id="aiRecognizeBtnText">开始识别</span>
+                    <span id="aiRecognizeSpinner" class="spinner-border spinner-border-sm ml-1" style="display:none;"></span>
+                </button>
+            </div>
+        </div>
+    </div>
+</div>
 <% if (ctx.session.sessionUser.is_admin) { %>
 <% include modal.ejs %>
     <!-- 节点授权 -->

+ 2 - 0
config/config.default.js

@@ -286,6 +286,8 @@ module.exports = appInfo => {
             image2AncGclDetail: 'app-BIOhBbFlNSakoRGKU91e2ksJ',
             image2Pos: 'app-FtAs299O9xFzTOVB4TlaZ73O',
             billsIdentify: 'app-ixkrpfrLOovuASn3DLJLT4VB',
+            inspection: 'app-fiJr1pNxjdKXpetko8ANawnY',
+            contract: 'app-lBZeuPBIPSnNljJ3hcXq8y1n',
         },
     };
     return config;

+ 2 - 0
config/config.local.js

@@ -126,6 +126,8 @@ module.exports = appInfo => {
             image2AncGclDetail: 'app-BIOhBbFlNSakoRGKU91e2ksJ',
             image2Pos: 'app-FtAs299O9xFzTOVB4TlaZ73O',
             billsIdentify: 'app-ixkrpfrLOovuASn3DLJLT4VB',
+            inspection: 'app-fiJr1pNxjdKXpetko8ANawnY',
+            contract: 'app-lBZeuPBIPSnNljJ3hcXq8y1n',
         },
     };
     return config;

+ 2 - 0
config/config.qa.js

@@ -101,6 +101,8 @@ module.exports = appInfo => {
             image2AncGclDetail: 'app-BIOhBbFlNSakoRGKU91e2ksJ',
             image2Pos: 'app-FtAs299O9xFzTOVB4TlaZ73O',
             billsIdentify: 'app-ixkrpfrLOovuASn3DLJLT4VB',
+            inspection: 'app-fiJr1pNxjdKXpetko8ANawnY',
+            contract: 'app-lBZeuPBIPSnNljJ3hcXq8y1n',
         },
     };
     return config;