Explorar el Código

Merge branch 'dev' of http://192.168.1.41:3000/maixinrong/Calculation into dev

Tony Kang hace 1 semana
padre
commit
bb05a3b085
Se han modificado 34 ficheros con 1970 adiciones y 149 borrados
  1. 1 0
      AGENTS.md
  2. 7 0
      app/base/base_budget_service.js
  3. 38 6
      app/base/base_service.js
  4. 150 0
      app/controller/change_controller.js
  5. 34 0
      app/controller/contract_controller.js
  6. 1 1
      app/controller/project_screen_controller.js
  7. 23 0
      app/lib/ai_dify.js
  8. 418 0
      app/lib/change_ai_import.js
  9. 183 9
      app/public/css/main.css
  10. 801 0
      app/public/js/change_ai_import_modal.js
  11. 1 0
      app/public/js/change_apply_information.js
  12. 1 0
      app/public/js/change_information.js
  13. 1 0
      app/public/js/change_information_set.js
  14. 1 0
      app/public/js/change_plan_information.js
  15. 66 2
      app/public/js/contract_detail.js
  16. 39 0
      app/public/js/project_screen_guangyuan.js
  17. 1 15
      app/public/js/project_screen_guangyuan_building.js
  18. 1 15
      app/public/js/project_screen_guangyuan_finished.js
  19. 2 17
      app/public/js/project_screen_guangyuan_planned.js
  20. 29 58
      app/public/js/sp_setting_permission.js
  21. 10 0
      app/router.js
  22. 13 3
      app/service/project_screen.js
  23. 111 0
      app/view/change/ai_import_modal.ejs
  24. 3 0
      app/view/change/apply_information.ejs
  25. 4 3
      app/view/change/apply_information_modal.ejs
  26. 3 0
      app/view/change/information.ejs
  27. 4 3
      app/view/change/information_modal.ejs
  28. 3 0
      app/view/change/plan_information.ejs
  29. 1 0
      app/view/change/plan_information_modal.ejs
  30. 1 2
      app/view/project_screen/guangyuan_modal.ejs
  31. 16 15
      app/view/sp_setting/user_modal.ejs
  32. 1 0
      config/config.default.js
  33. 1 0
      config/config.local.js
  34. 1 0
      config/config.qa.js

+ 1 - 0
AGENTS.md

@@ -21,6 +21,7 @@
 
 ## Service 与查询代码风格
 
+- Controller 负责请求处理与响应;较复杂的字段配置、权限判断、业务校验和流程编排抽取到 lib。Service 层只负责数据库查询、写入及相关事务操作,供 Controller 或 lib 调用,不得混入上述业务逻辑。
 - Service 写法必须参考系统现有实现,优先复用已有方法,保持项目代码风格一致。
 - 普通数据库操作优先使用系统已有的 Service、基类方法或数据库封装;只有遇到复杂 SQL 查询时才使用手写 SQL。
 - 手写 SQL 时,使用 `const sql` 和 `const sqlParam` 分别声明 SQL 语句与参数。

+ 7 - 0
app/base/base_budget_service.js

@@ -554,6 +554,13 @@ class BaseBudget extends TreeService {
 
         return await this.importYbpData(budget, ybpData, needGcl);
     }
+
+    async getSumTpByBudgetIds(budgetIds) {
+        if (budgetIds.length === 0) return [];
+        const sql = 'SELECT bid, SUM(total_price) AS total_price FROM ' + this.tableName + ' WHERE bid IN (?) AND is_leaf = true GROUP BY bid';
+        const sqlParam = [budgetIds];
+        return await this.db.query(sql, sqlParam);
+    }
 }
 
 module.exports = BaseBudget;

+ 38 - 6
app/base/base_service.js

@@ -95,9 +95,10 @@ class BaseService extends Service {
      * @param {Object} condition - 筛选条件
      * @return {Array} - 返回数据
      */
-    async getAllDataByCondition(condition) {
+    async getAllDataByCondition(condition, transaction = false) {
         if (!condition.where && !condition.limit) throw '缺少查询条件,如查询全表,请自建sql';
-        return await this.db.select(this.tableName, condition);
+        const conn = transaction || this.db;
+        return await conn.select(this.tableName, condition);
     }
 
     /**
@@ -138,13 +139,15 @@ class BaseService extends Service {
         return result;
     }
 
-    async defaultUpdate(data, options) {
-        const result = await this.db.update(this.tableName, data, options);
+    async defaultUpdate(data, options, transaction = false) {
+        const conn = transaction || this.db;
+        const result = await conn.update(this.tableName, data, options);
         return result;
     }
 
-    async defaultUpdateRows(data) {
-        const result = await this.db.updateRows(this.tableName, data);
+    async defaultUpdateRows(data, transaction = false) {
+        const conn = transaction || this.db;
+        const result = await conn.updateRows(this.tableName, data);
         return result;
     }
 
@@ -254,5 +257,34 @@ class BaseService extends Service {
             await transaction.insert(this.tableName, batchDatas);
         }
     }
+
+    /**
+     * 开启数据库事务。
+     */
+    async beginTransaction() {
+        return await this.db.beginTransaction();
+    }
+
+    /**
+     * 在事务中锁定符合非空标量等值条件的数据,可按指定字段升序排列。
+     */
+    async getAllDataForUpdate(condition, transaction, orderBy) {
+        if (!transaction) throw '锁定查询必须使用事务';
+        if (!condition || typeof condition !== 'object' || Array.isArray(condition) || !Object.keys(condition).length) {
+            throw '锁定查询缺少等值条件';
+        }
+        const where = [];
+        const sqlParam = [this.tableName];
+        for (const key of Object.keys(condition)) {
+            if (condition[key] === null || !['string', 'number', 'boolean'].includes(typeof condition[key])) {
+                throw '锁定查询仅支持非空标量等值条件';
+            }
+            where.push('?? = ?');
+            sqlParam.push(key, condition[key]);
+        }
+        if (orderBy) sqlParam.push(orderBy);
+        const sql = 'SELECT * FROM ?? WHERE ' + where.join(' AND ') + (orderBy ? ' ORDER BY ?? ASC' : '') + ' FOR UPDATE';
+        return await transaction.query(sql, sqlParam);
+    }
 }
 module.exports = BaseService;

+ 150 - 0
app/controller/change_controller.js

@@ -28,6 +28,8 @@ const spreadConst = require('../const/spread');
 const stdConst = require('../const/standard');
 // const tenderMenu = require('../../config/menu').tenderMenu;
 const spreadSetting = require('../lib/spread_setting');
+const AiDify = require('../lib/ai_dify');
+const ChangeAiImport = require('../lib/change_ai_import');
 
 module.exports = app => {
     class ChangeController extends app.BaseController {
@@ -1072,6 +1074,10 @@ module.exports = app => {
                     }
                 }
                 const bodyData = { bills: ctx.helper._.concat(ledgerData, changeLedgerData), pos: ctx.helper._.concat(posData, changePosData), dealBills };
+                const includeAiChange = !lastRevise || lastRevise.status === audit.revise.status.checked;
+                bodyData.aiLedger = new ChangeAiImport(ctx).buildLedgerContext(
+                    includeAiChange ? ledgerData.concat(changeLedgerData) : ledgerData,
+                    includeAiChange ? posData.concat(changePosData) : posData);
                 if (data.from === 'batch') {
                     bodyData.changeLedgerList = changeLedgerData;
                     bodyData.changePosList = changePosData;
@@ -4621,6 +4627,150 @@ module.exports = app => {
                 };
             }
         }
+        async uploadAndAi(ctx) {
+            let stream;
+            const uploadedPaths = [];
+            try {
+                const scene = ctx.query.scene;
+                const extractScope = ctx.query.extract_scope || 'all';
+                if (!['apply', 'plan', 'change'].includes(scene)) throw '变更识别场景有误';
+                if (!['all', 'main', 'items'].includes(extractScope)) throw '变更识别范围有误';
+                const aiImport = new ChangeAiImport(ctx);
+                await aiImport.getContext(scene, ctx.query.change_id, null, false);
+                const parts = ctx.multipart({ autoFields: true });
+                const files = [];
+                const batchId = app.uuid.v4();
+                while ((stream = await parts()) !== undefined) {
+                    if (!stream.filename) {
+                        await sendToWormhole(stream);
+                        continue;
+                    }
+                    const fileInfo = path.parse(stream.filename);
+                    if (files.length || !['.jpg', '.jpeg', '.png', '.webp'].includes(fileInfo.ext.toLowerCase())) {
+                        throw '每次请上传一张 JPG、PNG 或 WebP 图片';
+                    }
+                    const content = await this._readAiImage(stream);
+                    const folder = { apply: 'change_apply', plan: 'change_plan', change: 'change' }[scene];
+                    const filepath = `app/public/upload/${ctx.tender.id}/${folder}/ai_temp_${batchId}${fileInfo.ext.toLowerCase()}`;
+                    const ossPath = ctx.app.config.fujianOssFolder + filepath;
+                    uploadedPaths.push(ossPath);
+                    await ctx.app.fujianOss.put(ossPath, content);
+                    files.push({ filename: stream.filename, viewpath: ctx.app.config.fujianOssPath + filepath });
+                    await sendToWormhole(stream);
+                    stream = null;
+                }
+                if (!files.length) throw '请上传文件';
+                const aiDify = new AiDify({ apiKey: ctx.difyInfo.apiKey.change });
+                const transResult = await aiDify.changeTrans(files, scene, extractScope, String(ctx.session.sessionUser.accountId));
+                try {
+                    transResult.res = typeof transResult.res === 'string' ? JSON.parse(transResult.res) : transResult.res;
+                } catch (err) {
+                    throw '变更识别返回的JSON无法解析';
+                }
+                if (!transResult.res || transResult.res.scene !== scene || !Array.isArray(transResult.res.documents)) {
+                    throw '变更识别返回格式有误';
+                }
+                ctx.body = { err: 0, msg: '', data: { ...transResult, extract_scope: extractScope } };
+            } catch (err) {
+                if (stream && !stream.destroyed) await sendToWormhole(stream);
+                this.log(err);
+                ctx.body = { err: 1, msg: err.toString(), data: null };
+            } finally {
+                for (const ossPath of uploadedPaths) {
+                    try {
+                        await ctx.app.fujianOss.delete(ossPath);
+                    } catch (err) {
+                        this.log('变更识别临时文件清理失败:' + ossPath);
+                    }
+                }
+            }
+        }
+        async aiImportContext(ctx) {
+            try {
+                const aiImport = new ChangeAiImport(ctx);
+                const input = JSON.parse(ctx.request.body.data || '{}');
+                const data = await aiImport.getContext(ctx.query.scene, ctx.query.change_id, null, input.include_ledger !== false);
+                delete data.change;
+                delete data.config;
+                ctx.body = { err: 0, msg: '', data };
+            } catch (err) {
+                this.log(err);
+                ctx.body = { err: 1, msg: err.toString(), data: null };
+            }
+        }
+
+        async aiImportSave(ctx) {
+            let stream;
+            let file;
+            let saved = false;
+            try {
+                const aiImport = new ChangeAiImport(ctx);
+                await aiImport.getContext(ctx.query.scene, ctx.query.change_id, null, false);
+                const parts = ctx.multipart({ autoFields: true });
+                while ((stream = await parts()) !== undefined) {
+                    if (!stream.filename) { await sendToWormhole(stream); continue; }
+                    const parsed = path.parse(stream.filename);
+                    if (file || !['.jpg', '.jpeg', '.png', '.webp'].includes(parsed.ext.toLowerCase())) throw '每次只能附加一张图片';
+                    const content = await this._readAiImage(stream);
+                    const folder = { apply: 'change_apply', plan: 'change_plan', change: 'change' }[ctx.query.scene];
+                    const filepath = `app/public/upload/${ctx.tender.id}/${folder}/fujian_${app.uuid.v4()}${parsed.ext.toLowerCase()}`;
+                    file = { filepath, filename: stream.filename, name: parsed.name, ext: parsed.ext.toLowerCase(), size: content.length };
+                    await ctx.app.fujianOss.put(ctx.app.config.fujianOssFolder + filepath, content);
+                    stream = null;
+                }
+                const payload = JSON.parse(parts.field.data);
+                const data = await aiImport.save(ctx.query.scene, ctx.query.change_id, payload, file);
+                saved = true;
+                ctx.body = { err: 0, msg: '导入成功', data };
+            } catch (err) {
+                if (stream && !stream.destroyed) await sendToWormhole(stream);
+                this.log(err);
+                ctx.body = { err: 1, msg: err.toString(), data: null };
+            } finally {
+                if (file && !saved) {
+                    try { await ctx.app.fujianOss.delete(ctx.app.config.fujianOssFolder + file.filepath); } catch (err) { this.log('变更导入附件清理失败'); }
+                }
+            }
+        }
+
+        async _readAiImage(stream) {
+            const chunks = [];
+            let size = 0;
+            for await (const chunk of stream) {
+                size += chunk.length;
+                if (size > 50 * 1024 * 1024) throw '图片大小不能超过50 MB';
+                chunks.push(chunk);
+            }
+            if (!size || stream.truncated) throw '图片为空或上传不完整';
+            const content = Buffer.concat(chunks);
+            const jpeg = content.length >= 3 && content[0] === 0xff && content[1] === 0xd8 && content[2] === 0xff;
+            const png = content.length >= 8 && content.slice(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
+            const webp = content.length >= 12 && content.toString('ascii', 0, 4) === 'RIFF' && content.toString('ascii', 8, 12) === 'WEBP';
+            if (!jpeg && !png && !webp) throw '文件内容不是有效的 JPG、PNG 或 WebP 图片';
+            return content;
+        }
+
+        async aiImportExisting(ctx) {
+            try {
+                const scene = ctx.query.scene;
+                const extractScope = ctx.query.extract_scope || 'all';
+                if (!['all', 'main', 'items'].includes(extractScope)) throw '变更识别范围有误';
+                const aiImport = new ChangeAiImport(ctx);
+                const data = await aiImport.getContext(scene, ctx.query.change_id, null, false);
+                const input = JSON.parse(ctx.request.body.data);
+                if (!input || !input.fid) throw '请选择图片附件';
+                const att = await ctx.service[data.config.att].getDataByCondition({ id: input.fid, tid: ctx.tender.id, [data.config.foreign]: ctx.query.change_id });
+                if (!att || !/\.(jpe?g|png|webp)$/i.test(att.filepath || '')) throw '请选择当前单据的图片附件';
+                const aiDify = new AiDify({ apiKey: ctx.difyInfo.apiKey.change });
+                const result = await aiDify.changeTrans([{ filename: att.filename, viewpath: ctx.app.config.fujianOssPath + att.filepath }], scene, extractScope, String(ctx.session.sessionUser.accountId));
+                result.res = typeof result.res === 'string' ? JSON.parse(result.res) : result.res;
+                if (!result.res || result.res.scene !== scene || !Array.isArray(result.res.documents)) throw '变更识别返回格式有误';
+                ctx.body = { err: 0, msg: '', data: { ...result, extract_scope: extractScope } };
+            } catch (err) {
+                this.log(err);
+                ctx.body = { err: 1, msg: err.toString(), data: null };
+            }
+        }
     }
 
     return ChangeController;

+ 34 - 0
app/controller/contract_controller.js

@@ -1237,6 +1237,40 @@ module.exports = app => {
                 ctx.body = { err: 1, msg: err.toString(), data: null };
             }
         }
