Quellcode durchsuchen

项目信息,阶段进度

MaiXinRong vor 7 Monaten
Ursprung
Commit
1fc9fb3d19

+ 11 - 11
app/base/base_tree_service.js

@@ -136,7 +136,7 @@ class TreeService extends Service {
     async getLastChildData(mid, pid) {
         this.initSqlBuilder();
         this.sqlBuilder.setAndWhere(this.setting.mid, {
-            value: mid,
+            value: this.db.escape(mid),
             operate: '=',
         });
         this.sqlBuilder.setAndWhere(this.setting.pid, {
@@ -168,7 +168,7 @@ class TreeService extends Service {
     async getChildBetween(mid, pid, order1, order2) {
         this.initSqlBuilder();
         this.sqlBuilder.setAndWhere(this.setting.mid, {
-            value: mid,
+            value: this.db.escape(mid),
             operate: '=',
         });
         this.sqlBuilder.setAndWhere(this.setting.pid, {
@@ -201,7 +201,7 @@ class TreeService extends Service {
     async getNextsData(mid, pid, order) {
         this.initSqlBuilder();
         this.sqlBuilder.setAndWhere(this.setting.mid, {
-            value: mid,
+            value: this.db.escape(mid),
             operate: '=',
         });
         this.sqlBuilder.setAndWhere(this.setting.pid, {
@@ -229,7 +229,7 @@ class TreeService extends Service {
     async getDataByFullPath(mid, fullPath) {
         this.initSqlBuilder();
         this.sqlBuilder.setAndWhere(this.setting.mid, {
-            value: mid,
+            value: this.db.escape(mid),
             operate: '=',
         });
         this.sqlBuilder.setAndWhere(this.setting.fullPath, {
@@ -252,7 +252,7 @@ class TreeService extends Service {
 
         this.initSqlBuilder();
         this.sqlBuilder.setAndWhere(this.setting.mid, {
-            value: mid,
+            value: this.db.escape(mid),
             operate: '=',
         });
         this.sqlBuilder.setAndWhere(this.setting.fullPath, {
@@ -278,7 +278,7 @@ class TreeService extends Service {
 
         this.initSqlBuilder();
         this.sqlBuilder.setAndWhere(this.setting.mid, {
-            value: mid,
+            value: this.db.escape(mid),
             operate: '=',
         });
         this.sqlBuilder.setAndWhere(this.setting.pid, {
@@ -305,7 +305,7 @@ class TreeService extends Service {
 
         this.initSqlBuilder();
         this.sqlBuilder.setAndWhere(this.setting.mid, {
-            value: mid,
+            value: this.db.escape(mid),
             operate: '=',
         });
         this.sqlBuilder.setAndWhere(this.setting.fullPath, {
@@ -378,7 +378,7 @@ class TreeService extends Service {
     async _updateChildrenOrder(mid, pid, order, incre = 1) {
         this.initSqlBuilder();
         this.sqlBuilder.setAndWhere(this.setting.mid, {
-            value: mid,
+            value: this.db.escape(mid),
             operate: '=',
         });
         this.sqlBuilder.setAndWhere(this.setting.order, {
@@ -530,7 +530,7 @@ class TreeService extends Service {
     async _deletePosterity(mid, node) {
         this.initSqlBuilder();
         this.sqlBuilder.setAndWhere(this.setting.mid, {
-            value: mid,
+            value: this.db.escape(mid),
             operate: '=',
         });
         this.sqlBuilder.setAndWhere(this.setting.fullPath, {
@@ -751,7 +751,7 @@ class TreeService extends Service {
     async _syncUplevelChildren(select) {
         this.initSqlBuilder();
         this.sqlBuilder.setAndWhere(this.setting.mid, {
-            value: select[this.setting.mid],
+            value: this.db.escape(select[this.setting.mid]),
             operate: '=',
         });
         this.sqlBuilder.setAndWhere(this.setting.fullPath, {
@@ -910,7 +910,7 @@ class TreeService extends Service {
     async _syncDownlevelChildren(select, newFullPath) {
         this.initSqlBuilder();
         this.sqlBuilder.setAndWhere(this.setting.mid, {
-            value: select[this.setting.mid],
+            value: this.db.escape(select[this.setting.mid]),
             operate: '=',
         });
         this.sqlBuilder.setAndWhere(this.setting.fullPath, {

+ 175 - 1
app/controller/sub_proj_controller.js

@@ -9,6 +9,9 @@
  */
 const auditConst = require('../const/audit');
 const accountGroup = require('../const/account_group').group;
+const sendToWormhole = require('stream-wormhole');
+const path = require('path');
+
 module.exports = app => {
     class SubProjController extends app.BaseController {
 
@@ -242,7 +245,178 @@ module.exports = app => {
                 ctx.body = { err: 0, msg: '', data: result };
             } catch(err) {
                 ctx.log(err);
-                ctx.ajaxErrorBody(err, '保存数据失败');
+            }
+        }
+
+        async progress(ctx) {
+            try {
+                const renderData = {
+                    jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.subProject.progress),
+                };
+                await this.layout('sub_proj/progress.ejs', renderData, 'sub_proj/progress_modal.ejs');
+            } catch (err) {
+                ctx.log(err);
+                ctx.postError(err, '查看阶段进度')
+            }
+        }
+
+        async load(ctx) {
+            try {
+                const data = JSON.parse(ctx.request.body.data);
+                const filter = data.filter.split(';');
+                const result = {};
+                for (const f of filter) {
+                    switch(f) {
+                        case 'progress':
+                            result[f] = await ctx.service.subProjProgress.getData(ctx.subProject);
+                            break;
+                        case 'progress_file':
+                            result[f] = await ctx.service.subProjFile.getData(ctx.subProject.id, 'progress');
+                            break;
+                        case 'push':
+                            result[f] = await ctx.service.subProjPush.getData(ctx.subProject.id);
+                            break;
+                        case 'push_file':
+                            result[f] = await ctx.service.subProjFile.getData(ctx.subProject.id, 'push');
+                            break;
+                        default:
+                            continue;
+                    }
+                }
+                ctx.body = { err: 0, msg: '', data: result };
+            } catch(err) {
+                ctx.log(err);
+                ctx.ajaxErrorBody(err, '获取阶段进度数据有误');
+            }
+        }
+
+        async _progressBase(subProj, type, data) {
+            if (isNaN(data.id) || data.id <= 0) throw '数据错误';
+            if (type !== 'add') {
+                if (isNaN(data.count) || data.count <= 0) data.count = 1;
+            }
+            switch (type) {
+                case 'add':
+                    return await this.ctx.service.subProjProgress.addProgressNode(subProj.id, data.id, data.count);
+                case 'delete':
+                    return await this.ctx.service.subProjProgress.delete(subProj.id, data.id, data.count);
+                case 'up-move':
+                    return await this.ctx.service.subProjProgress.upMoveNode(subProj.id, data.id, data.count);
+                case 'down-move':
+                    return await this.ctx.service.subProjProgress.downMoveNode(subProj.id, data.id, data.count);
+                case 'up-level':
+                    return await this.ctx.service.subProjProgress.upLevelNode(subProj.id, data.id, data.count);
+                case 'down-level':
+                    return await this.ctx.service.subProjProgress.downLevelNode(subProj.id, data.id, data.count);
+            }
+        }
+
+        async progressUpdate(ctx) {
+            try {
+                const data = JSON.parse(ctx.request.body.data);
+                if (!data.postType || !data.postData) throw '数据错误';
+                const responseData = { err: 0, msg: '', data: {} };
+
+                switch (data.postType) {
+                    case 'add':
+                    case 'delete':
+                    case 'up-move':
+                    case 'down-move':
+                    case 'up-level':
+                    case 'down-level':
+                        responseData.data = await this._progressBase(ctx.subProject, data.postType, data.postData);
+                        break;
+                    case 'update':
+                        responseData.data = await this.ctx.service.subProjProgress.updateInfos(ctx.subProject.id, data.postData);
+                        break;
+                        break;
+                    default:
+                        throw '未知操作';
+                }
+                ctx.body = responseData;
+            } catch (err) {
+                this.log(err);
+                ctx.body = this.ajaxErrorBody(err, '数据错误');
+            }
+        }
+
+        async push(ctx) {
+            try {
+                const renderData = {
+                    jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.subProject.push),
+                };
+                await this.layout('sub_proj/push.ejs', renderData, 'sub_proj/push_modal.ejs');
+            } catch (err) {
+                ctx.log(err);
+            }
+        }
+
+        async pushUpdate(ctx) {
+
+        }
+
+        /**
+         * 上传附件
+         * @param {Object} ctx - egg全局变量
+         * @return {void}
+         */
+        async uploadFile(ctx) {
+            let stream;
+            try {
+                const parts = ctx.multipart({autoFields: true});
+
+                let index = 0;
+                const create_time = Date.parse(new Date()) / 1000;
+                let stream = await parts();
+                const user = await ctx.service.projectAccount.getDataById(ctx.session.sessionUser.accountId);
+                const rela_id = parts.field.rela_id;
+
+                const uploadfiles = [];
+                while (stream !== undefined) {
+                    if (!stream.filename) throw '未发现上传文件!';
+
+                    const fileInfo = path.parse(stream.filename);
+                    const filepath = `sp/progress/${ctx.subProject.id}/${ctx.moment().format('YYYYMMDD')}/${create_time + '_' + index + fileInfo.ext}`;
+
+                    // 保存文件
+                    await ctx.app.fujianOss.put(ctx.app.config.fujianOssFolder + filepath, stream);
+                    await sendToWormhole(stream);
+
+                    // 插入到stage_pay对应的附件列表中
+                    uploadfiles.push({
+                        rela_id,
+                        filename: fileInfo.name,
+                        fileext: fileInfo.ext,
+                        filesize: Array.isArray(parts.field.size) ? parts.field.size[index] : parts.field.size,
+                        filepath,
+                    });
+                    ++index;
+                    if (Array.isArray(parts.field.size) && index < parts.field.size.length) {
+                        stream = await parts();
+                    } else {
+                        stream = undefined;
+                    }
+                }
+
+                const result = await ctx.service.subProjFile.addFiles(ctx.subProject.id, ctx.request.url.split('/')[3], uploadfiles, user);
+                ctx.body = {err: 0, msg: '', data: result};
+            } catch (error) {
+                ctx.log(error);
+                // 失败需要消耗掉stream 以防卡死
+                if (stream) await sendToWormhole(stream);
+                ctx.body = this.ajaxErrorBody(error, '上传附件失败,请重试');
+            }
+        }
+
+        async deleteFile(ctx) {
+            try{
+                const data = JSON.parse(ctx.request.body.data);
+                if (!data) throw '缺少参数';
+                const result = await ctx.service.subProjFile.delFiles(data);
+                ctx.body = { err: 0, msg: '', data: result };
+            } catch(error) {
+                this.log(error);
+                ctx.ajaxErrorBody(error, '删除附件失败');
             }
         }
     }

+ 15 - 10
app/public/js/shares/tools_att.js

@@ -14,6 +14,7 @@
         if (!setting.selector) return;
         if (!setting.masterKey) setting.masterKey = setting.key;
         const obj = $(setting.selector);
+        const fileInfo = setting.fileInfo || { user_name: 'username', user_id: 'uid', create_time: 'in_time' };
         const pageLength = 20;
         let curNode = null, curPage = 0;
         obj.html(
@@ -59,11 +60,11 @@
             html.push(`<td width="25"><input type="checkbox" class="check-file" file-id=${att.id}></td>`);
             let nodeInfo = '';
             if (tipNode && att.node) nodeInfo = `${att.node.code || att.node.b_code || ''}/${att.node.name || ''}`;
-            const tipHtml = nodeInfo ? `${nodeInfo}\n${att.in_time}` : att.in_time;// nodeInfo ? `${nodeInfo}<br/>${att.in_time}` : att.in_time;
+            const tipHtml = nodeInfo ? `${nodeInfo}\n${att[fileInfo.create_time]}` : att[fileInfo.create_time];
             const tipType = 'title='; //'data-toggle="tooltip" data-html="true" data-placement="left" data-original-title=';
             html.push(`<td><div class="d-flex"><a href="javascript:void(0)" ${tipType}"${tipHtml}" class="pl-0 col-11" file-id=${att.id}>${att.filename}${att.fileext}</a></div></td>`);
-            html.push(`<td>${att.username}</td>`);
-            const canDel = setting.readOnly ? false : att.uid === userID && (!setting.checked || att.extra_upload);
+            html.push(`<td>${att[fileInfo.user_name]}</td>`);
+            const canDel = setting.readOnly ? false : att[fileInfo.user_id] === userID && (!setting.checked || att.extra_upload);
             html.push('<td width="80">',
                 `<a class="ml-1" href="javascript:void(0)" ${tipType}"定位" name="att-locate" file-id="${att.id}"><i class="fa fa-crosshairs"></i></a>`,
                 att.viewpath ? `<a class="ml-1" href="${att.viewpath}" ${tipType}"预览"  target="_blank"><i class="fa fa-eye"></i></a>` : '',
@@ -111,6 +112,12 @@
             }
             refreshCurAttHtml();
         };
+        const findFile = setting.fileIdType === 'string'
+            ? function (list, id) { return list.find(item => item.id === id); }
+            : function (list, id) { return list.find(item => item.id === parseInt(id)); };
+        const findFileIndex = setting.fileIdType === 'string'
+            ? function (list, id) { return list.findIndex(item => item.id === id); }
+            : function (list, id) { return list.findIndex(item => item.id === parseInt(id)); };
 
         // 选中行
         $('body').on('click', '#all-att-list tr', function() {
@@ -160,9 +167,7 @@
             }
             $(node).each(function() {
                 const fid = $(this).attr('file-id');
-                const att = allAtts.find(function (item) {
-                    return item.id === parseInt(fid);
-                });
+                const att = findFile(allAtts, fid);
                 att && files.push(att);
             });
 
@@ -189,7 +194,7 @@
             }
             const files = $('#upload-file')[0].files;
             const formData = new FormData();
-            formData.append('lid', curNode[setting.key]);
+            formData.append(setting.masterKey, curNode[setting.key]);
             for (const file of files) {
                 if (file === undefined) {
                     toastr.error('未选择上传文件!');
@@ -227,7 +232,7 @@
         });
         $('body').on('click', 'a[name=att-locate]', function () {
             const fid = this.getAttribute('file-id');
-            const att = allAtts.find(item => item.id === parseInt(fid));
+            const att = findFile(allAtts, fid);
             setting.locate && setting.locate(att);
         });
         $('body').on('click', 'a[name=att-delete]', function () {
@@ -235,11 +240,11 @@
             const data = {id: fid};
             postData(setting.deleteUrl, data, function (result) {
                 // 删除
-                const att_index = allAtts.findIndex(item => { return item.id === parseInt(fid); });
+                const att_index = findFileIndex(allAtts, fid);
                 const att = allAtts[att_index];
                 allAtts.splice(att_index, 1);
                 const xi = nodeIndexes[att.node[setting.key]];
-                xi.splice(xi.findIndex(x => { return x.id === parseInt(fid); }), 1);
+                xi.splice(findFileIndex(xi, fid), 1);
                 // 重新生成List
                 if (allAtts.length === 1) {
                     getAllAttHtml();

+ 615 - 0
app/public/js/sp_progress.js

@@ -0,0 +1,615 @@
+const showSideTools = function (show) {
+    const left = $('#left-view'), right = $('#right-view'), parent = left.parent();
+    if (show) {
+        right.show();
+        autoFlashHeight();
+        /**
+         * right.show()后, parent被撑开成2倍left.height, 导致parent.width减少了10px
+         * 第一次left.width调整后,parent的缩回left.height, 此时parent.width又增加了10px
+         * 故需要通过最终的parent.width再计算一次left.width
+         *
+         * Q: 为什么不通过先计算left.width的宽度,以避免计算两次left.width?
+         * A: 右侧工具栏不一定显示,当右侧工具栏显示过一次后,就必须使用parent和right来计算left.width
+         *
+         */
+            //left.css('width', parent.width() - right.outerWidth());
+            //left.css('width', parent.width() - right.outerWidth());
+        const percent = 100 - right.outerWidth() /parent.width() * 100;
+        left.css('width', percent + '%');
+    } else {
+        left.width(parent.width());
+        right.hide();
+    }
+};
+const progressCombo = [{ text: '', value: '' }, { text: '进行中', value: '进行中' }, { text: '已完成', value: '已完成' }];
+
+$(document).ready(() => {
+    autoFlashHeight();
+    let datepicker;
+
+    class ProgressObj {
+        constructor() {
+            this.spread = SpreadJsObj.createNewSpread($('#progress-spread')[0]);
+            this.sheet = this.spread.getActiveSheet();
+            this.treeSetting = {
+                id: 'tree_id',
+                pid: 'tree_pid',
+                order: 'tree_order',
+                level: 'tree_level',
+                isLeaf: 'tree_is_leaf',
+                fullPath: 'tree_full_path',
+                rootId: -1,
+                calcFields: [],
+                keys: ['id', 'spid', 'tree_id'],
+            };
+            this.tree = createNewPathTree('ledger', this.treeSetting);
+            this.spreadSetting = {
+                cols: [
+                    {title: '序号', colSpan: '1', rowSpan: '2', field: 'code', hAlign: 0, width: 165, formatter: '@', cellType: 'tree'},
+                    {title: '阶段/项目名称', colSpan: '1', rowSpan: '2', field: 'name', hAlign: 0, width: 210, formatter: '@', cellType: 'autoTip'},
+                    {title: '成果编制|进度', colSpan: '3|1', rowSpan: '1|1', field: 'edit_progress', hAlign: 1, width: 80, formatter: '@', cellType: 'customizeCombo', comboItems: progressCombo },
+                    {
+                        title: '|日期', colSpan: '|1', rowSpan: '|1', field: 'edit_date', hAlign: 2, width: 80, formatter: '@', readOnly: true,
+                        formatter: 'yyyy-MM-dd', cellType: 'activeImageBtn', normalImg: '#ellipsis-icon', indent: 5,
+                        showImage: function (data) {
+                            return data !== undefined && data !== null;
+                        }
+                    },
+                    {title: '|部门', colSpan: '|1', rowSpan: '|1', field: 'edit_department', hAlign: 2, width: 120, formatter: '@', },
+                    {title: '报审情况|进度', colSpan: '3|1', rowSpan: '1|1', field: 'submit_progress', hAlign: 1, width: 80, formatter: '@', cellType: 'customizeCombo', comboItems: progressCombo },
+                    {
+                        title: '|日期', colSpan: '|1', rowSpan: '|1', field: 'submit_date', hAlign: 2, width: 80, formatter: '@', readOnly: true,
+                        formatter: 'yyyy-MM-dd', cellType: 'activeImageBtn', normalImg: '#ellipsis-icon', indent: 5,
+                        showImage: function (data) {
+                            return data !== undefined && data !== null;
+                        }
+                    },
+                    {title: '|部门', colSpan: '|1', rowSpan: '|1', field: 'submit_department', hAlign: 2, width: 120, formatter: '@', },
+                    {title: '批复情况|进度', colSpan: '4|1', rowSpan: '1|1', field: 'reply_progress', hAlign: 1, width: 80, formatter: '@', cellType: 'customizeCombo', comboItems: progressCombo },
+                    {
+                        title: '|日期', colSpan: '|1', rowSpan: '|1', field: 'reply_date', hAlign: 2, width: 80, formatter: '@', readOnly: true,
+                        formatter: 'yyyy-MM-dd', cellType: 'activeImageBtn', normalImg: '#ellipsis-icon', indent: 5,
+                        showImage: function (data) {
+                            return data !== undefined && data !== null;
+                        }
+                    },
+                    {title: '|部门', colSpan: '|1', rowSpan: '|1', field: 'reply_department', hAlign: 2, width: 120, formatter: '@', },
+                    {title: '|文号', colSpan: '|1', rowSpan: '|1', field: 'reply_code', hAlign: 2, width: 120, formatter: '@', },
+                    {title: '备注', colSpan: '1', rowSpan: '2', field: 'memo', hAlign: 0, width: 120, formatter: '@', cellType: 'ellipsisAutoTip'},
+                ],
+                emptyRows: 0,
+                headRows: 2,
+                headRowHeight: [25, 25],
+                defaultRowHeight: 21,
+                headerFont: '12px 微软雅黑',
+                font: '12px 微软雅黑',
+                localCache: {
+                    key: 'sub-proj-progress',
+                    colWidth: true,
+                },
+                readOnly: false,
+                imageClick: function (data, hitinfo) {
+                    if (!data) return;
+
+                    const setting = hitinfo.sheet.zh_setting;
+                    if (!setting) return;
+                    const col = setting.cols[hitinfo.col];
+                    if (!col || col.field.indexOf('_date') < 0) return;
+
+                    const pos = SpreadJsObj.getObjPos(hitinfo.sheet.getParent().qo);
+                    if (!datepicker) {
+                        datepicker = $('.datepicker-here').datepicker({
+                            language: 'zh',
+                            dateFormat: 'yyyy-MM-dd',
+                            autoClose: true,
+                            onSelect: function (formattedDate, date, inst) {
+                                if (!inst.visible) return;
+                                const sels = hitinfo.sheet.getSelections();
+                                if (!sels || !sels[0]) return;
+                                const node = SpreadJsObj.getSelectObject(hitinfo.sheet);
+                                const uData = {id: node.id};
+                                const relaCol = hitinfo.sheet.zh_setting.cols[sels[0].col];
+                                uData[relaCol.field] = formattedDate;
+
+                                postData('progress/update', {postType: 'update', postData: uData}, function (result) {
+                                    const refreshNode = progressObj.tree.loadPostData(result);
+                                    progressObj.refreshTree(refreshNode);
+                                }, function () {
+                                    SpreadJsObj.reLoadRowData(hitinfo.sheet, sels[0].row, 1);
+                                });
+                            }
+                        }).data('datepicker');
+                    }
+                    const value = hitinfo.sheet.getValue(hitinfo.row, hitinfo.col);
+                    if (value) {
+                        datepicker.selectDate(value);
+                    } else {
+                        datepicker.clear();
+                    }
+                    datepicker.show();
+                    $('#datepickers-container').css('top', hitinfo.cellRect.y + pos.y).css('left', hitinfo.cellRect.x + pos.x);
+                    // $('#datepickers-container').css('top', pos.y).css('left', pos.x);
+                }
+            };
+            this.ckSpread = window.location.pathname + '-progressSelect';
+
+            this.initSpread();
+            this.initOtherEvent();
+        }
+        initSpread() {
+            SpreadJsObj.initSheet(this.sheet, this.spreadSetting);
+            this.spread.bind(spreadNS.Events.SelectionChanged, this.selectionChanged);
+            this.spread.bind(spreadNS.Events.topRowChanged, this.topRowChanged);
+            this.spread.bind(spreadNS.Events.ClipboardChanging, function (e, info) {
+                const copyText = SpreadJsObj.getFilterCopyText(info.sheet);
+                SpreadJsObj.Clipboard.setCopyData(copyText);
+            });
+            this.spread.bind(spreadNS.Events.EditEnded, this.editEnded);
+            this.spread.bind(spreadNS.Events.EditStarting, this.editStarting);
+            this.spread.bind(spreadNS.Events.ClipboardPasting, this.clipboardPasting);
+            SpreadJsObj.addDeleteBind(this.spread, this.deletePress);
+        }
+        initOtherEvent() {
+            const self = this;
+            // 增删上下移升降级
+            $('a[name="base-opr"]').click(function () {
+                self.baseOpr(this.getAttribute('type'));
+            });
+        }
+        refreshOperationValid() {
+            const setObjEnable = function (obj, enable) {
+                if (enable) {
+                    obj.removeClass('disabled');
+                } else {
+                    obj.addClass('disabled');
+                }
+            };
+            const invalidAll = function () {
+                setObjEnable($('a[name=base-opr][type=add]'), false);
+                setObjEnable($('a[name=base-opr][type=delete]'), false);
+                setObjEnable($('a[name=base-opr][type=up-move]'), false);
+                setObjEnable($('a[name=base-opr][type=down-move]'), false);
+                setObjEnable($('a[name=base-opr][type=up-level]'), false);
+                setObjEnable($('a[name=base-opr][type=down-level]'), false);
+            };
+            const sel = this.sheet.getSelections()[0];
+            const row = sel ? sel.row : -1;
+            const tree = this.sheet.zh_tree;
+            if (!tree) {
+                invalidAll();
+                return;
+            }
+            const first = tree.nodes[row];
+            if (!first) {
+                invalidAll();
+                return;
+            }
+            let last = first, sameParent = true;
+            if (sel.rowCount > 1 && first) {
+                for (let r = 1; r < sel.rowCount; r++) {
+                    const rNode = tree.nodes[sel.row + r];
+                    if (!rNode) {
+                        sameParent = false;
+                        break;
+                    }
+                    if (rNode.tree_level > first.tree_level) continue;
+                    if ((rNode.tree_level < first.tree_level) || (rNode.tree_level === first.tree_level && rNode.tree_pid !== first.tree_pid)) {
+                        sameParent = false;
+                        break;
+                    }
+                    last = rNode;
+                }
+            }
+            const preNode = tree.getPreSiblingNode(first);
+            const valid = !this.sheet.zh_setting.readOnly;
+
+            setObjEnable($('a[name=base-opr][type=add]'), valid && first);
+            setObjEnable($('a[name=base-opr][type=delete]'), valid && first && sameParent && first.tree_level > 1);
+            setObjEnable($('a[name=base-opr][type=up-move]'), valid && first && sameParent && first.tree_level > 1 && preNode);
+            setObjEnable($('a[name=base-opr][type=down-move]'), valid && first && sameParent && first.tree_level > 1 && !tree.isLastSibling(last));
+            setObjEnable($('a[name=base-opr][type=up-level]'), valid && first && sameParent && tree.getParent(first) && first.tree_level > 2);
+            setObjEnable($('a[name=base-opr][type=down-level]'), valid && first && sameParent && first.tree_level > 1 && preNode );
+        }
+        loadRelaData() {
+            this.refreshOperationValid();
+            SpreadJsObj.saveTopAndSelect(this.sheet, this.ckSpread);
+            progressFile.getCurAttHtml(SpreadJsObj.getSelectObject(this.sheet));
+        }
+        refreshTree(data) {
+            const sheet = this.sheet;
+            SpreadJsObj.massOperationSheet(sheet, function () {
+                const tree = sheet.zh_tree;
+                // 处理删除
+                if (data.delete) {
+                    data.delete.sort(function (a, b) {
+                        return b.deleteIndex - a.deleteIndex;
+                    });
+                    for (const d of data.delete) {
+                        sheet.deleteRows(d.deleteIndex, 1);
+                    }
+                }
+                // 处理新增
+                if (data.create) {
+                    const newNodes = data.create;
+                    if (newNodes) {
+                        newNodes.sort(function (a, b) {
+                            return a.index - b.index;
+                        });
+
+                        for (const node of newNodes) {
+                            sheet.addRows(node.index, 1);
+                            SpreadJsObj.reLoadRowData(sheet, tree.nodes.indexOf(node), 1);
+                        }
+                    }
+                }
+                // 处理更新
+                if (data.update) {
+                    const rows = [];
+                    for (const u of data.update) {
+                        rows.push(tree.nodes.indexOf(u));
+                    }
+                    SpreadJsObj.reLoadRowsData(sheet, rows);
+                }
+                // 处理展开
+                if (data.expand) {
+                    const expanded = [];
+                    for (const e of data.expand) {
+                        if (expanded.indexOf(e) === -1) {
+                            const posterity = tree.getPosterity(e);
+                            for (const p of posterity) {
+                                sheet.setRowVisible(tree.nodes.indexOf(p), p.visible);
+                                expanded.push(p);
+                            }
+                        }
+                    }
+                }
+            });
+        }
+        loadData(datas) {
+            this.tree.loadDatas(datas);
+            SpreadJsObj.loadSheetData(this.sheet, SpreadJsObj.DataType.Tree, this.tree);
+            SpreadJsObj.loadTopAndSelect(this.sheet, this.ckSpread);
+            this.refreshOperationValid();
+        }
+        getDefaultSelectInfo() {
+            if (!this.tree) return;
+            const sel = this.sheet.getSelections()[0];
+            const node = this.sheet.zh_tree.nodes[sel.row];
+            if (!node) return;
+            let count = 1;
+            if (sel.rowCount > 1) {
+                for (let r = 1; r < sel.rowCount; r++) {
+                    const rNode = this.sheet.zh_tree.nodes[sel.row + r];
+                    if (rNode.tree_level > node.tree_level) continue;
+                    if ((rNode.tree_level < node.tree_level) || (rNode.tree_level === node.tree_level && rNode.tree_pid !== node.tree_pid)) {
+                        toastr.warning('请选择同一节点下的节点,进行该操作');
+                        return;
+                    }
+                    count += 1;
+                }
+            }
+            return [this.tree, node, count];
+        }
+        baseOpr(type, addCount = 1) {
+            const self = this;
+            const sheet = self.sheet;
+            const sel = sheet.getSelections()[0];
+            const [tree, node, count] = this.getDefaultSelectInfo();
+            if (!tree || !node || !count) return;
+
+            const updateData = {
+                postType: type,
+                postData: {
+                    id: node.tree_id,
+                    count: type === 'add' ? addCount : count,
+                }
+            };
+            if (type === 'delete') {
+                deleteAfterHint(function () {
+                    postData('progress/update', updateData, function (result) {
+                        const refreshData = tree.loadPostData(result);
+                        self.refreshTree(refreshData);
+                        if (sel) {
+                            sheet.setSelection(sel.row, sel.col, 1, sel.colCount);
+                        }
+                        self.refreshOperationValid();
+                    });
+                });
+            } else {
+                postData('progress/update', updateData, function (result) {
+                    const refreshData = tree.loadPostData(result);
+                    self.refreshTree(refreshData);
+                    if (['up-move', 'down-move'].indexOf(type) > -1) {
+                        if (sel) {
+                            sheet.setSelection(tree.nodes.indexOf(node), sel.col, sel.rowCount, sel.colCount);
+                        }
+                    } else if (type === 'add') {
+                        const sel = sheet.getSelections()[0];
+                        if (sel) {
+                            sheet.setSelection(tree.nodes.indexOf(refreshData.create[0]), sel.col, sel.rowCount, sel.colCount);
+                        }
+                    }
+                    self.refreshOperationValid();
+                });
+            }
+        }
+        // 事件
+        selectionChanged(e, info) {
+            if (info.newSelections) {
+                if (!info.oldSelections || info.newSelections[0].row !== info.oldSelections[0].row) {
+                    progressObj.loadRelaData();
+                }
+            }
+        }
+        topRowChanged(e, info) {
+            SpreadJsObj.saveTopAndSelect(info.sheet, progressObj.ckBillsSpread);
+        }
+        editEnded(e, info) {
+            if (!info.sheet.zh_setting) return;
+
+            const tree = info.sheet.zh_tree;
+            const node = SpreadJsObj.getSelectObject(info.sheet);
+            const data = { id: node.id, spid: node.spid, tree_id: node.tree_id };
+            // 未改变值则不提交
+            const col = info.sheet.zh_setting.cols[info.col];
+            const orgValue = node[col.field];
+            const newValue = trimInvalidChar(info.editingText);
+            if (orgValue == info.editingText || ((!orgValue || orgValue === '') && (newValue === ''))) return;
+
+            if (info.editingText) {
+                const text = newValue;
+                if (col.type === 'Number') {
+                    const num = _.toNumber(text);
+                    if (_.isFinite(num)) {
+                        data[col.field] = num;
+                    } else {
+                        try {
+                            data[col.field] = math.evaluate(transExpr(text));
+                        } catch(err) {
+                            toastr.error('输入的表达式非法');
+                            SpreadJsObj.reLoadRowData(info.sheet, info.row);
+                            return;
+                        }
+                    }
+                } else {
+                    data[col.field] = text;
+                }
+            } else {
+                data[col.field] = col.type === 'Number' ? 0 : '';
+            }
+            // 更新至服务器
+            postData('progress/update', {postType: 'update', postData: data}, function (result) {
+                const refreshNode = progressObj.tree.loadPostData(result);
+                progressObj.refreshTree(refreshNode);
+            }, function () {
+                SpreadJsObj.reLoadRowData(info.sheet, info.row, 1);
+            });
+        }
+        editStarting(e, info) {
+            if (!info.sheet.zh_setting || !info.sheet.zh_tree) return;
+
+            const col = info.sheet.zh_setting.cols[info.col];
+            const node = info.sheet.zh_tree.nodes[info.row];
+            if (!node) {
+                info.cancel = true;
+                return;
+            }
+
+            switch (col.field) {
+                case 'edit_progress':
+                case 'edit_date':
+                case 'edit_department':
+                case 'submit_progress':
+                case 'submit_date':
+                case 'submit_department':
+                case 'reply_progress':
+                case 'reply_date':
+                case 'reply_department':
+                case 'reply_code':
+                    info.cancel = node.tree_level <= 1;
+                    break;
+            }
+        }
+        deletePress (sheet) {
+            if (!sheet.zh_setting) return;
+            const sel = sheet.getSelections()[0], datas = [];
+            for (let iRow = sel.row; iRow < sel.row + sel.rowCount; iRow++) {
+                let bDel = false;
+                const node = sheet.zh_tree.nodes[iRow];
+                const data = sheet.zh_tree.getNodeKeyData(node);
+                for (let iCol = sel.col; iCol < sel.col + sel.colCount; iCol++) {
+                    const col = sheet.zh_setting.cols[iCol];
+                    const style = sheet.getStyle(iRow, iCol);
+                    if (style.locked) continue;
+
+                    data[col.field] = col.type === 'Number' ? 0 : '';
+                    bDel = true;
+                }
+                if (bDel) datas.push(data);
+            }
+            if (datas.length > 0) {
+                postData('progress/update', {postType: 'update', postData: datas}, function (result) {
+                    const refreshNode = sheet.zh_tree.loadPostData(result);
+                    progressObj.refreshTree(refreshNode);
+                }, function () {
+                    SpreadJsObj.reLoadRowData(info.sheet, sel.row, sel.rowCount);
+                });
+            }
+        }
+        clipboardPasting(e, info) {
+            info.cancel = true;
+            const tree = info.sheet.zh_tree, setting = info.sheet.zh_setting;
+            if (!setting || !tree) return;
+
+            const pasteData = info.pasteData.html
+                ? SpreadJsObj.analysisPasteHtml(info.pasteData.html)
+                : (info.pasteData.text === ''
+                    ? SpreadJsObj.Clipboard.getAnalysisPasteText()
+                    : SpreadJsObj.analysisPasteText(info.pasteData.text));
+            const hint = {
+                invalidExpr: {type: 'warning', msg: '粘贴的表达式非法'},
+                parent: {type: 'warning', msg: '含有子项的清单,不可粘贴成果编制、报审情况、批复情况'},
+            };
+            const datas = [], filterNodes = [];
+
+            let level, filterRow = 0;
+            for (let iRow = 0; iRow < info.cellRange.rowCount; iRow ++) {
+                const curRow = info.cellRange.row + iRow;
+                const node = tree.nodes[curRow];
+                if (!node) continue;
+
+                if (!level) level = node.level;
+                if (node.level < level) break;
+
+                let bPaste = false;
+                const data = info.sheet.zh_tree.getNodeKeyData(node);
+                for (let iCol = 0; iCol < info.cellRange.colCount; iCol++) {
+                    const curCol = info.cellRange.col + iCol;
+                    const colSetting = info.sheet.zh_setting.cols[curCol];
+                    const value = trimInvalidChar(pasteData[iRow-filterRow][iCol]);
+                    if (node.children && node.children.length > 0 && invalidFields.parent.indexOf(colSetting.field) >= 0) {
+                        toastMessageUniq(hint.parent);
+                        continue;
+                    }
+
+                    if (colSetting.type === 'Number') {
+                        const num = _.toNumber(value);
+                        if (num) {
+                            data[colSetting.field] = num;
+                        } else {
+                            try {
+                                data[colSetting.field] = math.evaluate(transExpr(value));
+                                bPaste = true;
+                            } catch (err) {
+                                toastMessageUniq(hint.invalidExpr);
+                                continue;
+                            }
+                        }
+                    } else {
+                        data[colSetting.field] = value;
+                    }
+                    bPaste = true;
+                }
+                if (bPaste) {
+                    datas.push(data);
+                } else {
+                    filterNodes.push(node);
+                }
+            }
+            if (datas.length > 0) {
+                postData('progress/update', {postType: 'update', postData: datas}, function (result) {
+                    const refreshNode = tree.loadPostData(result);
+                    if (refreshNode.update) refreshNode.update = refreshNode.update.concat(filterNodes);
+                    progressObj.refreshTree(refreshNode);
+                }, function () {
+                    SpreadJsObj.reLoadRowData(info.sheet, info.cellRange.row, info.cellRange.rowCount);
+                });
+            } else {
+                SpreadJsObj.reLoadRowData(info.sheet, info.cellRange.row, info.cellRange.rowCount);
+            }
+        }
+    }
+    const progressObj = new ProgressObj();
+
+    const progressFile = $.ledger_att({
+        selector: '#fujian',
+        key: 'id',
+        masterKey: 'rela_id',
+        uploadUrl: 'progress/file/upload',
+        deleteUrl: 'progress/file/delete',
+        checked: false,
+        zipName: `阶段进度-附件.zip`,
+        readOnly: false,
+        fileIdType: 'string',
+        fileInfo: {
+            user_name: 'user_name',
+            user_id: 'user_id',
+            create_time: 'create_time',
+        },
+        locate: function (att) {
+            if (!att) return;
+            SpreadJsObj.locateTreeNode(progressObj.sheet, att.node.tree_id, true);
+            progressFile.getCurAttHtml(att.node);
+        }
+    });
+    // 展开收起标准清单
+    $('a', '#side-menu').bind('click', function (e) {
+        e.preventDefault();
+        const tab = $(this), tabPanel = $(tab.attr('content'));
+        // 展开工具栏、切换标签
+        if (!tab.hasClass('active')) {
+            // const close = $('.active', '#side-menu').length === 0;
+            $('a', '#side-menu').removeClass('active');
+            $('.tab-content .tab-select-show.tab-pane.active').removeClass('active');
+            tab.addClass('active');
+            tabPanel.addClass('active');
+            // $('.tab-content .tab-pane').removeClass('active');
+            showSideTools(tab.hasClass('active'));
+        } else { // 收起工具栏
+            tab.removeClass('active');
+            tabPanel.removeClass('active');
+            showSideTools(tab.hasClass('active'));
+        }
+        progressObj.spread.refresh();
+    });
+
+    postData('load', { filter: 'progress;progress_file'}, function(result) {
+        progressObj.loadData(result.progress);
+        for (const f of result.progress_file) {
+            f.node = progressObj.tree.datas.find(x => { return x.id === f.rela_id; });
+        }
+        progressFile.loadDatas(result.progress_file);
+        progressFile.getCurAttHtml(SpreadJsObj.getSelectObject(progressObj.sheet));
+    });
+
+    // 工具栏spr
+    $.divResizer({
+        select: '#right-spr',
+        callback: function () {
+            progressObj.spread.refresh();
+        }
+    });
+    // 导航Menu
+    $.subMenu({
+        menu: '#sub-menu', miniMenu: '#sub-mini-menu', miniMenuList: '#mini-menu-list',
+        toMenu: '#to-menu', toMiniMenu: '#to-mini-menu',
+        key: 'menu.1.0.0',
+        miniHint: '#sub-mini-hint', hintKey: 'menu.hint.1.0.1',
+        callback: function (info) {
+            if (info.mini) {
+                $('.panel-title').addClass('fluid');
+                $('#sub-menu').removeClass('panel-sidebar');
+            } else {
+                $('.panel-title').removeClass('fluid');
+                $('#sub-menu').addClass('panel-sidebar');
+            }
+            autoFlashHeight();
+            progressObj.spread.refresh();
+        }
+    });
+    // 显示层次
+    (function (select, sheet) {
+        $(select).click(function () {
+            if (!sheet.zh_tree) return;
+            const tag = $(this).attr('tag');
+            const tree = sheet.zh_tree;
+            setTimeout(() => {
+                showWaitingView();
+                switch (tag) {
+                    case "1":
+                    case "2":
+                    case "3":
+                    case "4":
+                        tree.expandByLevel(parseInt(tag));
+                        SpreadJsObj.refreshTreeRowVisible(sheet);
+                        break;
+                    case "last":
+                        tree.expandByCustom(() => { return true; });
+                        SpreadJsObj.refreshTreeRowVisible(sheet);
+                        break;
+                }
+                closeWaitingView();
+            }, 100);
+        });
+    })('a[name=showLevel]', progressObj.sheet);
+});

+ 3 - 0
app/public/js/sp_push.js

@@ -0,0 +1,3 @@
+$(document).ready(() => {
+    autoFlashHeight();
+});

+ 3 - 2
app/public/js/spreadjs_rela/spreadjs_zh.js

@@ -2541,11 +2541,12 @@ const SpreadJsObj = {
                 const self = this;
                 if (editorContext) {
                     const $editor = $(editorContext);
-                    //spreadNS.CellTypes.Text.prototype.activateEditor.apply(this, arguments);
+                    spreadNS.CellTypes.Text.prototype.activateEditor.apply(this, arguments);
                     $editor.css("position", "absolute");
                     datepicker = $editor.datepicker({
                         language: 'zh',
-                        dateFormat: 'yyyy-MM-DD'
+                        dateFormat: 'yyyy-MM-DD',
+                        autoClose: true,
                     }).data('datepicker');
                     datepicker.show();
                 }

+ 11 - 0
app/router.js

@@ -841,6 +841,17 @@ module.exports = app => {
     app.get('/sp/:id/info', sessionAuth, subProjectCheck, 'subProjController.info');
     app.get('/sp/:id/data', sessionAuth, subProjectCheck, 'subProjController.dataIndex');
     app.post('/sp/:id/info/save', sessionAuth, subProjectCheck, 'subProjController.saveInfo');
+    app.post('/sp/:id/load', sessionAuth, subProjectCheck, 'subProjController.load');
+    // 阶段进度
+    app.get('/sp/:id/progress', sessionAuth, subProjectCheck, 'subProjController.progress');
+    app.post('/sp/:id/progress/update', sessionAuth, subProjectCheck, 'subProjController.progressUpdate');
+    app.post('/sp/:id/progress/file/upload', sessionAuth, subProjectCheck, 'subProjController.uploadFile');
+    app.post('/sp/:id/progress/file/delete', sessionAuth, subProjectCheck, 'subProjController.deleteFile');
+    //推进记录
+    app.get('/sp/:id/push', sessionAuth, subProjectCheck, 'subProjController.push');
+    app.post('/sp/:id/push/update', sessionAuth, subProjectCheck, 'subProjController.pushUpdate');
+    app.post('/sp/:id/push/file/upload', sessionAuth, subProjectCheck, 'subProjController.uploadFile');
+    app.post('/sp/:id/push/file/delete', sessionAuth, subProjectCheck, 'subProjController.deleteFile');
     // 概算投资
     app.get('/budget', sessionAuth, 'budgetController.list');
     app.get('/budget/:id', sessionAuth, budgetCheck, 'budgetController.budgetInfo');

+ 94 - 0
app/service/sub_proj_file.js

@@ -0,0 +1,94 @@
+'use strict';
+
+/**
+ *
+ *  附件
+ * @author Ellisran
+ * @date 2019/1/11
+ * @version
+ */
+
+module.exports = app => {
+    class SubProjFile extends app.BaseService {
+        /**
+         * 构造函数
+         *
+         * @param {Object} ctx - egg全局变量
+         * @return {void}
+         */
+        constructor(ctx) {
+            super(ctx);
+            this.tableName = 'sub_project_file';
+        }
+
+        _analysisData(files) {
+            const helper = this.ctx.helper;
+            const userId = this.ctx.session.sessionUser.accountId;
+            const ossPath = this.ctx.app.config.fujianOssPath;
+            files.forEach(x => {
+                x.viewpath = helper.canPreview(x.fileext) ? ossPath + x.filepath : '';
+                x.filepath = ossPath + x.filepath;
+                x.fileext_str = helper.fileExtStr(x.fileext);
+                x.canEdit = x.user_id === userId;
+            });
+        }
+
+        async getData(spid, type) {
+            const data = await this.getAllDataByCondition({
+                where: { type, spid, is_deleted: 0 },
+                orders: [['create_time', 'desc']],
+            });
+            this._analysisData(data);
+            return data;
+        }
+
+        async getFiles(condition) {
+            condition.orders = [['create_time', 'desc']];
+            const result = await this.getAllDataByCondition(condition);
+            this._analysisData(result);
+            return result;
+        }
+
+        async addFiles(spid, type, fileInfo, user) {
+            const conn = await this.db.beginTransaction();
+            const result = {};
+            try {
+                const insertData = fileInfo.map(x => {
+                    return {
+                        id: this.uuid.v4(), spid, type, rela_id: x.rela_id,
+                        user_id: user.id, user_name: user.name, user_company: user.company, user_role: user.role,
+                        filename: x.filename, fileext: x.fileext, filesize: x.filesize, filepath: x.filepath,
+                    };
+                });
+                await conn.insert(this.tableName, insertData);
+                await conn.commit();
+                result.files = { id: insertData.map(x => { return x.id; })};
+            } catch (err) {
+                await conn.rollback();
+                throw err;
+            }
+            return await this.getFiles({ where: result.files });
+        }
+
+        async delFiles(files) {
+            if (files.length === 0) return;
+
+            const fileDatas = await this.getAllDataByCondition({ where: { id: files } });
+            const result = {};
+
+            const conn = await this.db.beginTransaction();
+            try {
+                const updateData = fileDatas.map(x => { return { id: x.id, is_deleted: 1 }; });
+                if (updateData.length > 0) await conn.updateRows(this.tableName, updateData);
+                await conn.commit();
+                result.del = files;
+            } catch (err) {
+                await conn.rollback();
+                throw err;
+            }
+            return result;
+        }
+    }
+
+    return SubProjFile;
+};

+ 180 - 0
app/service/sub_proj_progress.js

@@ -0,0 +1,180 @@
+'use strict';
+
+/**
+ *
+ *
+ * @author Mai
+ * @date
+ * @version
+ */
+
+const readOnlyFields = ['id', 'spid', 'tree_id', 'tree_pid', 'tree_order', 'tree_level', 'tree_full_path', 'tree_is_leaf'];
+const defaultData = [
+    { code: '1', name: '前期阶段', tree_id: 1, tree_pid: -1, tree_level: 1, tree_order: 1, tree_full_path: '1', tree_is_leaf: 1 },
+    { code: '2', name: '实施阶段', tree_id: 2, tree_pid: -1, tree_level: 1, tree_order: 2, tree_full_path: '2', tree_is_leaf: 1 },
+    { code: '3', name: '竣(交)工阶段', tree_id: 3, tree_pid: -1, tree_level: 1, tree_order: 3, tree_full_path: '3', tree_is_leaf: 1 },
+];
+
+module.exports = app => {
+    class SubProjProgress extends app.BaseTreeService {
+
+        /**
+         * 构造函数
+         *
+         * @param {Object} ctx - egg全局变量
+         * @param {String} tableName - 表名
+         * @return {void}
+         */
+        constructor(ctx) {
+            super(ctx, {
+                mid: 'spid',
+                kid: 'tree_id',
+                pid: 'tree_pid',
+                order: 'tree_order',
+                level: 'tree_level',
+                isLeaf: 'tree_is_leaf',
+                fullPath: 'tree_full_path',
+                cacheKey: false,
+                uuid: true,
+            });
+            this.tableName = 'sub_project_progress';
+        }
+
+        _getDefaultData(data, spid) {
+            data.id = this.uuid.v4();
+            data.spid = spid;
+            data.add_user_id = this.ctx.session.sessionUser.accountId;
+            data.update_user_id = this.ctx.session.sessionUser.accountId;
+        }
+
+        async init(subProj) {
+            if (!subProj) throw '阶段进度数据错误';
+
+            const insertData = [];
+            for (const b of defaultData) {
+                const bills = JSON.parse(JSON.stringify(b));
+                this._getDefaultData(bills, subProj.id);
+                insertData.push(bills);
+            }
+
+            const operate = await this.db.insert(this.tableName, insertData);
+            return operate.affectedRows === insertData.length;
+        }
+
+        async getData(subProj) {
+            let result = await this.getAllDataByCondition({ where: { spid: subProj.id } });
+            if (result.length === 0) {
+                await this.init(subProj);
+                result = await this.getAllDataByCondition({ where: { spid: subProj.id } });
+            }
+            return result;
+        }
+
+        async addChild(spid, select, count) {
+            const maxId = await this._getMaxLid(spid);
+            const children = await this.getChildrenByParentId(spid, select[this.setting.kid]);
+            const newDatas = [];
+            for (let i = 1; i < count + 1; i++) {
+                const newData = {};
+                newData[this.setting.kid] = maxId + i;
+                newData[this.setting.pid] = select[this.setting.kid];
+                newData[this.setting.level] = select[this.setting.level] + 1;
+                newData[this.setting.order] = children.length + i;
+                newData[this.setting.fullPath] = select[this.setting.fullPath] + '-' + newData[this.setting.kid];
+                newData[this.setting.isLeaf] = true;
+                this._getDefaultData(newData, spid);
+                newDatas.push(newData);
+            }
+
+            this.transaction = await this.db.beginTransaction();
+            try {
+                const result = await this.transaction.insert(this.tableName, newDatas);
+                if (children.length === 0) await this.transaction.update(this.tableName, { id: select.id, tree_is_leaf: 0 });
+                await this.transaction.commit();
+            } catch(err) {
+                this.transaction.rollback();
+                throw err;
+            }
+
+            // 查询应返回的结果
+            const resultData = {};
+            resultData.create = await this.getNextsData(spid, select[this.setting.kid], children.length);
+            if (children.length === 0) resultData.update = await this.getDataByKid(spid, select[this.setting.kid]);
+            return resultData;
+        }
+
+        async addNextSibling(spid, select, count) {
+            const maxId = await this._getMaxLid(spid);
+            const newDatas = [];
+
+            for (let i = 1; i < count + 1; i++) {
+                const newData = {};
+                newData[this.setting.kid] = maxId + i;
+                newData[this.setting.pid] = select ? select[this.setting.pid] : this.rootId;
+                newData[this.setting.level] = select ? select[this.setting.level] : 1;
+                newData[this.setting.order] = select ? select[this.setting.order] + i : i;
+                newData[this.setting.fullPath] = newData[this.setting.level] > 1
+                    ? select[this.setting.fullPath].replace('-' + select[this.setting.kid], '-' + newData[this.setting.kid])
+                    : newData[this.setting.kid] + '';
+                newData[this.setting.isLeaf] = true;
+                this._getDefaultData(newData, spid);
+                newDatas.push(newData);
+            }
+
+            this.transaction = await this.db.beginTransaction();
+            try {
+                if (select) await this._updateChildrenOrder(spid, select[this.setting.pid], select[this.setting.order] + 1, count);
+                const insertResult = await this.transaction.insert(this.tableName, newDatas);
+                if (insertResult.affectedRows !== count) throw '新增节点数据错误';
+                await this.transaction.commit();
+            } catch (err) {
+                await this.transaction.rollback();
+                this.transaction = null;
+                throw err;
+            }
+
+            if (select) {
+                const createData = await this.getChildBetween(spid, select[this.setting.pid], select[this.setting.order], select[this.setting.order] + count + 1);
+                const updateData = await this.getNextsData(spid, select[this.setting.pid], select[this.setting.order] + count);
+                return {create: createData, update: updateData};
+            } else {
+                const createData = await this.getChildBetween(spid, -1, 0, count + 1);
+                return {create: createData};
+            }
+        }
+
+        async addProgressNode(spid, targetId, count) {
+            if (!spid) return null;
+
+            const select = targetId ? await this.getDataByKid(spid, targetId) : null;
+            if (targetId && !select) throw '新增节点数据错误';
+
+            if (select[this.setting.level] === 1) {
+                return await this.addChild(spid, select, count);
+            } else {
+                return await this.addNextSibling(spid, select, count);
+            }
+        }
+
+        async updateInfos(spid, data) {
+            if (!spid) throw '数据错误';
+
+            const datas = Array.isArray(data) ? data : [data];
+            const orgDatas = await this.getAllDataByCondition({ where: { id: datas.map(x => { return x.id; })} });
+
+            const updateDatas = [];
+            for (const d of datas) {
+                const node = orgDatas.find(x => { return x.id === d.id; });
+                if (!node || node.spid !== spid) throw '提交数据错误';
+                d.update_user_id = this.ctx.session.sessionUser.accountId;
+                updateDatas.push(this._filterUpdateInvalidField(node.id, d));
+            }
+            await this.db.updateRows(this.tableName, updateDatas);
+
+            const resultData = await this.getDataById(this._.map(datas, 'id'));
+            return { update: resultData };
+        }
+    }
+
+    return SubProjProgress;
+}

+ 9 - 0
app/service/sub_proj_push.js

@@ -0,0 +1,9 @@
+'use strict';
+
+/**
+ *
+ *
+ * @author Mai
+ * @date
+ * @version
+ */

+ 3 - 3
app/view/sub_proj/info.ejs

@@ -5,9 +5,9 @@
             <% include ./sp_info_mini_menu.ejs %>
             <div class="d-inline-block">
                 <div class="btn-group group-tab">
-                    <a class="btn btn-sm btn-light active" href="sp/<%- info.id %>/info">项目信息</a>
-                    <a class="btn btn-sm btn-light" href="sp/<%- info.id %>/progress">阶段进度</a>
-                    <a class="btn btn-sm btn-light" href="sp/<%- info.id %>/push">推进记录</a>
+                    <a class="btn btn-sm btn-light active" href="/sp/<%- info.id %>/info">项目信息</a>
+                    <a class="btn btn-sm btn-light" href="/sp/<%- info.id %>/progress">阶段进度</a>
+                    <a class="btn btn-sm btn-light" href="/sp/<%- info.id %>/push">推进记录</a>
                 </div>
             </div>
         </div>

+ 107 - 0
app/view/sub_proj/progress.ejs

@@ -0,0 +1,107 @@
+<% include ./sp_info_menu.ejs %>
+<div class="panel-content">
+    <div class="panel-title">
+        <div class="title-main d-flex">
+            <% include ./sp_info_mini_menu.ejs %>
+            <div>
+                <div class="d-inline-block">
+                    <div class="btn-group group-tab">
+                        <a class="btn btn-sm btn-light" href="/sp/<%- ctx.subProject.id %>/info">项目信息</a>
+                        <a class="btn btn-sm btn-light active" href="/sp/<%- ctx.subProject.id %>/progress">阶段进度</a>
+                        <a class="btn btn-sm btn-light" href="/sp/<%- ctx.subProject.id %>/push">推进记录</a>
+                    </div>
+                </div>
+                <div class="d-inline-block">
+                    <div class="dropdown">
+                        <button class="btn btn-sm btn-light dropdown-toggle text-primary" type="button" id="dropdownMenuButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                            <i class="fa fa-list-ol"></i> 显示层级
+                        </button>
+                        <div class="dropdown-menu" aria-labelledby="dropdownMenuButton">
+                            <a class="dropdown-item" name="showLevel" tag="1" href="javascript: void(0);">第一层</a>
+                            <a class="dropdown-item" name="showLevel" tag="2" href="javascript: void(0);">第二层</a>
+                            <a class="dropdown-item" name="showLevel" tag="3" href="javascript: void(0);">第三层</a>
+                            <a class="dropdown-item" name="showLevel" tag="4" href="javascript: void(0);">第四层</a>
+                            <a class="dropdown-item" name="showLevel" tag="last" href="javascript: void(0);">最底层</a>
+                        </div>
+                    </div>
+                </div>
+                <div class="d-inline-block">
+                    <a href="javascript: void(0);" name="base-opr" type="add" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="新增"><i class="fa fa-plus" aria-hidden="true"></i></a>
+                    <a href="javascript: void(0);" name="base-opr" type="delete" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="删除"><i class="fa fa-remove" aria-hidden="true"></i></a>
+                    <a href="javascript: void(0);" name="base-opr" type="up-level" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="升级"><i class="fa fa-arrow-left" aria-hidden="true"></i></a>
+                    <a href="javascript: void(0);" name="base-opr" type="down-level" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="降级"><i class="fa fa-arrow-right" aria-hidden="true"></i></a>
+                    <a href="javascript: void(0);" name="base-opr" type="down-move" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="下移"><i class="fa fa-arrow-down" aria-hidden="true"></i></a>
+                    <a href="javascript: void(0);" name="base-opr" type="up-move" class="btn btn-sm btn-light text-primary" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="上移"><i class="fa fa-arrow-up" aria-hidden="true"></i></a>
+                </div>
+            </div>
+        </div>
+    </div>
+    <div class="content-wrap row pr-46">
+        <div class="c-header p-0 col-12">
+        </div>
+        <!--核心内容(两栏)-->
+        <div class="row w-100 sub-content">
+            <!--左栏-->
+            <div class="c-body" id="left-view" style="width: 100%">
+                <div id="progress-spread" class="sjs-height-1"></div>
+                <div z-index="0" style="display: none;">
+                    <input class="datepicker-here form-control form-control-sm" data-date-format="yyyy-MM-DD" data-language="zh" type="text" autocomplete="off" id="dp-input">
+                </div>
+            </div>
+            <div class="c-body" id="right-view" style="display: none; width: 33%;">
+                <div class="resize-x" id="right-spr" r-Type="width" div1="#left-view" div2="#right-view" title="调整大小" a-type="percent"><!--调整左右高度条--></div>
+                <div class="tab-content">
+                    <div id="fujian" class="tab-pane tab-select-show">
+                        <div class="sjs-bar">
+                            <ul class="nav nav-tabs">
+                                <li class="nav-item">
+                                    <a class="nav-link active" data-toggle="tab" href="#cur-att" role="tab" fujian-content="cur-att">当前节点</a>
+                                </li>
+                                <li class="nav-item">
+                                    <a class="nav-link" data-toggle="tab" href="#all-att" role="tab" fujian-content="all-att">所有附件</a>
+                                </li>
+                                <li class="nav-item ml-auto pt-1">
+                                    <button  id="batch-download-att" class="btn btn-sm btn-primary" type="curr">批量下载</button>
+                                    <!--所有附件 翻页-->
+                                    <span id="showPage" style="display: none"><a href="javascript:void(0);" class="page-select ml-3" content="pre"><i class="fa fa-chevron-left"></i></a> <span id="currentPage">1</span>/<span id="totalPage">10</span> <a href="javascript:void(0);" class="page-select mr-3" content="next"><i class="fa fa-chevron-right"></i></a></span>
+                                    <a href="#upload" data-toggle="modal" data-target="#upload" class="btn btn-sm btn-outline-primary ml-3">上传</a>
+                                </li>
+                            </ul>
+                        </div>
+                        <div class="tab-content">
+                            <div class="tab-pane active" id="cur-att">
+                                <div class="sjs-sh-3" style="overflow:auto; overflow-x:hidden;">
+                                    <table class="table table-sm table-bordered table-hover" style="word-break:break-all; table-layout: fixed">
+                                        <tr><th width="25"><input type="checkbox" class="check-all-file"><th>文件名</th><th width="80">上传</th></tr>
+                                        <tbody id="cur-att-list" class="list-table">
+                                        </tbody>
+                                    </table>
+                                </div>
+                            </div>
+                            <div class="tab-pane" id="all-att">
+                                <div class="sjs-sh-3" style="overflow:auto; overflow-x:hidden;">
+                                    <table class="table table-sm table-bordered table-hover" style="word-break:break-all; table-layout: fixed">
+                                        <tr><th width="25"><input type="checkbox" class="check-all-file"></th><th>文件名</th><th width="80">上传</th></tr>
+                                        <tbody id="all-att-list" class="list-table">
+                                        </tbody>
+                                    </table>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+        <!--右侧菜单-->
+        <div class="side-menu">
+            <ul class="nav flex-column right-nav" id="side-menu">
+                <li class="nav-item">
+                    <a class="nav-link" content="#fujian" href="javascript: void(0);">附件</a>
+                </li>
+            </ul>
+        </div>
+    </div>
+    <div style="display: none">
+        <img src="/public/images/ellipsis_horizontal.png" id="ellipsis-icon" />
+    </div>
+</div>

+ 2 - 0
app/view/sub_proj/progress_modal.ejs

@@ -0,0 +1,2 @@
+<% include ../shares/delete_hint_modal.ejs %>
+<% include ../shares/upload_att.ejs %>

+ 31 - 0
app/view/sub_proj/push.ejs

@@ -0,0 +1,31 @@
+<% include ./sp_info_menu.ejs %>
+<div class="panel-content">
+    <div class="panel-title">
+        <div class="title-main d-flex">
+            <% include ./sp_info_mini_menu.ejs %>
+            <div>
+                <div class="d-inline-block">
+                    <div class="btn-group group-tab">
+                        <a class="btn btn-sm btn-light" href="/sp/<%- ctx.subProject.id %>/info">项目信息</a>
+                        <a class="btn btn-sm btn-light" href="/sp/<%- ctx.subProject.id %>/progress">阶段进度</a>
+                        <a class="btn btn-sm btn-light active" href="/sp/<%- ctx.subProject.id %>/push">推进记录</a>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+    <div class="content-wrap row">
+        <div class="c-header p-0 col-12">
+        </div>
+        <!--核心内容(两栏)-->
+        <div class="row w-100 sub-content">
+            <!--左栏-->
+            <div class="c-body" id="left-view" style="width: 100%">
+                <div id="bills-spread" class="sjs-height-1"></div>
+            </div>
+        </div>
+    </div>
+</div>
+<script>
+    const whiteList = JSON.parse('<%- JSON.stringify(ctx.app.config.multipart.whitelist) %>');
+</script>

+ 0 - 0
app/view/sub_proj/push_modal.ejs


+ 34 - 0
config/web.js

@@ -1166,6 +1166,40 @@ const JsFiles = {
                 ],
                 mergeFile: 'sp_info',
             },
+            progress: {
+                files: [
+                    '/public/js/datepicker/datepicker.min.js', '/public/js/datepicker/datepicker.zh.js',
+                    '/public/js/axios/axios.min.js', '/public/js/file-saver/FileSaver.js', '/public/js/js-xlsx/jszip.min.js',
+                    '/public/js/shares/aliyun-oss-sdk.min.js', '/public/js/component/menu.js',
+                    '/public/js/spreadjs/sheets/v11/gc.spread.sheets.all.11.2.2.min.js',
+                ],
+                mergeFiles: [
+                    '/public/js/div_resizer.js',
+                    '/public/js/path_tree.js',
+                    '/public/js/spreadjs_rela/spreadjs_zh.js',
+                    '/public/js/shares/tools_att.js',
+                    '/public/js/shares/ali_oss.js',
+                    '/public/js/sub_menu.js',
+                    '/public/js/sp_progress.js',
+                ],
+                mergeFile: 'sp_progress',
+            },
+            push: {
+                files: [
+                    '/public/js/axios/axios.min.js', '/public/js/file-saver/FileSaver.js', '/public/js/js-xlsx/jszip.min.js',
+                    '/public/js/shares/aliyun-oss-sdk.min.js', '/public/js/component/menu.js',
+                    '/public/js/spreadjs/sheets/v11/gc.spread.sheets.all.11.2.2.min.js',
+                ],
+                mergeFiles: [
+                    '/public/js/div_resizer.js',
+                    '/public/js/spreadjs_rela/spreadjs_zh.js',
+                    '/public/js/shares/tools_att.js',
+                    '/public/js/shares/ali_oss.js',
+                    '/public/js/sub_menu.js',
+                    '/public/js/sp_push.js',
+                ],
+                mergeFile: 'sp_push',
+            },
             data: {
                 files: [
                     '/public/js/component/menu.js',

+ 48 - 0
sql/update.sql

@@ -37,6 +37,54 @@ ALTER TABLE `zh_sub_project_info`
 ADD COLUMN `jg_tp` decimal(24, 8) NOT NULL DEFAULT 0 COMMENT '交工-金额' AFTER `jg_level`,
 ADD COLUMN `jg_memo` text NULL COMMENT '交工-备注' AFTER `jg_tp`;
 
+CREATE TABLE `zh_sub_project_progress`  (
+  `id` varchar(36) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT 'uuid',
+  `spid` varchar(36) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT 'uuid',
+  `tree_id` int(11) NOT NULL COMMENT '节点id',
+  `tree_pid` int(11) NOT NULL COMMENT '父节点id',
+  `tree_order` tinyint(4) NOT NULL COMMENT '同级排序',
+  `tree_level` tinyint(4) NOT NULL COMMENT '层级',
+  `tree_full_path` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT '层级定位辅助字段parent.full_path.ledger_id',
+  `tree_is_leaf` tinyint(4) UNSIGNED NOT NULL DEFAULT 1 COMMENT '是否叶子节点,界面显示辅助字段',
+  `code` varchar(50) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '编号/序号',
+  `name` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '阶段/项目名称',
+  `edit_progress` varchar(20) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '成果编制-进度',
+  `edit_date` varchar(20) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '成果编制-日期',
+  `edit_department` varchar(50) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '成果编制-部门',
+  `submit_progress` varchar(20) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '报审情况-进度',
+  `submit_date` varchar(20) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '报审情况-日期',
+  `submit_department` varchar(50) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '报审情况-部门',
+  `reply_progress` varchar(20) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '批复情况-进度',
+  `reply_date` varchar(20) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '批复情况-日期',
+  `reply_department` varchar(50) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '批复情况-部门',
+  `reply_code` varchar(50) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '批复情况-文号',
+  `memo` varchar(1000) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '备注',
+  `add_user_id` int(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人',
+  `add_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_user_id` int(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后编辑人',
+  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后编辑时间',
+  PRIMARY KEY (`id`) USING BTREE
+) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_unicode_ci ROW_FORMAT = Dynamic;
+
+CREATE TABLE `zh_sub_project_file`  (
+  `id` varchar(36) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT 'uuid',
+  `spid` varchar(36) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT '项目id(sub_project.id)',
+  `type` varchar(20) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '类型(progress/push/...)',
+  `rela_id` varchar(36) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT 'uuid(zh_sub_project_progress.id/zh_sub_project_push.id/...)',
+  `filename` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT '文件名',
+  `fileext` varchar(10) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT '文件后缀',
+  `filesize` int(11) NOT NULL COMMENT '文件大小',
+  `filepath` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT '文件存储路径',
+  `user_id` int(11) UNSIGNED NOT NULL COMMENT '用户id(zh_project_account.id)',
+  `user_name` varchar(50) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '用户名(缓存)',
+  `user_company` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT '公司(缓存)',
+  `user_role` varchar(50) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT '角色(缓存)',
+  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间',
+  `is_deleted` tinyint(4) UNSIGNED NOT NULL DEFAULT 0 COMMENT '是否删除',
+  PRIMARY KEY (`id`) USING BTREE
+) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_unicode_ci ROW_FORMAT = Dynamic;
+
 
 ------------------------------------
 -- 表数据