瀏覽代碼

信息价库页面功能

vian 4 年之前
父節點
當前提交
d6bb80200b

+ 10 - 0
modules/all_models/std_price_info_areas.js

@@ -0,0 +1,10 @@
+// 信息价地区(一个费用定额共用地区)
+const mongoose = require('mongoose');
+
+const Schema = mongoose.Schema;
+const priceInfoArea = new Schema({
+    ID: String,
+    compilationID: String,
+    name: String
+}, {versionKey: false});
+mongoose.model('std_price_info_areas', priceInfoArea, 'std_price_info_areas');

+ 1 - 1
modules/all_models/std_price_info_class.js

@@ -7,7 +7,7 @@ const priceInfoClass = new Schema({
     ParentID: String,
     NextSiblingID: String,
     name: String,
-    area: String,
+    areaID: String,
     libID: String
 }, {versionKey: false});
 mongoose.model('std_price_info_class', priceInfoClass, 'std_price_info_class');

+ 1 - 1
modules/all_models/std_price_info_items.js

@@ -14,7 +14,7 @@ const priceInfoItems = new Schema({
     noTaxPrice: String, // 不含税价格
     // 以下冗余数据为方便前台信息价功能处理
     period: String, // 期数 eg: 2020-05
-    area: String, // 地区
+    areaID: String, // 地区
     compilationID: String, // 费用定额
     remark: String
 }, {versionKey: false});

+ 0 - 4
modules/all_models/std_price_info_lib.js

@@ -6,10 +6,6 @@ const priceInfoLib = new Schema({
     ID: String,
     name: String,
     period: String, // 期数 eg: 2020-05
-    areas: {
-        type: Array,
-        default: []
-    }, // 地区
     compilationID: String,
     createDate: Number,
 }, {versionKey: false});

+ 175 - 0
modules/price_info_lib/controllers/index.js

@@ -0,0 +1,175 @@
+import BaseController from "../../common/base/base_controller";
+import CompilationModel from '../../users/models/compilation_model';
+const facade = require('../facade/index');
+const config = require("../../../config/config.js");
+
+class PriceInfoController extends BaseController {
+    async main(req, res) {
+        const compilationModel = new CompilationModel();
+        const compilationList = await compilationModel.getCompilationList({ _id: 1, name: 1 });
+        compilationList.unshift({ _id: 'all', name: '所有' });
+        const activeCompilation = compilationList.find(compilation => compilation._id.toString() === req.query.filter);
+        if (activeCompilation) {
+            activeCompilation.active = 'active';
+        } else {
+            compilationList[0].active = 'active'
+        }
+        const filter = req.query.filter ? { compilationID: req.query.filter } : {};
+        const libs = await facade.getLibs(filter);
+        libs.forEach(lib => {
+            compilationList.forEach(compilation => {
+                if (compilation._id.toString() === lib.compilationID) {
+                    lib.compilationName = compilation.name;
+                }
+            });
+        });
+        const listItem = `
+            <li class="nav-item">
+                <a class="nav-link" href="javascript:void(0);" aria-haspopup="true" aria-expanded="false" data-toggle="modal" data-target="#crawl">导入材料价格信息</a>
+            </li>`
+        const renderData = {
+            title: '材料信息价库',
+            userAccount: req.session.managerData.username,
+            userID: req.session.managerData.userID,
+            libs: libs,
+            compilationList: compilationList,
+            listItem,
+            layout: 'maintain/common/html/layout'
+        };
+        res.render("maintain/price_info_lib/html/main.html", renderData);
+    }
+
+    async editView(req, res) {
+        const { libID } = req.query;
+        const libs = await facade.getLibs({ ID: libID });
+        if (!libs.length) {
+            return res.send(404);
+        }
+        const areaList = await facade.getAreas(libs[0].compilationID);
+        const renderData = {
+            compilationID: libs[0].compilationID,
+            libName: libs[0].name,
+            areaList: JSON.stringify(areaList),
+            userAccount: req.session.managerData.username,
+            userID: req.session.managerData.userID,
+            LicenseKey: config.getLicenseKey(process.env.NODE_ENV),
+        };
+        res.render("maintain/price_info_lib/html/edit.html", renderData);
+    }
+
+    async addLib(req, res) {
+        try {
+            const { name, period, compilationID } = req.body;
+            await facade.createLib(name, period, compilationID)
+        } catch (err) {
+            console.log(err);
+        }
+        res.redirect(req.headers.referer);
+    }
+
+    async renameLib(req, res) {
+        try {
+            const { libID, name } = JSON.parse(req.body.data);
+            await facade.updateLib({ ID: libID }, { name });
+            res.json({ error: 0, message: 'rename success' });
+        } catch (err) {
+            console.log(err);
+            res.json({ error: 1, message: err.toString() });
+        }
+    }
+
+    async deleteLib(req, res) {
+        try {
+            const { libID } = JSON.parse(req.body.data);
+            await facade.deleteLib(libID);
+            res.json({ error: 0, message: 'delete success' });
+        } catch (err) {
+            console.log(err);
+            res.json({ error: 1, message: err.toString() });
+        }
+    }
+
+    // 爬取数据
+    async crawlData(req, res) {
+        try {
+            const { from, to, compilationID } = JSON.parse(req.body.data);
+            res.setTimeout(1000 * 60 * 60 * 2); // 不设置的话,处理时间过长,会触发默认的响应超时,报错(前端报错,后台还继续在处理)
+            await facade.crawlDataByCompilation(compilationID, from, to);
+            res.json({ error: 0, message: 'crawl success' });
+        } catch (err) {
+            console.log(err);
+            res.json({ error: 1, message: err.toString() });
+        }
+    }
+
+    async editArea(req, res) {
+        try {
+            const { updateData } = JSON.parse(req.body.data);
+            await facade.updateAres(updateData);
+            res.json({ error: 0, message: 'update areas success' });
+        } catch (err) {
+            console.log(err);
+            res.json({ error: 1, message: err.toString() });
+        }
+    }
+
+    async insertArea(req, res) {
+        try {
+            const { insertData } = JSON.parse(req.body.data);
+            await facade.insertAreas(insertData);
+            res.json({ error: 0, message: 'update areas success' });
+        } catch (err) {
+            console.log(err);
+            res.json({ error: 1, message: err.toString() });
+        }
+    }
+
+    async deleteArea(req, res) {
+        try {
+            const { deleteData } = JSON.parse(req.body.data);
+            await facade.deleteAreas(deleteData);
+            res.json({ error: 0, message: 'update areas success' });
+        } catch (err) {
+            console.log(err);
+            res.json({ error: 1, message: err.toString() });
+        }
+    }
+
+    async getClassData(req, res) {
+        try {
+            const { libID, areaID } = JSON.parse(req.body.data);
+            const data = await facade.getClassData(libID, areaID);
+            res.json({ error: 0, message: 'getCLass success', data });
+        } catch (err) {
+            console.log(err);
+            res.json({ error: 1, message: err.toString() });
+        }
+    }
+
+    async getPriceData(req, res) {
+        try {
+            const { classID } = JSON.parse(req.body.data);
+            const data = await facade.getPriceData(classID);
+            res.json({ error: 0, message: 'getPriceData success', data });
+        } catch (err) {
+            console.log(err);
+            res.json({ error: 1, message: err.toString() });
+        }
+    }
+
+    async editPriceData(req, res) {
+        try {
+            const { postData } = JSON.parse(req.body.data);
+            await facade.editPriceData(postData);
+            res.json({ error: 0, message: 'editPrice success' });
+        } catch (err) {
+            console.log(err);
+            res.json({ error: 1, message: err.toString() });
+        }
+    }
+
+}
+
+module.exports = {
+    priceInfoController: new PriceInfoController()
+};

+ 138 - 0
modules/price_info_lib/facade/index.js