+
+        /**
+         * 重命名合同附件
+         */
+        async renameFile(ctx) {
+            try {
+                const cid = ctx.params.cid;
+                const tid = parseInt(ctx.params.tid);
+                const cpid = parseInt(ctx.params.cpid);
+                const csid = parseInt(ctx.params.csid);
+                const data = JSON.parse(ctx.request.body.data);
+                if (!cid || !data.id) throw '参数错误';
+                const attService = cpid ? ctx.service.contractPayAtt : (csid ? ctx.service.contractSupplementAtt : ctx.service.contractAtt);
+                const file = await attService.getDataById(data.id);
+                if (!file || file.cid !== cid ||
+                    file.contract_type !== ctx.contract_type ||
+                    (tid ? file.tid !== tid : (file.tid || file.spid !== ctx.contractOptions.spid)) ||
+                    (cpid && file.cpid !== cpid) ||
+                    (csid && file.csid !== csid)) {
+                    throw '不存在该文件';
+                }
+                if (file.uid !== ctx.session.sessionUser.accountId) throw '没有权限重命名此附件';
+                const filename = typeof data.filename === 'string' ? data.filename.trim() : '';
+                if (!filename || filename.length > 255 || /[\\/:*?"<>|\x00-\x1f]/.test(filename)) throw '请输入有效的文件名称(最多255个字符)';
+                if (path.extname(filename).toLowerCase() !== (file.fileext || '').toLowerCase()) throw '不能修改文件后缀';
+                if (!path.parse(filename).name.trim()) throw '文件名称不能为空';
+                await attService.defaultUpdate({ id: file.id, filename });
+                const files = await attService.getAtt(cpid || csid || cid);
+                ctx.body = { err: 0, msg: '', data: files };
+            } catch (err) {
+                this.log(err);
+                ctx.body = { err: 1, msg: err.toString(), data: null };
+            }
+        }
     }
 
     return ContractController;

+ 1 - 1
app/controller/project_screen_controller.js

@@ -28,7 +28,7 @@ module.exports = app => {
                 const finishedProjects = summaries.filter(project => project.status === '已建');
                 const plannedProjects = summaries.filter(project => project.status === '拟建');
                 const buildingProjects = summaries.filter(project => project.status === '在建');
-                const listOnly = projects => projects.map(project => ({ id: project.id, name: project.name, group: project.group }));
+                const listOnly = projects => projects.map(project => ({ id: project.id, name: project.name, folders: project.folders }));
                 const renderData = {
                     screens: screenList,
                     screen,

+ 23 - 0
app/lib/ai_dify.js

@@ -191,6 +191,29 @@ class AiDify {
         }
         return { result, conversationId: data.conversation_id };
     }
+
+    async changeTrans(files, scene, extractScope = 'all', userId = 'user_123') {
+        if (!['apply', 'plan', 'change'].includes(scene)) throw new Error('变更识别场景有误');
+        extractScope = extractScope || 'all';
+        if (!['all', 'main', 'items'].includes(extractScope)) throw new Error('变更识别范围有误');
+        if (!Array.isArray(files) || !files.length) throw new Error('请上传文件');
+        const body = {
+            inputs: { fileList: [], scene, extract_scope: extractScope },
+            user: userId,
+            response_mode: 'blocking',
+        };
+        for (const file of files) {
+            if (!file.viewpath) throw new Error('文件缺少viewpath字段');
+            body.inputs.fileList.push({
+                type: this.resolveDifyFileType(file.filename),
+                transfer_method: 'remote_url',
+                url: file.viewpath,
+            });
+        }
+        const result = await this.workflows(body);
+        if (!result || !result.res) throw new Error('变更识别未返回有效结果');
+        return result;
+    }
 }
 
 module.exports = AiDify;

+ 418 - 0
app/lib/change_ai_import.js

@@ -0,0 +1,418 @@
+'use strict';
+
+const crypto = require('crypto');
+const audit = require('../const/audit');
+const changeConst = require('../const/change');
+const GclGather = require('./gcl_gather').gclGather;
+
+class ChangeAiImport {
+    constructor(ctx) {
+        this.ctx = ctx;
+    }
+
+    async getContext(scene, id, transaction, includeLedger = true) {
+        const ctx = this.ctx;
+        const configs = {
+            apply: {
+                service: 'changeApply',
+                list: 'changeApplyList',
+                att: 'changeApplyAtt',
+                key: 'id',
+                foreign: 'caid',
+                feature: 'openChangeApply',
+            },
+            plan: {
+                service: 'changePlan',
+                list: 'changePlanList',
+                att: 'changePlanAtt',
+                key: 'id',
+                foreign: 'cpid',
+                feature: 'openChangePlan',
+            },
+            change: {
+                service: 'change',
+                list: 'changeAuditList',
+                att: 'changeAtt',
+                key: 'cid',
+                foreign: 'cid',
+            },
+        };
+        const config = configs[scene];
+        if (!config || !id) throw '变更识别参数有误';
+        if (config.feature && !ctx.subProject.page_show[config.feature]) throw '该功能已关闭';
+
+        const service = ctx.service[config.service];
+        const where = { [config.key]: id, tid: ctx.tender.id };
+        const change = transaction
+            ? (await service.getAllDataForUpdate(where, transaction))[0]
+            : await service.getDataByCondition(where);
+        const status = audit[config.service].status;
+        // 与详情页一致:只允许原报人在未上报、退回或修订时导入。
+        if (!change || String(change.uid) !== String(ctx.session.sessionUser.accountId)
+            || ![status.uncheck, status.checkNo, status.revise].includes(change.status)) {
+            throw '当前单据不可导入,请刷新页面检查权限或审批状态';
+        }
+
+        const listService = ctx.service[config.list];
+        const detailWhere = { [config.foreign]: id, tid: ctx.tender.id };
+        const lists = transaction
+            ? await listService.getAllDataForUpdate(detailWhere, transaction, 'id')
+            : await listService.getAllDataByCondition({ where: detailWhere, orders: [['id', 'asc']] });
+        const attachments = await ctx.service[config.att].getAllDataByCondition({
+            where: detailWhere,
+            columns: ['id'],
+            orders: [['id', 'asc']],
+        }, transaction);
+        change.decimal = typeof change.decimal === 'string' ? JSON.parse(change.decimal) : change.decimal;
+        change.decimal = change.decimal || {
+            tp: ctx.tender.info.decimal.tp,
+            up: ctx.tender.info.decimal.up,
+            precision: ctx.tender.info.precision,
+        };
+        ctx.change = change;
+        const ledgerTargets = scene === 'change' && includeLedger ? await this._getLedgerTargets(transaction) : [];
+        const ledgerVersion = crypto.createHash('sha256').update(JSON.stringify(ledgerTargets)).digest('hex');
+        const version = crypto.createHash('sha256').update(JSON.stringify({ change, lists, attachments })).digest('hex');
+
+        const fields = this._getFields(scene);
+        const original = {};
+        for (const field of fields) {
+            original[field.key] = change[field.key] === null || change[field.key] === undefined ? '' : String(change[field.key]);
+        }
+        const classes = (ctx.subProject.fun_set.change_class || []).filter(item => item.checked).map(item => ({
+            value: String(scene === 'change' ? item.value : item.new_name || item.name),
+            name: item.new_name || item.name,
+            aliases: [item.name, item.new_name].filter(Boolean),
+        }));
+        const qualities = Object.values(changeConst.quality).map(item => ({
+            value: String(scene === 'change' ? item.value : item.name),
+            name: item.name,
+            aliases: [item.name.replace('设计', '')],
+        }));
+        return {
+            scene,
+            id,
+            config,
+            change,
+            fields,
+            original,
+            lists,
+            ledgerTargets,
+            ledgerVersion,
+            version,
+            precision: change.decimal.precision || ctx.tender.info.precision,
+            upDecimal: change.up_decimal === null || change.up_decimal === undefined ? change.decimal.up : change.up_decimal,
+            options: { class: classes, quality: qualities },
+            // AI 导入匹配不到已有清单和台账时,允许新增空白清单。
+            allowAdd: true,
+        };
+    }
+
+    _getFields(scene) {
+        const relatedKeys = { apply: 'project_code', plan: 'apply_code', change: 'plan_code' };
+        const fields = [
+            ['code', '编号'],
+            ['name', '变更名称'],
+            ['peg', '桩号'],
+            [relatedKeys[scene], '关联单据编号'],
+            ['org_name', '原设计名称'],
+            [scene === 'change' ? 'org_code' : 'new_code', '原设计图号'],
+            [scene === 'change' ? 'new_name' : 'design_name', '变更设计名称'],
+            [scene === 'change' ? 'new_code' : 'c_new_code', '变更设计图号'],
+            ['class', '变更类别'],
+            ['quality', '变更性质'],
+        ];
+        const textFields = {
+            apply: [['reason', '变更原因'], ['org_content', '原设计情况'], ['site_content', '现场情况'], ['content', '变更内容']],
+            plan: [['reason', '拟变更方案'], ['content', '变更内容'], ['memo', '备注'], ['expr', '计算依据']],
+            change: [['content', '变更内容'], ['basis', '变更依据'], ['expr', '计算依据'], ['memo', '备注']],
+        };
+        const result = fields.map(item => ({
+            key: item[0],
+            name: item[1],
+            type: ['class', 'quality'].includes(item[0]) ? 'select' : 'text',
+        }));
+        return result.concat(textFields[scene].map(item => ({ key: item[0], name: item[1], type: 'textarea' })));
+    }
+
+    async save(scene, id, payload, file) {
+        if (!payload || !['all', 'main', 'items'].includes(payload.extract_scope)
+            || !Array.isArray(payload.rows) || payload.rows.length > 500) {
+            throw '导入数据格式有误';
+        }
+        if (file && scene === 'apply'
+            && !changeConst.file_type.some(item => String(item.key) === String(payload.attachment_type))) {
+            throw '请选择附件资料类型';
+        }
+
+        const ctx = this.ctx;
+        const transaction = await ctx.service.change.beginTransaction();
+        try {
+            // 加锁后重新检查权限和预览版本,所有写入使用同一个事务。
+            const needsLedger = payload.extract_scope !== 'main' && payload.rows.some(row => row.action === '台账插入');
+            const data = await this.getContext(scene, id, transaction, needsLedger);
+            if (payload.version !== data.version) throw '单据已发生变化或本次导入已保存,请关闭窗口并重新识别';
+            if (needsLedger && payload.ledgerVersion !== data.ledgerVersion) throw '台账已发生变化,请返回第一步重新识别并核对台账目标';
+            const info = {};
+            if (payload.extract_scope !== 'items') {
+                for (const field of data.fields) {
+                    if (!Object.prototype.hasOwnProperty.call(payload.main || {}, field.key)) continue;
+                    const value = payload.main[field.key];
+                    if (typeof value !== 'string' || value.length > (field.type === 'textarea' ? 60000 : 2000)) {
+                        throw '基本信息字段格式有误';
+                    }
+                    if (value === data.original[field.key]) continue;
+                    if (field.type === 'select' && !data.options[field.key].some(item => item.value === value)) {
+                        throw field.name + '不在系统选项内';
+                    }
+                    if (['code', 'name'].includes(field.key) && !value.trim()) throw field.name + '不能为空';
+                    info[field.key] = field.type === 'select' && scene === 'change' ? Number(value) : value;
+                }
+            }
+            if (info.code && await ctx.service[data.config.service].isRepeat(id, info.code, ctx.tender.id)) {
+                throw '该编号已存在';
+            }
+
+            const updates = [];
+            const inserts = [];
+            const usedTargets = new Set();
+            const usedLedgerTargets = new Set();
+            const beforeKey = scene === 'change' ? 'oamount2' : 'oamount';
+            const selected = payload.extract_scope === 'main' ? [] : payload.rows;
+            for (const row of selected) {
+                if (row.action === '跳过') continue;
+                if (!['新增', '更新', '台账插入'].includes(row.action)) throw '清单处理方式有误';
+                const target = row.action === '更新' ? data.lists.find(item => String(item.id) === String(row.target)) : null;
+                const ledgerTarget = row.action === '台账插入' ? data.ledgerTargets.find(item => item.id === row.target) : null;
+                if (row.action === '台账插入') {
+                    if (!ledgerTarget) throw '台账清单或部位已失效,请重新选择';
+                    if (usedLedgerTargets.has(ledgerTarget.id) || data.lists.some(item =>
+                        String(item.gcl_id) === String(ledgerTarget.gcl_id) && String(item.mx_id || '') === String(ledgerTarget.mx_id))) {
+                        throw '该台账清单部位已添加或被多行选中,请改为更新已有清单或重新选择';
+                    }
+                    usedLedgerTargets.add(ledgerTarget.id);
+                }
+                if (row.action === '更新' && (!target || usedTargets.has(String(target.id)))) {
+                    throw '更新目标不存在或多行选择了同一清单,请重新核对';
+                }
+                if (target) usedTargets.add(String(target.id));
+                if (!target && !ledgerTarget && !data.allowAdd) throw '空白清单添加功能未开启';
+
+                const item = {};
+                if (ledgerTarget) {
+                    Object.assign(item, ledgerTarget);
+                    delete item.id;
+                    delete item.source;
+                } else if (!target) {
+                    for (const key of ['code', 'name', 'unit']) {
+                        if (typeof row[key] !== 'string' || row[key].length > 500) throw '清单文本格式有误';
+                        item[key] = row[key].trim();
+                    }
+                    if (!item.name || !item.unit) throw '新增清单名称、单位不能为空';
+                    item.unit_price = this._parseNumber(row.price, true);
+                }
+                const before = this._parseNumber(row.before, false);
+                const delta = this._parseNumber(row.delta, false);
+                const unit = target ? target.unit : item.unit;
+                const precision = data.change.decimal.precision || ctx.tender.info.precision;
+                const unitSetting = Object.values(precision || {}).find(value => value.unit === unit);
+                const digits = unitSetting ? unitSetting.value : precision && precision.other ? precision.other.value : 3;
+                const syncOriginal = scene === 'change' && !this._hasLedgerReference(ledgerTarget || target);
+                item[beforeKey] = ctx.helper.round(before, digits);
+                if (syncOriginal) item.oamount = item[beforeKey];
+                item.camount = ctx.helper.round(delta, digits);
+                const sameBefore = target && target[beforeKey] !== null && target[beforeKey] !== undefined
+                    && target[beforeKey] !== '' && Number(target[beforeKey]) === item[beforeKey]
+                    && (!syncOriginal || target.oamount !== null && target.oamount !== undefined
+                        && target.oamount !== '' && Number(target.oamount) === item.oamount);
+                const sameDelta = target && target.camount !== null && target.camount !== undefined
+                    && target.camount !== '' && Number(target.camount) === item.camount;
+                if (sameBefore && sameDelta) continue;
+                if (!sameDelta) {
+                    item.spamount = item.camount;
+                    if (scene === 'change') item.camount_expr = '';
+                }
+                if (target) {
+                    if (scene === 'change') await this._checkTarget(target, item);
+                    item.id = target.id;
+                    updates.push(item);
+                } else {
+                    item.tid = ctx.tender.id;
+                    item[data.config.foreign] = id;
+                    if (item.unit_price !== null) item.unit_price = ctx.helper.round(item.unit_price, data.upDecimal);
+                    if (scene === 'change') {
+                        Object.assign(item, {
+                            lid: '0',
+                            bwmx: '',
+                            samount: '',
+                            detail: '',
+                            gcl_id: '',
+                            mx_id: '',
+                            is_valuation: 1,
+                            delimit: data.change.delimit === undefined || data.change.delimit === null ? 100 : data.change.delimit,
+                            order: data.change.order_by ? Math.max(0, ...data.lists.map(list => Number(list.order) || 0)) + inserts.length + 1 : null,
+                        });
+                        if (ledgerTarget) {
+                            for (const key of ['lid', 'gcl_id', 'mx_id', 'bwmx', 'xmj_code', 'xmj_jldy', 'xmj_dwgc', 'xmj_fbgc', 'xmj_fxgc', 'oamount']) {
+                                item[key] = ledgerTarget[key];
+                            }
+                            await this._checkTarget({ gcl_id: item.gcl_id, mx_id: item.mx_id }, item);
+                        }
+                    }
+                    inserts.push(item);
+                }
+            }
+            if (!Object.keys(info).length && !inserts.length && !updates.length && !file) throw '没有需要保存的变更';
+
+            let attachment;
+            if (file) {
+                attachment = {
+                    tid: ctx.tender.id,
+                    [data.config.foreign]: id,
+                    uid: ctx.session.sessionUser.accountId,
+                    filename: scene === 'change' ? file.name : file.filename,
+                    fileext: file.ext,
+                    filepath: file.filepath,
+                    filesize: scene === 'change' ? file.size : ctx.helper.bytesToSize(file.size),
+                    extra_upload: false,
+                };
+                if (scene === 'change') {
+                    attachment.in_time = Math.floor(Date.now() / 1000);
+                } else {
+                    attachment.upload_time = new Date();
+                    attachment.type = scene === 'apply' ? Number(payload.attachment_type) : 5;
+                }
+            }
+            if (Object.keys(info).length) {
+                await ctx.service[data.config.service].defaultUpdate(info, {
+                    where: { [data.config.key]: id, tid: ctx.tender.id },
+                }, transaction);
+            }
+            const listService = ctx.service[data.config.list];
+            if (inserts.length) {
+                await listService.insertDatasSplit(transaction, listService.tableName, inserts);
+            }
+            if (updates.length) {
+                await listService.defaultUpdateRows(updates, transaction);
+            }
+            if (inserts.length || updates.length) {
+                await listService.calcCamountSum(transaction);
+            }
+            if (attachment) {
+                const attachmentService = ctx.service[data.config.att];
+                await attachmentService.insertDatasSplit(transaction, attachmentService.tableName, [attachment]);
+            }
+            await transaction.commit();
+            return { added: inserts.length, updated: updates.length, fields: Object.keys(info).length };
+        } catch (err) {
+            await transaction.rollback();
+            throw err;
+        }
+    }
+
+    _parseNumber(value, optional) {
+        if (value === '' || value === null || value === undefined) {
+            if (optional) return null;
+            throw '原设计数量和增减数量不能为空,请核对或跳过该行';
+        }
+        if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(String(value))
+            || !Number.isFinite(Number(value)) || Math.abs(Number(value)) > 1e15) {
+            throw '清单数值格式有误';
+        }
+        return Number(value);
+    }
+
+    async _checkTarget(target, values) {
+        const ctx = this.ctx;
+        const ready = await ctx.service.settle.getReadySettle(ctx.tender.id);
+        if (ready && target.gcl_id) {
+            const service = target.mx_id ? ctx.service.settlePos : ctx.service.settleBills;
+            const where = { settle_id: ready.id, lid: target.gcl_id };
+            if (target.mx_id) where.pid = target.mx_id;
+            const settled = await service.getDataByCondition(where);
+            if (settled && settled.settle_status === ctx.service.settle.settleStatus.finish) throw '已结算清单不能修改';
+        }
+        if (!target.id) return;
+        const used = await ctx.service.stageChange.getAllFinalUsedData(ctx.tender.id, ctx.change.cid);
+        let quantity = 0;
+        for (const item of used.filter(item => String(item.cbid) === String(target.id))) {
+            quantity = ctx.helper.add(quantity, item.qty || 0);
+        }
+        if ((quantity > 0 && values.camount < quantity) || (quantity < 0 && values.camount > quantity)) {
+            throw '清单增减数量不能小于已调用数量范围';
+        }
+    }
+
+    async _getLedgerTargets(transaction) {
+        const ctx = this.ctx;
+        const lastRevise = await ctx.service.ledgerRevise.getLastestRevise(ctx.tender.id);
+        const includeChange = !lastRevise || lastRevise.status === audit.revise.status.checked;
+        const ledgerCondition = { where: { tender_id: ctx.tender.id }, orders: [['ledger_id', 'asc']] };
+        const posCondition = { where: { tid: ctx.tender.id }, orders: [['porder', 'asc'], ['id', 'asc']] };
+        const bills = await ctx.service.ledger.getAllDataByCondition(ledgerCondition, transaction);
+        const positions = await ctx.service.pos.getAllDataByCondition(posCondition, transaction);
+        if (includeChange) {
+            bills.push(...await ctx.service.changeLedger.getAllDataByCondition(ledgerCondition, transaction));
+            positions.push(...await ctx.service.changePos.getAllDataByCondition(posCondition, transaction));
+        }
+        return this.buildLedgerContext(bills, positions).ledgerTargets;
+    }
+
+    buildLedgerContext(bills, positions) {
+        const ctx = this.ctx;
+        const gather = new GclGather(ctx);
+        gather.billsTree.loadDatas(bills.slice().sort((a, b) => (Number(a.order) || 0) - (Number(b.order) || 0) || String(a.id).localeCompare(String(b.id))));
+        const posByLedger = new Map();
+        for (const pos of positions.slice().sort((a, b) => (Number(a.porder) || 0) - (Number(b.porder) || 0) || String(a.id).localeCompare(String(b.id)))) {
+            const key = String(pos.lid);
+            if (!posByLedger.has(key)) posByLedger.set(key, []);
+            posByLedger.get(key).push(pos);
+        }
+        const groups = new Map();
+        const targets = [];
+        for (const node of gather.billsTree.nodes) {
+            if (!node.b_code || node.children.length) continue;
+            const groupKey = JSON.stringify([node.b_code, node.name || '', node.unit || '', Number(node.unit_price) || 0]);
+            if (!groups.has(groupKey)) groups.set(groupKey, node.id);
+            let section = node;
+            while (section.b_code && gather.billsTree.getParent(section)) section = gather.billsTree.getParent(section);
+            const peg = gather.getPegNode(section);
+            const details = posByLedger.get(String(node.id)) || [node];
+            for (const detail of details) {
+                const mxId = detail === node ? '' : String(detail.id);
+                const quantity = Number(detail.quantity) || 0;
+                targets.push({
+                    id: JSON.stringify([String(node.id), mxId]),
+                    source: 'ledger',
+                    lid: groups.get(groupKey),
+                    gcl_id: node.id,
+                    mx_id: mxId,
+                    code: node.b_code,
+                    name: node.name || '',
+                    unit: node.unit || '',
+                    unit_price: node.unit_price === undefined ? null : node.unit_price,
+                    oamount: quantity,
+                    oamount2: quantity,
+                    camount: 0,
+                    bwmx: detail.name !== node.name ? detail.name || section.name || '' : section.name || '',
+                    xmj_code: section.code || '',
+                    xmj_jldy: section.name || '',
+                    xmj_dwgc: gather.getDwgc(peg, section),
+                    xmj_fbgc: gather.getFbgc(peg, section),
+                    xmj_fxgc: gather.getFxgc(peg, section),
+                });
+            }
+        }
+        const ledgerVersion = crypto.createHash('sha256').update(JSON.stringify(targets)).digest('hex');
+        return { ledgerTargets: targets, ledgerVersion };
+    }
+
+    _hasLedgerReference(item) {
+        return !!item && [item.lid, item.gcl_id, item.mx_id].some(value => {
+            const id = value === null || value === undefined ? '' : String(value).trim();
+            return id !== '' && id !== '0';
+        });
+    }
+}
+
+module.exports = ChangeAiImport;