@@ -0,0 +1,138 @@
+const mongoose = require('mongoose');
+const uuidV1 = require('uuid/v1');
+
+const priceInfoLibModel = mongoose.model('std_price_info_lib');
+const priceInfoClassModel = mongoose.model('std_price_info_class');
+const priceInfoItemModel = mongoose.model('std_price_info_items');
+const priceInfoAreaModel = mongoose.model('std_price_info_areas');
+const compilationModel = mongoose.model('compilation');
+
+async function getLibs(query) {
+    return await priceInfoLibModel.find(query).lean();
+}
+
+async function createLib(name, period, compilationID) {
+    // 将2020-01变成2020年01月
+    const reg = /(\d{4})-(\d{2})/;
+    const formattedPeriod = period.replace(reg, '$1年$2月');
+    const lib = {
+        ID: uuidV1(),
+        name,
+        period: formattedPeriod,
+        compilationID,
+        createDate: Date.now(),
+    };
+    await priceInfoLibModel.create(lib);
+    return lib;
+}
+
+async function updateLib(query, updateData) {
+    await priceInfoLibModel.update(query, updateData);
+}
+
+async function deleteLib(libID) {
+    await priceInfoClassModel.remove({ libID });
+    await priceInfoItemModel.remove({ libID });
+    await priceInfoLibModel.remove({ ID: libID });
+}
+
+// 爬取数据
+async function crawlDataByCompilation(compilationID, from, to) {
+    if (!compilationID) {
+        throw '无有效费用定额。';
+    }
+    const compilationData = await compilationModel.findOne({ _id: mongoose.Types.ObjectId(compilationID) }, 'overWriteUrl').lean();
+    if (!compilationData || !compilationData.overWriteUrl) {
+        throw '无有效费用定额。';
+    }
+    // 从overWriteUrl提取并组装爬虫文件
+    const reg = /\/([^/]+)\.js/;
+    const matched = compilationData.overWriteUrl.match(reg);
+    const crawlURL = `${matched[1]}_price_crawler.js`;
+    let crawlData;
+    try {
+        const crawler = require(`../../../web/over_write/js/${crawlURL}`);
+        crawlData = crawler.crawlData;
+    } catch (e) {
+        throw '该费用定额无可用爬虫方法。'
+    }
+    await crawlData(from, to);
+}
+
+// 获取费用定额的地区数据
+async function getAreas(compilationID) {
+    return await priceInfoAreaModel.find({ compilationID }, '-_id ID name').lean();
+}
+
+async function updateAres(updateData) {
+    const bulks = [];
+    updateData.forEach(({ ID, name }) => bulks.push({
+        updateOne: {
+            filter: { ID },
+            update: { name }
+        }
+    }));
+    if (bulks.length) {
+        await priceInfoAreaModel.bulkWrite(bulks);
+    }
+}
+
+async function insertAreas(insertData) {
+    await priceInfoAreaModel.insertMany(insertData);
+}
+
+async function deleteAreas(deleteData) {
+    await priceInfoAreaModel.remove({ ID: { $in: deleteData } });
+}
+
+async function getClassData(libID, areaID) {
+    return await priceInfoClassModel.find({ libID, areaID }, '-_id').lean();
+}
+
+async function getPriceData(classID) {
+    return await priceInfoItemModel.find({ classID }, '-_id').lean();
+}
+
+async function editPriceData(postData) {
+    const bulks = [];
+    postData.forEach(data => {
+        if (data.type === 'update') {
+            bulks.push({
+                updateOne: {
+                    filter: { ID: data.ID },
+                    update: { ...data.data }
+                }
+            });
+        } else if (data.type === 'delete') {
+            bulks.push({
+                deleteOne: {
+                    filter: { ID: data.ID }
+                }
+            });
+        } else {
+            bulks.push({
+                insertOne: {
+                    document: data.data
+                }
+            });
+        }
+    });
+    if (bulks.length) {
+        await priceInfoItemModel.bulkWrite(bulks);
+    }
+}
+
+module.exports = {
+    getLibs,
+    createLib,
+    updateLib,
+    deleteLib,
+    crawlDataByCompilation,
+    getAreas,
+    updateAres,
+    insertAreas,
+    deleteAreas,
+    getClassData,
+    getPriceData,
+    editPriceData
+}

+ 28 - 0
modules/price_info_lib/routes/index.js

@@ -0,0 +1,28 @@
+/**
+ * Created by zhang on 2018/9/3.
+ */
+
+const express = require("express");
+const router = express.Router();
+const { priceInfoController } = require('../controllers/index');
+
+module.exports = function (app) {
+
+    router.get("/main", priceInfoController.auth, priceInfoController.init, priceInfoController.main);
+    router.get("/edit", priceInfoController.auth, priceInfoController.init, priceInfoController.editView);
+    router.post("/addLib", priceInfoController.auth, priceInfoController.init, priceInfoController.addLib);
+    router.post("/renameLib", priceInfoController.auth, priceInfoController.init, priceInfoController.renameLib);
+    router.post("/deleteLib", priceInfoController.auth, priceInfoController.init, priceInfoController.deleteLib);
+    router.post("/crawlData", priceInfoController.auth, priceInfoController.init, priceInfoController.crawlData);
+
+    router.post("/editArea", priceInfoController.auth, priceInfoController.init, priceInfoController.editArea);
+    router.post("/insertArea", priceInfoController.auth, priceInfoController.init, priceInfoController.insertArea);
+    router.post("/deleteArea", priceInfoController.auth, priceInfoController.init, priceInfoController.deleteArea);
+    router.post("/getClassData", priceInfoController.auth, priceInfoController.init, priceInfoController.getClassData);
+    router.post("/getPriceData", priceInfoController.auth, priceInfoController.init, priceInfoController.getPriceData);
+    router.post("/editPriceData", priceInfoController.auth, priceInfoController.init, priceInfoController.editPriceData);
+
+    app.use("/priceInfo", router);
+};
+
+

文件差異過大導致無法顯示
+ 70 - 16
public/web/PerfectLoad.js


+ 3 - 4
public/web/common_ajax.js

@@ -119,7 +119,7 @@ var CommonAjax = {
     }
 };
 
-async function ajaxPost(url, data) {
+async function ajaxPost(url, data, timeout = 50000) {
     return new Promise(function (resolve, reject) {
         $.ajax({
             type:"POST",
@@ -127,7 +127,7 @@ async function ajaxPost(url, data) {
             data: {'data': JSON.stringify(data)},
             dataType: 'json',
             cache: false,
-            timeout: 50000,
+            timeout,
             success: function(result){
                 if (result.error === 0 ||result.error ===false) {
                     resolve(result.data);
@@ -145,12 +145,11 @@ async function ajaxPost(url, data) {
     });
 }
 
-
 function ajaxErrorInfo(jqXHR, textStatus, errorThrown) {
     if(textStatus == 'timeout'){
         alert('网络连接超时,请刷新您的网页。');
     }else {
-        alert('url: ' + url +' error ' + textStatus + " " + errorThrown);
+        alert('error ' + textStatus + " " + errorThrown);
     }
 }
 

+ 9 - 0
public/web/sheet/sheet_common.js

@@ -465,4 +465,13 @@ var sheetCommonObj = {
             sheet.resumePaint();
         }
     },
+    renderSheetFunc: function (sheet, func){
+        sheet.suspendEvent();
+        sheet.suspendPaint();
+        if(func){
+            func();
+        }
+        sheet.resumeEvent();
+        sheet.resumePaint();
+    }
 }

+ 4 - 2
public/web/tree_sheet/tree_sheet_controller.js

@@ -3,7 +3,7 @@
  */
 
 var TREE_SHEET_CONTROLLER = {
-    createNew: function (tree, sheet, setting) {
+    createNew: function (tree, sheet, setting, loadHeader = true) {
         var controller = function () {
             this.tree = tree;
             this.sheet = sheet;
@@ -12,7 +12,9 @@ var TREE_SHEET_CONTROLLER = {
                 refreshBaseActn: null,
                 treeSelectedChanged: null
             };
-            TREE_SHEET_HELPER.loadSheetHeader(this.setting, this.sheet);
+            if (loadHeader) {
+                TREE_SHEET_HELPER.loadSheetHeader(this.setting, this.sheet);
+            }
         };
 
         controller.prototype.showTreeData = function () {

+ 144 - 54
web/maintain/common/css/main.css

@@ -1,15 +1,21 @@
 /*building SAAS 0.1*/
+
 /*bootstrap 初始化*/
+
 body {
     font-size: 0.9rem
 }
+
 .dropdown-menu {
     font-size: 0.9rem
 }
+
 /*自定义css*/
+
 .header {
     background: #e1e1e1
 }
+
 .header .header-logo {
     background: #ff6501;
     color: #fff;
@@ -20,28 +26,50 @@ body {
     font-size: 1.25rem;
     line-height: inherit
 }
-.top-msg{
+
+.top-msg {
     position: fixed;
-    top:0;
-    width:100%;
+    top: 0;
+    width: 100%;
     z-index: 999
 }
-.in-1{padding-left:0rem!important}
-.in-2{padding-left:1rem!important}
-.in-3{padding-left:1.5rem!important}
-.in-4{padding-left:2rem!important}
-.in-5{padding-left:2.5rem!important}
-.in-6{padding-left:3rem!important}
+
+.in-1 {
+    padding-left: 0rem!important
+}
+
+.in-2 {
+    padding-left: 1rem!important
+}
+
+.in-3 {
+    padding-left: 1.5rem!important
+}
+
+.in-4 {
+    padding-left: 2rem!important
+}
+
+.in-5 {
+    padding-left: 2.5rem!important
+}
+
+.in-6 {
+    padding-left: 3rem!important
+}
+
 .main {
     position: relative;
     background: #f7f7f9;
 }
+
 .main-nav {
     position: absolute;
     text-align: center;
     z-index: 999;
     padding: 2px 0 0 2px
 }
+
 .main-nav .nav a {
     display: block;
     width: 28px;
@@ -51,81 +79,101 @@ body {
     padding: 10px 0;
     border-right: 1px solid #ccc;
 }
+
 .main-nav .nav a:hover {
     background: #fff;
     color: #333;
     text-decoration: none;
 }
+
 .main-nav .nav a.active {
     border: 1px solid #ccc;
     border-right: 1px solid #fff;
     background: #fff;
     color: #333
 }
+
 .content {
     background: #fff
 }
+
 .tools-btn {
     height: 30px;
     line-height: 30px;
 }
+
 .toolsbar .tools-btn.btn:hover {
     background: #f7f7f9;
 }
+
 .main-side {
     border-right: 1px solid #ccc;
     border-left: 1px solid #ccc;
-    overflow:hidden;
+    overflow: hidden;
 }
+
 .main-side .tab-bar {
-    padding:5px 10px;
-    height:38px;
-    position:fixed;
+    padding: 5px 10px;
+    height: 38px;
+    position: fixed;
 }
+
 .main-side .tab-content {
     margin-top: 38px
 }
+
 .top-content, .fluid-content {
     overflow: hidden;
     border-bottom: 1px solid #ccc;
 }
+
 .warp-p2 {
     padding: 2px
 }
-.bottom-content .nav,.top-content .nav {
+
+.bottom-content .nav, .top-content .nav {
     background: #f7f7f9;
-    padding:0 0 0 2px
+    padding: 0 0 0 2px
 }
-.bottom-content .nav-tabs .nav-link, .side-tabs .nav-tabs .nav-link,.top-content .nav-tabs .nav-link {
+
+.bottom-content .nav-tabs .nav-link, .side-tabs .nav-tabs .nav-link, .top-content .nav-tabs .nav-link {
     border-radius: 0;
     padding: 0.2em 0.5em
 }
+
 .side-tabs .nav-tabs .nav-item {
     z-index: 999
 }
+
 .side-tabs .nav-tabs {
     border-bottom: none;
     margin-bottom: -1px
 }
+
 .side-tabs .nav-tabs .nav-link {
     border-radius: 0;
     padding: 0em 0.5em;
     line-height: 30px;
     z-index: 999
 }
+
 .bottom-content .nav-tabs .nav-link.active {
     border-top: 1px solid #f7f7f9
 }
+
 .side-tabs .nav-tabs .nav-link.active {
     border-top: none;
-    border-bottom:1px solid #fff
+    border-bottom: 1px solid #fff
 }
+
 .side-tabs a.active, .sub-nav a.active {
     background: #ccc
 }
+
 .poj-manage {
     background: #fff
 }
+
 .slide-sidebar {
     border-left: 1px solid #E1E1E1;
     box-shadow: 0px 15px 15px rgba(0, 0, 0, 0.1);
@@ -137,10 +185,12 @@ body {
     z-index: 999;
     width: 0px;
 }
+
 .new-msg {
     -webkit-animation: tada 1s infinite .2s ease both;
     -moz-animation: tada 1s infinite .2s ease both;
 }
+
 @-webkit-keyframes tada {
     0% {
         -webkit-transform: scale(1)
@@ -158,6 +208,7 @@ body {
         -webkit-transform: scale(1) rotate(0)
     }
 }
+
 @-moz-keyframes tada {
     0% {
         -moz-transform: scale(1)
@@ -175,11 +226,13 @@ body {
         -moz-transform: scale(1) rotate(0)
     }
 }
+
 .has-danger {
     -webkit-animation: shake 1s .2s ease both;
     -moz-animation: shake 1s .2s ease both;
     animation: shake 1s .2s ease both;
 }
+
 @-webkit-keyframes shake {
     0%, 100% {
         -webkit-transform: translateX(0);
@@ -191,6 +244,7 @@ body {
         -webkit-transform: translateX(10px);
     }
 }
+
 @-moz-keyframes shake {
     0%, 100% {
         -moz-transform: translateX(0);
@@ -202,6 +256,7 @@ body {
         -moz-transform: translateX(10px);
     }
 }
+
 @keyframes shake {
     0%, 100% {
         transform: translateX(0);
@@ -213,89 +268,109 @@ body {
         transform: translateX(10px);
     }
 }
+
 .bottom-content {
     height: 370px;
     overflow: hidden;
 }
-.bottom-content .tab-content .main-data-bottom{
+
+.bottom-content .tab-content .main-data-bottom {
     height: 340px;
     overflow: auto;
 }
+
 .form-signin {
     max-width: 500px;
     margin: 150px auto;
 }
+
 .poj-list, .side-content {
     overflow: auto;
 }
+
 .poj-list span.poj-icon {
-    padding-right:10px;
-    color:#ccc
+    padding-right: 10px;
+    color: #ccc
 }
-.print-toolsbar{
-    padding:5px
+
+.print-toolsbar {
+    padding: 5px
 }
+
 .print-toolsbar .panel {
-    display:inline-block;
-    vertical-align:top;
-    background:#f7f7f9
+    display: inline-block;
+    vertical-align: top;
+    background: #f7f7f9
 }
-.print-toolsbar .panel .panel-foot{
+
+.print-toolsbar .panel .panel-foot {
     text-align: center;
     font-size: 12px
 }
+
 .print-list {
-    border-right:1px solid #ccc
+    border-right: 1px solid #ccc
 }
+
 .print-list .form-list {
     overflow: auto
 }
-.print-list .list-tools{
-    height:50px;
-    padding:10px 0;
-    border-bottom:1px solid #f2f2f2
+
+.print-list .list-tools {
+    height: 50px;
+    padding: 10px 0;
+    border-bottom: 1px solid #f2f2f2
 }
+
 .pageContainer {
     background: #ededed;
     text-align: center
 }
-.pageContainer .page{
-    border:9px solid transparent;
+
+.pageContainer .page {
+    border: 9px solid transparent;
     display: inline-block;
 }
-.pageContainer .page img{
-    width:inherit;
+
+.pageContainer .page img {
+    width: inherit;
     height: inherit;
 }
-.codeList{
+
+.codeList {
     max-height: 200px;
-    overflow:auto;
+    overflow: auto;
 }
-.main-data-top,.main-data-bottom,.main-data,.main-side,.main-data-side-q{
+
+.main-data-top, .main-data-bottom, .main-data, .main-side, .main-data-side-q {
     overflow: hidden;
 }
+
 .modal-fixed-height {
-    height:400px;
-    overflow-y:auto;
+    height: 400px;
+    overflow-y: auto;
 }
+
 .modal-fixed-height2 {
-    height:368px;
-    overflow-y:auto;
+    height: 368px;
+    overflow-y: auto;
 }
+
 .btn.disabled, .btn:disabled {
     cursor: not-allowed;
     opacity: .65;
-    color:#666
+    color: #666
 }
+
 .modal-lgx {
     max-width: 1022px
 }
 
-.second_header{
+.second_header {
     background: #e1e1e1;
 }
 
-.input-group-addon{
+.input-group-addon {
     padding: 6px 12px;
     font-size: 14px;
     font-weight: 400;
@@ -307,36 +382,41 @@ body {
     border-radius: 4px;
 }
 
-.input-sm{
+.input-sm {
     height: 30px;
     padding: 5px 10px;
     font-size: 12px;
     line-height: 1.5;
     border-radius: 3px;
 }
-.btn-default{
+
+.btn-default {
     color: #333;
     background-color: #fff;
     border-color: #ccc;
 }
-.checkbox{
+
+.checkbox {
     position: relative;
     display: block;
     margin-top: 10px;
     margin-bottom: 10px;
 }
-input[type=checkbox]{
+
+input[type=checkbox] {
     position: absolute;
     margin-top: 5px;
     margin-left: -20px;
 }
-.col-md-2{
+
+.col-md-2 {
     position: relative;
     min-height: 1px;
     padding-right: 15px;
-    padding-left:15px;
-    width:16.66666667%
+    padding-left: 15px;
+    width: 16.66666667%
 }
+
 .checkbox label, .radio label {
     min-height: 20px;
     padding-left: 20px;
@@ -346,7 +426,7 @@ input[type=checkbox]{
     width: 200px;
 }
 
-.close{
+.close {
     float: right;
     font-size: 21px;
     font-weight: 700;
@@ -360,5 +440,15 @@ input[type=checkbox]{
 .disabled {
     pointer-events: none;
     opacity: .65;
-    color:#666;
+    color: #666;
+}
+
+.split {
+    padding: .5rem .75rem;
+    font-size: 1rem;
+    line-height: 1.25;
+}
+
+.tips {
+    font-size: .5rem;
 }

+ 3 - 0
web/maintain/common/html/layout.html

@@ -23,6 +23,9 @@
             <li class="nav-item">
                 <a class="nav-link" href="javascript:void(0);" aria-haspopup="true" aria-expanded="false" data-toggle="modal" data-target="#add">新建<%= title%></a>
             </li>
+            <% if (typeof listItem !=='undefined') { %>
+                <%- listItem %>
+            <% } %>
         </ul>
     </nav>
 </div>

+ 112 - 0
web/maintain/price_info_lib/css/index.css

@@ -0,0 +1,112 @@
+html {
+    height: 100%;
+}
+
+body {
+    height: 100%;
+    font-size: 0.9rem;
+}
+
+.dropdown-menu {
+    font-size: 0.9rem
+}
+
+/*自定义css*/
+
+.header {
+    position: relative;
+    background: #e1e1e1
+}
+
+.header .header-logo {
+    background: #ff6501;
+    color: #fff;
+    float: left;
+    padding-top: .25rem;
+    padding-bottom: .25rem;
+    margin-right: 1rem;
+    font-size: 1.25rem;
+    line-height: inherit
+}
+
+.top-msg {
+    position: fixed;
+    top: 0;
+    width: 100%;
+    z-index: 999
+}
+
+.in-1 {
+    padding-left: 0rem!important
+}
+
+.in-2 {
+    padding-left: 1rem!important
+}
+
+.in-3 {
+    padding-left: 1.5rem!important
+}
+
+.in-4 {
+    padding-left: 2rem!important
+}
+
+.in-5 {
+    padding-left: 2.5rem!important
+}
+
+.in-6 {
+    padding-left: 3rem!important
+}
+
+.disabled {
+    pointer-events: none;
+    opacity: .65;
+    color: #666;
+}
+
+.wrapper {
+    position: absolute;
+    top: 38px;
+    bottom: 0;
+    width: 100%;
+}
+
+.main {
+    height: 100%;
+    width: 100%;
+}
+
+.main .left {
+    float: left;
+    width: 40%;
+    height: 100%;
+}
+
+.main .left .top {
+    height: 40%;
+}
+
+.main .left .bottom {
+    position: relative;
+    height: 60%;
+}
+
+.main .left .bottom .tab-bar {
+    padding: 5px 10px;
+    height: 38px;
+}
+
+.main .left .bottom .spread {
+    position: absolute;
+    top: 38px;
+    bottom: 0;
+    width: 100%;
+}
+
+.main .right {
+    float: left;
+    width: 60%;
+    height: 100%;
+}

+ 83 - 0
web/maintain/price_info_lib/html/edit.html

@@ -0,0 +1,83 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+    <meta http-equiv="x-ua-compatible" content="ie=edge">
+    <title>材料信息价库编辑器</title>
+    <link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css">
+    <link rel="stylesheet" href="/web/maintain/price_info_lib/css/index.css">
+    <link rel="stylesheet" href="/lib/font-awesome/font-awesome.min.css">
+    <link rel="stylesheet" href="/lib/spreadjs/sheets/css/gc.spread.sheets.sc.css" type="text/css">
+    <link rel="stylesheet" href="/lib/jquery-contextmenu/jquery.contextMenu.css" type="text/css">
+</head>
+
+<body>
+    <div class="header">
+        <nav class="navbar navbar-toggleable-lg navbar-light bg-faded p-0 ">
+            <span class="header-logo px-2">Smartcost</span>
+            <div class="navbar-text"><a href="/priceInfo/main">信息价库</a><i
+                    class="fa fa-angle-right fa-fw"></i><%= libName  %></div>
+        </nav>
+    </div>
+    <div class="wrapper">
+        <div class="main">
+            <div class="left">
+                <div class="top" id="area-spread"></div>
+                <div class="bottom">
+                    <div class="tab-bar">
+                        <a href="javascript:void(0);" id="tree_Insert" class="btn btn-sm lock-btn-control disabled"
+                            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);" id="tree_remove" class="btn btn-sm lock-btn-control disabled"
+                            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);" id="tree_upLevel" class="btn btn-sm lock-btn-control disabled"
+                            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);" id="tree_downLevel" class="btn btn-sm lock-btn-control disabled"
+                            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);" id="tree_downMove" class="btn btn-sm lock-btn-control disabled"
+                            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);" id="tree_upMove" class="btn btn-sm lock-btn-control disabled"
+                            data-toggle="tooltip" data-placement="bottom" title="" data-original-title="上移"><i
+                                class="fa fa-arrow-up" aria-hidden="true"></i></a>
+                    </div>
+                    <div class="spread" id="class-spread"></div>
+                </div>
+            </div>
+            <div class="right">
+                <div id="price-spread" style="width: 100%; height: 100%"></div>
+            </div>
+        </div>
+    </div>
+    <!-- JS. -->
+    <script src="/lib/jquery/jquery.min.js"></script>
+    <script src="/lib/jquery-contextmenu/jquery.contextMenu.min.js"></script>
+    <script src="/lib/jquery-contextmenu/jquery.ui.position.js"></script>
+    <script src="/lib/tether/tether.min.js"></script>
+    <script src="/lib/bootstrap/bootstrap.min.js"></script>
+    <script src="/public/web/PerfectLoad.js"></script>
+    <script src="/lib/spreadjs/sheets/gc.spread.sheets.all.11.1.2.min.js"></script>
+    <script>GC.Spread.Sheets.LicenseKey = '<%- LicenseKey %>';</script>
+    <script src="/public/web/uuid.js"></script>
+    <script src="/public/web/scMathUtil.js"></script>
+    <script src="/public/web/treeDataHelper.js"></script>
+    <script src="/public/web/common_ajax.js"></script>
+    <script src="/public/web/lock_util.js"></script>
+    <script src="/public/web/id_tree.js"></script>
+    <script src="/public/web/tools_const.js"></script>
+    <script src="/public/web/tree_sheet/tree_sheet_controller.js"></script>
+    <script src="/public/web/tree_sheet/tree_sheet_helper.js"></script>
+    <script src="/public/web/sheet/sheet_common.js"></script>
+    <script>
+        const areaList = JSON.parse('<%- areaList %>');
+        const compilationID = '<%- compilationID %>';
+    </script>
+    <script src="/web/maintain/price_info_lib/js/index.js"></script>
+</body>
+
+</html>

+ 191 - 0
web/maintain/price_info_lib/html/main.html

@@ -0,0 +1,191 @@
+<div class="main">
+    <div class="content">
+        <div class="container-fluid">
+            <div class="row">
+                <div class="col-md-2">
+                    <div class="list-group mt-3">
+                        <% for (let compilation of compilationList) { %>
+                        <% if (compilation._id === 'all') { %>
+                        <a href="/priceInfo/main"
+                            class="list-group-item list-group-item-action <%= compilation.active %>">
+                            所有
+                        </a>
+                        <% } else { %>
+                        <a id="<%= compilation._id %>" href="/priceInfo/main?filter=<%= compilation._id %>"
+                            class="list-group-item list-group-item-action <%= compilation.active %>">
+                            <%= compilation.name %>
+                        </a>
+                        <% }} %>
+                    </div>
+                </div>
+                <div class="col-md-10">
+                    <div class="warp-p2 mt-3">
+                        <table class="table table-hover table-bordered">
+                            <thead>
+                                <tr>
+                                    <th>材料信息价库名称</th>
+                                    <th width="160">期数</th>
+                                    <th width="160">费用定额</th>
+                                    <th width="160">添加时间</th>
+                                    <th width="70">操作</th>
+                                </tr>
+                            </thead>
+                            <tbody id="showArea">
+                                <% for(let lib of libs){ %>
+                                <tr class="libTr">
+                                    <td id="<%= lib.ID%>"><a
+                                            href="/priceInfo/edit/?libID=<%= lib.ID%>&locked=true"><%= lib.name%></a>
+                                    </td>
+                                    <td><%= lib.period %></td>
+                                    <td><%= lib.compilationName%></td>
+                                    <td><%= moment(lib.createDate).format('YYYY-MM-DD')%></td>
+                                    <td>
+                                        <a class="lock-btn-control disabled" href="javascript:void(0);"
+                                            style="color: #0275d8" onclick='handleEditClick("<%= lib.ID%>")'
+                                            title="编辑"><i class="fa fa-pencil-square-o"></i></a>
+                                        <a class="text-danger lock-btn-control disabled" href="javascript:void(0);"
+                                            onclick='handleDeleteClick("<%= lib.ID%>")' title="删除"><i
+                                                class="fa fa-remove"></i></a>
+                                        <a class="lock" data-locked="true" href="javascript:void(0);" title="解锁"><i
+                                                class="fa fa-unlock-alt"></i></a>
+                                    </td>
+                                </tr>
+                                <% } %>
+                            </tbody>
+                        </table>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+
+<!--弹出添加-->
+<div class="modal fade" id="add" data-backdrop="static" style="display: none;" aria-hidden="true">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title">添加材料信息价库</h5>
+                <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+                    <span aria-hidden="true">×</span>
+                </button>
+            </div>
+            <div class="modal-body">
+                <form id="add-lib-form" method="post" action="/priceInfo/addLib"
+                    enctype="application/x-www-form-urlencoded">
+                    <div class="form-group">
+                        <label>材料信息价库名称</label>
+                        <input id="name" name="name" class="form-control" placeholder="请输入库名称" type="text">
+                        <small class="form-text text-danger" id="name-error" style="display: none">请输入库名称。</small>
+                    </div>
+                    <div class="form-group">
+                        <label>编办名称</label>
+                        <select class="form-control" name="compilationID">
+                            <% for (let compilation of compilationList) { %>
+                            <% if (compilation.name !== '所有') { %>
+                            <option value="<%= compilation._id %>"><%= compilation.name %></option>
+                            <% }} %>
+                        </select>
+                    </div>
+                    <div class="form-group">
+                        <label>期数</label>
+                        <input id="period" name="period" class="form-control" placeholder="请输入期数" type="text">
+                        <small class="form-text text-danger" id="period-error" style="display: none">请输入有效期数(eg:
+                            2020-01)。</small>
+                    </div>
+                    <input type="hidden" name="userAccount" value="<%= userAccount%>">
+                </form>
+            </div>
+            <div class="modal-footer">
+                <button id="add-lib" class="btn btn-primary">新建</button>
+                <button type="button" class="btn btn-secondary" data-dismiss="modal">取消</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<!--弹出编辑-->
+<div class="modal fade" id="edit" data-backdrop="static" style="display: none;" aria-hidden="true">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title">材料信息价库</h5>
+                <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+                    <span aria-hidden="true">×</span>
+                </button>
+            </div>
+            <div class="modal-body">
+                <form>
+                    <div class="form-group">
+                        <label>材料信息价库名称</label>
+                        <input id="rename-text" class="form-control" placeholder="输入名称" type="text" value="">
+                        <small class="form-text text-danger" id="rename-error" style="display: none">请输入名称。</small>
+                    </div>
+                </form>
+            </div>
+            <div class="modal-footer">
+                <a id="rename" href="javascript: void(0);" class="btn btn-primary">确定</a>
+                <button type="button" class="btn btn-secondary" data-dismiss="modal">取消</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<!--弹出导入-->
+<div class="modal fade" id="crawl" data-backdrop="static" style="display: none;" aria-hidden="true">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title">材料信息价库</h5>
+                <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+                    <span aria-hidden="true">×</span>
+                </button>
+            </div>
+            <div class="modal-body">
+                <div class="row">
+                    <div class="col-5 form-group">
+                        <input id="period-start" class="form-control" name="from" placeholder="起始期数" type="text"
+                            value="" autocomplete="off">
+                    </div>
+                    <div class="col-2 form-group">
+                        <label class="split">——</label>
+                    </div>
+                    <div class="col-5 form-group">
+                        <input id="period-end" class="form-control" name="to" placeholder="结束期数" type="text" value="" autocomplete="off">
+                    </div>
+                </div>
+                <small class="form-text text-danger" id="crawl-error" style="display: none">请输入有效期数。</small>
+                <small>期数格式:2020-01</label>
+            </div>
+            <div class="modal-footer">
+                <a id="crawl-confirm" href="javascript: void(0);" class="btn btn-primary">确定</a>
+                <button type="button" class="btn btn-secondary" data-dismiss="modal">取消</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<!--弹出删除-->
+<div class="modal fade" id="del" data-backdrop="static" style="display: none;" aria-hidden="true">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title">删除确认</h5>
+                <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+                    <span aria-hidden="true">×</span>
+                </button>
+            </div>
+            <div class="modal-body">
+                <h5 class="text-danger">删除后无法恢复,确认是否删除?(需确认三次)</h5>
+            </div>
+            <div class="modal-footer">
+                <a id="delete" href="javascript:void(0);" class="btn btn-danger">确认</a>
+                <button type="button" class="btn btn-secondary" data-dismiss="modal">取消</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<script src="/public/web/lock_util.js"></script>
+<script src="/public/web/PerfectLoad.js"></script>
+<script src="/web/maintain/price_info_lib/js/main.js"></script>

+ 449 - 0
web/maintain/price_info_lib/js/index.js

@@ -0,0 +1,449 @@
+function setAlign(sheet, headers) {
+    const fuc = () => {
+        headers.forEach(({ hAlign, vAlign }, index) => {
+            sheetCommonObj.setAreaAlign(sheet.getRange(-1, index, -1, 1), hAlign, vAlign)
+        });
+    };
+    sheetCommonObj.renderSheetFunc(sheet, fuc);
+}
+
+function initSheet(dom, setting) {
+    const workBook = sheetCommonObj.buildSheet(dom, setting);
+    const sheet = workBook.getSheet(0);
+    setAlign(sheet, setting.header);
+    return workBook;
+}
+
+function showData(sheet, data, headers, emptyRows) {
+    const fuc = () => {
+        sheet.setRowCount(data.length);
+        data.forEach((item, row) => {
+            headers.forEach(({ dataCode }, col) => {
+                sheet.setValue(row, col, item[dataCode] || '');
+            });
+        });
+        if (emptyRows) {
+            sheet.addRows(data.length, emptyRows);
+        }
+    };
+    sheetCommonObj.renderSheetFunc(sheet, fuc);
+}
+
+const TIME_OUT = 10000;
+const libID = window.location.search.match(/libID=([^&]+)/)[1];
+
+
+// 地区表
+const AREA_BOOK = (() => {
+    const cache = areaList;
+    const setting = {
+        header: [{ headerName: '地区', headerWidth: $('#area-spread').width(), dataCode: 'name', dataType: 'String', hAlign: 'center', vAlign: 'center' }]
+    };
+    // 初始化表格
+    const workBook = initSheet($('#area-spread')[0], setting);
+    const sheet = workBook.getSheet(0);
+
+    // 显示数据
+    showData(sheet, cache, setting.header);
+
+    // 编辑处理
+    async function handleEdit(changedCells) {
+        const updateData = [];
+        changedCells.forEach(({ row, col }) => {
+            updateData.push({
+                row,
+                ID: cache[row].ID,
+                name: sheet.getValue(row, col)
+            });
+        });
+        try {
+            await ajaxPost('/priceInfo/editArea', { updateData }, TIME_OUT);
+            updateData.forEach(({ row, name }) => cache[row].name = name);
+        } catch (err) {
+            // 恢复各单元格数据
+            sheetCommonObj.renderSheetFunc(sheet, () => {
+                changedCells.forEach(({ row }) => {
+                    sheet.setValue(cache[row].name);
+                });
+            });
+        }
+    }
+    sheet.bind(GC.Spread.Sheets.Events.ValueChanged, function (e, info) {
+        const changedCells = [{ row: info.row, col: info.col }];
+        handleEdit(changedCells);
+    });
+    sheet.bind(GC.Spread.Sheets.Events.RangeChanged, function (e, info) {
+        handleEdit(info.changedCells);
+    });
+
+    const curArea = { ID: null };
+    // 焦点变更处理
+    function handleSelectionChanged(row) {
+        const areaItem = cache[row];
+        curArea.ID = areaItem && areaItem.ID || null;
+        CLASS_BOOK.initData(libID, curArea.ID);
+    }
+    sheet.bind(GC.Spread.Sheets.Events.SelectionChanged, function (e, info) {
+        const row = info.newSelections && info.newSelections[0] ? info.newSelections[0].row : 0;
+        handleSelectionChanged(row);
+    });
+
+    // 新增
+    async function insert() {
+        const data = {
+            compilationID,
+            ID: uuid.v1(),
+            name: '',
+        };
+        try {
+            $.bootstrapLoading.start();
+            await ajaxPost('/priceInfo/insertArea', { insertData: [data] });
+            // 新增的数据总是添加在最后
+            sheet.addRows(cache.length, 1);
+            cache.push(data);
+            const lastRow = cache.length - 1;
+            sheet.setSelection(lastRow, 0, 1, 1);
+            sheet.showRow(lastRow, GC.Spread.Sheets.VerticalPosition.top);
+            handleSelectionChanged(lastRow);
+        } catch (err) {
+            alert(err);
+        } finally {
+            $.bootstrapLoading.end();
+        }
+    }
+    
+    // 删除
+    async function del() {
+        try {
+            $.bootstrapLoading.start();
+            await ajaxPost('/priceInfo/deleteArea', { deleteData: [curArea.ID] });
+            const index = cache.findIndex(item => item.ID === curArea.ID);
+            sheet.deleteRows(index, 1);
+            cache.splice(index, 1);
+            const row = sheet.getActiveRowIndex();
+            handleSelectionChanged(row);
+        } catch (err) {
+            alert(err);
+        } finally {
+            $.bootstrapLoading.end();
+        }
+    }
+
+    // 右键功能
+    function buildContextMenu() {
+        $.contextMenu({
+            selector: '#area-spread',
+            build: function ($triggerElement, e) {
+                // 控制允许右键菜单在哪个位置出现
+                const offset = $('#area-spread').offset();
+                const x = e.pageX - offset.left;
+                const y = e.pageY - offset.top;
+                const target = sheet.hitTest(x, y);
+                if (target.hitTestType === 3 && typeof target.row !== 'undefined' && typeof target.col !== 'undefined') { // 在表格内
+                    const sel = sheet.getSelections()[0];
+                    if (sel && sel.rowCount === 1) {
+                        const orgRow = sheet.getActiveRowIndex();
+                        if (orgRow !== target.row) {
+                            sheet.setActiveCell(target.row, target.col);
+                            handleSelectionChanged(target.row);
+                        }
+                    }
+                    return {
+                        items: {
+                            insert: {
+                                name: '新增',
+                                icon: "fa-arrow-left",
+                                callback: function (key, opt) {
+                                    insert();
+                                }
+                            },
+                            del: {
+                                name: '删除',
+                                icon: "fa-arrow-left",
+                                disabled: function () {
+                                    return !cache[target.row];
+                                },
+                                callback: function (key, opt) {
+                                    del();
+                                }
+                            },
+                        }
+                    };
+                }
+                else {
+                    return false;
+                }
+            }
+        });
+    }
+    buildContextMenu();
+
+    return {
+        handleSelectionChanged,
+        curArea,
+    }
+
+})();
+
+// 分类表
+const CLASS_BOOK = (() => {
+    const setting = {
+        header: [{ headerName: '分类', headerWidth: $('#area-spread').width(), dataCode: 'name', dataType: 'String', hAlign: 'left', vAlign: 'center' }],
+        controller: {
+            cols: [
+                {
+                    data: {
+                        field: 'name',
+                        vAlign: 1,
+                        hAlign: 0,
+                        font: 'Arial'
+                    },
+                }
+            ],
+            headRows: 1,
+            headRowHeight: [30],
+            emptyRows: 0,
+            treeCol: 0
+        },
+        tree: {
+            id: 'ID',
+            pid: 'ParentID',
+            nid: 'NextSiblingID',
+            rootId: -1
+        }
+    };
+    // 初始化表格
+    const workBook = initSheet($('#class-spread')[0], setting);
+    const sheet = workBook.getSheet(0);
+
+    let tree;
+    let controller;
+    // 初始化数据
+    async function initData(libID, areaID) {
+        if (!areaID) {
+            tree = null;
+            controller = null;
+            sheet.setRowCount(0);
+            PRICE_BOOK.clear();
+            return;
+        }
+        $.bootstrapLoading.start();
+        try {
+            const data = await ajaxPost('/priceInfo/getClassData', { libID, areaID }, TIME_OUT);
+            tree = idTree.createNew(setting.tree);
+            tree.loadDatas(data);
+            tree.selected = tree.items.length > 0 ? tree.items[0] : null;
+            controller = TREE_SHEET_CONTROLLER.createNew(tree, sheet, setting.controller, false);
+            controller.showTreeData();
+            handleSelectionChanged(0);
+        } catch (err) {
+            tree = null;
+            controller = null;
+            sheet.setRowCount(0);
+            alert(err);
+        } finally {
+            $.bootstrapLoading.end();
+        }
+    }
+
+    // 编辑处理
+    async function handleEdit(changedCells) {
+        const updateData = [];
+        changedCells.forEach(({ row, col }) => {
+            updateData.push({
+                row,
+                ID: tree.items[row].data.ID,
+                name: sheet.getValue(row, col)
+            });
+        });
+        try {
+            await ajaxPost('/priceInfo/editClassData', { updateData }, TIME_OUT);
+            updateData.forEach(({ row, name }) => tree.items[row].data.name = name);
+        } catch (err) {
+            // 恢复各单元格数据
+            sheetCommonObj.renderSheetFunc(sheet, () => {
+                changedCells.forEach(({ row }) => {
+                    sheet.setValue(tree.items[row].data.name);
+                });
+            });
+        }
+    }
+    sheet.bind(GC.Spread.Sheets.Events.ValueChanged, function (e, info) {
+        const changedCells = [{ row: info.row, col: info.col }];
+        handleEdit(changedCells);
+    });
+    sheet.bind(GC.Spread.Sheets.Events.RangeChanged, function (e, info) {
+        handleEdit(info.changedCells);
+    });
+
+    // 焦点变更处理
+    const curClass = { ID: null };
+    function handleSelectionChanged(row) {
+        const classNode = tree.items[row];
+        curClass.ID = classNode && classNode.data && classNode.data.ID || null;
+        PRICE_BOOK.initData(curClass.ID);
+    }
+    sheet.bind(GC.Spread.Sheets.Events.SelectionChanged, function (e, info) {
+        const row = info.newSelections && info.newSelections[0] ? info.newSelections[0].row : 0;
+        handleSelectionChanged(row);
+    });
+
+    return {
+        initData,
+        handleSelectionChanged,
+        curClass,
+    }
+
+})();
+
+// 价格信息表
+const PRICE_BOOK = (() => {
+    const setting = {
+        header: [
+            { headerName: '编码', headerWidth: 100, dataCode: 'code', dataType: 'String', hAlign: 'left', vAlign: 'center' },
+            { headerName: '名称', headerWidth: 200, dataCode: 'name', dataType: 'String', hAlign: 'left', vAlign: 'center' },
+            { headerName: '规格型号', headerWidth: 120, dataCode: 'specs', dataType: 'String', hAlign: 'left', vAlign: 'center' },
+            { headerName: '单位', headerWidth: 80, dataCode: 'unit', dataType: 'String', hAlign: 'center', vAlign: 'center' },
+            { headerName: '不含税价', headerWidth: 80, dataCode: 'noTaxPrice', dataType: 'String', hAlign: 'right', vAlign: 'center' },
+            { headerName: '含税价', headerWidth: 80, dataCode: 'taxPrice', dataType: 'String', hAlign: 'right', vAlign: 'center' },
+        ],
+    };
+    // 初始化表格
+    const workBook = initSheet($('#price-spread')[0], setting);
+    const sheet = workBook.getSheet(0);
+
+    let cache = [];
+    // 清空
+    function clear() {
+        cache = [];
+        sheet.setRowCount(0);
+    }
+    // 初始化数据
+    async function initData(classID) {
+        if (!classID) {
+            return clear();
+        }
+        $.bootstrapLoading.start();
+        try {
+            cache = await ajaxPost('/priceInfo/getPriceData', { classID }, TIME_OUT);
+            showData(sheet, cache, setting.header, 5);
+        } catch (err) {
+            cache = [];
+            sheet.setRowCount(0);
+            alert(err);
+        } finally {
+            $.bootstrapLoading.end();
+        }
+    }
+
+    // 获取当前表中行数据
+    function getRowData(sheet, row, headers) {
+        const item = {};
+        headers.forEach(({ dataCode }, index) => {
+            const value = sheet.getValue(row, index) || '';
+            if (value) {
+                item[dataCode] = value;
+            }
+        });
+        return item;
+    }
+
+    // 获取表数据和缓存数据的不同数据
+    function getRowDiffData(curRowData, cacheRowData, headers) {
+        let item = null;
+        headers.forEach(({ dataCode }) => {
+            const curValue = curRowData[dataCode];
+            const cacheValue = cacheRowData[dataCode];
+            if (!cacheValue && !curValue) {
+                return;
+            }
+            if (cacheValue !== curValue) {
+                if (!item) {
+                    item = {};
+                }
+                item[dataCode] = curValue || '';
+            }
+        });
+        return item;
+    }
+
+    // 编辑处理
+    async function handleEdit(changedCells) {
+        const postData = []; // 请求用
+        // 更新缓存用
+        const updateData = [];
+        const deleteData = [];
+        const insertData = [];
+        try {
+            changedCells.forEach(({ row }) => {
+                if (cache[row]) {
+                    const rowData = getRowData(sheet, row, setting.header);
+                    if (Object.keys(rowData).length) { // 还有数据,更新
+                        const diffData = getRowDiffData(rowData, cache[row], setting.header);
+                        if (diffData) {
+                            postData.push({ type: 'update', ID: cache[row].ID, data: diffData });
+                            updateData.push({ row, data: diffData });
+                        }
+                    } else { // 该行无数据了,删除
+                        postData.push({ type: 'delete', ID: cache[row].ID });
+                        deleteData.push(cache[row]);
+                    }
+                } else { // 新增
+                    const rowData = getRowData(sheet, row, setting.header);
+                    if (Object.keys(rowData).length) {
+                        rowData.ID = uuid.v1();
+                        rowData.libID = libID;
+                        rowData.areaID = AREA_BOOK.curArea.ID;
+                        rowData.classID = CLASS_BOOK.curClass.ID;
+                        postData.push({ type: 'create', data: rowData });
+                        insertData.push(rowData);
+                    }
+                }
+            });
+            if (postData.length) {
+                await ajaxPost('/priceInfo/editPriceData', { postData }, TIME_OUT);
+                // 更新缓存,先更新然后删除,最后再新增,防止先新增后缓存数据的下标与更新、删除数据的下标对应不上
+                updateData.forEach(item => {
+                    Object.assign(cache[item.row], item.data);
+                });
+                deleteData.forEach(item => {
+                    const index = cache.indexOf(item);
+                    if (index >= 0) {
+                        cache.splice(index, 1);
+                    }
+                });
+                insertData.forEach(item => cache.push(item));
+                if (deleteData.length || insertData.length) {
+                    showData(sheet, cache, setting.header, 5);
+                }
+            }
+        } catch (err) {
+            // 恢复各单元格数据
+            showData(sheet, cache, setting.header, 5);
+        }
+    }
+    sheet.bind(GC.Spread.Sheets.Events.ValueChanged, function (e, info) {
+        const changedCells = [{ row: info.row }];
+        handleEdit(changedCells);
+    });
+    sheet.bind(GC.Spread.Sheets.Events.RangeChanged, function (e, info) {
+        const changedRows = [];
+        let preRow;
+        info.changedCells.forEach(({ row }) => {
+            if (row !== preRow) {
+                changedRows.push({ row });
+            }
+            preRow = row;
+        });
+        handleEdit(changedRows);
+    });
+
+    return {
+        clear,
+        initData,
+    }
+})();
+
+$(document).ready(() => {
+    AREA_BOOK.handleSelectionChanged(0);
+});

+ 129 - 0
web/maintain/price_info_lib/js/main.js

@@ -0,0 +1,129 @@
+// 节流
+function throttle(fn, time) {
+    let canRun = true;
+    return function () {
+        if (!canRun) {
+            return;
+        }
+        canRun = false;
+        const rst = fn.apply(this, arguments);
+        // 事件返回错误,说明没有发起请求,允许直接继续触发事件,不进行节流处理
+        if (rst === false) {
+            canRun = true;
+            return;
+        }
+        setTimeout(() => canRun = true, time);
+    }
+}
+
+const periodReg = /\d{4}-(0[1-9])|(1[0-2])$/;
+function createLib() {
+    const name = $('#name').val();
+    if (!name) {
+        $('#name-error').show();
+        return false;
+    }
+    const period = $('#period').val();
+    if (!period || !periodReg.test(period)) {
+        $('#period-error').show();
+        return false;
+    }
+    $('#add-lib-form').submit();
+}
+
+let curLib = {};
+
+//设置当前库
+function setCurLib(libID) {
+    curLib.id = libID;
+    curLib.name = $(`#${libID}`).text();
+}
+
+// 点击编辑按钮
+function handleEditClick(libID) {
+    setCurLib(libID);
+    $('#edit').modal('show');
+}
+
+// 点击确认编辑
+function handleEditConfirm() {
+    const rename = $('#rename-text').val();
+    if (!rename) {
+        $('#rename-error').show();
+        return false;
+    }
+    $('#edit').modal('hide');
+    ajaxPost('/priceInfo/renameLib', { libID: curLib.id, name: rename })
+        .then(() => $(`#${curLib.id} a`).text(rename));
+}
+
+// 删除需要连需点击三次才可删除
+let curDelCount = 0;
+// 点击删除按钮
+function handleDeleteClick(libID) {
+    setCurLib(libID);
+    curDelCount = 0;
+    $('#del').modal('show');
+}
+
+// 删除确认
+function handleDeleteConfirm() {
+    curDelCount++;
+    if (curDelCount === 3) {
+        $.bootstrapLoading.start();
+        curDelCount = -10; // 由于需要连续点击,因此没有对此事件进行节流,为防止多次请求,一旦连续点击到三次,马上清空次数。
+        $('#del').modal('hide');
+        ajaxPost('/priceInfo/deleteLib', { libID: curLib.id })
+            .then(() => $(`#${curLib.id}`).parent().remove())
+            .finally(() => $.bootstrapLoading.end());
+    }
+}
+
+// 爬取数据确认
+function handleCrawlConfirm() {
+    const from = $('#period-start').val();
+    const to = $('#period-end').val();
+    if (!periodReg.test(from) || !periodReg.test(to)) {
+        $('#crawl-error').show();
+        return false;
+    }
+    const matched = window.location.search.match(/filter=(.+)/);
+    const compilationID = matched && matched[1] || '';
+    $('#crawl').modal('hide');
+    $.bootstrapLoading.progressStart('爬取数据', true);
+    $("#progress_modal_body").text('正在爬取数据,请稍候……');
+    // 不用定时器的话,可能finally处理完后,进度条界面才显示,导致进度条界面没有被隐藏
+    const time = setInterval(() => {
+        if ($('#progressModal').is(':visible')) {
+            clearInterval(time);
+            ajaxPost('/priceInfo/crawlData', { from, to, compilationID }, 0) // 没有timeout
+                .then(() => {
+                    window.location.reload();
+                })
+                .finally(() => $.bootstrapLoading.progressEnd());
+        }
+    }, 500);
+}
+
+const throttleTime = 1000;
+$(document).ready(function () {
+    // 锁定、解锁
+    $('.lock').click(function () {
+        lockUtil.handleLockClick($(this));
+    });
+    // 新增
+    $('#add-lib').click(throttle(createLib, throttleTime));
+    // 重命名
+    $('#rename').click(throttle(handleEditConfirm, throttleTime));
+    // 删除
+    $('#delete').click(handleDeleteConfirm);
+    // 爬取数据
+    $('#crawl-confirm').click(throttle(handleCrawlConfirm, throttleTime));
+
+    $('#add').on('hidden.bs.modal', () => {
+        $('#name-error').hide();
+        $('#period-error').hide();
+    });
+    $('#edit').on('hidden.bs.modal', () => $('#rename-error').hide());
+    $('#crawl').on('hidden.bs.modal', () => $('#crawl-error').hide());
+});

+ 45 - 30
web/over_write/js/chongqing_2018_price_crawler.js

@@ -20,6 +20,7 @@ const compilationModel = mongoose.model('compilation');
 const priceInfoLibModel = mongoose.model('std_price_info_lib');
 const priceInfoClassModel = mongoose.model('std_price_info_class');
 const priceInfoItemModel = mongoose.model('std_price_info_items');
+const priceInfoAreaModel = mongoose.model('std_price_info_areas');
 
 const isDebug = true;
 
@@ -580,13 +581,13 @@ async function crawlMixedData(period) {
  * @param {String} libID - 库ID
  * @param {String} classID - 所属分类ID
  * @param {String} period - 期数 eg:2020年01月
- * @param {String} area - 地区
+ * @param {String} areaID - 地区ID
  * @param {String} compilationID - 费用定额ID
  * @param {Array<object>} items - 爬取的信息价源数据
  * @param {Number} tableType - 表格类型
  * @return {Array<obejct>}
  */
-function transformPriceItems(libID, classID, period, area, compilationID, items, tableType) {
+function transformPriceItems(libID, classID, period, areaID, compilationID, items, tableType) {
     const rst = [];
     if (tableType === TableType.GARDEN) {
         // 有的数据 高度(CM) | 干径(CM) | 冠径(CM) | 分枝高(CM) | 不含税价(元) = ‘’ | 14-17 | 大于400 | 200-300 | 430-780
@@ -630,10 +631,10 @@ function transformPriceItems(libID, classID, period, area, compilationID, items,
                     taxPrice: taxPriceList[1] || '',
                     noTaxPrice: noTaxPriceList[1] || ''
                 };
-                rst.push(transfromPriceItem(libID, classID, period, area, compilationID, minItem));
-                rst.push(transfromPriceItem(libID, classID, period, area, compilationID, maxItem));
+                rst.push(transfromPriceItem(libID, classID, period, areaID, compilationID, minItem));
+                rst.push(transfromPriceItem(libID, classID, period, areaID, compilationID, maxItem));
             } else {
-                rst.push(transfromPriceItem(libID, classID, period, area, compilationID, item));
+                rst.push(transfromPriceItem(libID, classID, period, areaID, compilationID, item));
             }
         })
     } else {
@@ -662,13 +663,13 @@ function transformPriceItems(libID, classID, period, area, compilationID, items,
                         taxPrice: taxPriceList[index] || taxPriceList[0],
                         noTaxPrice: noTaxPriceList[index] || noTaxPriceList[0]
                     };
-                    if (area) {
-                        newItem.area = area;
+                    if (areaID) {
+                        newItem.areaID = areaID;
                     }
-                    rst.push(transfromPriceItem(libID, classID, period, area, compilationID, newItem));
+                    rst.push(transfromPriceItem(libID, classID, period, areaID, compilationID, newItem));
                 });
             } else {
-                rst.push(transfromPriceItem(libID, classID, period, area, compilationID, item));
+                rst.push(transfromPriceItem(libID, classID, period, areaID, compilationID, item));
             }
         });
     }
@@ -676,10 +677,10 @@ function transformPriceItems(libID, classID, period, area, compilationID, items,
 }
 
 // 转换单条的价格数据
-function transfromPriceItem(libID, classID, period, area, compilationID, item) {
+function transfromPriceItem(libID, classID, period, areaID, compilationID, item) {
     // 源数据中的规格型号存在多个无意义的空格,合并为一个
     const reg = /\s{2,}/g;
-    item.specs = item.specs.replace(reg, ' ');
+    item.specs = item.specs ? item.specs.replace(reg, ' ') : '';
     return {
         ID: uuidV1(),
         libID,
@@ -693,7 +694,7 @@ function transfromPriceItem(libID, classID, period, area, compilationID, item) {
         remark: item.remark || '',
         // 以下冗余数据为方便前台信息价功能处理
         period,
-        area,
+        areaID,
         compilationID,
     }
 }
@@ -703,13 +704,17 @@ function transfromPriceItem(libID, classID, period, area, compilationID, item) {
  * @param {String} period - 日期: 2020年01月
  * @param {String} compilationID - 费用定额ID
  * @param {Object} generalData - 主要材料{ building, garden, energy }
- * @return {Object} { libData, classData, priceData }
+ * @return {Object} { libData, classData, priceData, compilationAreas }
  */
-function transfromGeneralData(period, compilationID, generalData) {
+async function transfromGeneralData(period, compilationID, generalData) {
     const area = '通用';
+    // 爬取数据的时候,地区数据先匹配名称,如果费用定额已有此地区,不新增
+    const matchedArea = await priceInfoAreaModel.findOne({ compilationID, name: area }).lean();
+    const areaID = matchedArea && matchedArea.ID || uuidV1();
+    const compilationAreas = [];
     const libData = {
         ID: uuidV1(),
-        name: `${area}信息价(${period})`,
+        name: `信息价(${period})`,
         period,
         areas: [],
         compilationID,
@@ -735,11 +740,11 @@ function transfromGeneralData(period, compilationID, generalData) {
     // 绿色节能分类数据:绿色、节能建筑工程材料
     const energyData = [{ materialClass: '绿色、节能建筑工程材料', items: energy }];
     handleClassAndItems(energyData, TableType.ENERGY);
-    // 有数据才将地区push入areas中,必须返回非空的libData,后续转换地区数据需要这个libData(一期只生成一个库)
-    if (classData.length || priceData.length) {
-        libData.areas.push(area);
+    // 有数据才将地区push入areas中(费用定额共用)
+    if ((classData.length || priceData.length) && !matchedArea) {
+        compilationAreas.push({ compilationID, ID: areaID, name: area })
     }
-    return { libData, classData, priceData };
+    return { libData, classData, priceData, compilationAreas };
 
     function handleClassAndItems(sourceData, tableType) {
         if (!sourceData) {
@@ -752,7 +757,7 @@ function transfromGeneralData(period, compilationID, generalData) {
                 NextSiblingID: treeData && treeData.NextSiblingID || '-1',
                 name: materialClass,
                 libID: libData.ID,
-                area,
+                areaID,
             };
             // 设置上一个节点数据的NextID
             let count = 1;
@@ -768,7 +773,7 @@ function transfromGeneralData(period, compilationID, generalData) {
             classData.push(classItem);
             // 转换价格数据
             if (items && items.length) {
-                const newItems = transformPriceItems(libData.ID, classItem.ID, period, area, compilationID, items, tableType);
+                const newItems = transformPriceItems(libData.ID, classItem.ID, period, areaID, compilationID, items, tableType);
                 newItems.forEach(item => priceData.push(item));
             }
         });
@@ -784,8 +789,9 @@ function transfromGeneralData(period, compilationID, generalData) {
  * @param {Object} libData - 当前期数库数据
  * @param {Array<object>} areaData - 各区县地方材料工地价格
  * @param {Array<object>} mixedData - 预拌砂浆信息价格
+ * @return {Object}
  */
-function transformAreaData(period, compilationID, libData, areaData, mixedData) {
+async function transformAreaData(period, compilationID, libData, areaData, mixedData) {
     // 根据地区进行分类
     const data = [];
     const hashMap = {}; // 保证地区顺序跟网页爬取数据的顺序一致。(object for in无法保证顺序)
@@ -820,10 +826,15 @@ function transformAreaData(period, compilationID, libData, areaData, mixedData)
     }
     buildData(areaData);
     buildData(mixedData);
+    const compilationAreas = [];
     const classData = [];
     const priceData = [];
-    data.forEach(({ area, subData }) => {
-        libData.areas.push(area);
+    for (const { area, subData } of data) {
+        const matchedArea = await priceInfoAreaModel.findOne({ compilationID, name: area }).lean();
+        const areaID = matchedArea && matchedArea.ID || uuidV1();
+        if (!matchedArea) {
+            compilationAreas.push({ compilationID, ID: areaID, name: area });
+        }
         let preClass;
         subData.forEach(subItem => {
             if (!subItem) {
@@ -836,18 +847,18 @@ function transformAreaData(period, compilationID, libData, areaData, mixedData)
                 NextSiblingID: '-1',
                 name: className,
                 libID: libData.ID,
-                area,
+                areaID,
             };
             classData.push(classItem);
             if (preClass) {
                 preClass.NextSiblingID = classItem.ID;
             }
             preClass = classItem;
-            const newItems = transformPriceItems(libData.ID, classItem.ID, period, area, compilationID, items, TableType.AREA);
+            const newItems = transformPriceItems(libData.ID, classItem.ID, period, areaID, compilationID, items, TableType.AREA);
             newItems.forEach(item => priceData.push(item));
         });
-    });
-    return { classData, priceData };
+    }
+    return { classData, priceData, compilationAreas };
 }
 
 /**
@@ -866,12 +877,13 @@ async function save(period, generalData, areaData, mixedData) {
     }
     const compilationID = compilation._id;
     // 转换数据
-    const generalSaveData = transfromGeneralData(period, compilationID, generalData);
+    const generalSaveData = await transfromGeneralData(period, compilationID, generalData);
     const libData = generalSaveData.libData;
-    const areaSaveData = transformAreaData(period, compilationID, libData, areaData, mixedData);
+    const areaSaveData = await transformAreaData(period, compilationID, libData, areaData, mixedData);
     // 入库
     const classData = [...generalSaveData.classData, ...areaSaveData.classData];
     const priceData = [...generalSaveData.priceData, ...areaSaveData.priceData];
+    const compilationAreas = [...generalSaveData.compilationAreas, ...areaSaveData.compilationAreas]
     // 删除已有的相同期数数据
     const originalLibs = await priceInfoLibModel.find({ period }, '-_id ID').lean();
     const originalLibIDList = originalLibs.reduce((acc, cur) => {
@@ -893,6 +905,9 @@ async function save(period, generalData, areaData, mixedData) {
     if (libData) {
         await priceInfoLibModel.insertMany([libData]);
     }
+    if (compilationAreas) {
+        await priceInfoAreaModel.insertMany(compilationAreas);
+    }
 }
 
 /**