+ 183 - 9
app/public/css/main.css

@@ -2846,22 +2846,50 @@ animation:shake 1s .2s ease both;}
 @supports selector(::-webkit-scrollbar){
     .gy-stage-dialog .modal-body{scrollbar-width:auto;scrollbar-color:auto}
 }
-.gy-stage-dialog #gy-dialog-project{font-size:18px;font-weight:600;line-height:1.6;color:#edf3fa;margin:0 0 18px;overflow-wrap:anywhere}
+.gy-stage-dialog .modal-title{display:flex;align-items:baseline;flex-wrap:wrap;gap:6px 12px;flex:1;min-width:0}
+.gy-stage-dialog .gy-dialog-title-divider{color:#8297aa}
+.gy-stage-dialog #gy-dialog-project{font-size:16px;font-weight:400;line-height:1.6;color:#c4d4e5;overflow-wrap:anywhere}
 .gy-stage-dialog .gy-table thead th{text-align:center!important}
-.gy-stage-dialog .gy-table tbody td{text-align:left!important}
-.gy-stage-dialog .gy-table tbody td:nth-child(2){text-align:center!important}
+.gy-stage-dialog .gy-table tbody td{text-align:center!important}
+.gy-stage-dialog .gy-table thead th:first-child,.gy-stage-dialog .gy-table tbody td:first-child{text-align:left!important}
+.gy-stage-dialog .gy-stage-group .gy-table{table-layout:fixed}
+.gy-stage-dialog .gy-stage-group th:first-child{width:46%}
+.gy-stage-dialog .gy-stage-group th:nth-child(2){width:14%}
+.gy-stage-dialog .gy-stage-group th:nth-child(3){width:18%}
+.gy-stage-dialog .gy-stage-group th:last-child{width:22%}
+.gy-stage-dialog .gy-stage-group td:first-child,.gy-stage-dialog .gy-stage-group td:last-child{white-space:normal;overflow-wrap:anywhere}
+.gy-stage-dialog .close:focus:not(:focus-visible){outline:none;box-shadow:none}
+.gy-stage-dialog .gy-stage-group{--gy-stage-accent:#67b7ff;border:1px solid #52616e;border-radius:6px;overflow:auto}
+.gy-stage-dialog .gy-stage-group + .gy-stage-group{margin-top:24px}
+.gy-stage-dialog .gy-stage-group-1{--gy-stage-accent:#b5a2ff}
+.gy-stage-dialog .gy-stage-group-2{--gy-stage-accent:#63cfc6}
+.gy-stage-dialog .gy-stage-group-header{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:12px 16px;border-left:4px solid var(--gy-stage-accent);border-bottom:1px solid #52616e;background:#2c3d4c}
+.gy-stage-dialog .gy-stage-group-header h3{display:flex;align-items:center;flex-wrap:wrap;gap:10px;margin:0;color:var(--gy-stage-accent);font-size:17px;font-weight:600;line-height:1.5}
+.gy-stage-dialog .gy-stage-group-count{flex-shrink:0;padding:3px 10px;border-radius:12px;background:#435565;color:#edf3fa;font-size:13px;font-variant-numeric:tabular-nums}
+.gy-stage-dialog .gy-stage-state{display:inline-block;min-width:62px;padding:3px 8px;border-radius:4px;font-size:12px;white-space:nowrap}
+.gy-stage-dialog .gy-stage-state.gy-status-0{background:rgba(103,214,162,.12)}
+.gy-stage-dialog .gy-stage-state.gy-status-1{background:rgba(255,193,107,.12)}
+.gy-stage-dialog .gy-stage-state.gy-status-2{background:rgba(170,182,195,.12)}
+.gy-stage-dialog .gy-stage-group .gy-table tbody tr:last-child td{border-bottom:0}
 .gy-planned-page{display:grid;grid-template-columns:minmax(0,4fr) minmax(0,6fr);gap:12px;flex:1;min-height:804px}
 .gy-planned-detail{display:grid;grid-template-rows:minmax(210px,1fr) minmax(270px,1.2fr) minmax(max-content,1fr);gap:12px;min-width:0;min-height:0}
 .gy-detail-top{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,3fr);gap:12px;min-height:0}
 .gy-detail-text{font-size:14px;line-height:2;background:#303b47;padding:16px;overflow:auto;overflow-wrap:anywhere}
 .gy-detail-text strong{display:block;color:#84c4ff;margin-bottom:12px}
 .gy-list-heading{background:#3a536a;padding:12px;text-align:center;font-size:13px}
-.gy-folder-row{display:flex;align-items:center;gap:6px;padding:12px;color:#c4d4e5;background:#394550;border-bottom:1px solid #48515a}
-.gy-folder-toggle{display:inline-flex;align-items:center;justify-content:center;background:none;border:0;color:inherit;padding:0;width:18px;height:20px;flex-shrink:0}
-.gy-project-row{display:flex;align-items:flex-start;gap:10px;width:100%;border:0;border-bottom:1px solid #48515a;border-left:3px solid transparent;text-align:left;background:transparent;color:#a1cfff;padding:15px 12px 15px 24px;line-height:1.7;font-size:14px!important}
-.gy-project-row:hover{background:#3a4856}.gy-project-row.active{background:#334e68;border-left-color:#3698ff;color:#fff}.gy-tree-branch{color:#7893aa}
+.gy-screen .gy-folder-row{display:flex;align-items:center;gap:6px;min-height:40px;padding:5px 12px 5px calc(12px + var(--gy-depth) * 16px);border-left:2px solid transparent;color:#9eafc0;line-height:1.4}
+.gy-screen .gy-folder-row:hover{background:#37424c}
+.gy-screen .gy-folder-toggle{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;width:18px;height:28px;padding:0;border:0;background:none;color:#9eafc0;font-size:13px}
+.gy-screen .gy-folder-icon{width:14px;text-align:center;color:#8297aa}
+.gy-screen .gy-project-row{display:flex;align-items:center;gap:6px;width:100%;min-height:40px;padding:6px 12px 6px calc(36px + var(--gy-depth) * 16px);border:0;border-left:2px solid transparent;text-align:left;background:transparent;color:#c5d6e7;line-height:1.4;font-size:14px!important}
+.gy-screen .gy-folder-row,.gy-screen .gy-project-row{border-bottom:1px solid #48515a}
+.gy-screen .gy-project-row:hover{background:#374552}
+.gy-screen .gy-project-row.active{background:#354d63;border-left-color:#3698ff;color:#fff}
+.gy-screen .gy-project-marker{flex:0 0 14px;width:14px;text-align:center;color:#7893aa}
+.gy-screen .gy-project-row span:last-child,.gy-screen .gy-folder-row span{min-width:0;overflow-wrap:anywhere}
+.gy-screen .gy-folder-toggle:focus-visible,.gy-screen .gy-project-row:focus-visible{outline:2px solid #80bfff;outline-offset:-2px}
 .gy-detail-button{background:transparent;border:1px solid #56718b;border-radius:4px;color:#8ac8ff;padding:5px 10px;font-size:12px!important}
-.gy-stage-table{table-layout:fixed}.gy-stage-table td,.gy-stage-table th{text-align:center!important}.gy-stage-table button{border:0;background:none;color:inherit;padding:4px 12px;text-decoration:underline;text-underline-offset:4px}.gy-status-0{color:#67d6a2}.gy-status-1{color:#ffc16b}.gy-status-2{color:#73baff}
+.gy-stage-table{table-layout:fixed}.gy-stage-table td,.gy-stage-table th{text-align:center!important}.gy-stage-table button{border:0;background:none;color:inherit;padding:4px 12px;text-decoration:underline;text-underline-offset:4px}.gy-status-0{color:#67d6a2}.gy-status-1{color:#ffc16b}.gy-status-2{color:#aab6c3}
 .gy-stage-dialog .modal-dialog{width:90vw;max-width:900px}.gy-stage-dialog .modal-content{background:#343a40;color:#edf3fa;border:1px solid #627589;border-radius:6px}.gy-stage-dialog .modal-header{border-bottom-color:#48515a}.gy-stage-dialog .modal-body{max-height:70vh;overflow:auto}.gy-stage-dialog .close{color:#edf3fa;text-shadow:none}.gy-stage-dialog p{color:#aab6c3}.gy-stage-dialog table{min-width:600px}.gy-empty{padding:24px;text-align:center;color:#aab6c3}
 @media(max-width:1000px){.gy-planned-page{grid-template-columns:1fr;flex:none}.gy-project-list{height:350px}.gy-planned-detail{grid-template-rows:auto 300px auto}.gy-detail-top{min-height:250px}}
 @media(max-width:650px){.gy-detail-top{grid-template-columns:1fr}.gy-detail-text{max-height:240px}}
@@ -2886,7 +2914,8 @@ button.gy-stat:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
 .gy-preview-table tbody tr:nth-child(even):not(.gy-year)>td:first-child{background:#38414a}
 .gy-screen::-webkit-scrollbar-thumb:horizontal,.gy-screen *::-webkit-scrollbar-thumb:horizontal{background:#626e7a}
 @supports selector(::-webkit-scrollbar){
-    .gy-screen,.gy-screen *{scrollbar-width:auto;scrollbar-color:auto}
+    /* Keep the custom scrollbar width when its thumb becomes visible. */
+    .gy-screen,.gy-screen *,.gy-screen.gy-scrolling,.gy-screen .gy-scrolling{scrollbar-width:auto;scrollbar-color:auto}
 }
 #gy-preview::-webkit-scrollbar:horizontal{height:10px}
 #gy-preview::-webkit-scrollbar-thumb:horizontal{background:#718393;border:2px solid #343a40;border-radius:6px}
@@ -3251,6 +3280,123 @@ button.gy-stat:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
 .safe-inspection-need-pending .safe-inspection-flow {
     display: none;
 }
+/* 工程变更 AI 导入静态预览,共用于申请、方案、变更令。 */
+.change-ai-preview .modal-dialog { width: calc(100% - 48px); max-width: 1440px; margin: 24px auto; }
+.change-ai-preview .modal-content { display: flex; flex-direction: column; height: calc(100vh - 48px); max-height: 900px; overflow: hidden; }
+.change-ai-preview .modal-header, .change-ai-preview .change-ai-steps, .change-ai-preview .modal-footer { flex-shrink: 0; }
+.change-ai-preview .modal-body { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; overflow: hidden; }
+.change-ai-preview [data-ai-message] { flex-shrink: 0; max-height: 120px; overflow: auto; white-space: pre-wrap; }
+.change-ai-preview [data-ai-panel] { flex: 1 1 auto; min-height: 0; overflow: auto; overscroll-behavior: contain; }
+.change-ai-preview [data-ai-panel="2"] { overflow: hidden; }
+.change-ai-preview [hidden] { display: none !important; }
+.change-ai-preview .change-ai-steps { display: flex; justify-content: center; align-items: center; gap: 14px; padding: 14px; background: #f6f8fa; border-bottom: 1px solid #dee2e6; }
+.change-ai-preview .change-ai-upload { display: flex; flex-direction: column; align-items: center; gap: 8px; width: 100%; border: 2px dashed #007bff; border-radius: 8px; background: #fff; color: #007bff; text-align: center; padding: 24px; cursor: pointer; transition: background-color .15s, border-color .15s; }
+.change-ai-preview .change-ai-upload:hover, .change-ai-preview .change-ai-upload:focus-visible { border-color: #1677ff; background: #eef5ff; }
+.change-ai-preview .change-ai-upload.is-dragging { border-color: #1677ff; background: #e5f0ff; }
+.change-ai-preview .change-ai-upload-icon { color: #6c757d; font-size: 28px; }
+.change-ai-preview .change-ai-upload strong { font-size: 14px; font-weight: 400; }
+.change-ai-preview .change-ai-existing-list { max-height: 160px; overflow-y: auto; border: 1px solid #eee; border-radius: 6px; padding: 4px 8px; }
+.change-ai-preview .change-ai-existing-item { display: flex; align-items: center; gap: 8px; padding: 8px; margin: 0; border-bottom: 1px solid #f5f5f5; cursor: pointer; font-size: 14px; }
+.change-ai-preview .change-ai-existing-item:last-child { border-bottom: none; }
+.change-ai-preview .change-ai-existing-item:hover, .change-ai-preview .change-ai-existing-item.is-selected { background: #f3f8ff; }
+.change-ai-preview .change-ai-existing-item input { margin: 0; flex-shrink: 0; }
+.change-ai-preview .change-ai-existing-item span { min-width: 0; overflow-wrap: anywhere; }
+.change-ai-preview .change-ai-attachment-types { display: flex; flex-wrap: wrap; gap: 12px 24px; }
+.change-ai-preview .change-ai-attachment-types label { display: inline-flex; align-items: center; gap: 6px; margin: 0; cursor: pointer; }
+.change-ai-preview .change-ai-attachment-types input { margin: 0; }
+.change-ai-preview .change-ai-hover-preview { position: fixed; z-index: 2; width: 320px; height: 240px; max-width: calc(100vw - 24px); max-height: calc(100vh - 24px); padding: 8px; border: 1px solid #dee2e6; border-radius: 6px; background: #fff; box-shadow: 0 4px 18px rgba(0, 0, 0, .18); pointer-events: none; }
+.change-ai-preview .change-ai-hover-preview img { width: 100%; height: 100%; object-fit: contain; }
+.change-ai-preview .change-ai-hover-preview span { display: flex; align-items: center; justify-content: center; height: 100%; color: #6c757d; font-size: 13px; }
+.change-ai-preview .change-ai-upload-subtitle { color: #64748b; }
+.change-ai-preview .change-ai-upload-formats { color: #8290a4; font-size: 12px; }
+.change-ai-preview .change-ai-drop-tip { display: none; color: #1677ff; font-weight: 600; }
+.change-ai-preview .is-dragging .change-ai-drop-tip { display: block; }
+.change-ai-preview .is-dragging .change-ai-upload-subtitle { display: none; }
+.change-ai-preview .change-ai-upload-feedback { color: #526579; }
+.change-ai-preview .change-ai-scope { margin-bottom: 20px; padding: 12px 16px; border: 1px solid #e3e8ef; border-radius: 6px; }
+.change-ai-preview .change-ai-scope legend { width: auto; padding: 0 6px; font-size: 14px; font-weight: 600; }
+.change-ai-preview .change-ai-scope label { display: inline-flex; align-items: center; gap: 6px; margin-right: 20px; vertical-align: middle; line-height: 24px; cursor: pointer; }
+.change-ai-preview .change-ai-scope input[type="radio"] { flex: 0 0 auto; width: 16px; height: 16px; margin: 0; }
+.change-ai-preview .change-ai-upload-feedback:empty { display: none; }
+.change-ai-preview .change-ai-upload-heading { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
+.change-ai-preview .change-ai-upload-heading h6 { margin: 0; }
+.change-ai-preview .change-ai-upload-notice { margin-top: 22px; padding: 16px 20px; border: 1px solid #e3e8ef; border-radius: 6px; background: #f8fafc; color: #526579; font-size: 13px; line-height: 1.8; }
+.change-ai-preview .change-ai-upload-notice h6 { color: #334155; font-size: 14px; margin-bottom: 8px; }
+.change-ai-preview .change-ai-upload-notice ul { margin: 0; padding-left: 20px; }
+.change-ai-preview .change-ai-upload-notice li + li { margin-top: 4px; }
+.change-ai-preview .change-ai-upload-note { margin-top: 8px; }
+.change-ai-preview .change-ai-upload-demo { margin-top: 12px; padding-top: 12px; border-top: 1px solid #e3e8ef; }
+.change-ai-preview .change-ai-files { margin-top: 14px; }
+.change-ai-preview .change-ai-file { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid #e5e8ec; }
+.change-ai-preview .change-ai-file img { width: 64px; height: 64px; object-fit: contain; border: 1px solid #dee2e6; }
+.change-ai-preview .change-ai-file span { flex: 1; min-width: 0; overflow-wrap: anywhere; }
+.change-ai-preview .change-ai-compare { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); gap: 20px; height: 100%; min-height: 0; }
+.change-ai-preview .change-ai-source { display: flex; flex-direction: column; min-width: 0; min-height: 0; padding: 12px; background: #f6f8fa; overflow: hidden; }
+.change-ai-preview .change-ai-result { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
+.change-ai-preview .change-ai-source > .d-flex, .change-ai-preview .change-ai-result > .d-flex { flex-shrink: 0; }
+.change-ai-preview [data-ai-info], .change-ai-preview [data-ai-bills] { flex: 1 1 auto; min-height: 0; overflow: auto; overscroll-behavior: contain; padding-right: 8px; }
+.change-ai-preview .change-ai-image-toolbar { display: flex; flex-shrink: 0; gap: 6px; margin-bottom: 10px; }
+.change-ai-preview .change-ai-image-toolbar select { flex: 1; min-width: 0; }
+.change-ai-preview .change-ai-image-toolbar button { flex-shrink: 0; }
+.change-ai-preview .change-ai-image-viewport { flex: 1 1 auto; min-height: 0; overflow: auto; overscroll-behavior: contain; background: #e9edf2; border: 1px solid #d8dfe7; }
+.change-ai-preview .change-ai-image-viewport img { display: block; max-width: none; height: auto; }
+.change-ai-preview .change-ai-image-viewport:has(img:not([hidden])) { cursor: grab; touch-action: none; user-select: none; }
+.change-ai-preview .change-ai-image-viewport.is-panning { cursor: grabbing; }
+.change-ai-preview .change-ai-no-image { padding: 90px 15px; text-align: center; color: #697586; }
+.change-ai-preview .change-ai-info-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 14px; }
+.change-ai-preview .change-ai-info-grid label { margin-bottom: 0; }
+.change-ai-preview .change-ai-info-review { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; padding-bottom: 12px; border-bottom: 1px solid #dee2e6; }
+.change-ai-preview .change-ai-info-review button { flex-shrink: 0; }
+.change-ai-preview .change-ai-info-review { flex-wrap: wrap; }
+.change-ai-preview .change-ai-info-actions { display: flex; flex-wrap: wrap; gap: 8px; }
+.change-ai-preview .change-ai-info-field { min-width: 0; padding: 10px; border: 1px solid #e3e8ef; border-radius: 5px; }
+.change-ai-preview .change-ai-info-field > label { display: block; margin-bottom: 0; }
+.change-ai-preview .change-ai-info-field.is-changed { border-color: #efcf8e; background: #fffcf4; }
+.change-ai-preview .change-ai-original { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-top: 6px; color: #687586; font-size: 12px; }
+.change-ai-preview .change-ai-original span { min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
+.change-ai-preview .change-ai-original button { flex-shrink: 0; padding: 0; font-size: 12px; }
+.change-ai-preview [data-ai-field-state] { color: #687586; }
+.change-ai-preview .is-changed [data-ai-field-state] { color: #986000; }
+.change-ai-preview [data-ai-info] > .change-ai-info-field { margin-top: 12px; }
+.change-ai-preview .change-ai-row { padding: 14px 0; border-bottom: 1px solid #dee2e6; }
+.change-ai-preview .change-ai-row:first-child { padding-top: 0; }
+.change-ai-preview .change-ai-row-tools { display: flex; flex-direction: column; gap: 8px; margin: 12px 0; }
+.change-ai-preview .change-ai-bill-table { overflow-x: auto; }
+.change-ai-preview .change-ai-bill-table table { width: 100%; table-layout: fixed; font-size: 12px; }
+.change-ai-preview .change-ai-bill-table th, .change-ai-preview .change-ai-bill-table td { padding: 7px 5px; border-bottom: 1px solid #edf0f4; overflow-wrap: anywhere; vertical-align: middle; }
+.change-ai-preview .change-ai-bill-table thead { background: #f6f8fa; color: #687586; }
+.change-ai-preview .change-ai-bill-table th:first-child { width: 20%; }
+.change-ai-preview .change-ai-bill-table th:last-child { width: 34%; }
+.change-ai-preview .change-ai-bill-table tbody th { font-weight: normal; }
+.change-ai-preview .change-ai-row-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
+.change-ai-preview .change-ai-row-heading > label { flex-shrink: 0; cursor: pointer; }
+.change-ai-preview .change-ai-row-mode { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
+.change-ai-preview .change-ai-row-mode .btn { padding: 0; font-size: 12px; }
+.change-ai-preview .change-ai-row-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 10px 0; }
+.change-ai-preview .change-ai-row-fields label { margin: 0; font-size: 12px; }
+.change-ai-preview .change-ai-summary { display: grid; grid-template-columns: 110px minmax(0, 1fr); gap: 14px; padding: 20px; background: #f8fafc; }
+.change-ai-preview .change-ai-summary dd { margin: 0; overflow-wrap: anywhere; }
+.change-ai-preview .change-ai-confirm-section { margin-bottom: 20px; padding: 16px; border: 1px solid #e3e8ef; border-radius: 6px; }
+.change-ai-preview .change-ai-confirm-table { overflow-x: auto; }
+.change-ai-preview .change-ai-confirm-table table { width: 100%; min-width: 540px; font-size: 13px; }
+.change-ai-preview .change-ai-confirm-table th, .change-ai-preview .change-ai-confirm-table td { padding: 10px; border-bottom: 1px solid #e3e8ef; vertical-align: top; white-space: pre-wrap; overflow-wrap: anywhere; max-width: 380px; }
+.change-ai-preview .change-ai-confirm-table thead { background: #f6f8fa; }
+.change-ai-preview .change-ai-confirm-section li { overflow-wrap: anywhere; }
+@media (max-width: 767px) {
+    .change-ai-preview .modal-dialog { width: calc(100% - 16px); margin: 8px auto; }
+    .change-ai-preview .modal-content { height: calc(100vh - 16px); }
+    .change-ai-preview .change-ai-compare { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 0.8fr) minmax(0, 1.2fr); gap: 12px; }
+    .change-ai-preview .change-ai-steps { gap: 5px; padding: 10px 5px; }
+    .change-ai-preview .change-ai-file { flex-wrap: wrap; }
+    .change-ai-preview .change-ai-row-fields, .change-ai-preview .change-ai-info-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+@supports (height: 100dvh) {
+    .change-ai-preview .modal-content { height: calc(100dvh - 48px); }
+    @media (max-width: 767px) {
+        .change-ai-preview .modal-content { height: calc(100dvh - 16px); }
+    }
+}
+
 .safe-inspection-flow .flow-picker > .dropdown-menu {
     width: 220px;
 }
@@ -3647,3 +3793,31 @@ button.gy-stat:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
     white-space: normal;
     overflow-wrap: break-word;
 }
+
+#sel-batch .project-member-filters .select2-container {
+    font-size: .875rem;
+}
+#sel-batch .project-member-filters .select2-selection--single {
+    position: relative;
+    display: flex;
+    align-items: center;
+    height: calc(1.5em + .5rem + 2px);
+    border-color: #ced4da;
+    border-radius: .2rem;
+}
+#sel-batch .project-member-filters .select2-selection__rendered {
+    width: 100%;
+    padding-right: 44px;
+    line-height: 1.5;
+}
+#sel-batch .project-member-filters .select2-selection__clear {
+    position: absolute;
+    top: 0;
+    right: 20px;
+    height: 100%;
+    margin-right: 0;
+}
+#sel-batch .project-member-filters .select2-selection__arrow {
+    top: 0;
+    height: 100%;
+}

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 801 - 0
app/public/js/change_ai_import_modal.js


+ 1 - 0
app/public/js/change_apply_information.js

@@ -59,6 +59,7 @@ $(document).ready(() => {
         }
     })
     function handleFileList(files = []) {
+        fileList.splice(0, fileList.length, ...files.slice());
         $('#file-content').empty();
         // const { uncheck, checkNo } = auditConst.status
         const newFiles = files.map(file => {

+ 1 - 0
app/public/js/change_information.js

@@ -5621,6 +5621,7 @@ $(document).ready(() => {
         postData(preUrl + '/defaultBills', {from: 'revise'}, function (result) {
             ledgerList = result.bills;
             posList = result.pos;
+            $('#change-ai-model').data('ledger-context', result.aiLedger);
             // if (!readOnly) {
             console.time('hello');
             gclGatherModel.loadLedgerData(result.bills);

+ 1 - 0
app/public/js/change_information_set.js

@@ -963,6 +963,7 @@ $(document).ready(() => {
         gclGatherModel.loadPosData(result.pos);
         ledgerList = result.bills;
         posList = result.pos;
+        $('#change-ai-model').data('ledger-context', result.aiLedger);
 
         gclGatherData = gclGatherModel.gatherGclData();
         gclGatherData = _.filter(gclGatherData, function (item) {

+ 1 - 0
app/public/js/change_plan_information.js

@@ -53,6 +53,7 @@ $(document).ready(() => {
         }
     })
     function handleFileList(files = []) {
+        fileList.splice(0, fileList.length, ...files.slice());
         $('#file-content').empty();
         // const { uncheck, checkNo } = auditConst.status
         const newFiles = files.map(file => {

+ 66 - 2
app/public/js/contract_detail.js

@@ -640,12 +640,13 @@ $(document).ready(function() {
                 return {...file, showDel}
             })
             newFiles.forEach((file, idx) => {
-                filesHtml += `<tr class="text-center">
-                                        <td>${idx + 1}</td><td class="text-left"><a href="${file.filepath}" target="_blank">${file.filename}</a></td><td>${file.username}</td><td>${moment(file.upload_time).format('YYYY-MM-DD HH:mm:ss')}</td>
+                filesHtml += `<tr class="text-center" data-file-id="${file.id}">
+                                        <td>${idx + 1}</td><td class="text-left contract-file-name"><a href="${file.filepath}" target="_blank">${_.escape(file.filename)}</a></td><td>${file.username}</td><td>${moment(file.upload_time).format('YYYY-MM-DD HH:mm:ss')}</td>
                                         <td>
                                             <div class="btn-group-table">
                                                 ${file.viewpath ? `<a href="${file.viewpath}" target="_blank" class="mr-1"><i class="fa fa-eye fa-fw"></i></a>` : ''}
                                                 <a href="${thisUrl}/${contractConst.typeMap[contract_type]}/${cid}${cpid ? '/pay/' + cpid : ''}/file/${file.id}/download" class="mr-1"><i class="fa fa-download fa-fw"></i></a>
+                                                ${file.showDel ? `<a href="javascript: void(0);" class="text-primary file-rename mr-1" data-id="${file.id}" title="重命名"><i class="fa fa-pencil fa-fw"></i></a>` : ''}
                                                 ${file.showDel ? `<a href="javascript: void(0);" class="text-danger file-del mr-1" data-id="${file.id}"><i class="fa fa-trash-o fa-fw text-danger"></i></a>` : ''}
                                             </div>
                                         </td>
@@ -5035,6 +5036,69 @@ $(document).ready(function() {
             );
         }
     };
+
+    $('body').on('click', '#htfile .file-rename, #cons-pay-file .file-rename, #cons-supplement-file .file-rename', function () {
+        const row = $(this).closest('tr');
+        const cell = row.find('.contract-file-name');
+        if (cell.find('input').length) return;
+        cell.data('filenameHtml', cell.html());
+        const filename = cell.find('a').text();
+        cell.empty().append(
+            $('<div class="d-flex align-items-center"></div>').append(
+                $('<input type="text" class="form-control form-control-sm form-control-width" maxlength="255">').val(filename),
+                $('<div class="btn-group-table ml-2"></div>').append(
+                    '<a href="javascript: void(0);" class="mr-1 file-rename-ok" title="保存"><i class="fa fa-check fa-fw"></i></a>',
+                    '<a href="javascript: void(0);" class="mr-1 file-rename-cancel" title="取消"><i class="fa fa-remove fa-fw"></i></a>'
+                )
+            )
+        );
+        cell.find('input').focus().select();
+    });
+    $('body').on('click', '#htfile .file-rename-cancel, #cons-pay-file .file-rename-cancel, #cons-supplement-file .file-rename-cancel', function () {
+        const cell = $(this).closest('.contract-file-name');
+        if (cell.data('saving')) return;
+        cell.html(cell.data('filenameHtml'));
+    });
+    $('body').on('click', '#htfile .file-rename-ok, #cons-pay-file .file-rename-ok, #cons-supplement-file .file-rename-ok', function () {
+        const row = $(this).closest('tr');
+        const cell = row.find('.contract-file-name');
+        if (cell.data('saving')) return;
+        const node = SpreadJsObj.getSelectObject(contractSheet);
+        if (!node || !node.c_code) return;
+        const payModal = row.closest('#cons-pay-file');
+        const supplementModal = row.closest('#cons-supplement-file');
+        const cpid = payModal.length ? Number(payModal.find('input[name="cpid"]').val()) : null;
+        const csid = supplementModal.length ? Number(supplementModal.find('input[name="csid"]').val()) : null;
+        const owner = cpid ? _.find(contractPays, { id: cpid }) :
+            (csid ? _.find(contractSupplements, { id: csid }) : node);
+        if (!owner) return;
+        const filename = cell.find('input').val().trim();
+        if (!filename) {
+            toastr.error('文件名称不能为空');
+            return;
+        }
+        cell.data('saving', true).find('input').prop('disabled', true);
+        const url = `${thisUrl}/${contractConst.typeMap[contract_type]}/${node.id}${cpid ? '/pay/' + cpid : (csid ? '/supplement/' + csid : '')}/file/rename`;
+        postData(url, { id: Number(row.attr('data-file-id')), filename }, function (files) {
+            owner.files = files;
+            if (cpid) {
+                contractTreeSpreadObj.openContractPayFiles(owner);
+            } else if (csid) {
+                contractTreeSpreadObj.openContractSupplementFiles(owner);
+            } else if (SpreadJsObj.getSelectObject(contractSheet) === node) {
+                contractTreeSpreadObj.setContractFiles(files, node.id);
+            }
+            toastr.success('重命名成功');
+        }, function () {
+            cell.data('saving', false).find('input').prop('disabled', false).focus();
+        });
+    });
+    $('body').on('keydown', '#htfile .contract-file-name input, #cons-pay-file .contract-file-name input, #cons-supplement-file .contract-file-name input', function (event) {
+        if (event.key === 'Enter' || event.key === 'Escape') {
+            event.preventDefault();
+            $(this).closest('.contract-file-name').find(event.key === 'Enter' ? '.file-rename-ok' : '.file-rename-cancel').click();
+        }
+    });
 });
 /**
  * 校验文件大小、格式

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 39 - 0
app/public/js/project_screen_guangyuan.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 1 - 15
app/public/js/project_screen_guangyuan_building.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 1 - 15
app/public/js/project_screen_guangyuan_finished.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 2 - 17
app/public/js/project_screen_guangyuan_planned.js


+ 29 - 58
app/public/js/sp_setting_permission.js

@@ -2,58 +2,27 @@ $(document).ready(() => {
     autoFlashHeight();
     // 选择账号
     const refreshUnitUsersHtml = function () {
-        const keyword = $('#sb-keyword').val();
+        const keyword = $('#sb-keyword').val().trim();
         const html = [];
         const select = $('#sel-batch-unit').val();
-        const selectGroup = accountGroup.find(x => { return x.name === select; });
-        if (selectGroup) {
-            const filter = keyword ? selectGroup.groupList.filter(x => {
-                return x.role.indexOf(keyword) >= 0 || x.name.indexOf(keyword) >= 0;
-            }) : selectGroup.groupList;
-            for (const u of filter) {
-                html.push(`<tr class="text-center">`);
-                html.push(`<td><input type="checkbox" name="sel-batch-check" id="${u.id}" unit="${selectGroup.name}" ${(u.select ? 'checked' : '')} ${u.sp_exist ? 'disabled' : ''}></td>`);
-                html.push(`<td>${u.name}</td>`);
-                html.push(`<td>${u.role}</td>`);
-                html.push(`<td>${u.company}</td>`);
-                html.push('<tr>');
-            }
-        }
-        const noCompanyAccount = accountList.filter(x => {
-            if (!x.company) return true;
-            const unit = accountGroup.find(y => { return y.name === x.company; });
-            if (unit) return false;
-            return keyword ? x.role.indexOf(keyword) >= 0 || x.name.indexOf(keyword) >= 0 : true;
-        });
-        for (const u of noCompanyAccount) {
-            html.push(`<tr class="text-center table-secondary">`);
-            html.push(`<td><input type="checkbox" name="sel-batch-check" id="${u.id}" unit="${selectGroup.name}" ${(u.select ? 'checked' : '')} disabled></td>`);
-            html.push(`<td>${u.name}</td>`);
-            html.push(`<td>${u.role}</td>`);
-            html.push(`<td>${u.company}</td>`);
-            html.push('<tr>');
-        }
-        const otherCompanyAccount = accountList.filter(x => {
-            if (!keyword) return false;
-            if (!x.company || x.company === selectGroup.name) return false;
-            const unit = accountGroup.find(y => { return y.name === x.company; });
-            if (!unit) return false;
-            return x.role.indexOf(keyword) >= 0 || x.name.indexOf(keyword) >= 0;
+        const users = accountList.filter(user => {
+            if (select && user.company !== select) return false;
+            return !keyword || (user.role || '').indexOf(keyword) >= 0 || (user.name || '').indexOf(keyword) >= 0;
         });
-        for (const u of otherCompanyAccount) {
-            html.push(`<tr class="text-center table-secondary">`);
-            html.push(`<td><input type="checkbox" name="sel-batch-check" id="${u.id}" unit="${selectGroup.name}" ${(u.select ? 'checked' : '')} disabled></td>`);
+        const selectableUsers = users.filter(user => !user.sp_exist);
+        for (const u of users) {
+            html.push('<tr class="text-center">');
+            html.push(`<td><input type="checkbox" name="sel-batch-check" id="${u.id}" ${(u.select ? 'checked' : '')} ${u.sp_exist ? 'disabled' : ''}></td>`);
             html.push(`<td>${u.name}</td>`);
-            html.push(`<td>${u.role}</td>`);
-            html.push(`<td>${u.company}</td>`);
-            html.push('<tr>');
+            html.push(`<td>${u.role || ''}</td>`);
+            html.push(`<td>${u.company || ''}</td>`);
+            html.push('</tr>');
         }
         $('#sel-batch-users').html(html.join(''));
+        $('#sel-batch-all').prop('checked', selectableUsers.length > 0 && selectableUsers.every(user => user.select));
     };
     $('#sel-batch').on('show.bs.modal', function() {
-        accountGroup.forEach(ag => {
-            ag.groupList.forEach(u => { u.select = false; });
-        });
+        accountList.forEach(user => { user.select = false; });
         refreshUnitUsersHtml();
     });
     $('#sel-batch-unit').change(function() {
@@ -66,26 +35,21 @@ $(document).ready(() => {
         refreshUnitUsersHtml();
     });
     $('body').on('click', '[name=sel-batch-check]', function() {
-        const select = $('#sel-batch-unit').val();
-        const selectGroup = accountGroup.find(x => { return x.name === select; });
-        const user = selectGroup.groupList.find(x => { return x.id === parseInt(this.getAttribute('id')); });
+        const user = accountList.find(user => user.id === parseInt(this.getAttribute('id')));
+        if (!user || user.sp_exist) return;
         user.select = this.checked;
+        const checks = $('[name=sel-batch-check]:enabled');
+        $('#sel-batch-all').prop('checked', checks.length > 0 && checks.filter(':checked').length === checks.length);
     });
     $('#sel-batch-all').change(function() {
-        const select = $('#sel-batch-unit').val();
-        const selectGroup = accountGroup.find(x => { return x.name === select; });
-        selectGroup.groupList.forEach(x => {
-            x.select = x.sp_exist ? false : this.checked;
-        });
+        for (const check of $('[name=sel-batch-check]:enabled')) {
+            const user = accountList.find(user => user.id === parseInt(check.getAttribute('id')));
+            if (user) user.select = this.checked;
+        }
         refreshUnitUsersHtml();
     });
     $('#sel-batch-ok').click(() => {
-        const select = [];
-        for (const ag of accountGroup) {
-            for (const u of ag.groupList) {
-                if (u.select && !u.sp_exist) select.push(u.id);
-            }
-        }
+        const select = accountList.filter(user => user.select && !user.sp_exist).map(user => user.id);
         postData(`/sp/${spid}/setting/user/permission/update`, { add: select }, function() {
             window.location.reload();
         });
@@ -294,4 +258,11 @@ $(document).ready(() => {
             $('#copy-user-batch').modal('hide');
         });
     });
+    $('#sel-batch-unit').select2({
+        placeholder: '全部单位',
+        allowClear: true,
+        width: '300px',
+        dropdownParent: $('#sel-batch'),
+        minimumResultsForSearch: 0,
+    });
 });

+ 10 - 0
app/router.js

@@ -349,9 +349,11 @@ module.exports = app => {
     app.post('/sp/:id/contract/tender/:tid/detail/:type/update', sessionAuth, subProjectCheck, contractCheck, 'contractController.updateBills');
     app.post('/sp/:id/contract/tender/:tid/detail/:type/:cid/file/upload', sessionAuth, subProjectCheck, contractCheck, 'contractController.uploadFile');
     app.post('/sp/:id/contract/tender/:tid/detail/:type/:cid/file/delete', sessionAuth, subProjectCheck, contractCheck, 'contractController.deleteFile');
+    app.post('/sp/:id/contract/tender/:tid/detail/:type/:cid/file/rename', sessionAuth, subProjectCheck, contractCheck, 'contractController.renameFile');
     app.get('/sp/:id/contract/tender/:tid/detail/:type/:cid/file/:fid/download', sessionAuth, subProjectCheck, contractCheck, 'contractController.downloadFile');
     app.post('/sp/:id/contract/tender/:tid/detail/:type/:cid/pay/:cpid/file/upload', sessionAuth, subProjectCheck, contractCheck, 'contractController.uploadFile');
     app.post('/sp/:id/contract/tender/:tid/detail/:type/:cid/pay/:cpid/file/delete', sessionAuth, subProjectCheck, contractCheck, 'contractController.deleteFile');
+    app.post('/sp/:id/contract/tender/:tid/detail/:type/:cid/pay/:cpid/file/rename', sessionAuth, subProjectCheck, contractCheck, 'contractController.renameFile');
     app.get('/sp/:id/contract/tender/:tid/detail/:type/:cid/pay/:cpid/file/:fid/download', sessionAuth, subProjectCheck, contractCheck, 'contractController.downloadFile');
     app.post('/sp/:id/contract/tender/:tid/col-set', sessionAuth, subProjectCheck, contractCheck, 'contractController.colSet');
     app.post('/sp/:id/contract/tender/:tid/col-set/copy', sessionAuth, subProjectCheck, contractCheck, 'contractController.copyColSet');
@@ -360,6 +362,7 @@ module.exports = app => {
     app.post('/sp/:id/contract/tender/:tid/detail/:type/check', sessionAuth, subProjectCheck, contractCheck, 'contractController.checkAudit');
     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.post('/sp/:id/contract/tender/:tid/detail/:type/:cid/supplement/:csid/file/rename', sessionAuth, subProjectCheck, contractCheck, 'contractController.renameFile');
     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');
@@ -378,9 +381,11 @@ module.exports = app => {
     app.post('/sp/:id/contract/detail/:type/update', sessionAuth, subProjectCheck, contractCheck, 'contractController.updateBills');
     app.post('/sp/:id/contract/detail/:type/:cid/file/upload', sessionAuth, subProjectCheck, contractCheck, 'contractController.uploadFile');
     app.post('/sp/:id/contract/detail/:type/:cid/file/delete', sessionAuth, subProjectCheck, contractCheck, 'contractController.deleteFile');
+    app.post('/sp/:id/contract/detail/:type/:cid/file/rename', sessionAuth, subProjectCheck, contractCheck, 'contractController.renameFile');
     app.get('/sp/:id/contract/detail/:type/:cid/file/:fid/download', sessionAuth, subProjectCheck, contractCheck, 'contractController.downloadFile');
     app.post('/sp/:id/contract/detail/:type/:cid/pay/:cpid/file/upload', sessionAuth, subProjectCheck, contractCheck, 'contractController.uploadFile');
     app.post('/sp/:id/contract/detail/:type/:cid/pay/:cpid/file/delete', sessionAuth, subProjectCheck, contractCheck, 'contractController.deleteFile');
+    app.post('/sp/:id/contract/detail/:type/:cid/pay/:cpid/file/rename', sessionAuth, subProjectCheck, contractCheck, 'contractController.renameFile');
     app.get('/sp/:id/contract/detail/:type/:cid/pay/:cpid/file/:fid/download', sessionAuth, subProjectCheck, contractCheck, 'contractController.downloadFile');
     app.post('/sp/:id/contract/detail/check', sessionAuth, subProjectCheck, contractCheck, 'contractController.checkAudit');
     app.post('/sp/:id/contract/detail/:type/check', sessionAuth, subProjectCheck, contractCheck, 'contractController.checkAudit');
@@ -392,6 +397,7 @@ module.exports = app => {
     app.post('/sp/:id/contract/setting/update', sessionAuth, subProjectCheck, 'contractController.updateSetting');
     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.post('/sp/:id/contract/detail/:type/:cid/supplement/:csid/file/rename', sessionAuth, subProjectCheck, contractCheck, 'contractController.renameFile');
     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');
@@ -1113,6 +1119,10 @@ module.exports = app => {
     app.post('/tender/:id/change/newCode', sessionAuth, tenderCheck, subProjectCheck, 'changeController.newCode');
     app.post('/tender/:id/change/add', sessionAuth, tenderCheck, subProjectCheck, uncheckTenderCheck, tenderBuildCheck, 'changeController.add');
     app.post('/tender/:id/change/defaultBills', sessionAuth, tenderCheck, subProjectCheck, uncheckTenderCheck, 'changeController.defaultBills');
+    app.post('/tender/:id/change/ai/upload', sessionAuth, tenderCheck, subProjectCheck, uncheckTenderCheck, 'changeController.uploadAndAi');
+    app.post('/tender/:id/change/ai/context', sessionAuth, tenderCheck, subProjectCheck, uncheckTenderCheck, 'changeController.aiImportContext');
+    app.post('/tender/:id/change/ai/save', sessionAuth, tenderCheck, subProjectCheck, uncheckTenderCheck, 'changeController.aiImportSave');
+    app.post('/tender/:id/change/ai/existing', sessionAuth, tenderCheck, subProjectCheck, uncheckTenderCheck, 'changeController.aiImportExisting');
     app.post('/tender/:id/change/:cid/info/file/upload', sessionAuth, 'changeController.uploadFile');
     app.get('/change/download/file/:id', sessionAuth, 'changeController.downloadFile');
     app.post('/change/download/file/:id', sessionAuth, 'changeController.checkFile');

+ 13 - 3
app/service/project_screen.js

@@ -1,6 +1,7 @@
 'use strict';
 
 const projectScreenConst = require('../const/project_screen');
+const Ledger = require('../lib/ledger');
 
 module.exports = app => {
     class ProjectScreen extends app.BaseService {
@@ -151,7 +152,15 @@ module.exports = app => {
                 return calc.div(value, 10000, 12);
             };
             const result = [];
-            const selectedNodes = nodes.filter(n => !n.is_folder && (!options.id || n.id === options.id) && infos.some(info => info.id === n.id)).sort((a, b) => a.tree_order - b.tree_order);
+            // Follow the project list's tree order before filtering by status.
+            const projectTree = new Ledger.baseTree(ctx, { id: 'id', pid: 'tree_pid', level: 'tree_level', order: 'tree_order', rootId: '-1' });
+            projectTree.loadDatas(nodes);
+            const selectedNodes = projectTree.nodes.filter(n => !n.is_folder && (!options.id || n.id === options.id) && infos.some(info => info.id === n.id));
+            // Match the live budget totals displayed on the project info page.
+            const budgetIds = Array.from(new Set(selectedNodes.map(node => node.budget_id).filter(Boolean)));
+            const budgetTotals = (await Promise.all([ctx.service.budgetGu, ctx.service.budgetGai, ctx.service.budgetYu]
+                .map(service => service.getSumTpByBudgetIds(budgetIds))))
+                .map(rows => new Map(rows.map(row => [String(row.bid), row.total_price])));
             const allTenders = [];
             if (selectedNodes.length && (user.is_admin === 1 || (permission && permission.tender && permission.tender.indexOf('2') !== -1))) {
                 allTenders.push(...await ctx.service.tender.getAllDataByCondition({ where: { spid: selectedNodes.map(n => n.id) } }));
@@ -227,13 +236,14 @@ module.exports = app => {
                     dates: keys.map(key => row[key + '_date'] || ''), departments: keys.map(key => row[key + '_department'] || '')
                 }));
                 result.push({ id: node.id, name: node.name, status: info.proj_status, group: parent ? parent.name : '未分组',
+                    folders: projectTree.getAllParents(node).filter(folder => folder.is_folder).map(folder => ({ id: folder.id, name: folder.name })),
                     intro: info.proj_intro || '', date: info.jg_date || '', department: info.jg_department || '',
                     measured: hasMeasured ? toWan(measured, '元') : null,
                     tenders: tenderInvestments,
                     changeRows, stages,
                     progressText: latestEvent ? `${latestEvent.date},${latestEvent.department || ''}已完成「${latest.name}」${latestEvent.step}。` : '暂无阶段动态',
-                    amounts: ['lx', 'cb', 'sgt', 'pf'].map(prefix => toWan(info[prefix + '_tp'], info[prefix + '_tp_unit']))
-                        .concat([tenders.length ? toWan(contract, '元') : null, hasMeasured ? toWan(measured, '元') : null, finalAmount]) });
+                    amounts: budgetTotals.map(totals => toWan(totals.get(String(node.budget_id)), '元'))
+                        .concat([toWan(info.pf_tp, info.pf_tp_unit), tenders.length ? toWan(contract, '元') : null, hasMeasured ? toWan(measured, '元') : null, finalAmount]) });
             }
             return result;
         }

+ 111 - 0
app/view/change/ai_import_modal.ejs

@@ -0,0 +1,111 @@
+<div class="modal fade change-ai-preview" id="change-ai-model" data-tid="<%= tender.id %>" data-change-id="<%= change.cid || change.id %>" data-backdrop="static" data-keyboard="false" tabindex="-1" role="dialog" aria-labelledby="change-ai-model-title" aria-hidden="true">
+    <div class="change-ai-hover-preview" data-ai-hover-preview hidden aria-hidden="true"><img alt=""><span data-ai-hover-status>图片加载中…</span></div>
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <div><h5 class="modal-title" id="change-ai-model-title">AI 识别导入</h5><small class="text-muted">识别结果仅供参考,确认后才保存。</small></div>
+                <button type="button" class="close" data-dismiss="modal" aria-label="关闭"><span aria-hidden="true">&times;</span></button>
+            </div>
+            <div class="change-ai-steps" aria-label="导入步骤">
+                <button type="button" class="btn btn-sm" data-ai-step="1">1 选择图片</button><span>—</span>
+                <button type="button" class="btn btn-sm" data-ai-step="2">2 预览与编辑</button><span>—</span>
+                <button type="button" class="btn btn-sm" data-ai-step="3">3 确认保存</button>
+            </div>
+            <div class="modal-body">
+                <div class="alert alert-warning" data-ai-message role="status" hidden></div>
+                <section data-ai-panel="1">
+                    <div class="change-ai-upload-heading"><h6>选择识别图片</h6><span class="text-muted small">每次仅识别一张图片</span></div>
+                    <div class="mb-3" data-ai-existing-area hidden>
+                        <div class="ai-section-label">从已有图片附件中选择一张识别</div>
+                        <div class="change-ai-existing-list" data-ai-existing-list hidden></div>
+                    </div>
+                    <div class="ai-section-label" data-ai-upload-label hidden>或上传新图片</div>
+                    <button type="button" class="change-ai-upload" data-ai-drop aria-describedby="change-ai-upload-help">
+                        <span class="change-ai-upload-icon"><i class="fa fa-cloud-upload" aria-hidden="true"></i></span>
+                        <strong>拖拽图片到这里,或点击选择</strong>
+                        <span class="change-ai-upload-formats" id="change-ai-upload-help">JPG / JPEG / PNG / WebP · 单张不超过 50 MB · 暂不支持 PDF、Word</span>
+                        <span class="change-ai-drop-tip">松开鼠标,添加图片</span>
+                    </button>
+                    <input type="file" data-ai-files accept="image/jpeg,image/png,image/webp" hidden>
+                    <div class="change-ai-upload-feedback small mt-2" data-ai-upload-feedback role="status" aria-live="polite"></div>
+                    <div data-ai-selected-files hidden>
+                        <div class="change-ai-files" data-ai-file-list></div>
+                        <fieldset class="change-ai-scope mt-3 mb-0">
+                            <legend>选择识别范围</legend>
+                            <label><input type="radio" name="extract_scope" value="all" checked> 全部(基本信息和清单)</label>
+                            <label><input type="radio" name="extract_scope" value="main"> 仅基本信息(内容)</label>
+                            <label><input type="radio" name="extract_scope" value="items"> 仅清单明细</label>
+                            <div class="small text-muted" data-ai-scope-hint>不确定时选择“全部”。</div>
+                        </fieldset>
+                        <label class="mt-3 mb-0"><input type="checkbox" data-ai-attach checked> 确认保存时,将原图片同时添加为单据附件</label>
+                        <div class="change-ai-attachment-types mt-3" data-ai-attachment-types hidden role="group" aria-label="附件资料类型"></div>
+                    </div>
+                    <aside class="change-ai-upload-notice" aria-label="识别注意事项">
+                        <h6><i class="fa fa-info-circle" aria-hidden="true"></i> 识别注意事项</h6>
+                        <ul>
+                            <li>每次仅选择一张单据或清单图片;多页清单需分次处理,请保留表头、页码及完整行列。</li>
+                            <li>图片应清晰、方向正确、无遮挡。手写内容、模糊印章及跨页表格需重点核对。</li>
+                            <li>预览时对照原图核对编号、单位、单价和数量,特别留意增减数量的正负号、小数点及漏行、重复行。</li>
+                            <li>先匹配已添加清单,再匹配台账;编号、名称、单位、单价中已识别的字段须全部相符,均未匹配时新增空白清单。多个候选会用实际识别的原设计量辅助筛选,请核对部位后选择。</li>
+                        </ul>
+                        <div class="change-ai-upload-note">清单预览不计算金额;金额由系统按清单数量汇总后计算。</div>
+                        <div class="change-ai-upload-demo">识别期间请勿重复提交;关闭窗口将放弃尚未保存的识别结果。</div>
+                    </aside>
+                </section>
+                <section data-ai-panel="2" hidden>
+                    <div class="change-ai-compare">
+                        <div class="change-ai-source">
+                            <div class="d-flex align-items-center justify-content-between mb-2"><strong>识别原图</strong><span class="text-muted" data-ai-page></span></div>
+                            <div class="change-ai-image-toolbar">
+                                <select class="form-control form-control-sm" data-ai-image-select aria-label="当前图片"></select>
+                                <button type="button" class="btn btn-sm btn-outline-secondary" data-ai-zoom="in">放大</button>
+                                <button type="button" class="btn btn-sm btn-outline-secondary" data-ai-zoom="out">缩小</button>
+                                <button type="button" class="btn btn-sm btn-outline-secondary" data-ai-zoom="fit">适应</button>
+                            </div>
+                            <div class="small text-muted mb-2">滚轮缩放 · 按住拖动</div>
+                            <div class="change-ai-image-viewport" data-ai-viewport title="滚轮缩放,按住鼠标左键拖动图片">
+                                <div class="change-ai-no-image" data-ai-image-empty>尚未选择图片<br><small>返回第一步选择清单图片,即可左右对照。</small></div>
+                                <img data-ai-image alt="当前选择的单据或清单图片" draggable="false" hidden>
+                            </div>
+                        </div>
+                        <div class="change-ai-result">
+                            <div class="d-flex justify-content-between align-items-center mb-3 flex-wrap">
+                                <div class="btn-group" data-ai-tabs><button type="button" class="btn btn-sm" data-ai-tab="info">基本信息</button><button type="button" class="btn btn-sm" data-ai-tab="bills">清单明细</button></div>
+                                <h6 class="mb-0" data-ai-single-title hidden></h6>
+                            </div>
+                            <div data-ai-info></div>
+                            <div data-ai-bills hidden>
+                                <div data-ai-rows></div>
+                            </div>
+                        </div>
+                    </div>
+                </section>
+                <section data-ai-panel="3" hidden>
+                    <h6>确认本次导入内容</h6>
+                    <p class="small">识别范围:<span data-ai-summary-scope></span></p>
+                    <p class="small">基本信息:<span data-ai-summary-info></span></p>
+                    <dl class="change-ai-summary"><dt>场景</dt><dd data-ai-summary-scene></dd><dt>单据编号</dt><dd data-ai-summary-code></dd><dt>单据名称</dt><dd data-ai-summary-name></dd><dt>清单处理</dt><dd data-ai-summary-rows></dd><dt>图片附件</dt><dd data-ai-summary-files></dd></dl>
+                    <div class="change-ai-confirm-section" data-ai-confirm-section="main">
+                        <div class="change-ai-upload-heading"><h6>基本信息修改明细</h6><button type="button" class="btn btn-sm btn-link" data-ai-review="info">返回修改基本信息</button></div>
+                        <div data-ai-confirm-info></div>
+                    </div>
+                    <div class="change-ai-confirm-section" data-ai-confirm-section="items">
+                        <div class="change-ai-upload-heading"><h6>清单处理明细</h6><button type="button" class="btn btn-sm btn-link" data-ai-review="bills">返回修改清单</button></div>
+                        <div data-ai-confirm-bills></div>
+                    </div>
+                    <div class="change-ai-confirm-section" data-ai-confirm-section="files" hidden>
+                        <div class="change-ai-upload-heading"><h6>新增图片附件</h6><button type="button" class="btn btn-sm btn-link" data-ai-review="files">返回调整图片</button></div>
+                        <div data-ai-confirm-files></div>
+                    </div>
+                    <div class="alert alert-warning mb-0" data-ai-validation role="status"></div>
+                </section>
+            </div>
+            <div class="modal-footer">
+                <span class="small text-muted mr-auto" data-ai-status aria-live="polite"></span>
+                <button type="button" class="btn btn-sm btn-secondary" data-ai-back>关闭</button>
+                <button type="button" class="btn btn-sm btn-primary" data-ai-next><span data-ai-next-text>开始识别</span><span class="spinner-border spinner-border-sm ml-1" data-ai-next-spinner style="display:none;" aria-hidden="true"></span></button>
+            </div>
+        </div>
+    </div>
+</div>
+<script src="/public/js/change_ai_import_modal.js"></script>

+ 3 - 0
app/view/change/apply_information.ejs

@@ -23,6 +23,9 @@
                 <% } %>
             </div>
             <div class="ml-auto" id="sp-btn">
+                <% if (!change.readOnly) { %>
+                    <button type="button" class="btn btn-sm btn-outline-primary mr-2 d-none" data-toggle="modal" data-target="#change-ai-model" data-scene="apply">AI 识别导入</button>
+                <% } %>
                 <% if (ctx.change.cancancel) { %>
                     <a href="javascript: void(0);" data-toggle="modal" data-target="#sp-down-cancel" class="btn btn-danger btn-sm mr-2">撤回</a>
                 <% } %>

+ 4 - 3
app/view/change/apply_information_modal.ejs

@@ -346,7 +346,7 @@
                                                                 <div class="card-text p-2 py-3 row <%- ( i > 0 ? 'border-top' : '') %>">
                                                                     <div class="col-10">
                                                                         <span class="h6"><%- auditor.name %></span>
-                                                                        
+
                                                                                 <span class="text-muted ml-1">
                                                                                     <%- auditor.company %>
                                                                                     <% if (auditor.role && auditor.role.trim()) { %>
@@ -541,7 +541,7 @@
                                                                     <div class="card-text p-2 py-3 row <%- ( i > 0 ? 'border-top' : '') %>">
                                                                         <div class="col-10">
                                                                             <span class="h6"><%- auditor.name %></span>
-                                                                            
+
                                                                                 <span class="text-muted ml-1">
                                                                                     <%- auditor.company %>
                                                                                     <% if (auditor.role && auditor.role.trim()) { %>
@@ -740,7 +740,7 @@
                                                                     <div class="card-text p-2 py-3 row <%- ( i > 0 ? 'border-top' : '') %>">
                                                                         <div class="col">
                                                                             <span class="h6"><%- auditor.name %></span>
-                                                                            
+
                                                                                 <span class="text-muted ml-1">
                                                                                     <%- auditor.company %>
                                                                                     <% if (auditor.role && auditor.role.trim()) { %>
@@ -1586,3 +1586,4 @@
         });
     </script>
 <% } %>
+<% include ai_import_modal.ejs %>

+ 3 - 0
app/view/change/information.ejs

@@ -21,6 +21,9 @@
                 </div>
                 <% } %>
                 <div class="pull-right mr-3" id="sp-btn">
+                    <% if (!change.readOnly) { %>
+                        <button type="button" class="btn btn-sm btn-outline-primary mr-2 d-none" data-toggle="modal" data-target="#change-ai-model" data-scene="change">AI 识别导入</button>
+                    <% } %>
                     <% if (ctx.change.cancancel) { %>
                         <a href="javascript: void(0);" data-toggle="modal" data-target="#sp-down-cancel" class="btn btn-danger btn-sm mr-2">撤回</a>
                     <% } %>

+ 4 - 3
app/view/change/information_modal.ejs

@@ -357,7 +357,7 @@
                                                                 <div class="card-text p-2 py-3 row <%- ( i > 0 ? 'border-top' : '') %>">
                                                                     <div class="col-10">
                                                                         <span class="h6"><%- auditor.name %></span>
-                                                                        
+
                                                                                 <span class="text-muted ml-1">
                                                                                     <%- auditor.company %>
                                                                                     <% if (auditor.role && auditor.role.trim()) { %>
@@ -520,7 +520,7 @@
                                                                 <div class="card-text p-2 py-3 row <%- ( i > 0 ? 'border-top' : '') %>">
                                                                     <div class="col-10">
                                                                         <span class="h6"><%- auditor.name %></span>
-                                                                        
+
                                                                                 <span class="text-muted ml-1">
                                                                                     <%- auditor.company %>
                                                                                     <% if (auditor.role && auditor.role.trim()) { %>
@@ -695,7 +695,7 @@
                                                                 <div class="card-text p-2 py-3 row <%- ( i > 0 ? 'border-top' : '') %>">
                                                                     <div class="col-10">
                                                                         <span class="h6"><%- auditor.name %></span>
-                                                                        
+
                                                                                 <span class="text-muted ml-1">
                                                                                     <%- auditor.company %>
                                                                                     <% if (auditor.role && auditor.role.trim()) { %>
@@ -1641,3 +1641,4 @@
         });
     </script>
 <% } %>
+<% include ai_import_modal.ejs %>

+ 3 - 0
app/view/change/plan_information.ejs

@@ -23,6 +23,9 @@
                 <% } %>
             </div>
             <div class="ml-auto" id="sp-btn">
+                <% if (!change.readOnly) { %>
+                    <button type="button" class="btn btn-sm btn-outline-primary mr-2 d-none" data-toggle="modal" data-target="#change-ai-model" data-scene="plan">AI 识别导入</button>
+                <% } %>
                 <% if (ctx.change.cancancel) { %>
                     <a href="javascript: void(0);" data-toggle="modal" data-target="#sp-down-cancel" class="btn btn-danger btn-sm mr-2">撤回</a>
                 <% } %>

+ 1 - 0
app/view/change/plan_information_modal.ejs

@@ -1590,3 +1590,4 @@
         });
     </script>
 <% } %>
+<% include ai_import_modal.ejs %>

+ 1 - 2
app/view/project_screen/guangyuan_modal.ejs

@@ -2,11 +2,10 @@
     <div class="modal-dialog modal-lg" role="document">
         <div class="modal-content">
             <div class="modal-header">
-                <h5 id="gy-stage-dialog-title" class="modal-title">阶段进度详情</h5>
+                <h5 id="gy-stage-dialog-title" class="modal-title">阶段进度详情<span class="gy-dialog-title-divider" aria-hidden="true">·</span><span id="gy-dialog-project"></span></h5>
                 <button id="gy-close-details" class="close" type="button" data-dismiss="modal" aria-label="关闭阶段详情"><span aria-hidden="true">&times;</span></button>
             </div>
             <div class="modal-body">
-                <p id="gy-dialog-project"></p>
                 <div id="gy-dialog-content"></div>
             </div>
         </div>

+ 16 - 15
app/view/sp_setting/user_modal.ejs

@@ -1,3 +1,6 @@
+<link href="/public/css/bootstrap/select2.min.css" rel="stylesheet" />
+<link rel="stylesheet" href="/public/css/bootstrap/select2-bootstrap4.min.css">
+<script src="/public/js/bootstrap/select2.min.js"></script>
 <div class="modal fade" id="sel-batch" data-backdrop="static">
     <div class="modal-dialog modal-lg" role="document">
         <div class="modal-content">
@@ -5,18 +8,8 @@
                 <h5 class="modal-title">添加账号至项目成员</h5>
             </div>
             <div class="modal-body">
-                <div class="d-flex justify-content-between mb-2">
-                    <div class="col">
-                        <div class="form-inline">
-                            <label for="inputPassword2" class="">单位:</label>
-                            <select class="form-control form-control-sm" style="width:300px" id="sel-batch-unit">
-                                <% for (const g of accountGroup) { %>
-                                <option value="<%- g.name %>"><%- g.name %></option>
-                                <% } %>
-                            </select>
-                        </div>
-                    </div>
-                    <div class="col-5">
+                <div class="d-flex align-items-center justify-content-between mb-2 project-member-filters">
+                    <div class="col-5 pl-0">
                         <div class="input-group input-group-sm">
                             <input type="text" class="form-control" placeholder="姓名/职位 搜索" aria-label="姓名/职位 搜索" id="sb-keyword" aria-describedby="sb-search">
                             <div class="input-group-append">
@@ -24,6 +17,17 @@
                             </div>
                         </div>
                     </div>
+                    <div class="col pr-0">
+                        <div class="d-flex align-items-center justify-content-end">
+                            <label for="sel-batch-unit" class="mb-0 text-nowrap">单位:</label>
+                            <select class="form-control form-control-sm" style="width:300px" id="sel-batch-unit">
+                                <option value="">全部单位</option>
+                                <% for (const g of accountGroup) { %>
+                                <option value="<%- g.name %>"><%- g.name %></option>
+                                <% } %>
+                            </select>
+                        </div>
+                    </div>
                 </div>
                 <div class="modal-height-500 scroll-y">
                     <table class="table table-bordered">
@@ -31,9 +35,6 @@
                         <tbody id="sel-batch-users"></tbody>
                     </table>
                 </div>
-                <div class="mt-3">
-                    <label class="form-text alert alert-danger">整行灰底显示的账号,请选择左上角的“单位”与之一致,即可勾选。</label>
-                </div>
             </div>
             <div class="modal-footer">
                 <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>

+ 1 - 0
config/config.default.js

@@ -281,6 +281,7 @@ module.exports = appInfo => {
     config.difyInfo = {
         baseUrl: 'http://workflow.smartcost.com.cn/v1',
         apiKey: {
+            change: 'app-OLxZdZViltFY4nlKsgwYatI6',
             assistant: 'app-2LYbz5fJVoSiH02z46dygOs6',
             image2Excel: 'app-UGCJ6nlhZIcldyArOUyNwM21',
             image2PosCalcDetail: 'app-BIOhBbFlNSakoRGKU91e2ksJ',

+ 1 - 0
config/config.local.js

@@ -121,6 +121,7 @@ module.exports = appInfo => {
     config.difyInfo = {
         baseUrl: 'http://39.108.81.93:8080/v1',
         apiKey: {
+            change: 'app-OLxZdZViltFY4nlKsgwYatI6',
             assistant: 'app-2LYbz5fJVoSiH02z46dygOs6',
             image2Excel: 'app-UGCJ6nlhZIcldyArOUyNwM21',
             image2PosCalcDetail: 'app-BIOhBbFlNSakoRGKU91e2ksJ',

+ 1 - 0
config/config.qa.js

@@ -96,6 +96,7 @@ module.exports = appInfo => {
     config.difyInfo = {
         baseUrl: 'http://39.108.81.93:8080/v1',
         apiKey: {
+            change: 'app-OLxZdZViltFY4nlKsgwYatI6',
             assistant: 'app-2LYbz5fJVoSiH02z46dygOs6',
             image2Excel: 'app-UGCJ6nlhZIcldyArOUyNwM21',
             image2PosCalcDetail: 'app-BIOhBbFlNSakoRGKU91e2ksJ',