Browse Source

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

Tony Kang 3 days atrás
parent
commit
24ad12eaa3
47 changed files with 2839 additions and 678 deletions
  1. 1 0
      app/const/page_show.js
  2. 3 1
      app/const/project_screen.js
  3. 5 4
      app/controller/file_controller.js
  4. 1 1
      app/controller/project_screen_controller.js
  5. 17 5
      app/controller/setting_controller.js
  6. 24 8
      app/controller/sub_proj_setting_controller.js
  7. 118 0
      app/lib/filing_directory.js
  8. 0 36
      app/lib/project_screen.js
  9. 128 0
      app/lib/tender_approval_setting.js
  10. 4 5
      app/middleware/stage_check.js
  11. 162 9
      app/public/css/main.css
  12. BIN
      app/public/images/guangyuandaping.png
  13. 60 23
      app/public/js/assistant.js
  14. 232 116
      app/public/js/filesjs.js
  15. 9 0
      app/public/js/ledger.js
  16. 31 10
      app/public/js/project_screen_guangyuan.js
  17. 3 8
      app/public/js/project_screen_setting.js
  18. 572 0
      app/public/js/project_screen_tender_setting.js
  19. 144 0
      app/public/js/setting_approval.js
  20. 62 64
      app/public/js/setting_manage.js
  21. 9 6
      app/public/js/shares/show_level.js
  22. 230 0
      app/public/js/shares/tender_approval_draft.js
  23. 102 18
      app/public/js/shenpi.js
  24. 1 0
      app/router.js
  25. 16 3
      app/service/filing.js
  26. 59 31
      app/service/project_screen.js
  27. 269 0
      app/service/project_screen_tender.js
  28. 19 12
      app/service/stage.js
  29. 3 3
      app/service/stage_audit.js
  30. 1 1
      app/service/stage_bills.js
  31. 2 0
      app/service/sub_project.js
  32. 4 0
      app/service/tender.js
  33. 56 0
      app/service/tender_approval_setting.js
  34. 19 0
      app/view/file/filesjs_modal.ejs
  35. 4 4
      app/view/layout/assistant.ejs
  36. 8 1
      app/view/project_screen/guangyuan.ejs
  37. 13 0
      app/view/project_screen/guangyuan_modal.ejs
  38. 60 2
      app/view/setting/module.ejs
  39. 24 0
      app/view/sp_setting/approval.ejs
  40. 12 0
      app/view/sp_setting/approval_editor_modal.ejs
  41. 297 0
      app/view/sp_setting/approval_modal.ejs
  42. 1 20
      app/view/sp_setting/manage.ejs
  43. 20 0
      app/view/sp_setting/manage_data.ejs
  44. 6 287
      app/view/sp_setting/manage_modal.ejs
  45. 6 0
      config/menu.js
  46. 11 0
      config/web.js
  47. 11 0
      sql/update.sql

+ 1 - 0
app/const/page_show.js

@@ -68,6 +68,7 @@ const defaultSetting = {
     isCommonSetup: 1,
     isPrivateSetup: 1,
     onlyOrgAdminArchivePDF: 0,
+    projectScreenTenderVersion: 0,
 };
 
 

+ 3 - 1
app/const/project_screen.js

@@ -10,12 +10,14 @@ const templates = [
         name: '通用大屏一',
         description: '通用项目综合展示模板',
         previewClass: 'project-screen-preview-general1',
+        hidden: true,
     },
     {
         code: 'general3',
         name: '通用大屏三',
         description: '通用项目图表展示模板',
         previewClass: 'project-screen-preview-general3',
+        hidden: true,
     },
     {
         code: 'guangyuan',
@@ -28,6 +30,6 @@ const templates = [
 const getTemplate = code => templates.find(item => item.code === code) || null;
 
 module.exports = {
-    templates,
+    templates: templates.filter(item => !item.hidden),
     getTemplate,
 };

+ 5 - 4
app/controller/file_controller.js

@@ -11,6 +11,7 @@ const auditConst = require('../const/audit');
 const sendToWormhole = require('stream-wormhole');
 const path = require('path');
 const advanceConst = require('../const/advance');
+const FilingDirectory = require('../lib/filing_directory');
 
 const FILE_MANAGEMENT_EXTRA_EXTENSIONS = [
     '.sbp', '.jygs', '.gkgs', '.sjgs', '.sgys', '.qdys', '.gcjs', '.sjys',
@@ -646,7 +647,8 @@ module.exports = app => {
                 const data = JSON.parse(ctx.request.body.data);
                 const filing = await this.getFilingFromDirectoryData(ctx, data);
                 await this.checkFilingOperation(ctx, filing, 'can_edit_dir');
-                const result = await ctx.service.filing.add(data);
+                const directory = new FilingDirectory(ctx, this.isAdmin(ctx), this.hasFileConfigPermission(ctx));
+                const result = await directory.add(data);
                 ctx.body = { err: 0, msg: '', data: result };
             } catch (err) {
                 ctx.log(err);
@@ -657,9 +659,8 @@ module.exports = app => {
             try {
                 this.checkUnlock(ctx);
                 const data = JSON.parse(ctx.request.body.data);
-                const filing = await this.getFilingFromDirectoryData(ctx, data);
-                await this.checkFilingOperation(ctx, filing, 'can_edit_dir');
-                const result = await ctx.service.filing.del(data);
+                const directory = new FilingDirectory(ctx, this.isAdmin(ctx), this.hasFileConfigPermission(ctx));
+                const result = await directory.remove(data);
                 ctx.body = { err: 0, msg: '', data: result };
             } catch (err) {
                 ctx.log(err);

+ 1 - 1
app/controller/project_screen_controller.js

@@ -37,7 +37,7 @@ module.exports = app => {
                 renderData.finishedProjects = listOnly(finishedProjects);
                 renderData.plannedProjects = listOnly(plannedProjects);
                 renderData.buildingProjects = listOnly(buildingProjects);
-                renderData.overview = template.code === 'guangyuan' ? await ctx.service.projectScreen.getOverview(plannedProjects, buildingProjects, finishedProjects) : null;
+                renderData.overview = template.code === 'guangyuan' ? await ctx.service.projectScreen.getOverview(summaries) : null;
                 await this.layout('project_screen/index.ejs', renderData, template.code === 'guangyuan' ? 'project_screen/guangyuan_modal.ejs' : '');
             } catch (error) {
                 this.log(error);

+ 17 - 5
app/controller/setting_controller.js

@@ -60,12 +60,13 @@ module.exports = app => {
                 const projectId = ctx.session.sessionProject.id;
                 await this._checkMenu(projectId);
                 const screens = await ctx.service.projectScreen.getList(projectId);
-                await this.layout('setting/module.ejs', {
+                const renderData = {
                     screens,
                     templates: projectScreenConst.templates,
                     projectScreenEnabled: ctx.session.sessionProject.page_show.openProjectScreen === 1,
-                    jsFiles: this.app.jsFiles.common.concat(['/public/js/project_screen_setting.js']),
-                }, 'setting/module_modal.ejs');
+                    jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.setting.module),
+                };
+                await this.layout('setting/module.ejs', renderData, 'setting/module_modal.ejs');
             } catch (error) {
                 ctx.log(error);
                 ctx.redirect('/dashboard');
@@ -83,8 +84,7 @@ module.exports = app => {
                     case 'status': {
                         errorMessage = '保存项目大屏开关失败';
                         const enabled = parseInt(data.enabled) === 1 ? 1 : 0;
-                        ctx.session.sessionProject.page_show.openProjectScreen = enabled;
-                        await ctx.service.project.updatePageshow(projectId);
+                        await ctx.service.projectScreen.saveModuleStatus(projectId, enabled);
                         responseData.data = { enabled };
                         break;
                     }
@@ -117,6 +117,18 @@ module.exports = app => {
                         errorMessage = '保存项目大屏权限失败';
                         await ctx.service.projectAccount.saveProjectScreenPermissions(projectId, data.changes);
                         break;
+                    case 'tenderinit':
+                        errorMessage = '加载大屏展示范围失败';
+                        responseData.data = await ctx.service.projectScreenTender.initTenderSettings(projectId);
+                        break;
+                    case 'tenderload':
+                        errorMessage = '加载项目标段失败';
+                        responseData.data = await ctx.service.projectScreenTender.loadTenderSettings(projectId, data.spid);
+                        break;
+                    case 'tendersave':
+                        errorMessage = '保存大屏展示范围失败';
+                        responseData.data = await ctx.service.projectScreenTender.saveTenderSettings(projectId, data);
+                        break;
                     default:
                         throw '提交数据错误';
                 }

+ 24 - 8
app/controller/sub_proj_setting_controller.js

@@ -19,6 +19,7 @@ const path = require('path');
 const funSet = require('../const/fun_set');
 const projectSettingConst = require('../const/project_setting');
 const shenpiConst = require('../const/shenpi');
+const TenderApprovalSetting = require('../lib/tender_approval_setting');
 
 module.exports = app => {
 
@@ -608,15 +609,15 @@ module.exports = app => {
             }
         }
 
-        async manage(ctx) {
+        async manage(ctx, approvalOnly = false) {
             try {
-                this.defaultCheck(ctx);
+                await this.defaultCheck(ctx);
                 const projectId = ctx.session.sessionProject.id;
                 const projectData = await ctx.service.project.getDataById(projectId);
                 if (projectData === null) throw '没有对应的项目数据';
                 if (ctx.session.sessionUser.is_admin === 0) throw '没有访问权限';
                 // 默认过滤完工的标段即显示build的标段
-                const { done = '1' } = ctx.query || {};
+                const done = approvalOnly ? '1' : (ctx.query.done || '1');
                 const buildStatusFilter = ctx.service.tender.db.format(' AND build_status = ?', [tenderConst.buildStatus.status.build]);
                 const tenderList = await ctx.service.tender.getList('', null, 1, done === '1' ? buildStatusFilter : '');
                 const removeTenders = await ctx.service.shenpiAudit.getRemoveTenders(tenderList);
@@ -644,8 +645,9 @@ module.exports = app => {
 
                 // 获取项目列表
                 // const subProjects = await ctx.service.subProject.getAllDataByCondition({ where: { project_id: projectId, is_folder: 0, is_delete: 0 } });
-                const subProjects = await ctx.service.subProject.getSubProject(projectId, ctx.session.sessionUser.accountId, ctx.session.sessionUser.is_admin);
+                const subProjects = approvalOnly ? [] : await ctx.service.subProject.getSubProject(projectId, ctx.session.sessionUser.accountId, ctx.session.sessionUser.is_admin);
                 const renderData = {
+                    approvalOnly,
                     projectData,
                     tenderList,
                     auditConst,
@@ -666,9 +668,10 @@ module.exports = app => {
                 };
                 const permissionKey = ['quality', 'inspection', 'safe_inspection', 'safe_payment', 'schedule'];
                 renderData.selfCategoryLevel = await this.ctx.service.projectAccount.getSelfCategoryLevel(this.ctx.session.sessionUser.accountId);
-                renderData.permissionConst = ctx.service.tenderPermission.partPermissionConst(permissionKey);
-                renderData.permissionBlock = ctx.service.tenderPermission.partPermissionBlock(permissionKey);
-                await this.layout('sp_setting/manage.ejs', renderData, 'sp_setting/manage_modal.ejs');
+                renderData.permissionConst = approvalOnly ? {} : ctx.service.tenderPermission.partPermissionConst(permissionKey);
+                renderData.permissionBlock = approvalOnly ? [] : ctx.service.tenderPermission.partPermissionBlock(permissionKey);
+                const page = approvalOnly ? 'approval' : 'manage';
+                await this.layout(`sp_setting/${page}.ejs`, renderData, `sp_setting/${page}_modal.ejs`);
             } catch (error) {
                 ctx.log(error);
                 ctx.redirect(`/sp/${ctx.subProject.id}/dashboard`);
@@ -677,7 +680,7 @@ module.exports = app => {
 
         async manageTenderSave(ctx) {
             try {
-                this.defaultCheck(ctx);
+                await this.defaultCheck(ctx);
                 if (ctx.session.sessionUser.is_admin === 0) throw '没有设置权限';
                 const projectId = ctx.session.sessionProject.id;
                 const responseData = {
@@ -697,6 +700,15 @@ module.exports = app => {
                     safe: ['safe_inspection', 'safe_payment'],
                 };
                 switch (data.type) {
+                    case 'approval-draft':
+                        responseData.data = await new TenderApprovalSetting(ctx).loadDraft(data.tid, data.code);
+                        break;
+                    case 'approval-save':
+                        responseData.data = await new TenderApprovalSetting(ctx).saveDraft(data.tid, data.code, data.version, data.operations);
+                        break;
+                    case 'approval-msg':
+                        responseData.data = await new TenderApprovalSetting(ctx).load(data.tid);
+                        break;
                     case 'msg':
                         if (!data.tid) {
                             throw '参数有误';
@@ -768,6 +780,10 @@ module.exports = app => {
                 ctx.body = { err: 1, msg: err.toString(), data: null };
             }
         }
+
+        async approval(ctx) {
+            await this.manage(ctx, true);
+        }
     }
 
     return SettingController;

+ 118 - 0
app/lib/filing_directory.js

@@ -0,0 +1,118 @@
+'use strict';
+
+class FilingDirectory {
+    constructor(ctx, isAdmin, canViewAll) {
+        this.ctx = ctx;
+        this.isAdmin = isAdmin;
+        this.canViewAll = canViewAll;
+    }
+
+    async add(data) {
+        const service = this.ctx.service.filing;
+        const spid = this.ctx.subProject.id;
+        const treePid = String(data.tree_pid);
+        if (treePid !== '-1') {
+            const parent = await service.getDataById(treePid);
+            if (!parent || parent.is_deleted || parent.spid !== spid) throw '父分类不存在';
+        }
+        if (data.tree_pre_id) {
+            const previous = await service.getDataById(data.tree_pre_id);
+            if (!previous || previous.is_deleted || previous.spid !== spid || String(previous.tree_pid) !== treePid) {
+                throw '同级分类不存在,请刷新页面后重试';
+            }
+            if (Number(previous.is_fixed)) throw '固定分类不可添加同级';
+        }
+        if (data.name !== undefined && (typeof data.name !== 'string' || data.name.length > 100)) {
+            throw '分类名称格式错误或超过100个字符';
+        }
+        const result = await service.add({ tree_pid: treePid, tree_pre_id: data.tree_pre_id, name: data.name });
+        result.permissionMap = await this.getPermissionMap(result.create);
+        return result;
+    }
+
+    async remove(data) {
+        const values = data && data.ids !== undefined ? data.ids : [data && data.id];
+        if (!Array.isArray(values) || values.length === 0 || values.some(id => typeof id !== 'string' || !id.trim())) {
+            throw '请选择需要删除的分类';
+        }
+        const ids = Array.from(new Set(values));
+        const ctx = this.ctx;
+        const spid = ctx.subProject.id;
+        const service = ctx.service.filing;
+        const transaction = await service.beginTransaction();
+        try {
+            const filingList = await service.getAllDataForUpdate({ spid, is_deleted: 0 }, transaction, 'id');
+            const scope = this.getDeleteScope(filingList, ids);
+            const permissionMap = await this.getPermissionMap(filingList);
+            for (const filing of scope) {
+                if (Number(filing.is_fixed)) throw '固定分类不可删除';
+                const permission = permissionMap[filing.id];
+                if (!permission || !permission.can_view || !permission.can_edit_dir) {
+                    throw '部分分类或子分类没有编辑目录权限,无法删除';
+                }
+            }
+            if (scope.some(filing => Number(filing.file_count || 0) !== 0)) {
+                throw '此分类已存在文件,请先删除所有文件。';
+            }
+            const deleteIds = scope.map(filing => filing.id);
+            const files = await service.getDirectoryFilesForUpdate(spid, deleteIds, transaction);
+            if (files.length > 0) throw '此分类已存在文件,请先删除所有文件。';
+            const updateData = this.getRemainingOrders(filingList, scope);
+            await service.deleteEmptyDirectories(spid, deleteIds, updateData, transaction);
+            await transaction.commit();
+            return { delete: deleteIds, update: updateData };
+        } catch (err) {
+            await transaction.rollback();
+            throw err;
+        }
+    }
+
+    async getPermissionMap(filingList) {
+        const ctx = this.ctx;
+        const permissionMap = await ctx.service.subProjectFilingPermission.getPermissionMap(
+            ctx.subProject.id, ctx.session.sessionUser.accountId, filingList,
+            ctx.subProject.permission.filing_type, ctx.subProject.permission.file_permission, this.isAdmin
+        );
+        if (this.canViewAll) Object.keys(permissionMap).forEach(id => { permissionMap[id].can_view = 1; });
+        return permissionMap;
+    }
+
+    getDeleteScope(filingList, ids) {
+        const filingMap = new Map(filingList.map(filing => [filing.id, filing]));
+        const children = new Map();
+        filingList.forEach(filing => {
+            const parentId = String(filing.tree_pid);
+            if (!children.has(parentId)) children.set(parentId, []);
+            children.get(parentId).push(filing.id);
+        });
+        if (ids.some(id => !filingMap.has(id))) throw '部分分类不存在,请刷新页面后重试';
+        const pending = ids.slice();
+        const seen = new Set();
+        const scope = [];
+        for (let i = 0; i < pending.length; i++) {
+            const id = pending[i];
+            if (seen.has(id)) continue;
+            seen.add(id);
+            scope.push(filingMap.get(id));
+            pending.push(...(children.get(id) || []));
+        }
+        return scope;
+    }
+
+    getRemainingOrders(filingList, scope) {
+        const deletedIds = new Set(scope.map(filing => filing.id));
+        const parentIds = new Set(scope.map(filing => String(filing.tree_pid)));
+        const updateData = [];
+        for (const parentId of parentIds) {
+            if (deletedIds.has(parentId)) continue;
+            const siblings = filingList.filter(filing => String(filing.tree_pid) === parentId && !deletedIds.has(filing.id));
+            siblings.sort((a, b) => Number(a.tree_order) - Number(b.tree_order) || a.id.localeCompare(b.id));
+            siblings.forEach((filing, index) => {
+                if (Number(filing.tree_order) !== index + 1) updateData.push({ id: filing.id, tree_order: index + 1 });
+            });
+        }
+        return updateData;
+    }
+}
+
+module.exports = FilingDirectory;

+ 0 - 36
app/lib/project_screen.js

@@ -1,36 +0,0 @@
-'use strict';
-
-module.exports = {
-    getProgressPaths(rows) {
-        const nodes = new Map(rows.map(row => [String(row.tree_id), row]));
-        const paths = new Map();
-        for (const row of rows) {
-            const path = [];
-            const visited = new Set();
-            let node = row;
-            while (node && !visited.has(String(node.tree_id))) {
-                path.unshift(node);
-                visited.add(String(node.tree_id));
-                node = nodes.get(String(node.tree_pid));
-            }
-            paths.set(row.id, path);
-        }
-        return paths;
-    },
-    sortProgressRows(rows, paths) {
-        return rows.slice().sort((a, b) => {
-            const pathA = paths.get(a.id);
-            const pathB = paths.get(b.id);
-            for (let i = 0; i < Math.min(pathA.length, pathB.length); i++) {
-                if (pathA[i].id === pathB[i].id) continue;
-                return pathA[i].tree_order - pathB[i].tree_order || String(pathA[i].id).localeCompare(String(pathB[i].id));
-            }
-            return pathA.length - pathB.length;
-        });
-    },
-    formatProgressPath(path, abbreviated = false) {
-        const names = path.map(node => node.name);
-        if (abbreviated && names.length > 2) return [names[0], '...', names[names.length - 1]].join(' / ');
-        return names.join(' / ');
-    },
-};

+ 128 - 0
app/lib/tender_approval_setting.js

@@ -0,0 +1,128 @@
+'use strict';
+
+const auditConst = require('../const/audit');
+const shenpiConst = require('../const/shenpi');
+const crypto = require('crypto');
+const TenderApprovalDraft = require('../public/js/shares/tender_approval_draft');
+
+class TenderApprovalSetting {
+    constructor(ctx) {
+        this.ctx = ctx;
+    }
+
+    async load(tid) {
+        const ctx = this.ctx;
+        if (!ctx.session.sessionUser.is_admin) throw '没有设置权限';
+        const tenderId = Number(tid);
+        if (!Number.isSafeInteger(tenderId) || tenderId <= 0) throw '参数有误';
+        const tender = await ctx.service.tender.getDataById(tenderId);
+        if (!tender || tender.spid !== ctx.subProject.id || tender.project_id !== ctx.session.sessionProject.id) {
+            throw '标段不存在或不属于当前项目';
+        }
+        const tenderInfo = await ctx.service.tenderInfo.getTenderInfo(tender.id);
+        if (!tenderInfo) throw '标段信息不存在';
+        const lastRevise = await ctx.service.ledgerRevise.getLastestRevise(tender.id);
+        return {
+            tender,
+            revising: Boolean(lastRevise && lastRevise.status !== auditConst.revise.status.checked),
+            shenpi: await ctx.service.shenpiAudit.getShenpi(tender.id, tenderInfo),
+        };
+    }
+
+    async loadDraft(tid, code) {
+        const current = await this.load(tid);
+        const spec = shenpiConst.sp_lc.find(x => x.code === code);
+        if (!spec) throw '审批栏目不存在';
+        const snapshot = await this.ctx.service.tenderApprovalSetting.getSnapshot(current.tender.id, spec.type, code === 'stage');
+        if (!snapshot.info) throw '标段信息不存在';
+        const options = await this.getOptions(current, spec);
+        return { snapshot: this.toState(snapshot, code, options.accounts), options, version: this.version(snapshot, code) };
+    }
+
+    async saveDraft(tid, code, version, operations) {
+        const current = await this.load(tid);
+        const spec = shenpiConst.sp_lc.find(x => x.code === code);
+        if (!spec || !Array.isArray(operations) || operations.length > 1000 || !operations.length) throw '保存参数有误';
+        const service = this.ctx.service.tenderApprovalSetting;
+        const transaction = await service.beginTransaction();
+        try {
+            const tenders = await this.ctx.service.tender.getAllDataForUpdate({ id: current.tender.id }, transaction);
+            if (!tenders.length || tenders[0].spid !== this.ctx.subProject.id || tenders[0].project_id !== this.ctx.session.sessionProject.id) throw '标段已变更,请刷新';
+            current.tender = tenders[0];
+            const snapshot = await service.getSnapshot(current.tender.id, spec.type, code === 'stage', transaction);
+            if (!snapshot.info || this.version(snapshot, code) !== version) throw '审批配置已被修改,请关闭弹窗后重新编辑';
+            const options = await this.getOptions(current, spec);
+            const before = this.toState(snapshot, code, options.accounts);
+            const draft = new TenderApprovalDraft(before, options);
+            for (const operation of operations) draft.apply(operation);
+            draft.validate();
+            const shenpi = snapshot.info.shenpi ? JSON.parse(snapshot.info.shenpi) : {};
+            shenpi[code] = draft.state.status;
+            await service.persist(transaction, current.tender.id, spec.type, {
+                before: this.databaseRows(before), after: this.databaseRows(draft.state), shenpi,
+                stage: code === 'stage', cooperation: draft.cooperation,
+            });
+            await transaction.commit();
+        } catch (error) {
+            await transaction.rollback();
+            throw error;
+        }
+        return { saved: true };
+    }
+
+    async getOptions(current, spec) {
+        const accounts = await this.ctx.service.projectAccount.getAllSubProjectAccount(this.ctx.subProject);
+        let ledgerIds = null;
+        if (spec.code === 'stage') {
+            const ledgers = await this.ctx.service.ledger.getAllDataByCondition({ columns: ['ledger_id', 'b_code'], where: { tender_id: current.tender.id } });
+            ledgerIds = ledgers.filter(x => !x.b_code).map(x => String(x.ledger_id));
+        }
+        return {
+            code: spec.code, spType: spec.type, name: spec.name, owner: current.tender.user_id,
+            canCooperate: !current.revising && current.tender.ledger_status === auditConst.ledger.status.checked,
+            accounts: accounts.map(x => ({ id: x.id, name: x.name, company: x.company, role: x.role })),
+            auditTypes: auditConst.auditType.types, ledgerIds,
+        };
+    }
+
+    toState(snapshot, code, accounts) {
+        const shenpi = snapshot.info.shenpi ? JSON.parse(snapshot.info.shenpi) : {};
+        const rows = this.databaseRows(snapshot);
+        for (const row of rows.groups) row.change_type = row.change_type ? JSON.parse(row.change_type) : null;
+        for (const row of rows.audits) {
+            const account = accounts.find(x => x.id === row.audit_id);
+            Object.assign(row, { name: account ? account.name : '用户' + row.audit_id, company: account ? account.company : '', role: account ? account.role : '' });
+        }
+        return Object.assign(rows, { status: shenpi[code] || 1 });
+    }
+
+    version(snapshot, code) {
+        const shenpi = snapshot.info.shenpi ? JSON.parse(snapshot.info.shenpi) : {};
+        const rows = this.databaseRows(snapshot);
+        for (const key of Object.keys(rows)) rows[key].sort((a, b) => a.id - b.id);
+        return crypto.createHash('sha256').update(JSON.stringify({ status: shenpi[code] || 1, rows })).digest('hex');
+    }
+
+    databaseRows(state) {
+        const fields = {
+            groups: ['id', 'name', 'is_select', 'change_type'],
+            audits: ['id', 'audit_id', 'sp_status', 'sp_group', 'audit_order', 'audit_type', 'audit_group', 'audit_group_order', 'audit_group_limit', 'audit_checkno_valid', 'audit_group_need', 'audit_ledger_id'],
+            assistants: ['id', 'user_id', 'ass_user_id', 'name', 'company', 'role', 'ass_ledger_id'],
+        };
+        const result = {};
+        for (const key of Object.keys(fields)) {
+            result[key] = state[key].map(row => {
+                const data = {};
+                for (const field of fields[key]) {
+                    if (row[field] !== undefined) data[field] = row[field];
+                }
+                if (key === 'groups' && data.change_type && typeof data.change_type !== 'string') data.change_type = JSON.stringify(data.change_type);
+                return data;
+            });
+        }
+        return result;
+    }
+
+}
+
+module.exports = TenderApprovalSetting;

+ 4 - 5
app/middleware/stage_check.js

@@ -110,17 +110,16 @@ module.exports = options => {
                 if (!stage.readOnly) {
                     stage.assist = ass;
                     stage.curOrder = stage.curAuditors[0].order;
-                } else {
-                    stage.curOrder = stage.curAuditors[0].order - 1;
-                }
-                if (!stage.readOnly) {
                     if (stage.curAuditors[0].audit_type === auditType.key.and) {
                         stage.readOnly = !_.isEqual(stage.flowAuditorIds, stage.curAuditorIds);
                         stage.canCheck = true;
                     } else if (stage.curAuditors[0].audit_type === auditType.key.union) {
-                        stage.readOnly = stage.relaAuditor.status === status.checked;
+                        console.log(stage.relaAuditor);
+                        stage.readOnly = stage.relaAuditor.status !== status.checking;
                         stage.canCheck = !stage.readOnly;
                     }
+                } else {
+                    stage.curOrder = stage.curAuditors[0].order - 1;
                 }
             }
             if (stage.readOnly) {

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

@@ -2549,13 +2549,16 @@ animation:shake 1s .2s ease both;}
 .project-screen-preview-content main:before, .project-screen-preview-content main:after { content: ""; position: absolute; border-radius: 50%; background: #c9d6ff; }
 .project-screen-preview-content main:before { width: 28%; aspect-ratio: 1 / 1; right: 12%; top: 12%; }
 .project-screen-preview-content main:after { width: 55%; aspect-ratio: 1 / 1; right: 18%; bottom: -42%; }
-.project-screen-preview-guangyuan .project-screen-preview-nav { background: #295f70; }
+.project-screen-page .project-screen-preview.project-screen-preview-guangyuan { width: 94%; max-width: none; min-width: 0; box-sizing: content-box; aspect-ratio: 3594 / 1908; display: block; background: #2e3238 url('/public/images/guangyuandaping.png?v=0939983fbcff') center / contain no-repeat; }
+.project-screen-page .project-screen-preview-guangyuan > div { display: none; }
 
 .project-screen-template-item { cursor: pointer; }
 .project-screen-template-item.added { cursor: not-allowed; opacity: .6; }
 .project-screen-template-preview { height: 120px; display: grid; place-items: center; background: #e9ecef; }
 .project-screen-template-preview > div { width: 82%; height: 85px; display: grid; grid-template-columns: 22% 1fr; border: 4px solid #ced4da; background: #4d7881; }
 .project-screen-template-preview > div:after { content: ""; margin: 7px; background: #f8f9fa; }
+#project-screen-select-modal .project-screen-template-preview.project-screen-preview-guangyuan { background: #2e3238 url('/public/images/guangyuandaping.png?v=0939983fbcff') center / contain no-repeat; }
+#project-screen-select-modal .project-screen-template-preview.project-screen-preview-guangyuan > div { display: none; }
 
 .project-screen-view-page { height: 100vh; padding: 18px; color: #dce9ff; background: #07152e; }
 .project-screen-view-head { height: 55px; display: flex; justify-content: space-between; align-items: flex-start; }
@@ -2801,6 +2804,16 @@ animation:shake 1s .2s ease both;}
 @media(max-width:1200px){.gy-header{flex-wrap:wrap}.gy-nav{order:3;width:100%;justify-content:center}.gy-grid{grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:auto}.gy-card{min-height:270px}.gy-summary,.gy-progress,.gy-investment,.gy-building{grid-column:span 2}.gy-planned,.gy-finished{height:300px}.gy-ratio{height:300px}.gy-progress{height:330px}.gy-screen:fullscreen .gy-grid{grid-template-rows:auto}}
 @media(max-width:650px){.gy-screen{padding:10px}.gy-brand strong{font-size:15px}.gy-mark{display:none}.gy-nav{gap:5px}.gy-nav button{padding:8px 12px}.gy-grid{display:flex;flex-direction:column}.gy-card{padding:12px}.gy-stats{grid-template-columns:repeat(2,1fr)}.gy-stat{padding:14px}.gy-stat strong{font-size:30px}.gy-fullscreen{padding:8px}.gy-title h2{font-size:15px}}
 
+.gy-preview-dialog{--gy-card:#343a40;color:#edf3fa}
+.gy-preview-dialog .modal-dialog{width:90vw;max-width:1600px;margin-left:auto;margin-right:auto}
+.gy-preview-dialog .modal-content{background:var(--gy-card);border-color:#627589;border-radius:6px}
+.gy-preview-dialog .modal-body{max-height:70vh}
+.gy-preview-dialog .modal-header{border-bottom-color:#48515a}
+.gy-preview-dialog .modal-title .small{color:#aab6c3}
+.gy-preview-dialog .gy-preview-dialog-table{border:1px solid #4a545e;background:var(--gy-card)}
+.gy-preview-dialog .close{color:inherit;text-shadow:none}
+.gy-preview-dialog .gy-preview-table .gy-ellipsis{white-space:normal;overflow-wrap:anywhere}
+
 /* Fill the available screen; short windows scroll inside the dark container. */
 /* Keep both header rows together, without collapsed-border gaps during scrolling. */
 .gy-preview-table{border-collapse:separate;border-spacing:0;table-layout:fixed}
@@ -2812,6 +2825,13 @@ animation:shake 1s .2s ease both;}
 .gy-preview-table th,.gy-preview-table td{border:0;border-right:1px solid #4a545e;border-bottom:1px solid #4a545e}
 .gy-preview-table tr>*:first-child{border-left:1px solid #4a545e}
 .gy-preview-table thead tr:first-child th{border-top:1px solid #4a545e}
+.gy-preview-table .gy-preview-name{position:relative;display:flex;align-items:center;gap:8px;min-width:0;line-height:20px}
+.gy-preview-table .gy-preview-name>.fa{flex:0 0 14px;width:14px;color:#829bb0;text-align:center}
+.gy-preview-table .gy-preview-name>.gy-ellipsis{min-width:0}
+.gy-preview-table .gy-preview-child{padding-left:22px}
+.gy-preview-table .gy-year td{background:#30495d;color:#bddfff;font-weight:600;padding:9px}
+.gy-preview-table .gy-year .fa{color:#80bfff}
+.gy-preview-table .gy-preview-folder-cell{position:sticky;left:0;z-index:1;box-shadow:2px 0 0 #586674}
 .gy-header,.gy-message{flex-shrink:0}
 @media(min-width:1201px){
     .gy-header{display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr)}
@@ -2873,15 +2893,16 @@ animation:shake 1s .2s ease both;}
 .gy-detail-text{font-size:14px;line-height:2;background:#303b47;padding:16px;overflow:auto;overflow-wrap:anywhere}
 .gy-detail-text strong{display:block;color:#84c4ff;margin-bottom:12px}
 .gy-list-heading{background:#3a536a;padding:12px;text-align:center;font-size:13px}
-.gy-screen .gy-folder-row{display:flex;align-items:center;gap:6px;min-height:40px;padding:5px 12px 5px calc(12px + var(--gy-depth) * 16px);border-left:2px solid transparent;color:#9eafc0;line-height:1.4}
-.gy-screen .gy-folder-row:hover{background:#37424c}
-.gy-screen .gy-folder-toggle{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;width:18px;height:28px;padding:0;border:0;background:none;color:#9eafc0;font-size:13px}
-.gy-screen .gy-folder-icon{width:14px;text-align:center;color:#8297aa}
-.gy-screen .gy-project-row{display:flex;align-items:center;gap:6px;width:100%;min-height:40px;padding:6px 12px 6px calc(36px + var(--gy-depth) * 16px);border:0;border-left:2px solid transparent;text-align:left;background:transparent;color:#c5d6e7;line-height:1.4;font-size:14px!important}
+.gy-screen .gy-folder-row{display:flex;align-items:center;gap:8px;min-height:42px;padding:6px 12px 6px calc(12px + var(--gy-depth) * 22px);border-left:2px solid transparent;background:#2f4354;color:#c2d7e9;font-weight:600;line-height:1.4}
+.gy-screen .gy-folder-row.gy-folder-nested{background:#36414b;color:#cbd5df;font-weight:400}
+.gy-screen .gy-folder-row:hover{background:#384e60}
+.gy-screen .gy-folder-row.gy-folder-nested:hover{background:#404c57}
+.gy-screen .gy-folder-toggle{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;width:18px;height:28px;padding:0;border:0;background:none;color:#a8c5de;font-size:16px}
+.gy-screen .gy-folder-icon{flex:0 0 16px;width:16px;text-align:center;color:#80bfff}
+.gy-screen .gy-project-row{display:flex;align-items:center;width:100%;min-height:42px;padding:8px 12px 8px calc(36px + var(--gy-depth) * 22px);border:0;border-left:2px solid transparent;text-align:left;background:transparent;color:#c5d6e7;line-height:1.5;font-size:14px!important}
 .gy-screen .gy-folder-row,.gy-screen .gy-project-row{border-bottom:1px solid #48515a}
 .gy-screen .gy-project-row:hover{background:#374552}
-.gy-screen .gy-project-row.active{background:#354d63;border-left-color:#3698ff;color:#fff}
-.gy-screen .gy-project-marker{flex:0 0 14px;width:14px;text-align:center;color:#7893aa}
+.gy-screen .gy-project-row.active{background:#225e91;border-left-color:#73c6ff;color:#fff}
 .gy-screen .gy-project-row span:last-child,.gy-screen .gy-folder-row span{min-width:0;overflow-wrap:anywhere}
 .gy-screen .gy-folder-toggle:focus-visible,.gy-screen .gy-project-row:focus-visible{outline:2px solid #80bfff;outline-offset:-2px}
 .gy-detail-button{background:transparent;border:1px solid #56718b;border-radius:4px;color:#8ac8ff;padding:5px 10px;font-size:12px!important}
@@ -2942,11 +2963,15 @@ button.gy-stat:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
 }
 
 .global-assistant-entry {
+  position: fixed;
+  right: 0;
+  top: calc(100vh - 68px);
   display: flex;
   align-items: center;
   justify-content: center;
   gap: 7px;
-  min-width: 76px;
+  width: 92px;
+  min-width: 0;
   height: 44px;
   padding: 0 17px;
   border: 0;
@@ -2957,6 +2982,44 @@ button.gy-stat:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
   cursor: grab;
   touch-action: none;
   user-select: none;
+  transition: width .16s ease, padding .16s ease, border-radius .16s ease, background-color .16s ease;
+}
+
+.global-assistant-entry-label {
+  max-width: 28px;
+  overflow: hidden;
+  white-space: nowrap;
+  opacity: 1;
+  transition: max-width .16s ease, opacity .12s ease;
+}
+
+.global-assistant-entry.docked {
+  width: 32px;
+  padding: 0;
+  gap: 0;
+  border-radius: 22px 0 0 22px;
+  box-shadow: 0 2px 10px rgba(39, 126, 220, .2);
+}
+
+.global-assistant-entry.docked .global-assistant-entry-label {
+  max-width: 0;
+  opacity: 0;
+}
+
+.global-assistant-entry.docked.expanded {
+  width: 92px;
+  padding: 0 17px;
+  gap: 7px;
+}
+
+.global-assistant-entry.docked.expanded .global-assistant-entry-label {
+  max-width: 28px;
+  opacity: 1;
+}
+
+.global-assistant-entry:focus-visible {
+  outline: 2px solid #1d65b8;
+  outline-offset: 3px;
 }
 
 .global-assistant-entry:hover {
@@ -2965,6 +3028,12 @@ button.gy-stat:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
 
 .global-assistant-entry.dragging {
   cursor: grabbing;
+  transition: none;
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .global-assistant-entry,
+  .global-assistant-entry-label { transition: none; }
 }
 
 .global-assistant-panel {
@@ -3846,6 +3915,8 @@ button.gy-stat:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
 .inspection-detail-attachments-table td {
     overflow-wrap: break-word;
 }
+.inspection-detail-attachments-table thead th:first-child,
+.inspection-detail-attachments-table .inspection-attachment-row td:first-child,
 .inspection-detail-attachments-table thead th:nth-child(n+3),
 .inspection-detail-attachments-table .inspection-attachment-row td:nth-child(n+3) {
     text-align: center;
@@ -3860,3 +3931,85 @@ button.gy-stat:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
     text-overflow: ellipsis;
     white-space: nowrap;
 }
+
+/* 项目大屏展示范围 */
+.project-screen-page .project-screen-tender-setting { min-height: 0; }
+.project-screen-page .project-screen-tender-columns { display: grid; grid-template-columns: 36% minmax(0, 1fr); min-height: 0; }
+.project-screen-page .project-screen-tender-projects,
+.project-screen-page .project-screen-tender-detail { min-height: 0; min-width: 0; }
+.project-screen-page .project-screen-tender-list { table-layout: fixed; border: 0; background: transparent; border-collapse: collapse; font-size: 13px; }
+.project-screen-page .project-screen-tender-list thead th { padding: 8px 12px; border: 0; border-bottom: 1px solid #e3e7ef; background: #f3f5f9; color: #758091; font-size: 12px; font-weight: 500; }
+.project-screen-page .project-screen-tender-list td { padding: 5px 10px; border: 0; border-bottom: 1px solid #edf0f5; background: #fff; vertical-align: middle; line-height: 24px; }
+.project-screen-page .project-screen-tender-list td:first-child { padding-left: calc(12px + var(--tree-depth, 0) * 18px); overflow-wrap: anywhere; }
+.project-screen-page .project-screen-tender-list .project-screen-tender-directory > td { color: #758091; }
+.project-screen-page .project-screen-tender-list tbody tr:hover > td { background: #f3f6fa; }
+.project-screen-page .project-screen-tender-project-list .project-screen-tender-project-active > td,
+.project-screen-page .project-screen-tender-project-list .project-screen-tender-project-active:hover > td { background: #eaf2ff; }
+.project-screen-page .project-screen-tender-project-list .project-screen-tender-project-total { width: 82px; padding-left: 6px; padding-right: 6px; white-space: nowrap; }
+.project-screen-page .project-screen-tender-project-list .project-screen-tender-project-check-cell { width: 38px; padding-left: 6px; padding-right: 6px; }
+.project-screen-page .project-screen-tender-project-check-cell input[type="checkbox"] { margin: 0; vertical-align: middle; }
+.project-screen-page .project-screen-tender-project-label { display: flex; align-items: center; gap: 6px; min-width: 0; min-height: 24px; }
+.project-screen-page .project-screen-tender-project-label > .fa,
+.project-screen-page .project-screen-tender-project-label > .project-screen-tender-fold,
+.project-screen-page .project-screen-tender-project-branch { flex-shrink: 0; }
+.project-screen-page .project-screen-tender-project-directory .project-screen-tender-project-label { color: #596579; font-weight: 500; }
+.project-screen-page .project-screen-tender-project-name,
+.project-screen-page .project-screen-tender-project-folder-name { flex: 1; min-width: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.project-screen-page .project-screen-tender-project-name { text-align: left; font: inherit; vertical-align: baseline; }
+.project-screen-page .project-screen-tender-project-count { display: inline-block; color: #758091; font-size: 12px; line-height: 24px; vertical-align: top; font-variant-numeric: tabular-nums; }
+.project-screen-page .project-screen-tender-project-active .project-screen-tender-project-count { color: #1677ff; }
+.project-screen-page .project-screen-tender-fold { padding: 0; border: 0; color: inherit; background: transparent; font: inherit; }
+.project-screen-page .project-screen-tender-fold:hover { color: #0056b3; }
+.project-screen-page .project-screen-tender-fold:disabled { opacity: .45; cursor: default; }
+.project-screen-page .project-screen-tender-fold:focus:not(:focus-visible) { outline: none; box-shadow: none; }
+.project-screen-page .project-screen-tender-tree-actions .btn { font-size: 12px; }
+.project-screen-page .project-screen-tender-heading { min-height: 28px; }
+.project-screen-page #project-screen-tender-total,
+.project-screen-page .project-screen-tender-description { color: #495057; font-size: 14px; line-height: 1.5; }
+.project-screen-page .project-screen-tender-heading .project-screen-tender-tree-actions { flex-shrink: 0; }
+.project-screen-page .project-screen-tender-intro { flex: 1; min-width: 0; padding-right: 12px; }
+.project-screen-page .project-screen-tender-intro strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.project-screen-page .project-screen-tender-search { height: 30px; }
+.project-screen-page .project-screen-tender-search.form-control,
+.project-screen-page .project-screen-tender-search .form-control,
+.project-screen-page .project-screen-tender-search .input-group-text,
+.project-screen-page .project-screen-tender-search .btn { height: 30px; padding: 5px 8px; font-size: 12px; line-height: 18px; }
+.project-screen-page .project-screen-tender-select-all { cursor: pointer; }
+.project-screen-page .project-screen-tender-tree-table .project-screen-tender-check-cell { width: 46px; padding-left: 6px; padding-right: 6px; vertical-align: middle; white-space: nowrap; }
+.project-screen-page .project-screen-tender-check-cell input[type="checkbox"] { margin: 0; vertical-align: middle; }
+.project-screen-page .project-screen-tender-match { padding: 0; color: inherit; background: #fff09c; }
+.project-screen-page .project-screen-tender-current-match { background: #ffb448; }
+.project-screen-page #project-screen-tender-tree-scroll,
+.project-screen-page #project-screen-tender-project-tree { min-height: 0; }
+@media (max-width: 1100px) {
+    .project-screen-page .project-screen-module-tabs .nav-link { padding-left: 10px; padding-right: 10px; }
+    .project-screen-page .project-screen-tender-columns { grid-template-columns: 38% minmax(0, 1fr); }
+}
+
+.setting-approval-page .approval-heading {
+    font-weight: 500;
+}
+.setting-approval-page .approval-layout {
+    background-color: #fff;
+    overflow: hidden;
+}
+.setting-approval-page .approval-tenders,
+.setting-approval-page .approval-content {
+    overflow: auto;
+}
+.setting-approval-page .tender-info {
+    cursor: pointer;
+}
+
+.setting-approval-page .approval-summary-table thead th { text-align: center; }
+.setting-approval-page .approval-column-name { width: 17%; min-width: 120px; }
+.setting-approval-page .approval-column-actions { width: 110px; }
+.setting-approval-page .approval-actions { text-align: center; white-space: nowrap; }
+.setting-approval-page .approval-mode { background: #ddd; font-weight: normal; font-size: 12px; }
+.setting-approval-page .approval-summary-table .dropdown-menu { min-width: 140px; }
+#approval-editor .modal-body { min-height: 300px; max-height: 70vh; overflow: auto; }
+#approval-editor .card { border: 0; margin-bottom: 0 !important; }
+#approval-editor .card-body { padding: 0; }
+#approval-editor .approval-saving { pointer-events: none; opacity: .65; }
+.approval-child-modal { z-index: 1060; }
+.modal-backdrop.approval-child-backdrop { z-index: 1055; }

BIN
app/public/images/guangyuandaping.png


+ 60 - 23
app/public/js/assistant.js

@@ -1,3 +1,5 @@
+/* global $, userID, postData */
+/* eslint-env browser */
 'use strict';
 
 (function() {
@@ -13,13 +15,15 @@
         const entry = root.find('.global-assistant-entry');
         const projectId = root.data('project-id');
         const storageKey = 'global-assistant-history-v2-' + projectId + '-' + userID;
-        const entryPositionKey = 'global-assistant-entry-position-' + userID;
+        const entryPositionKey = 'global-assistant-entry-position-v2-' + userID;
         let history = loadHistory();
         let conversationId = getConversationId();
         let sending = false;
         let entryPosition = loadEntryPosition();
         let entryDrag = null;
         let suppressEntryClick = false;
+        let suppressClickTimer = null;
+        let entryExpanded = false;
 
         applyEntryPosition();
 
@@ -30,7 +34,7 @@
                 return;
             }
             panel.addClass('show').attr('aria-hidden', 'false');
-            $(this).hide();
+            $(this).attr('aria-expanded', 'true').hide();
             input.trigger('focus');
             scrollToBottom();
         });
@@ -38,42 +42,69 @@
         entry.on('pointerdown', function(event) {
             const pointer = event.originalEvent;
             if (pointer.pointerType === 'mouse' && pointer.button !== 0) return;
+            if (entryDrag) return;
+            const rect = this.getBoundingClientRect();
             entryDrag = {
                 pointerId: pointer.pointerId,
                 startX: pointer.clientX,
                 startY: pointer.clientY,
-                positionX: entryPosition.x,
-                positionY: entryPosition.y,
+                grabRatio: rect.width ? (pointer.clientX - rect.left) / rect.width : 0.5,
+                grabY: pointer.clientY - rect.top,
+                originalPosition: Object.assign({}, entryPosition),
                 moved: false,
             };
+            clearTimeout(suppressClickTimer);
             suppressEntryClick = false;
             this.setPointerCapture(pointer.pointerId);
-            entry.addClass('dragging');
             event.preventDefault();
         });
 
         entry.on('pointermove', function(event) {
             const pointer = event.originalEvent;
             if (!entryDrag || pointer.pointerId !== entryDrag.pointerId) return;
-            const offsetX = entryDrag.startX - pointer.clientX;
-            const offsetY = entryDrag.startY - pointer.clientY;
-            if (Math.abs(offsetX) > 3 || Math.abs(offsetY) > 3) entryDrag.moved = true;
+            const offsetX = pointer.clientX - entryDrag.startX;
+            const offsetY = pointer.clientY - entryDrag.startY;
+            if (!entryDrag.moved && Math.abs(offsetX) <= 4 && Math.abs(offsetY) <= 4) return;
+            entryDrag.moved = true;
+            entryExpanded = false;
+            entry.addClass('dragging');
+            const width = 92;
             entryPosition = clampEntryPosition({
-                x: entryDrag.positionX + offsetX,
-                y: entryDrag.positionY + offsetY,
+                right: window.innerWidth - pointer.clientX - width * (1 - entryDrag.grabRatio),
+                top: pointer.clientY - entryDrag.grabY,
+                docked: false,
             });
             applyEntryPosition();
             event.preventDefault();
         });
 
-        entry.on('pointerup pointercancel', function(event) {
+        entry.on('pointerup pointercancel lostpointercapture', function(event) {
             const pointer = event.originalEvent;
             if (!entryDrag || pointer.pointerId !== entryDrag.pointerId) return;
-            suppressEntryClick = event.type === 'pointerup' && entryDrag.moved;
-            if (this.hasPointerCapture(pointer.pointerId)) this.releasePointerCapture(pointer.pointerId);
+            const drag = entryDrag;
             entryDrag = null;
+            suppressEntryClick = event.type === 'pointerup' && drag.moved;
+            if (event.type !== 'pointerup') entryPosition = drag.originalPosition;
+            else if (drag.moved) entryPosition.docked = entryPosition.right <= 24;
+            if (drag.moved || event.type !== 'pointerup') entryExpanded = false;
+            entryPosition = clampEntryPosition(entryPosition);
             entry.removeClass('dragging');
+            applyEntryPosition();
+            if (this.hasPointerCapture(pointer.pointerId)) this.releasePointerCapture(pointer.pointerId);
             saveEntryPosition();
+            suppressClickTimer = setTimeout(function() { suppressEntryClick = false; }, 0);
+        });
+
+        entry.on('pointerenter focusin', function(event) {
+            if (!entryPosition.docked || entryDrag || (event.originalEvent && event.originalEvent.pointerType === 'touch')) return;
+            entryExpanded = true;
+            applyEntryPosition();
+        });
+
+        entry.on('pointerleave focusout', function() {
+            if (entryDrag) return;
+            entryExpanded = false;
+            applyEntryPosition();
         });
 
         $(window).on('resize.globalAssistant', function() {
@@ -84,7 +115,9 @@
 
         root.find('.global-assistant-close').on('click', function() {
             panel.removeClass('show').attr('aria-hidden', 'true');
-            root.find('.global-assistant-entry').show();
+            entryExpanded = false;
+            entry.attr('aria-expanded', 'false').show();
+            applyEntryPosition();
         });
 
         root.find('.global-assistant-clear').on('click', function() {
@@ -282,11 +315,13 @@
         }
 
         function loadEntryPosition() {
+            const initial = { right: 0, top: window.innerHeight - 44 - 24, docked: true };
             try {
                 const value = JSON.parse(localStorage.getItem(entryPositionKey) || '{}');
-                return clampEntryPosition({ x: Number(value.x) || 0, y: Number(value.y) || 0 });
+                if (!value || typeof value.docked !== 'boolean') return clampEntryPosition(initial);
+                return clampEntryPosition(value);
             } catch (err) {
-                return { x: 0, y: 0 };
+                return clampEntryPosition(initial);
             }
         }
 
@@ -299,17 +334,19 @@
         }
 
         function applyEntryPosition() {
-            entry.css('transform', `translate(${-entryPosition.x}px, ${-entryPosition.y}px)`);
+            entry.toggleClass('docked', entryPosition.docked)
+                .toggleClass('expanded', entryPosition.docked && entryExpanded)
+                .css({ right: entryPosition.right, top: entryPosition.top, transform: 'none' });
         }
 
         function clampEntryPosition(position) {
-            const panelWidth = parseFloat(panel.css('width')) || 420;
-            const panelHeight = Math.min(650, Math.max(0, window.innerHeight - 48));
-            const maxX = Math.max(0, panelWidth - entry.outerWidth());
-            const maxY = Math.max(0, panelHeight - entry.outerHeight());
+            const docked = position.docked === true;
+            const maxRight = Math.max(0, window.innerWidth - 92 - 8);
+            const maxTop = Math.max(0, window.innerHeight - 44 - 8);
             return {
-                x: Math.min(maxX, Math.max(0, Number(position.x) || 0)),
-                y: Math.min(maxY, Math.max(0, Number(position.y) || 0)),
+                right: docked ? 0 : Math.min(maxRight, Math.max(Math.min(8, maxRight), Number(position.right) || 0)),
+                top: Math.min(maxTop, Math.max(Math.min(8, maxTop), Number(position.top) || 0)),
+                docked,
             };
         }
     });

+ 232 - 116
app/public/js/filesjs.js

@@ -11,6 +11,7 @@ $(document).ready(function() {
             // 初始化 SpreadJS
             this.spread = SpreadJsObj.createNewSpread($('#filing-spread')[0]);
             this.spread.options.showHorizontalScrollbar = false; // 隐藏水平滚动条
+            this.spread.options.allowUndo = false; // 名称逐次保存到服务端,不使用仅修改单元格的撤销。
             this.sheet = this.spread.getActiveSheet();
             this.treeIcons = {
                 closed: new Image(),
@@ -41,7 +42,7 @@ $(document).ready(function() {
             this.spreadSetting = {
                 cols: [
                     {
-                        title: '分类名称', colSpan: '1', rowSpan: '1', field: 'name', hAlign: 0, width: 220, formatter: '@', cellType: 'tree',
+                        title: '分类名称', colSpan: '1', rowSpan: '1', field: 'name', hAlign: 0, width: 220, formatter: '@', cellType: 'tree', showTips: true,
                         getValue: function(data) {
                             return data.name;
                         },
@@ -53,8 +54,10 @@ $(document).ready(function() {
                                 size: 18,
                             };
                         },
+                        getTip: function(data) { return _.escape(data.name || ''); },
+                        readOnly: data => !this._canEditFilingName(data),
                     },
-                    { title: '文件数', colSpan: '1', rowSpan: '1', field: 'total_file_count', hAlign: 1, width: 70, formatter: '@' },
+                    { title: '文件数', colSpan: '1', rowSpan: '1', field: 'total_file_count', hAlign: 1, width: 70, formatter: '@', readOnly: true },
                 ],
                 emptyRows: 0,
                 headRows: 1,
@@ -63,7 +66,7 @@ $(document).ready(function() {
                 headColWidth: [25],
                 headerFont: '12px 微软雅黑',
                 font: '12px 微软雅黑',
-                readOnly: true,
+                readOnly: false,
                 selectedBackColor: '#e8f4fd',
             };
 
@@ -74,11 +77,15 @@ $(document).ready(function() {
             this.curPage = 1;
             this.curTotalPage = 1;
             this.curFiling = null;
-            this.selectRemoveNode = null;
+            this.selectRemoveNodes = [];
+            this._directorySaving = false;
+            this._reloadingFiling = false;
             this.fileOrderKey = 'fileOrderSjs';
             this.fileOrder = getLocalCache(this.fileOrderKey) || 'create_time|desc';
             this.curFilingKey = 'cur-filing-sjs-' + window.location.pathname.split('/')[2];
             this._isRenaming = false;
+            this._nameEdit = null;
+            this._nameSaving = false;
 
             this._bindEvents();
             this._initNameClipboard();
@@ -229,7 +236,7 @@ $(document).ready(function() {
 
             // 选中行变化事件
             this.spread.bind(spreadNS.Events.SelectionChanged, async function(e, info) {
-                if (self._isRenaming) return;
+                if (self._isRenaming || self._reloadingFiling) return;
                 const node = SpreadJsObj.getSelectObject(self.sheet);
                 if (node && (!self.curFiling || self.curFiling.id !== node.id)) {
                     await self.setCurFiling(node);
@@ -282,66 +289,7 @@ $(document).ready(function() {
                 self.nextPage();
             });
 
-            // 双击行内重命名分类
-            $('#filing-spread').dblclick(function() {
-                if (!canFiling) return;
-                if (self._isRenaming) return;
-                const node = SpreadJsObj.getSelectObject(self.sheet);
-                if (!node || node.is_fixed) return;
-
-                self._isRenaming = true;
-                const rowIndex = self.filingTree.nodes.indexOf(node);
-                const cellRect = self.sheet.getCellRect(rowIndex, 0);
-                const containerRect = $('#filing-spread')[0].getBoundingClientRect();
-
-                const inputLeft = containerRect.left + window.scrollX + cellRect.x + 2;
-                const inputTop = containerRect.top + window.scrollY + cellRect.y + 2;
-                const inputWidth = cellRect.width - 4;
-                const inputHeight = cellRect.height - 4;
-
-                const $input = $('<input type="text" class="form-control form-control-sm">')
-                    .val(node.name)
-                    .css({
-                        position: 'absolute',
-                        left: inputLeft,
-                        top: inputTop,
-                        width: inputWidth,
-                        height: inputHeight,
-                        zIndex: 1000,
-                        fontSize: '12px',
-                        padding: '0 4px',
-                        border: '2px solid #4a90d9',
-                        borderRadius: '2px',
-                        outline: 'none',
-                        boxShadow: '0 0 4px rgba(74,144,217,0.4)',
-                    });
-
-                let handled = false;
-                const finishEdit = async (save) => {
-                    if (handled) return;
-                    handled = true;
-                    const newName = $input.val().trim();
-                    $input.remove();
-                    self._isRenaming = false;
-                    if (save && newName && newName !== node.name) {
-                        await self.renameFiling(node, newName);
-                    }
-                };
-
-                $input.on('keydown', function(e) {
-                    if (e.keyCode === 13) { // Enter
-                        e.preventDefault();
-                        finishEdit(true);
-                    } else if (e.keyCode === 27) { // Escape
-                        e.preventDefault();
-                        finishEdit(false);
-                    }
-                });
-                $input.on('blur', function() { finishEdit(true); });
-
-                $('body').append($input);
-                $input.focus().select();
-            });
+            this._bindFilingNameEditing();
 
             // 分类右键菜单
             $.contextMenu({
@@ -357,20 +305,20 @@ $(document).ready(function() {
                         name: '粘贴名称',
                         icon: 'fa-paste',
                         callback: function() { self._showNamePastePreview(); },
-                        disabled: function() { return !self._referenceNameClipboard || self._namePasteSaving; },
+                        disabled: function() { return !self._referenceNameClipboard || self._namePasteSaving || self._nameSaving; },
                     },
                     'delete': {
                         name: '删除',
                         icon: 'fa-trash-o',
                         callback: function () {
-                            const node = SpreadJsObj.getSelectObject(self.sheet);
-                            if (!node || node.is_fixed) return;
-                            self.selectRemoveNode = node;
-                            $('#del-filing').modal('show');
+                            self._showDeletePreview();
                         },
                         disabled: function () {
-                            const node = SpreadJsObj.getSelectObject(self.sheet);
-                            return !canFiling || !node || node.is_fixed;
+                            const nodes = self._getSelectedFilings();
+                            return self._directorySaving || self._nameSaving || projectFileLocked || !nodes.length || nodes.some(node => {
+                                const permission = self._getFilingPermission(node.id);
+                                return Number(node.is_fixed) || !permission.can_view || !permission.can_edit_dir;
+                            });
                         },
                     },
                 },
@@ -596,47 +544,91 @@ $(document).ready(function() {
          * 添加同级分类
          */
         addSiblingFiling(node) {
-            const self = this;
             const treePid = node.tree_pid === -1 ? '-1' : this._findOriginalId(node.tree_pid);
-            postData('filing/add', { tree_pid: treePid, tree_pre_id: node.id }, function(result) {
-                filing.push(result);
-                self._reloadFilingTree();
-            });
+            this._addFiling({ tree_pid: treePid, tree_pre_id: node.id, name: '' });
         }
 
         /**
          * 添加子级分类
          */
         addChildFiling(node) {
-            const self = this;
-            postData('filing/add', { tree_pid: node.id }, function(result) {
-                filing.push(result);
-                self._reloadFilingTree();
-            });
+            this._addFiling({ tree_pid: node.id, name: '' });
         }
 
         /**
          * 重新加载归档树
          */
-        _reloadFilingTree() {
-            const convertedData = this._convertData(filing);
-            this.filingTree.loadDatas(convertedData);
-            SpreadJsObj.loadSheetData(this.sheet, SpreadJsObj.DataType.Tree, this.filingTree);
-            this.refreshFileCountHint();
+        _reloadFilingTree(focusId) {
+            const expanded = new Map(this.filingTree.nodes.map(node => [node.id, node.expanded]));
+            const currentId = focusId || (this.curFiling && this.curFiling.id);
+            const topRow = this.sheet.getViewportTopRow(1);
+            const topNode = this.filingTree.nodes[topRow];
+            this._reloadingFiling = true;
+            let currentNode;
+            try {
+                this.filingTree.loadDatas(this._convertData(filing));
+                this.filingTree.nodes.forEach(node => {
+                    if (expanded.has(node.id)) node.expanded = expanded.get(node.id);
+                });
+                currentNode = this.filingTree.nodes.find(node => node.id === currentId);
+                if (focusId && currentNode) {
+                    let parent = this.filingTree.getParent(currentNode);
+                    while (parent) {
+                        parent.expanded = true;
+                        parent = this.filingTree.getParent(parent);
+                    }
+                }
+                this.filingTree.refreshNodeVisible();
+                SpreadJsObj.loadSheetData(this.sheet, SpreadJsObj.DataType.Tree, this.filingTree);
+                this.resizeSpreadColumns();
+                const highlightedRows = this.sheet.getSelections().map(selection => selection.row)
+                    .filter(row => row >= 0 && row < this.sheet.getRowCount());
+                this.sheet.clearSelection();
+                if (currentNode) {
+                    const selectedRow = this.filingTree.nodes.indexOf(currentNode);
+                    this.sheet.setSelection(selectedRow, 0, 1, 1);
+                    // 程序定位选区后,单独刷新组件绘制的整行选中背景。
+                    SpreadJsObj.reloadRowsBackColor(this.sheet, [...new Set(highlightedRows.concat(selectedRow))]);
+                } else {
+                    highlightedRows.forEach(row => {
+                        this.sheet.getRange(row, 0, 1, this.sheet.getColumnCount()).backColor(this.sheet.getDefaultStyle().backColor);
+                    });
+                }
+                const targetRow = focusId && currentNode ? this.filingTree.nodes.indexOf(currentNode)
+                    : this.filingTree.nodes.findIndex(node => topNode && node.id === topNode.id);
+                if (targetRow >= 0) this.sheet.showRow(targetRow, focusId ? spreadNS.VerticalPosition.center : spreadNS.VerticalPosition.top);
+                this.refreshFileCountHint();
+            } finally {
+                this._reloadingFiling = false;
+            }
+            this.curFiling = null;
+            if (currentNode) {
+                this.setCurFiling(currentNode).catch(() => { $('#file-view').hide(); });
+                if (focusId) this.spread.focus();
+            } else {
+                canFiling = false;
+                $('.js-can-edit-dir').hide();
+                $('#file-view').hide();
+            }
         }
 
         /**
          * 删除分类
          */
-        delFiling(node, callback) {
+        delFiling(nodes, callback) {
+            if (this._directorySaving || this._nameSaving || !nodes.length) return;
             const self = this;
-            const parent = this.filingTree.getParent(node);
-            postData('filing/del', { id: node.id }, function(result) {
+            const modal = $('#filesjs-del-filing');
+            this._directorySaving = true;
+            $('button', modal).prop('disabled', true);
+            $('#filesjs-del-filing-error').text('');
+            postData('filing/del', { ids: nodes.map(node => node.id) }, function(result) {
                 // 更新 filing 数组:移除已删除的节点
                 if (result && result.delete) {
                     for (const delId of result.delete) {
                         const idx = filing.findIndex(x => x.id === delId);
                         if (idx >= 0) filing.splice(idx, 1);
+                        delete filingPermissionMap[delId];
                     }
                 }
                 // 更新兄弟节点的 tree_order
@@ -648,33 +640,34 @@ $(document).ready(function() {
                 }
                 // 重新加载树
                 self._reloadFilingTree();
-                $('#file-view').hide();
-                self.curFiling = null;
+                self._directorySaving = false;
+                $('button', modal).prop('disabled', false);
                 if (callback) callback();
+                toastr.success(`已删除 ${result.delete.length} 个分类`);
+            }, function(error) {
+                self._directorySaving = false;
+                $('button', modal).prop('disabled', false);
+                $('#filesjs-del-filing-error').text(error || '删除失败,请重试');
             });
         }
 
         /**
          * 重命名分类
          */
-        async renameFiling(node, newName) {
-            const result = await postDataAsync('filing/save', { id: node.id, name: newName });
-            const srcNode = filing.find(x => x.id === node.id || x.id == node.id);
-            if (srcNode) srcNode.name = newName;
-            node.name = newName;
-            const countHint = node.total_file_count > 0 ? `(${node.total_file_count})` : '';
-            node.name = newName;
-            const rowIndex = this.filingTree.nodes.indexOf(node);
-            if (rowIndex >= 0) {
-                SpreadJsObj.reLoadRowsData(this.sheet, [rowIndex]);
-            }
-            let parent = this.filingTree.getParent(node);
-            while (parent) {
-                const pIdx = this.filingTree.nodes.indexOf(parent);
-                if (pIdx >= 0) SpreadJsObj.reLoadRowsData(this.sheet, [pIdx]);
-                parent = this.filingTree.getParent(parent);
-            }
-            return result;
+        renameFiling(node, newName) {
+            this._nameSaving = true;
+            const finishSave = () => {
+                this._nameSaving = false;
+                const row = this.filingTree.nodes.findIndex(item => item.id === node.id);
+                if (row >= 0) SpreadJsObj.reLoadRowsData(this.sheet, [row]);
+            };
+            postData('filing/save', { id: node.id, name: newName }, () => {
+                const source = filing.find(item => item.id === node.id);
+                if (source) source.name = newName;
+                const currentNode = this.filingTree.nodes.find(item => item.id === node.id);
+                if (currentNode) currentNode.name = newName;
+                finishSave();
+            }, finishSave, false);
         }
 
         /**
@@ -873,6 +866,9 @@ $(document).ready(function() {
             const differ = count - (node.file_count || 0);
             node.file_count = count;
             node.total_file_count = count;
+            // 分类增删后会从 filing 重建树,同步原始数据以保留最新文件数。
+            const source = filing.find(item => item.id === node.id);
+            if (source) source.file_count = count;
             // 更新 SpreadJS 显示
             const rowIndex = this.filingTree.nodes.indexOf(node);
             if (rowIndex >= 0) {
@@ -1014,6 +1010,7 @@ $(document).ready(function() {
                 this._pasteReferenceText(text);
             }, true);
             this.sheet.bind(spreadNS.Events.ClipboardPasting, (e, info) => {
+                if (this._isRenaming || this.sheet.isEditing()) return;
                 info.cancel = true;
                 this._pasteReferenceText(info.pasteData ? info.pasteData.text : '');
             });
@@ -1131,7 +1128,7 @@ $(document).ready(function() {
         }
 
         _showNamePastePreview() {
-            if (this._namePasteSaving || $('#filing-name-paste').hasClass('show')) return;
+            if (this._namePasteSaving || this._nameSaving || $('#filing-name-paste').hasClass('show')) return;
             const clipboard = this._referenceNameClipboard;
             if (!clipboard) { toastr.warning('请先从右侧参考文件复制名称'); return; }
             if (this._isRenaming) return;
@@ -1163,7 +1160,7 @@ $(document).ready(function() {
         }
 
         _savePastedNames() {
-            if (this._namePasteSaving || !this._namePasteItems.length || this._namePasteItems.some(item => item.errors.length)) return;
+            if (this._namePasteSaving || this._nameSaving || !this._namePasteItems.length || this._namePasteItems.some(item => item.errors.length)) return;
             const items = this._namePasteItems.map(item => ({ id: item.id, old_name: item.old_name, name: item.name }));
             if (!items.some(item => item.name !== item.old_name)) return;
             const modal = $('#filing-name-paste');
@@ -1192,6 +1189,119 @@ $(document).ready(function() {
                 $('#filing-name-paste-error').text(error || '保存失败,请重试;若名称已变化,请刷新页面后重新粘贴。');
             });
         }
+
+        _addFiling(data) {
+            if (this._directorySaving || this._nameSaving || !canFiling || this._isRenaming) return;
+            this._directorySaving = true;
+            postData('filing/add', data, result => {
+                for (const update of result.update || []) {
+                    const source = filing.find(node => node.id === update.id);
+                    if (source) Object.assign(source, update);
+                }
+                for (const node of result.create || []) {
+                    if (!filing.some(source => source.id === node.id)) filing.push(node);
+                }
+                Object.assign(filingPermissionMap, result.permissionMap || {});
+                this._directorySaving = false;
+                const created = (result.create || [])[0];
+                this._reloadFilingTree(created && created.id);
+            }, () => { this._directorySaving = false; });
+        }
+
+        _getSelectedFilings() {
+            const rows = new Set();
+            for (const selection of this.sheet.getSelections()) {
+                const first = Math.max(0, selection.row);
+                const last = selection.row < 0 ? this.filingTree.nodes.length
+                    : Math.min(first + selection.rowCount, this.filingTree.nodes.length);
+                for (let row = first; row < last; row++) {
+                    if (this.sheet.getRowVisible(row)) rows.add(row);
+                }
+            }
+            return [...rows].sort((a, b) => a - b).map(row => this.filingTree.nodes[row]);
+        }
+
+        _showDeletePreview() {
+            if (this._directorySaving || this._nameSaving || this._isRenaming || projectFileLocked) return;
+            const nodes = this._getSelectedFilings();
+            if (!nodes.length) return;
+            const included = new Set(nodes.map(node => node.id));
+            // 树节点按父子顺序排列,检查选中的分类以及折叠的子分类。
+            const scope = this.filingTree.nodes.filter(node => {
+                const parent = this.filingTree.getParent(node);
+                if (parent && included.has(parent.id)) included.add(node.id);
+                return included.has(node.id);
+            });
+            if (scope.some(node => Number(node.file_count || 0) !== 0 || Number(node.total_file_count || 0) !== 0)) {
+                toastr.error('此分类已存在文件,请先删除所有文件。');
+                return;
+            }
+            if (scope.some(node => Number(node.is_fixed))) {
+                toastr.error('固定分类不可删除');
+                return;
+            }
+            if (scope.some(node => {
+                const permission = this._getFilingPermission(node.id);
+                return !permission.can_view || !permission.can_edit_dir;
+            })) {
+                toastr.error('部分分类或子分类没有编辑目录权限,无法删除');
+                return;
+            }
+            this.selectRemoveNodes = nodes;
+            const list = $('#filesjs-del-filing-list').empty();
+            nodes.forEach(node => { $('<li>').text(node.name || '(未命名分类)').appendTo(list); });
+            $('#filesjs-del-filing-summary').text(`已选择 ${nodes.length} 个分类,确认删除?`);
+            $('#filesjs-del-filing-error').text('');
+            $('#filesjs-del-filing').modal('show');
+        }
+
+        _canEditFilingName(node) {
+            if (!node || projectFileLocked || Number(node.is_fixed)) return false;
+            const permission = this._getFilingPermission(node.id);
+            return Boolean(Number(permission.can_view) && Number(permission.can_edit_dir));
+        }
+
+        _bindFilingNameEditing() {
+            this.sheet.bind(spreadNS.Events.EditStarting, (e, info) => {
+                const node = this.filingTree.nodes[info.row];
+                if (info.col !== 0 || !this._canEditFilingName(node) || this._directorySaving
+                    || this._nameSaving || this._namePasteSaving || this._reloadingFiling) {
+                    info.cancel = true;
+                    return;
+                }
+                this._isRenaming = true;
+                this._nameEdit = { node, row: info.row, oldName: String(node.name || '') };
+            });
+            this.sheet.bind(spreadNS.Events.EditEnded, (e, info) => { this._finishFilingNameEdit(info); });
+            $('#filing-spread')[0].addEventListener('keydown', e => {
+                if (e.isComposing || e.keyCode === 229) return;
+                // 不允许快捷键绕过单个名称保存,批量替换继续走粘贴确认弹窗。
+                if (!this.sheet.isEditing() && (e.keyCode === 46 || e.keyCode === 8 || ((e.ctrlKey || e.metaKey) && e.keyCode === 88))) {
+                    e.preventDefault();
+                    e.stopImmediatePropagation();
+                } else if ((e.ctrlKey || e.metaKey) && e.keyCode === 13) {
+                    e.preventDefault();
+                    e.stopImmediatePropagation();
+                    if (this.sheet.isEditing()) this.sheet.endEdit();
+                }
+            }, true);
+        }
+
+        _finishFilingNameEdit(info) {
+            const editing = this._nameEdit;
+            this._nameEdit = null;
+            this._isRenaming = false;
+            if (!editing || info.col !== 0 || info.row !== editing.row) return;
+            // Esc 取消时 editingText 仍是输入内容;读取实际单元格值,避免误保存。
+            const value = String(this.sheet.getValue(info.row, 0) || '');
+            if (value === editing.oldName) return;
+            const newName = value.trim();
+            if (!newName || newName === editing.oldName) {
+                this.sheet.setValue(info.row, 0, editing.oldName);
+                return;
+            }
+            this.renameFiling(editing.node, newName);
+        }
     }
 
     // 初始化
@@ -2496,13 +2606,19 @@ $(document).ready(function() {
     });
 
     // 删除分类确认弹窗
-    $('#del-filing-ok').off('click').on('click', function() {
-        if (filingSjs.selectRemoveNode) {
-            filingSjs.delFiling(filingSjs.selectRemoveNode, function() {
-                $('#del-filing').modal('hide');
+    $('#filesjs-del-filing-ok').on('click', function() {
+        if (filingSjs.selectRemoveNodes.length) {
+            filingSjs.delFiling(filingSjs.selectRemoveNodes, function() {
+                $('#filesjs-del-filing').modal('hide');
             });
         }
     });
+    $('#filesjs-del-filing').on('hide.bs.modal', function(e) {
+        if (filingSjs._directorySaving) e.preventDefault();
+    }).on('hidden.bs.modal', function() {
+        filingSjs.selectRemoveNodes = [];
+        filingSjs.spread.focus();
+    });
 
     // 批量删除确认
     $('#batch-del-file-ok').off('click').on('click', function() {

+ 9 - 0
app/public/js/ledger.js

@@ -1986,7 +1986,12 @@ $(document).ready(function() {
                 posCalcDetail.loadCurDetailData();
             });
         },
+        focusIn: function() {
+            const curNode = SpreadJsObj.getSelectObject(ledgerSheet);
+            aiTrans.setRelaInfo('bills', curNode, curNode);
+        }
     };
+    $('#ledger-spread').focusin(treeOperationObj.focusIn);
     const ledgerTemplate = $.ledger_template({
         selector: '#ledger-template',
         id: 'ledger-template',
@@ -3582,9 +3587,13 @@ $(document).ready(function() {
                 removeLocalCache(copyBlockTag);
                 posOperationObj.loadCurPosData();
             });
+        },
+        focusIn: function() {
+            aiTrans.setRelaInfo('pos', SpreadJsObj.getSelectObject(posSheet), SpreadJsObj.getSelectObject(ledgerSheet));
         }
     };
     posSpread.bind(spreadNS.Events.SelectionChanged, posOperationObj.selectionChanged);
+    $('#pos-spread').focusin(posOperationObj.focusIn);
     if (!posSpreadSetting.readOnly) {
         $('a[name="pos-opr"]').click(function () {
             posOperationObj.baseOpr(posSpread.getActiveSheet(), this.getAttribute('type'));

File diff suppressed because it is too large
+ 31 - 10
app/public/js/project_screen_guangyuan.js


+ 3 - 8
app/public/js/project_screen_setting.js

@@ -122,14 +122,9 @@ $(function() {
         const tab = $(this).attr('data-tab');
         $('.project-screen-tab').removeClass('active');
         $(this).addClass('active');
-        if (tab === 'permission') {
-            $('#project-screen-config').hide();
-            $('#project-screen-permission').show();
-            loadPermissions(1);
-        } else {
-            $('#project-screen-permission').hide();
-            $('#project-screen-config').show();
-        }
+        $('#project-screen-config, #project-screen-permission, #project-screen-tender').hide();
+        $(`#project-screen-${tab}`).show();
+        if (tab === 'permission') loadPermissions(1);
     });
 
     $('#project-screen-select').click(function() {

File diff suppressed because it is too large
+ 572 - 0
app/public/js/project_screen_tender_setting.js


+ 144 - 0
app/public/js/setting_approval.js

@@ -0,0 +1,144 @@
+'use strict';
+
+const tenderApprovalEditor = {
+    data: null,
+    active: null,
+    saving: false,
+    loading: false,
+    saved: false,
+
+    init() {
+        const self = this;
+        $('body').on('click', '.approval-edit', function () { self.open($(this).attr('data-code')); });
+        $('body').on('click', '.approval-copy', function () {
+            const code = $(this).attr('data-code'), kind = $(this).attr('data-kind');
+            $('#approval-card-store .' + code + '_div .' + kind).trigger('click');
+        });
+        $('#approval-save').on('click', function () { self.save(); });
+        $('#approval-editor').on('hide.bs.modal', function (event) {
+            if (self.saving || (self.active && self.active.operations.length && !self.saved && !window.confirm('有未保存的修改,确定放弃并关闭吗?'))) event.preventDefault();
+        }).on('hidden.bs.modal', function () {
+            const saved = self.saved, tid = self.active ? self.active.tid : cur_tenderid;
+            self.active = null; self.saved = false;
+            $('#approval-edit-body').empty();
+            if (self.data) self.setData(self.data);
+            if (saved) loadTenderApproval(tid);
+        });
+        const children = $('#batch, #batch2, #cooperation, #union, #multi, #spzsave, #spzdelete');
+        children.on('show.bs.modal', function () {
+            if ($('#approval-editor').hasClass('show')) $(this).addClass('approval-child-modal');
+        }).on('shown.bs.modal', function () {
+            if ($(this).hasClass('approval-child-modal')) $('.modal-backdrop').last().addClass('approval-child-backdrop');
+        }).on('hidden.bs.modal', function () {
+            $(this).removeClass('approval-child-modal');
+            if ($('#approval-editor').hasClass('show')) $('body').addClass('modal-open');
+        });
+    },
+
+    setData(data) {
+        this.data = data;
+        sp_lc = _.cloneDeep(data.shenpi.sp_lc);
+        this.render();
+        renderTenderApproval(Object.assign({}, data.shenpi, { sp_lc }), data.tender, data.revising, '#approval-card-store');
+    },
+
+    summary(flow) {
+        if (flow.status === 1) return '';
+        if (flow.status === 3) return flow.audit ? flow.audit.name : '未设置';
+        const groups = flow.auditGroupList || [];
+        const selected = (flow.groupList || []).find(x => x.is_select === 1);
+        const text = groups.map(group => {
+            const type = group.length && auditType.info[group[0].audit_type];
+            return group.map(x => x.name).join('/') + (type && type.value !== 1 ? '(' + type.name + ')' : '');
+        }).join('、') || '未设置';
+        return (selected ? selected.name + ':' : '') + text;
+    },
+
+    render() {
+        const html = ['<table class="table table-bordered table-sm approval-summary-table"><thead><tr><th class="approval-column-name">栏目</th><th>审批流程</th><th class="approval-column-actions">操作</th></tr></thead><tbody>'];
+        for (const flow of this.data.shenpi.sp_lc) {
+            html.push('<tr><td>', _.escape(flow.name), '</td><td><span class="badge badge-light approval-mode">', _.escape(sp_status_list[flow.status].name), '</span> ', _.escape(this.summary(flow)), '</td>');
+            html.push('<td class="approval-actions"><a href="javascript:void(0);" class="approval-edit" data-code="', flow.code, '">编辑</a> ');
+            html.push('<div class="dropdown d-inline-block ml-2"><a href="javascript:void(0);" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">更多</a><div class="dropdown-menu dropdown-menu-right">');
+            for (const item of [{ kind: 'set-otherShenpi', name: '设置其他流程' }, { kind: 'set-otherTender', name: '设置其他标段' }]) {
+                html.push('<a class="dropdown-item approval-copy" href="javascript:void(0);" data-code="', flow.code, '" data-kind="', item.kind, '">', item.name, '</a>');
+            }
+            html.push('</div></div></td></tr>');
+        }
+        html.push('</tbody></table>');
+        $('#splc').html(html.join(''));
+    },
+
+    open(code) {
+        if (this.loading || this.active || !this.data) return;
+        const self = this, tid = cur_tenderid;
+        self.loading = true;
+        postData(`/sp/${spid}/setting/manage/tender/save`, { type: 'approval-draft', tid, code }, function (result) {
+            self.loading = false;
+            if (tid !== cur_tenderid) return;
+            self.active = { tid, code, version: result.version, model: new TenderApprovalDraft(result.snapshot, result.options), operations: [] };
+            self.saved = false;
+            sp_lc = _.cloneDeep(self.data.shenpi.sp_lc);
+            const flow = self.active.model.view();
+            sp_lc[sp_lc.findIndex(x => x.code === code)] = flow;
+            $('#approval-card-store .' + code + '_div').closest('.card').remove();
+            renderTenderApproval(Object.assign({}, self.data.shenpi, { sp_lc: [flow] }), self.data.tender, self.data.revising, '#approval-edit-body');
+            $('#approval-editor').modal('show');
+            $('body').trigger('tender-approval-edit-open');
+        }, function () { self.loading = false; });
+    },
+
+    save() {
+        if (!this.active || this.saving) return;
+        if (!this.active.operations.length) { $('#approval-editor').modal('hide'); return; }
+        try { this.active.model.validate(); } catch (error) { toastr.warning(error.message); return; }
+        const self = this, draft = self.active;
+        self.saving = true;
+        $('#approval-save').prop('disabled', true);
+        $('#approval-edit-body').addClass('approval-saving');
+        postData(`/sp/${spid}/setting/manage/tender/save`, {
+            type: 'approval-save', tid: draft.tid, code: draft.code, version: draft.version, operations: draft.operations,
+        }, function () {
+            self.saving = false; self.saved = true;
+            $('#approval-save').prop('disabled', false);
+            $('#approval-edit-body').removeClass('approval-saving');
+            toastr.success('审批流程已保存');
+            $('#approval-editor').modal('hide');
+        }, function () {
+            self.saving = false;
+            $('#approval-save').prop('disabled', false);
+            $('#approval-edit-body').removeClass('approval-saving');
+        });
+    },
+
+    request(url, data, success, failure, waiting, message) {
+        const self = this, draft = self.active;
+        if (self.saving) { if (failure) failure('正在保存'); return; }
+        if (url.endsWith('/ass/load') || url.endsWith('/union/load')) {
+            postData(url, data, function (result) {
+                if (self.active !== draft) return;
+                if (url.endsWith('/ass/load')) result.auditAssList = _.cloneDeep(draft.model.state.assistants);
+                else result.unionAuditors = _.cloneDeep(draft.model.activeAudits(2).filter(x => x.audit_order === Number(data.audit_order)));
+                if (success) success(result);
+            }, failure, waiting, message);
+            return;
+        }
+        if (['copy2ot', 'copy2os'].includes(data.type)) {
+            if (draft.operations.length) { toastr.warning('请先保存当前修改,再设置其他流程或标段'); return; }
+            return postData(url, data, success, failure, waiting, message);
+        }
+        const kind = url.endsWith('/shenpi/save') ? 'status' : url.endsWith('/audit/save') ? 'audit' : url.endsWith('/multi/save') ? 'multi' : url.endsWith('/union/save') ? 'union' : '';
+        try {
+            const operation = { kind, data: _.cloneDeep(data) };
+            const result = draft.model.apply(operation);
+            draft.operations.push(operation);
+            if (success) success(result);
+            // Existing callbacks update the controls; keep every group's cached view in sync with the draft.
+            const index = sp_lc.findIndex(x => x.code === draft.code);
+            sp_lc[index] = draft.model.view();
+        } catch (error) {
+            toastr.warning(error.message || String(error));
+            if (failure) failure(error.message);
+        }
+    },
+};

+ 62 - 64
app/public/js/setting_manage.js

@@ -171,6 +171,11 @@ $(document).ready(() => {
     const cHeader = getObjHeight($(".c-header"));
     $('.tab-content').height($(window).height()-cHeader-90+53-46);
 
+    if (typeof approvalOnly !== 'undefined' && approvalOnly) {
+        initTenderApprovalPage();
+        return;
+    }
+
     const tabTypes = {
         tourist: '游客',
         schedule: '标段进度',
@@ -1054,17 +1059,21 @@ const tenderListSpec = (function(){
             html.push(arr.indexOf(node) === arr.length - 1 ? '└' : '├');
             html.push('</span>');
             //html.push('<a href="/tender/' + node.id + '">', node[c.field], '</a>');
-            html.push('<a href="javascript: void(0)" id="' + node.id + '" name="name">', node.name, '</a>');
+            const onlyApproval = typeof approvalOnly !== 'undefined' && approvalOnly;
+            html.push('<a href="javascript: void(0)" id="' + node.id + '"' + (onlyApproval ? '' : ' name="name"') + '>', node.name, '</a>');
             html.push('</div>');
-            html.push('<div class="btn-group-table" tid="' + node.id + '">');
-            html.push('<a href="javascript:void(0);" name="edit" class="mr-1"><i class="fa fa-pencil fa-fw"></i></a>');
-            const hasStage = node.progress ? (node.stage_count > 0 ? true : node.stage_status !== 0) : !!node.lastStage;
-            if (!hasStage) {
-                html.push('<a href="javascript:void(0);" name="del" class="mr-1"><i class="fa fa-trash-o fa-fw text-danger"></i></a>');
-            } else {
-                html.push('<a href="javascript:void(0);" data-toggle="tooltip" title="请先删除所有期" class="mr-1"><i class="fa fa-trash-o fa-fw text-secondary"></i></a>');
+            if (!onlyApproval) {
+                html.push('<div class="btn-group-table" tid="' + node.id + '">');
+                html.push('<a href="javascript:void(0);" name="edit" class="mr-1"><i class="fa fa-pencil fa-fw"></i></a>');
+                const hasStage = node.progress ? (node.stage_count > 0 ? true : node.stage_status !== 0) : !!node.lastStage;
+                if (!hasStage) {
+                    html.push('<a href="javascript:void(0);" name="del" class="mr-1"><i class="fa fa-trash-o fa-fw text-danger"></i></a>');
+                } else {
+                    html.push('<a href="javascript:void(0);" data-toggle="tooltip" title="请先删除所有期" class="mr-1"><i class="fa fa-trash-o fa-fw text-secondary"></i></a>');
+                }
+                html.push('</div>');
             }
-            html.push('</div></div>');
+            html.push('</div>');
         }
         html.push('</td>');
         // 创建人
@@ -1126,61 +1135,7 @@ function updateSelectedMarks(forcedType = null) {
 }
 
 function setShenpiHtml(shenpi, tender, revising) {
-    let html = '';
-    if (shenpi.sp_lc.length > 0) {
-        for (const sp of shenpi.sp_lc) {
-            html += `<div class="card mb-3">
-                                <div class="card-body ${sp.code}_div">
-                                    <a class="pull-right set-otherTender" data-name="${sp.name}" data-code="${sp.code}" href="#batch" data-toggle="modal" data-target="#batch">设置其他标段</a>
-                                    <a class="pull-right set-otherShenpi mr-3" data-name="${sp.name}" data-code="${sp.code}" href="javascript: void(0);">设置其他流程</a>`;
-            if (sp.code === 'stage' && !revising && tender.ledger_status === auditConst.ledger.status.checked) {
-                html += `<a class="pull-right mr-3" id="stage_cooperation" ${sp.status !== shenpi.sp_status.gdspl ? 'style="display: none"' : '' } data-name="${sp.name}" data-code="${sp.code}" href="#cooperation" data-toggle="modal" data-target="#cooperation">多人协同 <i class="fa fa-lock"></i></a>`;
-            }
-            html += `
-                                    <h5 class="card-title">${sp.name}</h5>
-                                    <div class="form-group">
-                                        <div class="form-group form-check">`;
-            for (const st in shenpi.sp_status_list) {
-                if (shenpi.sp_status_list[st]) {
-                    html += `<div class="custom-control custom-checkbox custom-control-inline">
-                                                    <input type="radio" class="custom-control-input" data-code="${sp.code}" value="${shenpi.sp_status_list[st].status}" name="${sp.code}" id="${sp.code}_${shenpi.sp_status_list[st].status}" ${sp.status === shenpi.sp_status_list[st].status ? 'checked' : ''}>
-                                                    <label class="custom-control-label" for="${sp.code}_${shenpi.sp_status_list[st].status}">${shenpi.sp_status_list[st].name}</label>
-                                                </div>`;
-                }
-            }
-            html += `
-                                        </div>
-                                    </div>
-                                    <div class="alert alert-warning">${shenpi.sp_status_list[sp.status].name}:${shenpi.sp_status_list[sp.status].msg}</div>
-                                    <div class="lc-show">`;
-            if (sp.status === sp_status.sqspr) {
-                html += ``;
-            } else if (sp.status === sp_status.gdspl) {
-                const flow = sp_lc.find(x => { return x.code === sp.code; });
-                flow.auditGroupList = sp.auditGroupList;
-                html += auditUtils.getGroupHtml(flow, sp.code);
-                let addhtml = '<ul class="list-unstyled">\n';
-                addhtml += auditUtils.getgdsplHtml(flow, sp.code);
-                addhtml += '</ul>\n';
-                html += addhtml;
-            } else if (sp.status === sp_status.gdzs) {
-                let addhtml = '<ul class="list-unstyled">\n' +
-                    '                                        <li class="d-flex justify-content-start mb-3">\n' +
-                    '                                            <span class="col-auto">授权审批人</span>\n' +
-                    '                                            <span class="col-7">\n' +
-                    '                                                <span class="d-inline-block"></span>\n' +
-                    '                                            </span>\n' +
-                    '                                        </li>\n';
-                addhtml += sp.audit ? makeAudit(sp.audit) : makeSelectAudit(sp.code);
-                addhtml += '</ul>\n';
-                html += addhtml;
-            }
-            html += `                </div>
-                                </div>
-                            </div>`;
-        }
-    }
-    $('#splc').html(html);
+    renderTenderApproval(shenpi, tender, revising);
 }
 
 let selectedUserIdsByType = {
@@ -1399,3 +1354,46 @@ const getUserPermissionHtml = function(user, key) {
     html.push('</tr>');
     return html.join('');
 };
+
+
+let tenderApprovalLoadId = 0;
+
+function initTenderApprovalPage() {
+    tenderApprovalEditor.init();
+    $('body').on('click', '.setting-approval-page .tender-info', function (event) {
+        event.preventDefault();
+        loadTenderApproval(Number($(this).attr('data-id')));
+    });
+    $('body').on('click', '.setting-approval-page .approval-retry', function () {
+        loadTenderApproval(Number($(this).attr('data-id')));
+    });
+    const firstTender = $('.setting-approval-page .tender-info').eq(0);
+    if (firstTender.length) loadTenderApproval(Number(firstTender.attr('data-id')));
+}
+
+function loadTenderApproval(tid) {
+    if (!Number.isSafeInteger(tid) || tid <= 0) return;
+    const loadId = ++tenderApprovalLoadId;
+    const rows = $('.setting-approval-page .tender-info');
+    rows.removeClass('table-warning');
+    rows.filter('[data-id="' + tid + '"]').addClass('table-warning');
+    // Loading or failed requests must not leave the previous tender editable.
+    cur_tenderid = 0;
+    cur_uid = null;
+    sp_lc = [];
+    $('#splc').html('<div class="text-muted p-3">正在加载审批流程…</div>');
+    postData(`/sp/${spid}/setting/manage/tender/save`, { type: 'approval-msg', tid }, function (result) {
+        if (loadId !== tenderApprovalLoadId) return;
+        sp_lc = result.shenpi.sp_lc;
+        sp_type = result.shenpi.sp_type;
+        sp_status = result.shenpi.sp_status;
+        sp_status_list = result.shenpi.sp_status_list;
+        cur_tenderid = result.tender.id;
+        cur_uid = result.tender.user_id;
+        tenderApprovalEditor.setData(result);
+    }, function () {
+        if (loadId !== tenderApprovalLoadId) return;
+        $('#splc').html('<div class="alert alert-warning">审批流程加载失败,请重试。' +
+            '<button type="button" class="btn btn-sm btn-outline-primary ml-2 approval-retry" data-id="' + tid + '">重新加载</button></div>');
+    });
+}

+ 9 - 6
app/public/js/shares/show_level.js

@@ -21,10 +21,12 @@ $.cs_showLevel = function (setting) {
 
     const initShowLevel = function () {
         const obj = $(setting.selector);
+        const buttonId = setting.buttonId || 'dropdownMenuButton';
+        const menuId = setting.buttonId ? `${buttonId}-menu` : 'showLevelList';
         const html = [];
-        html.push('<button class="btn btn-sm btn-light dropdown-toggle text-primary" type="button" id="dropdownMenuButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">' +
+        html.push(`<button class="btn btn-sm btn-light dropdown-toggle text-primary" type="button" id="${buttonId}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">` +
             '<i class="fa fa-list-ol"></i> 显示层级</button>');
-        html.push('<div class="dropdown-menu" aria-labelledby="dropdownMenuButton" id="showLevelList">');
+        html.push(`<div class="dropdown-menu" aria-labelledby="${buttonId}" id="${menuId}">`);
         for (const l of setting.levels) {
             if (l.type === 'sort') {
                 let count = typeof l.count === 'function' ? l.count() : l.count;
@@ -40,7 +42,7 @@ $.cs_showLevel = function (setting) {
         obj.html(html.join(''));
 
 
-        $('a[name=showLevel]').click(function () {
+        obj.find('a[name=showLevel]').click(function () {
             setTimeout(() => {
                 const tag = $(this).attr('tag');
                 showWaitingView();
@@ -52,9 +54,10 @@ $.cs_showLevel = function (setting) {
     };
 
     const refreshMenuVisible = function () {
+        const obj = $(setting.selector);
         const showMenu = function (tag, visible) {
-            if (visible) $(`[tag=${tag}]`).show();
-            if (!visible) $(`[tag=${tag}]`).hide();
+            if (visible) obj.find(`a[name=showLevel][tag="${tag}"]`).show();
+            if (!visible) obj.find(`a[name=showLevel][tag="${tag}"]`).hide();
         };
         for (const l of setting.levels) {
             if (l.type === 'sort') {
@@ -70,4 +73,4 @@ $.cs_showLevel = function (setting) {
     };
 
     return { initShowLevel, refreshMenuVisible } ;
-};
+};

+ 230 - 0
app/public/js/shares/tender_approval_draft.js

@@ -0,0 +1,230 @@
+'use strict';
+
+// Both the editor and server replay the same draft operations; no database access here.
+class TenderApprovalDraft {
+    constructor(snapshot, options) {
+        this.state = JSON.parse(JSON.stringify(snapshot));
+        this.options = options;
+        this.nextId = -1;
+        this.cooperation = [];
+    }
+
+    member(id) {
+        const member = this.options.accounts.find(x => Number(x.id) === Number(id));
+        if (!member) throw new Error('审批人不属于当前项目');
+        return member;
+    }
+
+    currentGroup() {
+        const group = this.state.groups.find(x => x.is_select === 1);
+        return group ? group.id : 0;
+    }
+
+    activeAudits(status = this.state.status) {
+        return this.state.audits.filter(x => x.sp_status === status &&
+            (status !== 2 || Number(x.sp_group || 0) === this.currentGroup()));
+    }
+
+    view() {
+        const copy = value => JSON.parse(JSON.stringify(value));
+        const groupAudits = id => {
+            const list = this.state.audits.filter(x => x.sp_status === 2 && Number(x.sp_group || 0) === id)
+                .sort((a, b) => a.audit_order - b.audit_order || (a.id > 0 && b.id > 0 ? a.id - b.id : 0));
+            const groups = [];
+            for (const row of list) {
+                let group = groups.find(x => x[0].audit_order === row.audit_order);
+                if (!group) { group = []; groups.push(group); }
+                group.push(row);
+            }
+            return copy(groups);
+        };
+        return {
+            code: this.options.code, type: this.options.spType, name: this.options.name,
+            status: this.state.status,
+            groupList: this.state.groups.map(x => Object.assign(copy(x), { auditGroupList: groupAudits(x.id) })),
+            auditGroupList: groupAudits(this.currentGroup()),
+            audit: copy(this.state.audits.find(x => x.sp_status === 3) || null),
+        };
+    }
+
+    apply(operation) {
+        // Reject a failed operation without leaving a partially modified draft.
+        const previous = JSON.stringify(this.state), nextId = this.nextId, effects = this.cooperation.length;
+        try { return JSON.parse(JSON.stringify(this.change(operation))); } catch (error) {
+            this.state = JSON.parse(previous); this.nextId = nextId; this.cooperation.length = effects;
+            throw error;
+        }
+    }
+
+    change(operation) {
+        const data = operation.data || {};
+        if (operation.kind === 'status') {
+            if (data.code !== this.options.code || ![1, 2, 3].includes(Number(data.status))) throw new Error('审批模式有误');
+            this.state.status = Number(data.status);
+            if (this.options.code === 'stage') this.cooperation.push({ status: this.state.status === 2 ? 1 : 0 });
+            const flow = this.view();
+            return { auditList: this.state.status === 2 ? flow.auditGroupList : flow.audit, groupList: flow.groupList };
+        }
+        if (operation.kind === 'multi' || operation.kind === 'union') {
+            if (!Array.isArray(data) || this.options.code !== 'stage') throw new Error('审批设置参数有误');
+            for (const update of data) {
+                const row = this.activeAudits().find(x => x.id === Number(update.id));
+                if (!row) throw new Error('审批节点已不存在');
+                if (operation.kind === 'union') {
+                    if (row.audit_type !== 4) throw new Error('该节点不是协同审批');
+                    row.audit_ledger_id = this.ledgerIds(update.audit_ledger_id);
+                } else {
+                    if (![2, 3, 5].includes(row.audit_type)) throw new Error('该节点不支持分组设置');
+                    for (const key of ['audit_group_order', 'audit_group_limit', 'audit_checkno_valid', 'audit_group_need']) {
+                        row[key] = this.integer(update[key]);
+                    }
+                    row.audit_group = String(update.audit_group || '').trim();
+                    if (row.audit_group.length > 20 || row.audit_group_limit > 1 || row.audit_checkno_valid > 1) throw new Error('分组设置有误');
+                }
+            }
+            return data;
+        }
+        if (operation.kind !== 'audit') throw new Error('不支持的审批操作');
+        if (['save-group', 'change-group', 'delete-group'].includes(data.type)) return this.changeGroup(data);
+        if (data.type === 'audit-ass') return this.changeAssistant(data);
+        if (Number(data.code) !== this.options.spType || Number(data.status) !== this.state.status) throw new Error('审批流程不匹配');
+        const current = this.activeAudits();
+        if (data.type === 'add') {
+            const member = this.member(data.audit_id);
+            if (current.some(x => x.audit_id === member.id)) throw new Error('该审批人已存在');
+            if (this.state.status === 1 || (this.state.status === 3 && current.length)) throw new Error('请先移除原终审人');
+            if (member.id === this.options.owner && !['advance', 'ledger', 'revise', 'change', 'contract'].includes(this.options.code)) throw new Error('当前流程不能选择原报人');
+            const order = this.state.status === 2 ? this.integer(data.audit_order, 1) : 0;
+            const type = this.state.status === 2 ? this.integer(data.audit_type, 1) : 1;
+            this.checkType(type);
+            const row = { id: this.nextId--, audit_id: member.id, name: member.name, company: member.company || '', role: member.role || '',
+                sp_status: this.state.status, sp_group: this.state.status === 2 ? this.currentGroup() : 0,
+                audit_order: order, audit_type: type, audit_group: '', audit_group_order: 0, audit_group_limit: 0,
+                audit_checkno_valid: 1, audit_group_need: 1, audit_ledger_id: '' };
+            this.state.audits.push(row);
+            if (this.options.code === 'stage' && this.state.status === 2) this.cooperation.push({ user_id: member.id, status: 1 });
+            return row;
+        }
+        if (data.type === 'del') {
+            const row = current.find(x => x.audit_id === Number(data.audit_id));
+            if (!row) throw new Error('审批人已不存在');
+            this.state.audits.splice(this.state.audits.indexOf(row), 1);
+            if (!current.some(x => x !== row && x.audit_order === row.audit_order)) {
+                for (const next of current) if (next.audit_order > row.audit_order) next.audit_order--;
+            }
+            if (this.options.code === 'stage' && this.state.status === 2) this.cooperation.push({ user_id: row.audit_id, status: 0 });
+            return true;
+        }
+        if (data.type === 'audit-type') {
+            const type = this.integer(data.audit_type, 1);
+            this.checkType(type);
+            if (!Array.isArray(data.audit_id) || !data.audit_id.length) throw new Error('未选择审批人');
+            const rows = current.filter(x => data.audit_id.includes(x.audit_id));
+            if (rows.length !== data.audit_id.length || new Set(rows.map(x => x.audit_order)).size !== 1) throw new Error('审批节点有误');
+            if (type === 1 && rows.length !== 1) throw new Error('个人审批只能设置一人');
+            for (const row of rows) row.audit_type = type;
+            return true;
+        }
+        throw new Error('不支持的审批操作');
+    }
+
+    changeGroup(data) {
+        if (!['stage', 'change', 'contract'].includes(this.options.code) || this.state.status !== 2) throw new Error('该流程不支持审批组');
+        let group = this.state.groups.find(x => x.id === Number(data.groupId));
+        if (data.type !== 'save-group' && !group) throw new Error('审批组已不存在');
+        if (data.type === 'save-group') {
+            if (data.code !== this.options.code) throw new Error('审批流程不匹配');
+            const name = String(data.name || '').trim();
+            if (!name || name.length > 50 || this.state.groups.some(x => x !== group && x.name === name)) throw new Error('审批组名称为空、重复或过长');
+            if (data.groupId && !group) throw new Error('审批组已不存在');
+            let change_type = null;
+            if (this.options.code !== 'stage') {
+                const keys = this.options.code === 'change' ? ['change', 'changeProject', 'changeApply', 'changePlan'] : ['contract', 'expenses', 'income'];
+                change_type = {};
+                for (const key of keys) change_type[key] = !!(data.change_type && data.change_type[key]);
+                if (!Object.values(change_type).some(Boolean)) throw new Error('请至少选择一种显示模块');
+            }
+            if (!group) {
+                group = { id: this.nextId--, name, is_select: 1, change_type };
+                this.state.groups.forEach(x => { x.is_select = 0; });
+                this.state.groups.push(group);
+                for (const row of this.state.audits) if (row.sp_status === 2 && !row.sp_group) row.sp_group = group.id;
+            } else { group.name = name; group.change_type = change_type; }
+            return { group: this.view().groupList.find(x => x.id === group.id) };
+        }
+        if (data.type === 'delete-group') {
+            this.state.groups.splice(this.state.groups.indexOf(group), 1);
+            this.state.audits = this.state.audits.filter(x => x.sp_group !== group.id);
+            group = this.state.groups[0];
+        }
+        this.state.groups.forEach(x => { x.is_select = group && x.id === group.id ? 1 : 0; });
+        return true;
+    }
+
+    changeAssistant(data) {
+        if (this.options.code !== 'stage' || !this.options.canCooperate || this.state.status !== 2) throw new Error('当前流程不能设置多人协同');
+        const result = { update: [] };
+        if (data.add) {
+            const user = this.member(data.add.user_id), assistant = this.member(data.add.ass_user_id);
+            if (user.id === assistant.id || (user.id !== this.options.owner && !this.activeAudits().some(x => x.audit_id === user.id && x.audit_type === 1))) throw new Error('协同人员设置有误');
+            if (this.state.assistants.some(x => x.user_id === user.id && x.ass_user_id === assistant.id)) throw new Error('协同人已存在');
+            result.add = { id: this.nextId--, user_id: user.id, ass_user_id: assistant.id, name: assistant.name, company: assistant.company || '', role: assistant.role || '', ass_ledger_id: this.ledgerIds(data.add.ass_ledger_id, 1000) };
+            this.state.assistants.push(result.add);
+        }
+        for (const update of data.update || []) {
+            const row = this.state.assistants.find(x => x.id === Number(update.id));
+            if (!row) throw new Error('协同人已不存在');
+            row.ass_ledger_id = this.ledgerIds(update.ass_ledger_id, 1000); result.update.push({ id: row.id, ass_ledger_id: row.ass_ledger_id });
+        }
+        if (data.del) {
+            const index = this.state.assistants.findIndex(x => x.id === Number(data.del.id));
+            if (index < 0) throw new Error('协同人已不存在');
+            result.del = { id: this.state.assistants[index].id }; this.state.assistants.splice(index, 1);
+        }
+        return result;
+    }
+
+    integer(value, min = 0) {
+        const number = Number(value);
+        if (!Number.isSafeInteger(number) || number < min) throw new Error('审批参数必须为有效整数');
+        return number;
+    }
+
+    checkType(type) {
+        const info = this.options.auditTypes.find(x => x.value === type);
+        if (!info || (info.valid && !info.valid.includes(this.options.code))) throw new Error('当前栏目不支持此审批类型');
+    }
+
+    ledgerIds(value, maxLength = 5000) {
+        const ids = String(value || '').split(',').filter(Boolean);
+        if (String(value || '').length > maxLength || !Array.isArray(this.options.ledgerIds) || ids.some(x => !this.options.ledgerIds.includes(x))) throw new Error('协同台账不属于当前标段');
+        return Array.from(new Set(ids)).join(',');
+    }
+
+    validate() {
+        const active = this.activeAudits();
+        if (this.state.status !== 1 && !active.length) throw new Error('请先设置审批人');
+        if (this.state.status === 3 && active.length !== 1) throw new Error('固定终审只能设置一名终审人');
+        const flow = this.view();
+        const lists = flow.groupList.length ? flow.groupList.map(x => x.auditGroupList) : [flow.auditGroupList];
+        if (this.state.groups.length && this.state.groups.filter(x => x.is_select === 1).length !== 1) throw new Error('请选择一个审批组');
+        for (const list of lists) {
+            for (const group of list) {
+                const type = group[0].audit_type;
+                this.checkType(type);
+                if (group.some(x => x.audit_type !== type) || (type === 1 && group.length !== 1)) throw new Error('同一审批节点的类型或人数有误');
+                if (type === 5 && group.some(x => !x.audit_group || !x.audit_group_order)) throw new Error('分组审批的部门及顺序不能为空');
+                if (type === 2 && group.some(x => !!x.audit_group_order !== !!group[0].audit_group_order)) throw new Error('会签顺序必须全部设置或全部留空');
+                if (type === 3 && group.some(x => x.audit_group_need < 1 || x.audit_group_need > group.length)) throw new Error('或签人数设置有误');
+            }
+            const orders = list.map(x => x[0].audit_order);
+            if (orders.some((x, i) => x !== i + 1)) throw new Error('审批顺序必须从1开始连续');
+            const ids = [].concat(...list).map(x => x.audit_id);
+            if (new Set(ids).size !== ids.length) throw new Error('同一审批组的审批人不能重复');
+        }
+        if (new Set(active.map(x => x.audit_id)).size !== active.length) throw new Error('审批人不能重复');
+        return true;
+    }
+}
+
+if (typeof module !== 'undefined' && module.exports) module.exports = TenderApprovalDraft;

+ 102 - 18
app/public/js/shenpi.js

@@ -465,7 +465,7 @@ $(document).ready(function () {
             status: this_status
         };
         const _self = $(this);
-        postData('/tender/' + cur_tenderid + '/shenpi/save', prop, function (data) {
+        postTenderApprovalData('/tender/' + cur_tenderid + '/shenpi/save', prop, function (data) {
             if (this_status === sp_status.sqspr) {
                 _self.parents('.form-group').siblings('.lc-show').html('');
             } else if (this_status === sp_status.gdspl) {
@@ -537,7 +537,7 @@ $(document).ready(function () {
                 prop.audit_order = $(this).parents('li').index() + 1;
             }
             const _self = $(this);
-            postData('/tender/' + cur_tenderid + '/shenpi/audit/save', prop, function (data) {
+            postTenderApprovalData('/tender/' + cur_tenderid + '/shenpi/audit/save', prop, function (data) {
                 if (this_status === sp_status.gdspl) {
                     const auditGroup = auditUtils.addAudit(this_code, data, prop.audit_order - 1);
                     if (_self.parents('ul').find('.add-audit').length === 0) {
@@ -580,7 +580,7 @@ $(document).ready(function () {
             type: 'del',
         };
         const _self = $(this);
-        postData('/tender/' + cur_tenderid + '/shenpi/audit/save', prop, function (data) {
+        postTenderApprovalData('/tender/' + cur_tenderid + '/shenpi/audit/save', prop, function (data) {
             if (this_status === sp_status.gdspl) {
                 const index = _self.parents('li').index();
                 const auditGroup = auditUtils.removeAudit(this_code, id, index);
@@ -626,6 +626,11 @@ $(document).ready(function () {
     initTenderTree2();
 
     $('body').on('click', '.set-otherTender', function () {
+        if (typeof tenderApprovalEditor !== 'undefined' && tenderApprovalEditor.active && tenderApprovalEditor.active.operations.length) {
+            toastr.warning('请先保存当前修改,再设置其他流程或标段');
+            return false;
+        }
+
         const this_code = $(this).data('code');
         const this_status = parseInt($(this).siblings('.lc-show').siblings('.form-group').find('input:checked').val());
         const lis = $(this).siblings('.lc-show').find('li');
@@ -638,7 +643,7 @@ $(document).ready(function () {
 
             const select = $(li).find('select[class*="audit-type-key"]');
             const audit_type = select.length > 0 ? parseInt(select.val()) : 1;
-            const multiData = flow.auditGroupList.find(x => { return x[0].audit_order == i+1; });
+            const multiData = (flow.auditGroupList || []).find(x => { return x[0].audit_order == i+1; }) || [];
             for (const remove of removes) {
                 const auditData = { audit_id: parseInt(remove.getAttribute('data-id')), audit_type, audit_order: i + 1 };
                 const removeMultiData = multiData.find(x => { return x.audit_id === auditData.audit_id; });
@@ -677,7 +682,7 @@ $(document).ready(function () {
             status: parseInt($('#shenpi_status').val()),
             code: $('#shenpi_code').val(),
         };
-        if(data.status !== shenpi_status.gdspl) {
+        if(data.status !== sp_status.gdspl) {
             data.auditList = JSON.parse($('#shenpi_auditors').val());
         }
         // 获取已选中的标段
@@ -689,7 +694,7 @@ $(document).ready(function () {
             }
         }
         data.tidList = tenderList.join(',');
-        postData('/tender/' + cur_tenderid + '/shenpi/audit/save', data, function () {
+        postTenderApprovalData('/tender/' + cur_tenderid + '/shenpi/audit/save', data, function () {
             toastr.success('设置成功');
             setTimeout(function () {
                 window.location.reload();
@@ -698,6 +703,11 @@ $(document).ready(function () {
     });
 
     $('body').on('click', '.set-otherShenpi', function () {
+        if (typeof tenderApprovalEditor !== 'undefined' && tenderApprovalEditor.active && tenderApprovalEditor.active.operations.length) {
+            toastr.warning('请先保存当前修改,再设置其他流程或标段');
+            return false;
+        }
+
         let canSetOther = true;
         const this_code = $(this).data('code');
         // if (['stage', 'change'].indexOf(this_code) !== -1) {
@@ -748,7 +758,7 @@ $(document).ready(function () {
             status: parseInt($('#shenpi_status2').val()),
             code: $('#shenpi_code2').val(),
         };
-        if(data.status !== shenpi_status.gdspl) {
+        if(data.status !== sp_status.gdspl) {
             data.flowList = $('#shenpi_auditors2').val();
         }
         // 获取已选中的标段
@@ -758,7 +768,7 @@ $(document).ready(function () {
             shenpiList.push(code);
         }
         data.shenpiList = shenpiList.join(',');
-        postData('/tender/' + cur_tenderid + '/shenpi/audit/save', data, function () {
+        postTenderApprovalData('/tender/' + cur_tenderid + '/shenpi/audit/save', data, function () {
             toastr.success('设置成功');
             setTimeout(function () {
                 window.location.reload();
@@ -789,7 +799,7 @@ $(document).ready(function () {
             return;
         }
         const _self = this;
-        postData('/tender/'+ cur_tenderid +'/shenpi/audit/save', prop, function () {
+        postTenderApprovalData('/tender/'+ cur_tenderid +'/shenpi/audit/save', prop, function () {
             _self.setAttribute('data-type', _self.value);
             const auditGroup = auditUtils.setAuditType(this_code, prop.audit_type, liParent.index());
             liParent.html(auditUtils.getAuditGroupInnerHtml(this_code, auditGroup, liParent.index() + 1));
@@ -909,7 +919,7 @@ $(document).ready(function () {
             this.spread.refresh();
             const self = this;
 
-            postData('/tender/' + cur_tenderid + '/shenpi/ass/load', {}, function (data) {
+            postTenderApprovalData('/tender/' + cur_tenderid + '/shenpi/ass/load', {}, function (data) {
                 self.loaded = true;
                 self.tid = cur_tenderid;
                 self.assList = data.auditAssList;
@@ -965,14 +975,14 @@ $(document).ready(function () {
                     data.update.push({ id: orgAss.id, ass_ledger_id: rela_lid.join(',')});
                 }
             }
-            postData('/tender/' + cur_tenderid + '/shenpi/audit/save', data, function (result) {
+            postTenderApprovalData('/tender/' + cur_tenderid + '/shenpi/audit/save', data, function (result) {
                 self.loadPostData(result);
                 self.refreshOperate();
             });
         }
         removeAuditAss(assistId) {
             const self = this;
-            postData('/tender/' + cur_tenderid + '/shenpi/audit/save', { type: 'audit-ass', del: { id: assistId } }, function (result) {
+            postTenderApprovalData('/tender/' + cur_tenderid + '/shenpi/audit/save', { type: 'audit-ass', del: { id: assistId } }, function (result) {
                 self.loadPostData(result);
                 self.refreshOperate();
             });
@@ -1038,7 +1048,7 @@ $(document).ready(function () {
             });
             $('#union-ok').click(function() {
                 const data = self.getUnionAuditLedgerData();
-                postData(`/tender/${cur_tenderid}/shenpi/union/save`, data, function(result) {
+                postTenderApprovalData(`/tender/${cur_tenderid}/shenpi/union/save`, data, function(result) {
                     $('#union').modal('hide');
                 });
             });
@@ -1087,7 +1097,7 @@ $(document).ready(function () {
             if (!this.loaded) data.ledger = 1;
 
             const self = this;
-            postData(`/tender/${cur_tenderid}/shenpi/union/load`, data, function(result) {
+            postTenderApprovalData(`/tender/${cur_tenderid}/shenpi/union/load`, data, function(result) {
                 self.loaded = true;
                 if (result.ledgerList) {
                     self.tree.loadDatas(result.ledgerList);
@@ -1173,7 +1183,7 @@ $(document).ready(function () {
                         }
                     }
                 }
-                postData(`/tender/${cur_tenderid}/shenpi/multi/save`, data, function(result) {
+                postTenderApprovalData(`/tender/${cur_tenderid}/shenpi/multi/save`, data, function(result) {
                     const flow = sp_lc.find(x => { return x.code === self.sp_type; });
                     const multiData = flow.auditGroupList.find(x => { return x[0].audit_order == self.audit_order});
                     for (const d of result) {
@@ -1244,6 +1254,15 @@ $(document).ready(function () {
         }
     }
     const auditMulti = new AuditMulti();
+    $('body').on('tender-approval-edit-open', function () {
+        auditAss.loaded = false;
+        auditAss.assList = [];
+        auditAss.tree.loadDatas([]);
+        SpreadJsObj.loadSheetData(auditAss.sheet, SpreadJsObj.DataType.Tree, auditAss.tree);
+        auditAss.uid = cur_uid;
+        auditAss.refreshOperate();
+        auditUnion.loaded = false;
+    });
     $('body').on('click', '[name=multi-set]', function() {
         auditMulti.loadData(auditType.key.multi, this.getAttribute('sp_type'), this.getAttribute('audit_order'), this);
     });
@@ -1465,7 +1484,7 @@ $(document).ready(function () {
             prop.change_type = change_type;
         }
         console.log(prop);
-        postData('/tender/' + cur_tenderid + '/shenpi/audit/save', prop, function (data) {
+        postTenderApprovalData('/tender/' + cur_tenderid + '/shenpi/audit/save', prop, function (data) {
             flow.auditGroupList = data.group.auditGroupList || [];
             if (groupId) {
                 const index = flow.groupList.findIndex(x => { return x.id === parseInt(groupId); });
@@ -1497,7 +1516,7 @@ $(document).ready(function () {
             type: 'change-group',
             groupId,
         }
-        postData('/tender/' + cur_tenderid + '/shenpi/audit/save', prop, function (data) {
+        postTenderApprovalData('/tender/' + cur_tenderid + '/shenpi/audit/save', prop, function (data) {
             flow.groupList.forEach(function (item) {
                 item.is_select = 0;
             });
@@ -1540,7 +1559,7 @@ $(document).ready(function () {
             groupId: group.id,
         }
         console.log(prop);
-        postData('/tender/' + cur_tenderid + '/shenpi/audit/save', prop, function (data) {
+        postTenderApprovalData('/tender/' + cur_tenderid + '/shenpi/audit/save', prop, function (data) {
             // const flow = findSplc(code);
             const index = flow.groupList.findIndex(x => { return x.id === group.id; });
             flow.groupList.splice(index, 1);
@@ -1617,3 +1636,68 @@ function makeSelectAudit(code, i = '终') {
         '                                        </li>';
     return html;
 }
+
+function renderTenderApproval(shenpi, tender, revising, target = '#splc') {
+    let html = '';
+    if (shenpi.sp_lc.length > 0) {
+        for (const sp of shenpi.sp_lc) {
+            html += `<div class="card mb-3">
+                                <div class="card-body ${sp.code}_div">
+                                    <a class="pull-right set-otherTender" data-name="${sp.name}" data-code="${sp.code}" href="#batch" data-toggle="modal" data-target="#batch">设置其他标段</a>
+                                    <a class="pull-right set-otherShenpi mr-3" data-name="${sp.name}" data-code="${sp.code}" href="javascript: void(0);">设置其他流程</a>`;
+            if (sp.code === 'stage' && !revising && tender.ledger_status === auditConst.ledger.status.checked) {
+                html += `<a class="pull-right mr-3" id="stage_cooperation" ${sp.status !== shenpi.sp_status.gdspl ? 'style="display: none"' : '' } data-name="${sp.name}" data-code="${sp.code}" href="#cooperation" data-toggle="modal" data-target="#cooperation">多人协同 <i class="fa fa-lock"></i></a>`;
+            }
+            html += `
+                                    <h5 class="card-title">${sp.name}</h5>
+                                    <div class="form-group">
+                                        <div class="form-group form-check">`;
+            for (const st in shenpi.sp_status_list) {
+                if (shenpi.sp_status_list[st]) {
+                    html += `<div class="custom-control custom-checkbox custom-control-inline">
+                                                    <input type="radio" class="custom-control-input" data-code="${sp.code}" value="${shenpi.sp_status_list[st].status}" name="${sp.code}" id="${sp.code}_${shenpi.sp_status_list[st].status}" ${sp.status === shenpi.sp_status_list[st].status ? 'checked' : ''}>
+                                                    <label class="custom-control-label" for="${sp.code}_${shenpi.sp_status_list[st].status}">${shenpi.sp_status_list[st].name}</label>
+                                                </div>`;
+                }
+            }
+            html += `
+                                        </div>
+                                    </div>
+                                    <div class="alert alert-warning">${shenpi.sp_status_list[sp.status].name}:${shenpi.sp_status_list[sp.status].msg}</div>
+                                    <div class="lc-show">`;
+            if (sp.status === sp_status.sqspr) {
+                html += ``;
+            } else if (sp.status === sp_status.gdspl) {
+                const flow = sp_lc.find(x => { return x.code === sp.code; });
+                flow.auditGroupList = sp.auditGroupList;
+                html += auditUtils.getGroupHtml(flow, sp.code);
+                let addhtml = '<ul class="list-unstyled">\n';
+                addhtml += auditUtils.getgdsplHtml(flow, sp.code);
+                addhtml += '</ul>\n';
+                html += addhtml;
+            } else if (sp.status === sp_status.gdzs) {
+                let addhtml = '<ul class="list-unstyled">\n' +
+                    '                                        <li class="d-flex justify-content-start mb-3">\n' +
+                    '                                            <span class="col-auto">授权审批人</span>\n' +
+                    '                                            <span class="col-7">\n' +
+                    '                                                <span class="d-inline-block"></span>\n' +
+                    '                                            </span>\n' +
+                    '                                        </li>\n';
+                addhtml += sp.audit ? makeAudit(sp.audit) : makeSelectAudit(sp.code);
+                addhtml += '</ul>\n';
+                html += addhtml;
+            }
+            html += `                </div>
+                                </div>
+                            </div>`;
+        }
+    }
+    $(target).html(html);
+}
+
+function postTenderApprovalData(url, data, success, failure, waiting = true, message = '') {
+    if (typeof approvalOnly !== 'undefined' && approvalOnly && typeof tenderApprovalEditor !== 'undefined' && tenderApprovalEditor.active) {
+        return tenderApprovalEditor.request(url, data, success, failure, waiting, message);
+    }
+    return postData(url, data, success, failure, waiting, message);
+}

+ 1 - 0
app/router.js

@@ -249,6 +249,7 @@ module.exports = app => {
     app.get('/sp/:id/setting/datacollect', sessionAuth, subProjectCheck, 'subProjSettingController.dataCollect');
     app.post('/sp/:id/setting/datacollect/save', sessionAuth, subProjectCheck, 'subProjSettingController.dataCollectSave');
     // 标段管理
+    app.get('/sp/:id/setting/approval', sessionAuth, subProjectCheck, 'subProjSettingController.approval');
     app.get('/sp/:id/setting/manage', sessionAuth, subProjectCheck, 'subProjSettingController.manage');
     app.post('/sp/:id/setting/manage/tender/save', sessionAuth, subProjectCheck, 'subProjSettingController.manageTenderSave');
 

+ 16 - 3
app/service/filing.js

@@ -260,7 +260,7 @@ module.exports = app => {
             // if (!parent) throw '添加数据结构错误';
             if (parent && parent.file_count > 0) throw `分类【${parent.name}】下存在文件,不可添加子分类`;
 
-            const sibling = await this.getAllDataByCondition({ where: { spid: this.ctx.subProject.id, tree_pid: parent ? parent.id : rootId }, orders: [['tree_order', 'asc']]});
+            const sibling = await this.getAllDataByCondition({ where: { spid: this.ctx.subProject.id, tree_pid: parent ? parent.id : rootId, is_deleted: 0 }, orders: [['tree_order', 'asc']]});
             const preChild = data.tree_pre_id ? sibling.find(x => { return x.id === data.tree_pre_id; }) : null;
             const filing_type = parent ? parent.filing_type : await this.getNewFilingType(this.ctx.subProject.id);
 
@@ -272,12 +272,12 @@ module.exports = app => {
                 const sessionProject = this.ctx.session.sessionProject;
 
                 const tree_order = preChild ? preChild.tree_order + 1 : (sibling.length > 0 ? sibling[sibling.length - 1].tree_order + 1 : 1);
-                const name = await this.getNewName(this.ctx.subProject.id);
+                const name = data.name === undefined ? await this.getNewName(this.ctx.subProject.id) : data.name;
                 const insertData = {
                     id: this.uuid.v4(), spid: this.ctx.subProject.id, add_user_id: sessionUser.accountId,
                     create_uid: sessionUser.accountId,
                     tree_pid: parent ? parent.id : rootId, tree_level: parent ? parent.tree_level + 1 : 1, tree_order,
-                    name, filing_type: filing_type, is_fixed: parent ? 0 : 1
+                    name, filing_type: filing_type, is_fixed: parent ? 0 : 1, file_count: 0, is_deleted: 0
                 };
                 const operate = await conn.insert(this.tableName, insertData);
                 if (operate.affectedRows === 0) throw '新增文件夹失败';
@@ -518,6 +518,19 @@ module.exports = app => {
                 throw err;
             }
         }
+        async getDirectoryFilesForUpdate(spid, filingIds, transaction) {
+            const sql = 'SELECT id FROM ?? WHERE spid = ? AND filing_id IN (?) AND is_deleted = 0 LIMIT 1 FOR UPDATE';
+            const sqlParam = [this.ctx.service.file.tableName, spid, filingIds];
+            return await transaction.query(sql, sqlParam);
+        }
+
+        async deleteEmptyDirectories(spid, filingIds, updateData, transaction) {
+            await transaction.update(this.tableName, { is_deleted: 1 }, {
+                where: { spid, id: filingIds, is_deleted: 0 },
+            });
+            if (updateData.length > 0) await transaction.updateRows(this.tableName, updateData);
+            await this.ctx.service.subProjectFilingPermission.removeFilingNodes(spid, filingIds, transaction);
+        }
     }
 
     return Filing;

+ 59 - 31
app/service/project_screen.js

@@ -1,7 +1,6 @@
 'use strict';
 
 const projectScreenConst = require('../const/project_screen');
-const projectScreenLib = require('../lib/project_screen');
 const Ledger = require('../lib/ledger');
 
 module.exports = app => {
@@ -137,12 +136,9 @@ module.exports = app => {
 
         async getStatusProjects(status, options = {}) {
             const ctx = this.ctx;
-            const user = ctx.session.sessionUser;
             const pid = ctx.session.sessionProject.id;
-            const nodes = await ctx.service.subProject.getSubProject(pid, user.accountId, user.is_admin);
+            const nodes = await ctx.service.projectScreenTender.getDisplayProjects(pid);
             const infos = await ctx.service.subProjInfo.getAllDataByCondition({ where: { project_id: pid, proj_status: status || ['拟建', '在建', '已建'] } });
-            const account = await ctx.service.projectAccount.getDataById(user.accountId);
-            const permission = account ? ctx.service.projectAccount.parseProjectScreenPermission(account.permission) : null;
             const calc = ctx.helper;
             // Preserve decimal precision during aggregation and unit conversion.
             // 元 fields have eight decimal places; conversion to 万元 needs twelve.
@@ -162,24 +158,8 @@ module.exports = app => {
             const budgetTotals = (await Promise.all([ctx.service.budgetGu, ctx.service.budgetGai, ctx.service.budgetYu]
                 .map(service => service.getSumTpByBudgetIds(budgetIds))))
                 .map(rows => new Map(rows.map(row => [String(row.bid), row.total_price])));
-            const allTenders = [];
-            if (selectedNodes.length && (user.is_admin === 1 || (permission && permission.tender && permission.tender.indexOf('2') !== -1))) {
-                allTenders.push(...await ctx.service.tender.getAllDataByCondition({ where: { spid: selectedNodes.map(n => n.id) } }));
-                allTenders.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
-            } else {
-                // Batch projects with identical permission-related page settings.
-                const permissionGroups = new Map();
-                for (const node of selectedNodes) {
-                    const sub = Object.assign({}, node, { page_show: ctx.service.subProject.getPageShow(node.page_show) });
-                    const key = JSON.stringify(['openChangeProject', 'openChangeApply', 'openChangePlan'].map(flag => !!sub.page_show[flag]));
-                    if (!permissionGroups.has(key)) permissionGroups.set(key, { id: [], page_show: sub.page_show });
-                    permissionGroups.get(key).id.push(node.id);
-                }
-                for (const sub of permissionGroups.values()) {
-                    allTenders.push(...await ctx.service.tender.getList('', permission, user.is_admin, '', sub));
-                }
-            }
-            await ctx.service.tenderCache.loadTenderCaches(allTenders, user.accountId);
+            const allTenders = await ctx.service.projectScreenTender.getDisplayTenders(pid, selectedNodes.map(node => node.id));
+            await ctx.service.tenderCache.loadTenderCaches(allTenders, '');
             const allChanges = !options.summary && status === '在建' && allTenders.length ? await ctx.service.change.getAllCheckedChanges(allTenders.map(t => t.id)) : [];
             const allProgress = !options.summary && selectedNodes.length ? await ctx.service.subProjProgress.getAllDataByCondition({ where: { spid: selectedNodes.map(n => n.id) }, orders: [['update_time', 'desc'], ['id', 'asc']] }) : [];
             for (const node of selectedNodes) {
@@ -226,36 +206,37 @@ module.exports = app => {
                 }
                 const parent = nodes.find(n => n.id === node.tree_pid);
                 const progressRows = allProgress.filter(row => row.spid === node.id);
-                const progressPaths = projectScreenLib.getProgressPaths(progressRows);
+                const progressPaths = this.getProgressPaths(progressRows);
                 const latest = progressRows[0];
                 const keys = ['edit', 'submit', 'reply'];
                 const latestEvent = latest && keys.map((key, index) => ({ date: latest[key + '_date'], department: latest[key + '_department'], step: ['编制', '报审', '批复'][index] }))
                     .filter(event => event.date).sort((a, b) => String(b.date).localeCompare(String(a.date)))[0];
-                const stages = projectScreenLib.sortProgressRows(progressRows, progressPaths).filter(row => row.tree_level > 1 && row.tree_is_leaf === 1).map(row => ({
+                const stages = this.sortProgressRows(progressRows, progressPaths).filter(row => row.tree_level > 1 && row.tree_is_leaf === 1).map(row => ({
                     id: row.id, name: row.name, code: row.code || '',
-                    path: projectScreenLib.formatProgressPath(progressPaths.get(row.id)),
-                    shortPath: projectScreenLib.formatProgressPath(progressPaths.get(row.id), true),
+                    path: this.formatProgressPath(progressPaths.get(row.id)),
+                    shortPath: this.formatProgressPath(progressPaths.get(row.id), true),
                     states: keys.map(key => row[key + '_progress'] === '已完成' ? 0 : row[key + '_progress'] === '进行中' ? 1 : 2),
                     dates: keys.map(key => row[key + '_date'] || ''), departments: keys.map(key => row[key + '_department'] || '')
                 }));
-                result.push({ id: node.id, name: node.name, status: info.proj_status, group: parent ? parent.name : '未分组',
+                result.push({ id: node.id, name: node.name, status: info.proj_status, group: parent ? parent.name : '',
                     folders: projectTree.getAllParents(node).filter(folder => folder.is_folder).map(folder => ({ id: folder.id, name: folder.name })),
                     intro: info.proj_intro || '', date: info.jg_date || '', department: info.jg_department || '',
                     measured: hasMeasured ? toWan(measured, '元') : null,
                     tenders: tenderInvestments,
                     changeRows, stages,
                     progressText: latestEvent ? `${latestEvent.date},${latestEvent.department || ''}已完成「${latest.name}」${latestEvent.step}。` : '暂无阶段动态',
+                    overviewAmounts: ['lx', 'cb', 'sgt'].map(key => toWan(info[key + '_tp'], info[key + '_tp_unit'])),
                     amounts: budgetTotals.map(totals => toWan(totals.get(String(node.budget_id)), '元'))
                         .concat([toWan(info.pf_tp, info.pf_tp_unit), tenders.length ? toWan(contract, '元') : null, hasMeasured ? toWan(measured, '元') : null, finalAmount]) });
             }
             return result;
         }
 
-        async getOverview(planned, building, finished) {
+        async getOverview(projects) {
             const ctx = this.ctx;
             const calc = ctx.helper;
+            const [planned, building, finished] = ['拟建', '在建', '已建'].map(status => projects.filter(project => project.status === status));
             const lists = [planned, building, finished];
-            const projects = [].concat(...lists);
             const present = value => value !== null && value !== undefined && value !== '';
             const rank = (list, amount) => list.map(p => ({ id: p.id, name: p.name, value: amount(p) }))
                 .filter(p => present(p.value)).sort((a, b) => b.value - a.value).slice(0, 10);
@@ -265,7 +246,7 @@ module.exports = app => {
             const dateText = value => value instanceof Date ? [value.getFullYear(), String(value.getMonth() + 1).padStart(2, '0'), String(value.getDate()).padStart(2, '0')].join('-') : String(value || '').slice(0, 10);
             const trees = new Map(projects.map(p => [p.id, rows.filter(row => row.spid === p.id)]));
             const leafColumns = tree => {
-                const paths = projectScreenLib.getProgressPaths(tree);
+                const paths = this.getProgressPaths(tree);
                 return tree.filter(row => Number(row.tree_level) > 1 && Number(row.tree_is_leaf) === 1)
                     .sort((a, b) => a.tree_order - b.tree_order).map(row => {
                         const path = paths.get(row.id);
@@ -326,6 +307,53 @@ module.exports = app => {
                 })
             };
         }
+
+        getProgressPaths(rows) {
+            const nodes = new Map(rows.map(row => [String(row.tree_id), row]));
+            const paths = new Map();
+            for (const row of rows) {
+                const path = [];
+                const visited = new Set();
+                let node = row;
+                while (node && !visited.has(String(node.tree_id))) {
+                    path.unshift(node);
+                    visited.add(String(node.tree_id));
+                    node = nodes.get(String(node.tree_pid));
+                }
+                paths.set(row.id, path);
+            }
+            return paths;
+        }
+
+        sortProgressRows(rows, paths) {
+            return rows.slice().sort((a, b) => {
+                const pathA = paths.get(a.id);
+                const pathB = paths.get(b.id);
+                for (let i = 0; i < Math.min(pathA.length, pathB.length); i++) {
+                    if (pathA[i].id === pathB[i].id) continue;
+                    return pathA[i].tree_order - pathB[i].tree_order || String(pathA[i].id).localeCompare(String(pathB[i].id));
+                }
+                return pathA.length - pathB.length;
+            });
+        }
+
+        formatProgressPath(path, abbreviated = false) {
+            const names = path.map(node => node.name);
+            if (abbreviated && names.length > 2) return [names[0], '...', names[names.length - 1]].join(' / ');
+            return names.join(' / ');
+        }
+
+        async saveModuleStatus(pid, enabled) {
+            const ctx = this.ctx;
+            const service = ctx.service.projectScreenTender;
+            const pageShow = await service.withTransaction(async transaction => {
+                const settings = service.parseSettings(await service.getProject(pid, transaction, true));
+                settings.pageShow.openProjectScreen = enabled;
+                await service.saveSettings(pid, settings.pageShow, transaction);
+                return settings.pageShow;
+            });
+            ctx.session.sessionProject.page_show = await ctx.service.projectAccount.getPageShow(JSON.stringify(pageShow));
+        }
     }
 
     return ProjectScreen;

+ 269 - 0
app/service/project_screen_tender.js

@@ -0,0 +1,269 @@
+'use strict';
+
+module.exports = app => {
+    class ProjectScreenTender extends app.BaseService {
+        constructor(ctx) {
+            super(ctx);
+            this.tableName = 'project_screen_tender';
+        }
+
+        async getProject(pid, transaction = null, lock = false) {
+            const db = transaction || this.db;
+            if (!lock) return await db.get(this.ctx.service.project.tableName, { id: pid });
+            const sql = 'SELECT id, page_show, common_json FROM ?? WHERE id = ? FOR UPDATE';
+            const sqlParam = [this.ctx.service.project.tableName, pid];
+            return await db.queryOne(sql, sqlParam);
+        }
+
+        async getProjects(pid, transaction = null) {
+            return await this.ctx.service.subProject.getAllDataByCondition({
+                where: { project_id: pid, is_delete: 0 },
+                orders: [['tree_level', 'asc'], ['tree_order', 'asc'], ['id', 'asc']],
+            }, transaction);
+        }
+
+        async getTenders(pid, spids, transaction = null) {
+            if (!spids.length) return [];
+            return await this.ctx.service.tender.getAllDataByCondition({
+                where: { project_id: pid, spid: spids },
+                orders: [['id', 'asc']],
+            }, transaction);
+        }
+
+        async getList(pid, spid = '', transaction = null) {
+            const where = { pid };
+            if (spid) where.spid = spid;
+            return await (transaction || this.db).select(this.tableName, { where });
+        }
+
+        async saveChanges(pid, changes, transaction) {
+            if (!changes.length) return;
+            await transaction.delete(this.tableName, { pid, tid: changes.map(item => item.tid) });
+            const excluded = changes.filter(item => !item.checked).map(item => ({ pid, spid: item.spid, tid: item.tid }));
+            await this.insertDatasSplit(transaction, this.tableName, excluded);
+        }
+
+        async saveSettings(pid, pageShow, transaction) {
+            return await transaction.update(this.ctx.service.project.tableName, {
+                page_show: JSON.stringify(pageShow),
+            }, { where: { id: pid } });
+        }
+
+        async withTransaction(action) {
+            const transaction = await this.db.beginTransaction();
+            try {
+                const result = await action(transaction);
+                await transaction.commit();
+                return result;
+            } catch (error) {
+                await transaction.rollback();
+                throw error;
+            }
+        }
+
+        async syncTenderProject(pid, spid, tid, transaction) {
+            return await transaction.update(this.tableName, { spid }, { where: { pid, tid } });
+        }
+
+        async getChangedTenders(pid, tids, transaction) {
+            if (!tids.length) return [];
+            const sql = 'SELECT id, spid FROM ?? WHERE project_id = ? AND id IN (?) ORDER BY id FOR UPDATE';
+            const sqlParam = [this.ctx.service.tender.tableName, pid, tids];
+            return await transaction.query(sql, sqlParam);
+        }
+
+        async saveScopeSettings(pid, pageShow, commonJson, transaction) {
+            return await transaction.update(this.ctx.service.project.tableName, {
+                page_show: JSON.stringify(pageShow),
+                common_json: JSON.stringify(commonJson),
+            }, { where: { id: pid } });
+        }
+
+        parseSettings(project) {
+            if (!project) throw '项目不存在';
+            const pageShow = project.page_show ? (typeof project.page_show === 'string' ? JSON.parse(project.page_show) : project.page_show) : {};
+            const commonJson = project.common_json ? (typeof project.common_json === 'string' ? JSON.parse(project.common_json) : project.common_json) : {};
+            const excludedProjectIds = Array.isArray(commonJson.projectScreenExcludedProjectIds) ? commonJson.projectScreenExcludedProjectIds :
+                Array.isArray(pageShow.projectScreenExcludedProjectIds) ? pageShow.projectScreenExcludedProjectIds : [];
+            return {
+                pageShow,
+                commonJson,
+                version: Number(pageShow.projectScreenTenderVersion) || 0,
+                excludedProjectIds: excludedProjectIds.filter(id => typeof id === 'string' && id && id.length <= 36),
+            };
+        }
+
+        async getTenderState(pid, transaction) {
+            const settings = this.parseSettings(await this.getProject(pid, transaction));
+            const projects = await this.getProjects(pid, transaction);
+            const spids = projects.filter(project => !project.is_folder).map(project => project.id);
+            const tenders = await this.getTenders(pid, spids, transaction);
+            const excluded = await this.getList(pid, '', transaction);
+            const validIds = new Set(tenders.map(tender => Number(tender.id)));
+            const excludedIds = Array.from(new Set(excluded.map(item => Number(item.tid)).filter(id => validIds.has(id))));
+            return { settings, projects, tenders, excludedIds };
+        }
+
+        async initTenderSettings(pid) {
+            return await this.withTransaction(async transaction => {
+                const state = await this.getTenderState(pid, transaction);
+                const counts = new Map();
+                const excludedCounts = new Map();
+                const excluded = new Set(state.excludedIds);
+                const excludedProjects = new Set(state.settings.excludedProjectIds);
+                state.tenders.forEach(tender => counts.set(tender.spid, (counts.get(tender.spid) || 0) + 1));
+                state.tenders.filter(tender => excluded.has(Number(tender.id))).forEach(tender => {
+                    excludedCounts.set(tender.spid, (excludedCounts.get(tender.spid) || 0) + 1);
+                });
+                return {
+                    version: state.settings.version,
+                    projects: state.projects.map(project => ({
+                        id: project.id, name: project.name, tree_pid: project.tree_pid,
+                        tree_order: project.tree_order, is_folder: project.is_folder,
+                        total: counts.get(project.id) || 0,
+                        excludedTotal: excludedCounts.get(project.id) || 0,
+                        checked: !excludedProjects.has(project.id),
+                    })),
+                    total: state.tenders.length, excludedIds: state.excludedIds,
+                };
+            });
+        }
+
+        async loadTenderSettings(pid, spid) {
+            if (typeof spid !== 'string' || !spid || spid.length > 36) throw '项目参数错误';
+            return await this.withTransaction(async transaction => {
+                const settings = this.parseSettings(await this.getProject(pid, transaction));
+                const projects = await this.getProjects(pid, transaction);
+                const project = projects.find(item => item.id === spid && !item.is_folder);
+                if (!project) throw '项目不存在或不属于当前总项目';
+                const tenders = await this.getTenders(pid, [spid], transaction);
+                const excluded = await this.getList(pid, spid, transaction);
+                const categories = await this.ctx.service.category.getAllCategory(project);
+                return {
+                    version: settings.version, project: { id: project.id, name: project.name },
+                    nodes: this.buildTenderTree(tenders, categories),
+                    excludedIds: excluded.map(item => Number(item.tid)),
+                };
+            });
+        }
+
+        buildTenderTree(tenders, categories) {
+            const levels = categories.filter(item => Number(item.level) > 0).sort((a, b) => a.level - b.level);
+            const root = { id: '', children: [], tree_level: -1 };
+            for (const tender of tenders) {
+                const values = typeof tender.category === 'string' ? JSON.parse(tender.category || '[]') : tender.category;
+                const selected = Array.isArray(values) ? values : [];
+                let parent = root;
+                let path = [];
+                for (let index = 0; index < levels.length; index++) {
+                    const item = levels[index];
+                    const value = selected.find(value => String(value.cid) === String(item.id));
+                    if (value) path.push({ item, valueId: value.value });
+                    else {
+                        if (index === 0) {
+                            path = selected.map(value => ({
+                                item: categories.find(item => String(item.id) === String(value.cid)), valueId: value.value,
+                            })).filter(value => value.item);
+                        }
+                        break;
+                    }
+                }
+                for (const entry of path) {
+                    const value = entry.item.value.find(value => String(value.id) === String(entry.valueId));
+                    if (!value) break;
+                    const id = `${parent.id}/category-${entry.item.id}-${value.id}`;
+                    let folder = parent.children.find(child => child.id === id);
+                    if (!folder) {
+                        folder = { id, parentId: parent.id, name: value.value, sort: value.sort,
+                            tree_level: parent.tree_level + 1, is_folder: true, children: [] };
+                        parent.children.push(folder);
+                    }
+                    parent = folder;
+                }
+                parent.children.push({ id: String(tender.id), tid: Number(tender.id), parentId: parent.id,
+                    name: tender.name, tree_level: parent.tree_level + 1, is_folder: false });
+            }
+            const result = [];
+            const visit = parent => {
+                parent.children.sort((a, b) => {
+                    if (!!a.is_folder !== !!b.is_folder) return a.is_folder ? -1 : 1;
+                    return a.is_folder ? Number(a.sort || 0) - Number(b.sort || 0) : a.name.localeCompare(b.name, 'zh-CN');
+                });
+                for (const node of parent.children) {
+                    const row = Object.assign({}, node);
+                    delete row.children;
+                    result.push(row);
+                    if (node.children) visit(node);
+                }
+            };
+            visit(root);
+            return result;
+        }
+
+        async saveTenderSettings(pid, data) {
+            const projectChanges = data.projectChanges === undefined ? [] : data.projectChanges;
+            if (!Number.isSafeInteger(data.version) || data.version < 0 || !Array.isArray(data.changes) || !Array.isArray(projectChanges)) throw '提交数据错误';
+            const projectIds = new Set();
+            for (const item of projectChanges) {
+                if (!item || typeof item.spid !== 'string' || !item.spid || item.spid.length > 36 || typeof item.checked !== 'boolean') throw '项目参数错误';
+                if (projectIds.has(item.spid)) throw '项目重复提交,请刷新后重试';
+                projectIds.add(item.spid);
+            }
+            const byId = new Map();
+            for (const item of data.changes) {
+                if (!item || !Number.isSafeInteger(item.tid) || item.tid <= 0 || typeof item.checked !== 'boolean' ||
+                    typeof item.spid !== 'string' || !item.spid || item.spid.length > 36) throw '标段参数错误';
+                if (byId.has(item.tid)) throw '标段重复提交,请刷新后重试';
+                byId.set(item.tid, item);
+            }
+            const changes = Array.from(byId.values());
+            const result = await this.withTransaction(async transaction => {
+                const settings = this.parseSettings(await this.getProject(pid, transaction, true));
+                if (settings.version !== data.version) throw '配置已被其他管理员修改,请刷新页面后重新设置';
+                const projects = await this.getProjects(pid, transaction);
+                const spids = new Set(projects.filter(project => !project.is_folder).map(project => project.id));
+                if (changes.concat(projectChanges).some(item => !spids.has(item.spid))) throw '项目不存在或不属于当前总项目';
+                const tenders = await this.getChangedTenders(pid, changes.map(item => item.tid), transaction);
+                const tenderById = new Map(tenders.map(tender => [Number(tender.id), tender]));
+                if (changes.some(item => !tenderById.has(item.tid) || tenderById.get(item.tid).spid !== item.spid)) throw '标段已变更或不属于当前项目,请刷新后重试';
+                await this.saveChanges(pid, changes, transaction);
+                const excludedProjects = new Set(settings.excludedProjectIds.filter(id => spids.has(id)));
+                projectChanges.forEach(item => {
+                    if (item.checked) excludedProjects.delete(item.spid);
+                    else excludedProjects.add(item.spid);
+                });
+                settings.commonJson.projectScreenExcludedProjectIds = Array.from(excludedProjects);
+                delete settings.pageShow.projectScreenExcludedProjectIds;
+                delete settings.pageShow.projectScreenTenderMode;
+                settings.pageShow.projectScreenTenderVersion = settings.version + 1;
+                await this.saveScopeSettings(pid, settings.pageShow, settings.commonJson, transaction);
+                return { version: settings.version + 1, pageShow: settings.pageShow };
+            });
+            this.ctx.session.sessionProject.page_show = await this.ctx.service.projectAccount.getPageShow(JSON.stringify(result.pageShow));
+            return { version: result.version };
+        }
+
+        async getDisplayTenders(pid, spids) {
+            if (!spids.length) return [];
+            return await this.withTransaction(async transaction => {
+                const settings = this.parseSettings(await this.getProject(pid, transaction));
+                const excludedProjects = new Set(settings.excludedProjectIds);
+                const excluded = new Set((await this.getList(pid, '', transaction)).map(item => Number(item.tid)));
+                const tenders = (await this.getTenders(pid, spids.filter(id => !excludedProjects.has(id)), transaction))
+                    .filter(tender => !excluded.has(Number(tender.id)));
+                return tenders.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
+            });
+        }
+
+        async getDisplayProjects(pid) {
+            return await this.withTransaction(async transaction => {
+                const settings = this.parseSettings(await this.getProject(pid, transaction));
+                const excluded = new Set(settings.excludedProjectIds);
+                const projects = await this.getProjects(pid, transaction);
+                return projects.filter(project => project.is_folder || !excluded.has(project.id));
+            });
+        }
+    }
+
+    return ProjectScreenTender;
+};

+ 19 - 12
app/service/stage.js

@@ -193,20 +193,24 @@ module.exports = app => {
                 stage.readOnly = true;
                 stage.curTimes = stage.times;
                 stage.curOrder = _.max(_.map(stage.auditors, 'order'));
-            } else {
-                const ass = stage.auditAssists.find(x => { return stage.flowAuditorIds.indexOf(x.user_id) >= 0 && x.ass_user_id === accountId; });
-                stage.readOnly = stage.flowAuditorIds.indexOf(accountId) < 0 && !ass;
+            } else if (stage.curAuditors.length > 0) {
                 stage.curTimes = stage.times;
-                if (!stage.readOnly) {
+                const ass = stage.auditAssists.find(x => { return stage.flowAuditorIds.indexOf(x.user_id) >= 0 && x.ass_user_id === accountId; });
+                const inCurFlow = stage.flowAuditorIds.indexOf(accountId) >= 0 || ass;
+                if (inCurFlow) {
                     stage.assist = ass;
                     stage.curOrder = stage.curAuditors[0].order;
+                    // 会签,会签人审批通过时,只读,但是curOrder需按原来的取值
+                    if (stage.curAuditors[0].audit_type === auditConst.auditType.key.and) {
+                        stage.readOnly = !_.isEqual(stage.flowAuditorIds, stage.curAuditorIds);
+                        stage.canCheck = true;
+                    } else if (stage.curAuditors[0].audit_type === auditConst.auditType.key.union) {
+                        stage.readOnly = stage.relaAuditor.status !== status.checking;
+                        stage.canCheck = !stage.readOnly;
+                    }
                 } else {
                     stage.curOrder = stage.curAuditors[0].order - 1;
-                }
-                // 会签,会签人审批通过时,只读,但是curOrder需按原来的取值
-                if (!stage.readOnly) {
-                    stage.readOnly = !_.isEqual(stage.flowAuditorIds, stage.curAuditorIds);
-                    stage.canCheck = true;
+                    stage.readOnly = true;
                 }
             }
             if (stage.readOnly) {
@@ -274,13 +278,16 @@ module.exports = app => {
                         return; // 不可以多次撤回
                     }
 
+                    const preAuditHasCheckNoPre = preAuditors.find(pa => { return pa.status === status.checkNoPre; });
                     const preAuditChecked = preAuditors.find(pa => { return pa.status === status.checked && pa.aid === accountId; });
                     const preAuditCheckNoPre = preAuditors.find(pa => { return pa.status === status.checkNoPre && pa.aid === accountId; });
                     if (preAuditorIds.indexOf(accountId) >= 0) {
-                        if (preAuditChecked) {
+                        if (preAuditHasCheckNoPre) {
+                            if (preAuditCheckNoPre) {
+                                stage.cancancel = 3;// 审批人撤回审批退回上一人
+                            }
+                        } else if (preAuditChecked) {
                             stage.cancancel = 2;// 审批人撤回审批通过
-                        } else if (preAuditCheckNoPre) {
-                            stage.cancancel = 3;// 审批人撤回审批退回上一人
                         }
                         stage.preAuditors = preAuditors;
                     } else if (preAuditors.length === 0 && accountId === stage.user_id) {

+ 3 - 3
app/service/stage_audit.js

@@ -439,7 +439,7 @@ module.exports = app => {
                 const sql = 'SELECT * FROM ?? WHERE id = ? FOR UPDATE';
                 const sqlParam = [this.ctx.service.stage.tableName, stageId];
                 const currentStage = await transaction.queryOne(sql, sqlParam);
-                if (!currentStage || currentStage.times !== times || currentStage.status !== auditConst.status.checking) {
+                if (!currentStage || currentStage.times !== times || [auditConst.status.checking, auditConst.status.checkNoPre].indexOf(currentStage.status) < 0) {
                     throw '计量期状态已变化,请刷新后重试';
                 }
                 this.ctx.stage.tp_history = currentStage.tp_history ? JSON.parse(currentStage.tp_history) : [];
@@ -1322,8 +1322,8 @@ module.exports = app => {
                 });
                 await transaction.insert(this.tableName, [...checkCancelAuditors, ...checkingAuditors]);
                 // 当前审批人变成待审批
-                await transaction.updateRows(this.tableName, stage.curAuditors.map(x => { return {
-                    id: x.id, begin_time: null, status: auditConst.status.uncheck, order: x.order + 2
+                await transaction.updateRows(this.tableName, stage.flowAuditors.map(x => { return {
+                    id: x.id, begin_time: null, status: auditConst.status.uncheck, order: x.order + 2, opinion: '',
                 }}));
                 await this.ctx.service.noticeAgain.deleteNoticeAgain(transaction, this.tableName, this._.map(stage.curAuditors, 'id'));
                 // 计算并合同支付最终数据

+ 1 - 1
app/service/stage_bills.js

@@ -147,7 +147,7 @@ module.exports = app => {
         }
 
         async getStageUsedBills(tid, sid) {
-            const sql = 'SELECT Bills.lid, ((not IsNull(Bills.contract_qty) and Bills.contract_qty <> 0) or (not IsNull(Bills.contract_tp) and Bills.contract_tp <> 0) or (not IsNull(Bills.qc_qty) and Bills.qc_qty <> 0) or (not IsNull(Bills.qc_tp) and Bills.qc_tp <> 0)) As used' +
+            const sql = 'SELECT Bills.lid, ((not IsNull(Bills.contract_qty) and Bills.contract_qty <> 0) or (not IsNull(Bills.contract_tp) and Bills.contract_tp <> 0) or (not IsNull(Bills.qc_qty) and Bills.qc_qty <> 0) or (not IsNull(Bills.qc_tp) and Bills.qc_tp <> 0)  or (not IsNull(Bills.qc_minus_qty) and Bills.qc_minus_qty <> 0)) As used' +
                 '  FROM ' + this.tableName + ' As Bills ' +
                 '  INNER JOIN ( ' +
                 '    SELECT MAX(`times` * ' + timesLen + ' + `order`) As `progress`, `lid`, `sid` From ' + this.tableName +

+ 2 - 0
app/service/sub_project.js

@@ -582,6 +582,8 @@ module.exports = app => {
                 const addTenderId = relaTenderId.filter(x => { return orgRelaTenderId.indexOf(x) < 0});
                 if (removeTenderId.length > 0) await conn.update(this.ctx.service.tender.tableName, { spid: '' }, { where: { id: removeTenderId }});
                 if (addTenderId.length > 0) await conn.update(this.ctx.service.tender.tableName, { spid: data.id }, { where: { id: addTenderId }});
+                if (removeTenderId.length > 0) await this.ctx.service.projectScreenTender.syncTenderProject(subProject.project_id, '', removeTenderId, conn);
+                if (addTenderId.length > 0) await this.ctx.service.projectScreenTender.syncTenderProject(subProject.project_id, data.id, addTenderId, conn);
                 await conn.commit();
                 return data;
             } catch (error) {

+ 4 - 0
app/service/tender.js

@@ -358,6 +358,9 @@ module.exports = app => {
                 }
                 rowData.spid = postData.spid || '';
                 const result = await conn.update(this.tableName, rowData);
+                if (tender.spid !== rowData.spid) {
+                    await this.ctx.service.projectScreenTender.syncTenderProject(tender.project_id, rowData.spid, id, conn);
+                }
                 await conn.commit();
                 return result.affectedRows > 0;
             } catch(err) {
@@ -756,6 +759,7 @@ module.exports = app => {
             const conn = await this.db.beginTransaction();
             try {
                 await conn.updateRows(this.tableName, updateTenders);
+                if (tids.length) await this.ctx.service.projectScreenTender.syncTenderProject(pid, spid, tids, conn);
                 await conn.update(this.ctx.service.subProject.tableName, { id: subProject.id, rela_tender: this._.uniq(orgRelaTenderId).join(',') });
                 await conn.update(this.ctx.service.budget.tableName, { id: subProject.budget_id, rela_tender: this._.uniq(orgRelaTenderId).join(',') });
                 await this._addSubProjPermission(conn, subProject, tids);

+ 56 - 0
app/service/tender_approval_setting.js

@@ -0,0 +1,56 @@
+'use strict';
+
+module.exports = app => {
+    class TenderApprovalSetting extends app.BaseService {
+        async getSnapshot(tid, spType, stage, transaction = null) {
+            const services = this.ctx.service;
+            const read = async (service, where) => transaction
+                ? await service.getAllDataForUpdate(where, transaction, 'id')
+                : await service.getAllDataByCondition({ where, orders: [['id', 'asc']] });
+            const infos = await read(services.tenderInfo, { tid });
+            return {
+                info: infos[0] || null,
+                groups: await read(services.shenpiGroup, { tid, sp_type: spType }),
+                audits: await read(services.shenpiAudit, { tid, sp_type: spType }),
+                assistants: stage ? await read(services.auditAss, { tid }) : [],
+            };
+        }
+
+        async persist(transaction, tid, spType, plan) {
+            const services = this.ctx.service;
+            const groups = await this.saveRows(transaction, services.shenpiGroup.tableName, plan.before.groups, plan.after.groups, { tid, sp_type: spType });
+            const audits = plan.after.audits.map(row => Object.assign({}, row, { sp_group: groups[row.sp_group] || row.sp_group }));
+            await this.saveRows(transaction, services.shenpiAudit.tableName, plan.before.audits, audits, { tid, sp_type: spType });
+            if (plan.stage) {
+                await this.saveRows(transaction, services.auditAss.tableName, plan.before.assistants, plan.after.assistants, { tid, pid: this.ctx.session.sessionProject.id });
+                for (const effect of plan.cooperation) {
+                    const where = { tid };
+                    if (effect.user_id) where.user_id = effect.user_id;
+                    await transaction.update(services.ledgerCooperation.tableName, { status: effect.status }, { where });
+                }
+            }
+            await transaction.update(services.tenderInfo.tableName, { shenpi: JSON.stringify(plan.shenpi) }, { where: { tid } });
+        }
+
+        async saveRows(transaction, table, before, after, scope) {
+            const ids = {};
+            for (const row of before) {
+                if (!after.some(x => x.id === row.id)) await transaction.delete(table, Object.assign({ id: row.id }, scope));
+            }
+            for (const row of after) {
+                const data = Object.assign({}, row, scope);
+                delete data.id;
+                if (row.id > 0) {
+                    const original = before.find(x => x.id === row.id);
+                    if (JSON.stringify(original) !== JSON.stringify(row)) await transaction.update(table, data, { where: Object.assign({ id: row.id }, scope) });
+                } else {
+                    if (table === this.ctx.service.shenpiGroup.tableName) data.create_time = new Date();
+                    const result = await transaction.insert(table, data);
+                    ids[row.id] = result.insertId;
+                }
+            }
+            return ids;
+        }
+    }
+    return TenderApprovalSetting;
+};

+ 19 - 0
app/view/file/filesjs_modal.ejs

@@ -24,3 +24,22 @@
         </div>
     </div>
 </div>
+<div class="modal fade" id="filesjs-del-filing" tabindex="-1" role="dialog" aria-labelledby="filesjs-del-filing-title" data-backdrop="static">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title" id="filesjs-del-filing-title">删除分类确认</h5>
+            </div>
+            <div class="modal-body">
+                <p id="filesjs-del-filing-summary"></p>
+                <div class="pre-scrollable"><ol id="filesjs-del-filing-list"></ol></div>
+                <p class="text-muted mb-0">所选分类下的空子分类也会一并删除。任一分类存在文件、属于固定分类或没有编辑目录权限时,本次删除不会执行。</p>
+                <div id="filesjs-del-filing-error" class="text-danger mt-2" role="alert"></div>
+            </div>
+            <div class="modal-footer">
+                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">取消</button>
+                <button type="button" class="btn btn-sm btn-danger" id="filesjs-del-filing-ok">确定删除</button>
+            </div>
+        </div>
+    </div>
+</div>

+ 4 - 4
app/view/layout/assistant.ejs

@@ -2,11 +2,11 @@
      class="global-assistant"
      data-project-id="<%- ctx.session.sessionProject.id %>"
      data-sub-project-id="<%- ctx.subProject ? ctx.subProject.id : '' %>">
-    <button type="button" class="global-assistant-entry" aria-label="打开业务查询助手" title="业务查询助手">
-        <i class="fa fa-comments"></i>
-        <span>助手</span>
+    <button type="button" class="global-assistant-entry docked" aria-label="打开业务查询助手,可拖拽调整位置" title="业务查询助手(可拖拽,靠右收起)" aria-controls="global-assistant-panel" aria-expanded="false">
+        <i class="fa fa-comments" aria-hidden="true"></i>
+        <span class="global-assistant-entry-label">助手</span>
     </button>
-    <section class="global-assistant-panel" aria-label="业务查询助手" aria-hidden="true">
+    <section id="global-assistant-panel" class="global-assistant-panel" aria-label="业务查询助手" aria-hidden="true">
         <header class="global-assistant-header">
             <div>
                 <strong>业务查询助手</strong>

File diff suppressed because it is too large
+ 8 - 1
app/view/project_screen/guangyuan.ejs


+ 13 - 0
app/view/project_screen/guangyuan_modal.ejs

@@ -11,3 +11,16 @@
         </div>
     </div>
 </div>
+<div id="gy-preview-dialog" class="modal fade gy-preview-dialog" tabindex="-1" role="dialog" aria-labelledby="gy-preview-dialog-title" aria-hidden="true">
+    <div class="modal-dialog modal-xl modal-dialog-scrollable" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 id="gy-preview-dialog-title" class="modal-title">进度预览<span class="small font-weight-normal ml-3">阶段批复日期</span></h5>
+                <button class="close" type="button" data-dismiss="modal" aria-label="关闭进度预览"><span aria-hidden="true">&times;</span></button>
+            </div>
+            <div class="modal-body d-flex overflow-hidden p-3">
+                <div id="gy-preview-dialog-content" class="gy-scroll gy-preview-dialog-table rounded" tabindex="0" role="region" aria-label="全部项目进度表格"></div>
+            </div>
+        </div>
+    </div>
+</div>

+ 60 - 2
app/view/setting/module.ejs

@@ -38,13 +38,13 @@
                     <div class="col-9 h-100">
                         <div class="card h-100 project-screen-panel-card">
                             <div class="card-header d-flex align-items-stretch p-0 project-screen-card-title">
-                                <div class="px-3 py-2">
+                                <div class="px-3 py-2 d-flex align-items-center">
                                     <strong class="project-screen-detail-title">项目大屏</strong>
-                                    <small class="d-block text-muted mt-1">配置大屏页面和模块访问权限</small>
                                 </div>
                                 <nav class="nav ml-auto project-screen-module-tabs" role="tablist">
                                     <a href="javascript:void(0);" class="nav-item nav-link project-screen-tab active" data-tab="config">功能配置</a>
                                     <a href="javascript:void(0);" class="nav-item nav-link project-screen-tab" data-tab="permission">权限管理</a>
+                                    <a href="javascript:void(0);" class="nav-item nav-link project-screen-tab" data-tab="tender">展示范围</a>
                                 </nav>
                             </div>
 
@@ -115,6 +115,64 @@
                                         </div>
                                     </div>
                                 </div>
+                                <div id="project-screen-tender" class="tab-pane h-100" style="display:none;">
+                                    <div class="h-100 d-flex flex-column project-screen-tender-setting">
+                                        <div class="project-screen-tender-columns flex-fill">
+                                            <div class="project-screen-tender-projects d-flex flex-column border-right">
+                                                <div class="p-3 flex-shrink-0">
+                                                    <div class="d-flex align-items-center project-screen-tender-heading">
+                                                        <strong>项目</strong>
+                                                        <div id="project-screen-tender-project-level" class="dropdown ml-auto project-screen-tender-tree-actions"></div>
+                                                    </div>
+                                                    <input id="project-screen-tender-project-search" class="form-control form-control-sm mt-2 project-screen-tender-search" placeholder="搜索项目" aria-label="搜索项目">
+                                                </div>
+                                                <div id="project-screen-tender-project-tree" class="flex-fill overflow-auto px-3 pb-3" aria-label="项目树"></div>
+                                            </div>
+                                            <div class="project-screen-tender-detail d-flex flex-column">
+                                                <div class="p-3 flex-shrink-0">
+                                                    <div class="d-flex align-items-center project-screen-tender-heading">
+                                                        <div class="project-screen-tender-intro">
+                                                            <strong id="project-screen-tender-project-name">请选择项目</strong>
+                                                        </div>
+                                                        <div id="project-screen-tender-tender-level" class="dropdown ml-auto project-screen-tender-tree-actions"></div>
+                                                    </div>
+                                                    <div class="input-group input-group-sm mt-2 project-screen-tender-search">
+                                                        <input id="project-screen-tender-locate" class="form-control form-control-sm" placeholder="输入标段名称定位" aria-label="定位标段名称" disabled>
+                                                        <div class="input-group-append">
+                                                            <span id="project-screen-tender-search-result" class="input-group-text" aria-live="polite">0/0</span>
+                                                            <button id="project-screen-tender-search-prev" type="button" class="btn btn-outline-secondary" aria-label="上一个匹配标段" title="上一个" disabled><i class="fa fa-arrow-up" aria-hidden="true"></i></button>
+                                                            <button id="project-screen-tender-search-next" type="button" class="btn btn-outline-secondary" aria-label="下一个匹配标段" title="下一个" disabled><i class="fa fa-arrow-down" aria-hidden="true"></i></button>
+                                                        </div>
+                                                    </div>
+                                                </div>
+                                                <div id="project-screen-tender-tree-scroll" class="flex-fill overflow-auto px-3 pb-3">
+                                                    <table class="table mb-0 project-screen-tender-list project-screen-tender-tree-table">
+                                                        <thead class="project-screen-sticky-head">
+                                                            <tr>
+                                                                <th><div class="d-flex align-items-center"><span>标段名称</span><label for="project-screen-tender-all" class="ml-auto mb-0 text-nowrap project-screen-tender-select-all">勾选全部</label></div></th>
+                                                                <th class="text-center project-screen-tender-check-cell"><input id="project-screen-tender-all" type="checkbox" disabled></th>
+                                                            </tr>
+                                                        </thead>
+                                                        <tbody id="project-screen-tender-tree"></tbody>
+                                                    </table>
+                                                    <div id="project-screen-tender-message" class="text-muted text-center p-4">请选择项目</div>
+                                                    <div class="text-center"><button id="project-screen-tender-retry" type="button" class="btn btn-outline-primary btn-sm" style="display:none;">重新加载</button></div>
+                                                </div>
+                                            </div>
+                                        </div>
+                                        <div class="px-3 py-2 border-top d-flex align-items-center flex-shrink-0">
+                                            <div class="project-screen-tender-description mr-3">勾选项目及标段用于大屏展示及统计,新增默认展示,所有大屏共用。</div>
+                                            <div class="ml-auto mr-3 flex-shrink-0 text-right">
+                                                <span id="project-screen-tender-total" aria-live="polite"></span>
+                                                <small id="project-screen-tender-save-status" class="text-muted ml-2"></small>
+                                            </div>
+                                            <div class="text-nowrap flex-shrink-0">
+                                                <button id="project-screen-tender-cancel" type="button" class="btn btn-outline-secondary btn-sm" disabled>取消修改</button>
+                                                <button id="project-screen-tender-save" type="button" class="btn btn-primary btn-sm ml-1" disabled>保存</button>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </div>
                             </div>
                         </div>
                     </div>

+ 24 - 0
app/view/sp_setting/approval.ejs

@@ -0,0 +1,24 @@
+<% include ./sub_menu.ejs %>
+<div class="panel-content setting-approval-page">
+    <div class="panel-title fluid">
+        <div class="title-main">
+            <div class="approval-heading">审批流程</div>
+        </div>
+    </div>
+    <div class="content-wrap">
+        <div class="sjs-height-0 row approval-layout">
+            <div class="col-4">
+                <div class="c-body sjs-height-0 approval-tenders"></div>
+            </div>
+            <div class="col-8">
+                <div class="m-3 approval-content sjs-height-0">
+                    <div id="splc">
+                        <div class="text-muted p-3"><%= tenderList.length ? '请选择标段查看审批流程' : '暂无可配置审批流程的标段' %></div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+<div id="approval-card-store" class="d-none"></div>
+<% include ./manage_data.ejs %>

+ 12 - 0
app/view/sp_setting/approval_editor_modal.ejs

@@ -0,0 +1,12 @@
+<div class="modal fade" id="approval-editor" data-backdrop="static" data-keyboard="false">
+    <div class="modal-dialog modal-lg" role="document">
+        <div class="modal-content">
+            <div class="modal-header"><h5 class="modal-title">流程编辑</h5></div>
+            <div class="modal-body" id="approval-edit-body"></div>
+            <div class="modal-footer">
+                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>
+                <button type="button" class="btn btn-sm btn-primary" id="approval-save">保存</button>
+            </div>
+        </div>
+    </div>
+</div>

+ 297 - 0
app/view/sp_setting/approval_modal.ejs

@@ -0,0 +1,297 @@
+<!--设置其他标段-->
+<div class="modal fade" id="batch" data-backdrop="static">
+    <div class="modal-dialog modal-lg" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title">设置其他标段</h5>
+            </div>
+            <div class="modal-body">
+                <div class="alert alert-warning">将「<span id="shenpi-name"></span>」设置至其他标段</div>
+                <div class="input-group input-group-sm mb-2">
+                    <input class="form-control" placeholder="输入审批人名称搜索" type="text" name="audit-name" id="search-audit">
+                    <div class="input-group-append">
+                        <span class="input-group-text" id="search-result">0/0</span>
+                        <button class="btn btn-outline-secondary" id="up-search" disabled><i class="fa fa-arrow-up" aria-hidden="true"></i></button>
+                        <button class="btn btn-outline-secondary" id="down-search" disabled><i class="fa fa-arrow-down" aria-hidden="true"></i></button>
+                    </div>
+                </div>
+                <div class="modal-height-300" id="tender-list">
+                </div>
+            </div>
+            <div class="modal-footer">
+                <input type="hidden" id="shenpi_auditors" value="">
+                <input type="hidden" id="shenpi_code" value="">
+                <input type="hidden" id="shenpi_status" value="">
+                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>
+                <button type="button" class="btn btn-sm btn-primary" id="save-other-tender">确认</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<!--设置其他流程-->
+<div class="modal fade" id="batch2" data-backdrop="static">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title">设置其他流程</h5>
+            </div>
+            <div class="modal-body">
+                <div class="alert alert-warning">将「<span id="shenpi-name2"></span>」设置至其他流程</div>
+                <div class="modal-height-300" id="shenpi-list">
+                    <table class="table table-hover table-bordered">
+                        <thead>
+                        <th>名称</th>
+                        <th width="100">审批流程</th>
+                        <th width="40">选择</th>
+                        </thead>
+                        <tr>
+                            <td>预付款审批</td>
+                            <td>固定流程<i class="fa fa-question-circle text-primary" data-container="body" data-toggle="tooltip" data-placement="bottom" data-original-title="王五-张三-李四"></i></td>
+                            <td></td>
+                        </tr>
+                        <tr>
+                            <td>台账审批</td>
+                            <td>固定流程<i class="fa fa-question-circle text-primary" data-container="body" data-toggle="tooltip" data-placement="bottom" data-original-title="王五-张三-李四"></i></td>
+                            <td><input type="checkbox"></td>
+                        </tr>
+                        <tr>
+                            <td>台账修订</td>
+                            <td>固定流程<i class="fa fa-question-circle text-primary" data-container="body" data-toggle="tooltip" data-placement="bottom" data-original-title="王五-张三-李四"></i></td>
+                            <td><input type="checkbox"></td>
+                        </tr>
+                        <tr>
+                            <td>计量期审批</td>
+                            <td>固定流程<i class="fa fa-question-circle text-primary" data-container="body" data-toggle="tooltip" data-placement="bottom" data-original-title="王五-张三-李四"></i></td>
+                            <td><input type="checkbox"></td>
+                        </tr>
+                        <tr>
+                            <td>工程变更审批</td>
+                            <td>固定流程<i class="fa fa-question-circle text-primary" data-container="body" data-toggle="tooltip" data-placement="bottom" data-original-title="王五-张三-李四"></i></td>
+                            <td><input type="checkbox"></td>
+                        </tr>
+                        <tr>
+                            <td>材料调差审批</td>
+                            <td>固定流程<i class="fa fa-question-circle text-primary" data-container="body" data-toggle="tooltip" data-placement="bottom" data-original-title="王五-张三-李四"></i></td>
+                            <td><input type="checkbox"></td>
+                        </tr>
+                    </table>
+                </div>
+            </div>
+            <div class="modal-footer">
+                <input type="hidden" id="shenpi_auditors2" value="">
+                <input type="hidden" id="shenpi_code2" value="">
+                <input type="hidden" id="shenpi_status2" value="">
+                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>
+                <button type="button" class="btn btn-sm btn-primary" id="save-other-shenpi">确认</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<!--多人协同-->
+<div class="modal fade" id="cooperation" data-backdrop="static">
+    <div class="modal-dialog modal-xl" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title">多人协同</h5>
+            </div>
+            <div class="modal-body">
+                <div class="row">
+                    <div class="col-6">
+                        <div class="modal-height-500" style="overflow: auto;">
+                            <table class="table table-hover table-bordered">
+                                <thead class="text-center">
+                                <tr><th colspan="4" id="stage_audit"></th></tr>
+                                <tr><th>姓名</th><th>单位</th><th>协同处理</th></tr>
+                                </thead>
+                                <tbody id="coo_table">
+                                </tbody>
+                            </table>
+                        </div>
+                    </div>
+                    <div class="col-6">
+                        <div class="row">
+                            <div class="col-6">
+                                <div class="mb-2">
+                                    <select class="form-control form-control-sm" id="stage_audits">
+                                    </select>
+                                </div>
+                            </div>
+                            <div class="col-6">
+                                <div class="text-right">
+                                    <div class="d-inline-block">
+                                        <button class="btn btn-outline-danger btn-sm" id="del-audit-ass">
+                                            移除协同人
+                                        </button>
+                                    </div>
+                                    <div class="dropdown d-inline-block" data-code="audit-ass">
+                                        <button class="btn btn-outline-primary btn-sm dropdown-toggle" type="button" id="audit-ass_dropdownMenuButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                                            添加协同人
+                                        </button>
+                                        <div class="dropdown-menu dropdown-menu-right" aria-labelledby="audit-ass_dropdownMenuButton" style="width:220px" id="audit-ass_dropdownMenu">
+                                            <div class="mb-2 p-2"><input class="form-control form-control-sm gr-search" placeholder="姓名/手机 检索" autocomplete="off" data-code="audit-ass"></div>
+                                            <dl class="list-unstyled book-list">
+                                                <% accountGroup.forEach((group, idx) => { %>
+                                                <dt><a href="javascript: void(0);" class="acc-btn" data-groupid="<%- idx %>" data-type="hide"><i class="fa fa-plus-square"></i></a> <%- group.groupName %></dt>
+                                                <div class="dd-content" data-toggleid="<%- idx %>">
+                                                    <% group.groupList.forEach(item => { %>
+                                                    <dd class="border-bottom p-2 mb-0 " data-id="<%- item.id %>">
+                                                        <p class="mb-0 d-flex"><span class="text-primary"><%- item.name %></span><span
+                                                                    class="ml-auto"><%- item.mobile %></span></p>
+                                                        <span class="text-muted"><%- item.role %></span>
+                                                    </dd>
+                                                    <% });%>
+                                                </div>
+                                                <% }) %>
+                                            </dl>
+                                        </div>
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+                        <div class="modal-height-500" style="overflow: auto;" id="ledger-spread">
+                        </div>
+                    </div>
+                </div>
+            </div>
+            <div class="modal-footer">
+                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<div class="modal fade" id="union" data-backdrop="static">
+    <div class="modal-dialog modal-xl" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title">协同审批</h5>
+            </div>
+            <div class="modal-body">
+                <div class="row">
+                    <div class="col-6">
+                        <div class="modal-height-500" style="overflow: auto;">
+                            <table class="table table-hover table-bordered">
+                                <thead class="text-center" >
+                                <tr><th>姓名</th><th>单位</th><th>协同处理</th></tr>
+                                </thead>
+                                <tbody id="union_table">
+                                </tbody>
+                            </table>
+                        </div>
+                    </div>
+                    <div class="col-6">
+                        <div class="modal-height-500" style="overflow: auto;" id="union-spread">
+                        </div>
+                    </div>
+                </div>
+            </div>
+            <div class="modal-footer">
+                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>
+                <button type="button" class="btn btn-sm btn-primary" id="union-ok">保存</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<!--存为审批组-->
+<div class="modal fade" id="spzsave" data-backdrop="static">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title">保存审批组</h5>
+            </div>
+            <div class="modal-body">
+                <div class="form-group mb-2">
+                    <label>审批组名称<b class="text-danger">*</b></label>
+                    <input class="form-control form-control-sm" name="group_name" placeholder="输入名称" type="text">
+                </div>
+                <div class="form-group" id="show-change-type" style="display: none">
+                    <label>显示模块</label>
+                    <div>
+                        <% for (const c of change_type_list) { %>
+                            <div class="form-check form-check-inline">
+                                <input class="form-check-input" type="checkbox" data-type="<%- c.code %>" name="change_type[]" id="<%- c.code %>_checkbox" disabled>
+                                <label class="form-check-label" for="<%- c.code %>_checkbox"><%- c.name %></label>
+                            </div>
+                        <% } %>
+                    </div>
+                </div>
+                <div class="form-group" id="show-contract-type" style="display: none">
+                    <label>显示模块</label>
+                    <div>
+                        <% for (const c of contract_type_list) { %>
+                            <div class="form-check form-check-inline">
+                                <input class="form-check-input" type="checkbox" data-type="<%- c.code %>" name="contract_type[]" id="<%- c.code %>_checkbox" disabled>
+                                <label class="form-check-label" for="<%- c.code %>_checkbox"><%- c.name %></label>
+                            </div>
+                        <% } %>
+                    </div>
+                </div>
+            </div>
+            <div class="modal-footer">
+                <input type="hidden" id="save-code">
+                <input type="hidden" id="save-group-id">
+                <button type="button" id="show-delete-group-btn" style="display: none;" class="btn btn-sm btn-danger mr-auto">删除审批组</button>
+                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">取消</button>
+                <button type="button" class="btn btn-sm btn-primary" id="save-group-btn">确定</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<!--删除审批组-->
+<div class="modal fade" id="spzdelete" data-backdrop="static">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title">删除审批组</h5>
+            </div>
+            <div class="modal-body">
+                <div class="form-group mb-2">
+                    <h5>确认删除<span id="delete-group-name"></span>?</h5>
+                    <h5>删除后,该审批组已添加的人员也将一同移除,请谨慎操作。</h5>
+                </div>
+            </div>
+            <div class="modal-footer">
+                <input type="hidden" id="delete-code">
+                <input type="hidden" id="delete-group-id">
+                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">取消</button>
+                <button type="button" class="btn btn-sm btn-danger" id="delete-group-btn">确定删除</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<div class="modal fade" id="multi" data-backdrop="static">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title" id="multi-title">分组审批</h5>
+            </div>
+            <div class="modal-body">
+                <div class="modal-height-500" style="overflow: auto;" id="multi-spread">
+                </div>
+                <div name="set-hint" htype="<%- auditType.key.multi %>">
+                    顺序:从1开始,务必连续,某审批人审批通过时,将会跳过其前面所有的审批人。<br/>
+                    受限:受限时,前面所有的审批人都审批通过后,方可审批。
+                </div>
+                <div name="set-hint" htype="<%- auditType.key.and %>">
+                    顺序:从1开始,务必连续,某审批人审批通过时,将会跳过其前面所有的审批人。
+                </div>
+                <div name="set-hint" htype="<%- auditType.key.or %>">
+                    最少审批:该审批人审批通过时,审批通过人数满足则结束本流程。
+                </div>
+            </div>
+            <div class="modal-footer">
+                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>
+                <button type="button" class="btn btn-sm btn-primary" id="multi-ok">保存</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<% if (approvalOnly) { %>
+<% include ./approval_editor_modal.ejs %>
+<% } %>

+ 1 - 20
app/view/sp_setting/manage.ejs

@@ -370,23 +370,4 @@
         </div>
     </div>
 </div>
-<script>
-    const accountGroup = JSON.parse(unescape('<%- escape(JSON.stringify(accountGroup)) %>'));
-    const accountList = JSON.parse(unescape('<%- escape(JSON.stringify(accountList)) %>'));
-    const auditConst = JSON.parse(unescape('<%- escape(JSON.stringify(auditConst)) %>'));
-    const tenders = JSON.parse(unescape('<%- escape(JSON.stringify(tenderList)) %>'));
-    const categoryType = JSON.parse('<%- JSON.stringify(settingConst.cType) %>');
-    const category = JSON.parse(unescape('<%- escape(JSON.stringify(categoryData)) %>'));
-    const selfCategoryLevel = '<%- (selfCategoryLevel || '') %>';
-    const measureType = JSON.parse('<%- JSON.stringify(measureType) %>');
-    const auditType = JSON.parse(unescape('<%- escape(JSON.stringify(auditType)) %>'));
-    const scPermission = JSON.parse(unescape('<%- escape(JSON.stringify(scPermission)) %>'));
-    const subProjects = JSON.parse(unescape('<%- escape(JSON.stringify(subProjects)) %>'));
-    console.log(subProjects);
-    const uid = '<%- uid %>';
-    const pid = '<%- pid %>';
-    const uphlname = 'user_' + uid + '_pro_' + pid + '_tender_manage_list';
-    let sp_lc, sp_type, sp_status, sp_status_list, cur_tenderid, cur_uid;
-    const permissionConst = JSON.parse('<%- JSON.stringify(permissionConst) %>');
-    const permissionBlock = JSON.parse('<%- JSON.stringify(permissionBlock) %>');
-</script>
+<% include ./manage_data.ejs %>

+ 20 - 0
app/view/sp_setting/manage_data.ejs

@@ -0,0 +1,20 @@
+<script>
+    const approvalOnly = <%- JSON.stringify(approvalOnly) %>;
+    const accountGroup = JSON.parse(unescape('<%- escape(JSON.stringify(accountGroup)) %>'));
+    const accountList = JSON.parse(unescape('<%- escape(JSON.stringify(accountList)) %>'));
+    const auditConst = JSON.parse(unescape('<%- escape(JSON.stringify(auditConst)) %>'));
+    const tenders = JSON.parse(unescape('<%- escape(JSON.stringify(tenderList)) %>'));
+    const categoryType = JSON.parse('<%- JSON.stringify(settingConst.cType) %>');
+    const category = JSON.parse(unescape('<%- escape(JSON.stringify(categoryData)) %>'));
+    const selfCategoryLevel = '<%- (selfCategoryLevel || '') %>';
+    const measureType = JSON.parse('<%- JSON.stringify(measureType) %>');
+    const auditType = JSON.parse(unescape('<%- escape(JSON.stringify(auditType)) %>'));
+    const scPermission = JSON.parse(unescape('<%- escape(JSON.stringify(scPermission)) %>'));
+    const subProjects = JSON.parse(unescape('<%- escape(JSON.stringify(subProjects)) %>'));
+    const uid = '<%- uid %>';
+    const pid = '<%- pid %>';
+    const uphlname = 'user_' + uid + '_pro_' + pid + (approvalOnly ? '_tender_approval_list' : '_tender_manage_list');
+    let sp_lc, sp_type, sp_status, sp_status_list, cur_tenderid, cur_uid;
+    const permissionConst = JSON.parse('<%- JSON.stringify(permissionConst) %>');
+    const permissionBlock = JSON.parse('<%- JSON.stringify(permissionBlock) %>');
+</script>

+ 6 - 287
app/view/sp_setting/manage_modal.ejs

@@ -1,33 +1,4 @@
-<!--设置其他标段-->
-<div class="modal fade" id="batch" data-backdrop="static">
-    <div class="modal-dialog modal-lg" role="document">
-        <div class="modal-content">
-            <div class="modal-header">
-                <h5 class="modal-title">设置其他标段</h5>
-            </div>
-            <div class="modal-body">
-                <div class="alert alert-warning">将「<span id="shenpi-name"></span>」设置至其他标段</div>
-                <div class="input-group input-group-sm mb-2">
-                    <input class="form-control" placeholder="输入审批人名称搜索" type="text" name="audit-name" id="search-audit">
-                    <div class="input-group-append">
-                        <span class="input-group-text" id="search-result">0/0</span>
-                        <button class="btn btn-outline-secondary" id="up-search" disabled><i class="fa fa-arrow-up" aria-hidden="true"></i></button>
-                        <button class="btn btn-outline-secondary" id="down-search" disabled><i class="fa fa-arrow-down" aria-hidden="true"></i></button>
-                    </div>
-                </div>
-                <div class="modal-height-300" id="tender-list">
-                </div>
-            </div>
-            <div class="modal-footer">
-                <input type="hidden" id="shenpi_auditors" value="">
-                <input type="hidden" id="shenpi_code" value="">
-                <input type="hidden" id="shenpi_status" value="">
-                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>
-                <button type="button" class="btn btn-sm btn-primary" id="save-other-tender">确认</button>
-            </div>
-        </div>
-    </div>
-</div>
+<% include ./approval_modal.ejs %>
 <div class="modal fade" id="filter-tender" data-backdrop="static">
     <div class="modal-dialog modal-lg" role="document">
         <div class="modal-content">
@@ -53,169 +24,7 @@
         </div>
     </div>
 </div>
-<!--设置其他流程-->
-<div class="modal fade" id="batch2" data-backdrop="static">
-    <div class="modal-dialog" role="document">
-        <div class="modal-content">
-            <div class="modal-header">
-                <h5 class="modal-title">设置其他流程</h5>
-            </div>
-            <div class="modal-body">
-                <div class="alert alert-warning">将「<span id="shenpi-name2"></span>」设置至其他流程</div>
-                <div class="modal-height-300" id="shenpi-list">
-                    <table class="table table-hover table-bordered">
-                        <thead>
-                        <th>名称</th>
-                        <th width="100">审批流程</th>
-                        <th width="40">选择</th>
-                        </thead>
-                        <tr>
-                            <td>预付款审批</td>
-                            <td>固定流程<i class="fa fa-question-circle text-primary" data-container="body" data-toggle="tooltip" data-placement="bottom" data-original-title="王五-张三-李四"></i></td>
-                            <td></td>
-                        </tr>
-                        <tr>
-                            <td>台账审批</td>
-                            <td>固定流程<i class="fa fa-question-circle text-primary" data-container="body" data-toggle="tooltip" data-placement="bottom" data-original-title="王五-张三-李四"></i></td>
-                            <td><input type="checkbox"></td>
-                        </tr>
-                        <tr>
-                            <td>台账修订</td>
-                            <td>固定流程<i class="fa fa-question-circle text-primary" data-container="body" data-toggle="tooltip" data-placement="bottom" data-original-title="王五-张三-李四"></i></td>
-                            <td><input type="checkbox"></td>
-                        </tr>
-                        <tr>
-                            <td>计量期审批</td>
-                            <td>固定流程<i class="fa fa-question-circle text-primary" data-container="body" data-toggle="tooltip" data-placement="bottom" data-original-title="王五-张三-李四"></i></td>
-                            <td><input type="checkbox"></td>
-                        </tr>
-                        <tr>
-                            <td>工程变更审批</td>
-                            <td>固定流程<i class="fa fa-question-circle text-primary" data-container="body" data-toggle="tooltip" data-placement="bottom" data-original-title="王五-张三-李四"></i></td>
-                            <td><input type="checkbox"></td>
-                        </tr>
-                        <tr>
-                            <td>材料调差审批</td>
-                            <td>固定流程<i class="fa fa-question-circle text-primary" data-container="body" data-toggle="tooltip" data-placement="bottom" data-original-title="王五-张三-李四"></i></td>
-                            <td><input type="checkbox"></td>
-                        </tr>
-                    </table>
-                </div>
-            </div>
-            <div class="modal-footer">
-                <input type="hidden" id="shenpi_auditors2" value="">
-                <input type="hidden" id="shenpi_code2" value="">
-                <input type="hidden" id="shenpi_status2" value="">
-                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>
-                <button type="button" class="btn btn-sm btn-primary" id="save-other-shenpi">确认</button>
-            </div>
-        </div>
-    </div>
-</div>
-<!--多人协同-->
-<div class="modal fade" id="cooperation" data-backdrop="static">
-    <div class="modal-dialog modal-xl" role="document">
-        <div class="modal-content">
-            <div class="modal-header">
-                <h5 class="modal-title">多人协同</h5>
-            </div>
-            <div class="modal-body">
-                <div class="row">
-                    <div class="col-6">
-                        <div class="modal-height-500" style="overflow: auto;">
-                            <table class="table table-hover table-bordered">
-                                <thead class="text-center">
-                                <tr><th colspan="4" id="stage_audit"></th></tr>
-                                <tr><th>姓名</th><th>单位</th><th>协同处理</th></tr>
-                                </thead>
-                                <tbody id="coo_table">
-                                </tbody>
-                            </table>
-                        </div>
-                    </div>
-                    <div class="col-6">
-                        <div class="row">
-                            <div class="col-6">
-                                <div class="mb-2">
-                                    <select class="form-control form-control-sm" id="stage_audits">
-                                    </select>
-                                </div>
-                            </div>
-                            <div class="col-6">
-                                <div class="text-right">
-                                    <div class="d-inline-block">
-                                        <button class="btn btn-outline-danger btn-sm" id="del-audit-ass">
-                                            移除协同人
-                                        </button>
-                                    </div>
-                                    <div class="dropdown d-inline-block" data-code="audit-ass">
-                                        <button class="btn btn-outline-primary btn-sm dropdown-toggle" type="button" id="audit-ass_dropdownMenuButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
-                                            添加协同人
-                                        </button>
-                                        <div class="dropdown-menu dropdown-menu-right" aria-labelledby="audit-ass_dropdownMenuButton" style="width:220px" id="audit-ass_dropdownMenu">
-                                            <div class="mb-2 p-2"><input class="form-control form-control-sm gr-search" placeholder="姓名/手机 检索" autocomplete="off" data-code="audit-ass"></div>
-                                            <dl class="list-unstyled book-list">
-                                                <% accountGroup.forEach((group, idx) => { %>
-                                                <dt><a href="javascript: void(0);" class="acc-btn" data-groupid="<%- idx %>" data-type="hide"><i class="fa fa-plus-square"></i></a> <%- group.groupName %></dt>
-                                                <div class="dd-content" data-toggleid="<%- idx %>">
-                                                    <% group.groupList.forEach(item => { %>
-                                                    <dd class="border-bottom p-2 mb-0 " data-id="<%- item.id %>">
-                                                        <p class="mb-0 d-flex"><span class="text-primary"><%- item.name %></span><span
-                                                                    class="ml-auto"><%- item.mobile %></span></p>
-                                                        <span class="text-muted"><%- item.role %></span>
-                                                    </dd>
-                                                    <% });%>
-                                                </div>
-                                                <% }) %>
-                                            </dl>
-                                        </div>
-                                    </div>
-                                </div>
-                            </div>
-                        </div>
-                        <div class="modal-height-500" style="overflow: auto;" id="ledger-spread">
-                        </div>
-                    </div>
-                </div>
-            </div>
-            <div class="modal-footer">
-                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>
-            </div>
-        </div>
-    </div>
-</div>
-<div class="modal fade" id="union" data-backdrop="static">
-    <div class="modal-dialog modal-xl" role="document">
-        <div class="modal-content">
-            <div class="modal-header">
-                <h5 class="modal-title">协同审批</h5>
-            </div>
-            <div class="modal-body">
-                <div class="row">
-                    <div class="col-6">
-                        <div class="modal-height-500" style="overflow: auto;">
-                            <table class="table table-hover table-bordered">
-                                <thead class="text-center" >
-                                <tr><th>姓名</th><th>单位</th><th>协同处理</th></tr>
-                                </thead>
-                                <tbody id="union_table">
-                                </tbody>
-                            </table>
-                        </div>
-                    </div>
-                    <div class="col-6">
-                        <div class="modal-height-500" style="overflow: auto;" id="union-spread">
-                        </div>
-                    </div>
-                </div>
-            </div>
-            <div class="modal-footer">
-                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>
-                <button type="button" class="btn btn-sm btn-primary" id="union-ok">保存</button>
-            </div>
-        </div>
-    </div>
-</div>
+
 <!--弹出编辑标段-->
 <div class="modal fade" id="edit-bd" data-backdrop="static">
     <div class="modal-dialog" role="document">
@@ -243,6 +52,7 @@
         </div>
     </div>
 </div>
+
 <!--删除标段-->
 <div class="modal fade" id="del-bd" data-backdrop="static">
     <div class="modal-dialog" role="document">
@@ -261,6 +71,7 @@
         </div>
     </div>
 </div>
+
 <!--移除账号-->
 <div class="modal fade" id="remove-user" data-backdrop="static">
     <div class="modal-dialog" role="document">
@@ -281,6 +92,7 @@
         </div>
     </div>
 </div>
+
 <!--应用账号至其他标段-->
 <div class="modal fade" id="bdcopy" data-backdrop="static">
     <div class="modal-dialog modal-lg" role="document">
@@ -307,73 +119,7 @@
         </div>
     </div>
 </div>
-<!--存为审批组-->
-<div class="modal fade" id="spzsave" data-backdrop="static">
-    <div class="modal-dialog" role="document">
-        <div class="modal-content">
-            <div class="modal-header">
-                <h5 class="modal-title">保存审批组</h5>
-            </div>
-            <div class="modal-body">
-                <div class="form-group mb-2">
-                    <label>审批组名称<b class="text-danger">*</b></label>
-                    <input class="form-control form-control-sm" name="group_name" placeholder="输入名称" type="text">
-                </div>
-                <div class="form-group" id="show-change-type" style="display: none">
-                    <label>显示模块</label>
-                    <div>
-                        <% for (const c of change_type_list) { %>
-                            <div class="form-check form-check-inline">
-                                <input class="form-check-input" type="checkbox" data-type="<%- c.code %>" name="change_type[]" id="<%- c.code %>_checkbox" disabled>
-                                <label class="form-check-label" for="<%- c.code %>_checkbox"><%- c.name %></label>
-                            </div>
-                        <% } %>
-                    </div>
-                </div>
-                <div class="form-group" id="show-contract-type" style="display: none">
-                    <label>显示模块</label>
-                    <div>
-                        <% for (const c of contract_type_list) { %>
-                            <div class="form-check form-check-inline">
-                                <input class="form-check-input" type="checkbox" data-type="<%- c.code %>" name="contract_type[]" id="<%- c.code %>_checkbox" disabled>
-                                <label class="form-check-label" for="<%- c.code %>_checkbox"><%- c.name %></label>
-                            </div>
-                        <% } %>
-                    </div>
-                </div>
-            </div>
-            <div class="modal-footer">
-                <input type="hidden" id="save-code">
-                <input type="hidden" id="save-group-id">
-                <button type="button" id="show-delete-group-btn" style="display: none;" class="btn btn-sm btn-danger mr-auto">删除审批组</button>
-                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">取消</button>
-                <button type="button" class="btn btn-sm btn-primary" id="save-group-btn">确定</button>
-            </div>
-        </div>
-    </div>
-</div>
-<!--删除审批组-->
-<div class="modal fade" id="spzdelete" data-backdrop="static">
-    <div class="modal-dialog" role="document">
-        <div class="modal-content">
-            <div class="modal-header">
-                <h5 class="modal-title">删除审批组</h5>
-            </div>
-            <div class="modal-body">
-                <div class="form-group mb-2">
-                    <h5>确认删除<span id="delete-group-name"></span>?</h5>
-                    <h5>删除后,该审批组已添加的人员也将一同移除,请谨慎操作。</h5>
-                </div>
-            </div>
-            <div class="modal-footer">
-                <input type="hidden" id="delete-code">
-                <input type="hidden" id="delete-group-id">
-                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">取消</button>
-                <button type="button" class="btn btn-sm btn-danger" id="delete-group-btn">确定删除</button>
-            </div>
-        </div>
-    </div>
-</div>
+
 <!--关联标段-->
 <div class="modal fade" id="select-tender" data-backdrop="static">
     <div class="modal-dialog modal-lg" role="document">
@@ -402,30 +148,3 @@
         </div>
     </div>
 </div>
-<div class="modal fade" id="multi" data-backdrop="static">
-    <div class="modal-dialog" role="document">
-        <div class="modal-content">
-            <div class="modal-header">
-                <h5 class="modal-title" id="multi-title">分组审批</h5>
-            </div>
-            <div class="modal-body">
-                <div class="modal-height-500" style="overflow: auto;" id="multi-spread">
-                </div>
-                <div name="set-hint" htype="<%- auditType.key.multi %>">
-                    顺序:从1开始,务必连续,某审批人审批通过时,将会跳过其前面所有的审批人。<br/>
-                    受限:受限时,前面所有的审批人都审批通过后,方可审批。
-                </div>
-                <div name="set-hint" htype="<%- auditType.key.and %>">
-                    顺序:从1开始,务必连续,某审批人审批通过时,将会跳过其前面所有的审批人。
-                </div>
-                <div name="set-hint" htype="<%- auditType.key.or %>">
-                    最少审批:该审批人审批通过时,审批通过人数满足则结束本流程。
-                </div>
-            </div>
-            <div class="modal-footer">
-                <button type="button" class="btn btn-sm btn-secondary" data-dismiss="modal">关闭</button>
-                <button type="button" class="btn btn-sm btn-primary" id="multi-ok">保存</button>
-            </div>
-        </div>
-    </div>
-</div>

+ 6 - 0
config/menu.js

@@ -525,6 +525,12 @@ const projectSettingMenu = {
         url: '/setting/user',
         caption: '项目成员',
     },
+    // approval: {
+    //     name: '标段审批流程',
+    //     display: true,
+    //     url: '/setting/approval',
+    //     caption: '标段审批流程',
+    // },
     fun: {
         name: '功能设置',
         display: true,

+ 11 - 0
config/web.js

@@ -1701,7 +1701,9 @@ const JsFiles = {
                     '/public/js/shares/tender_list_order.js',
                     '/public/js/shares/show_level.js',
                     '/public/js/tender_showhide.js',
+                    '/public/js/shares/tender_approval_draft.js',
                     '/public/js/shenpi.js',
+                    '/public/js/setting_approval.js',
                     '/public/js/popper/popper.min.js',
                     '/public/js/tender_list_base.js',
                     '/public/js/setting_manage.js',
@@ -1758,6 +1760,15 @@ const JsFiles = {
                 ],
                 mergeFile: 'sp_setting_data_range',
             },
+            module: {
+                files: [],
+                mergeFiles: [
+                    '/public/js/shares/show_level.js',
+                    '/public/js/project_screen_setting.js',
+                    '/public/js/project_screen_tender_setting.js',
+                ],
+                mergeFile: 'setting_module',
+            },
         },
         profile: {
             cert: {

+ 11 - 0
sql/update.sql

@@ -129,6 +129,17 @@ MODIFY COLUMN `tz_qc_tp` decimal(24, 8) NOT NULL DEFAULT 0 COMMENT '决算-变
 ADD COLUMN `un_tz_qc_qty` decimal(24, 8) NOT NULL DEFAULT 0 COMMENT '未批复变更令数量' AFTER `tz_qc_tp`,
 ADD COLUMN `un_tz_qc_tp` decimal(24, 8) NOT NULL DEFAULT 0 COMMENT '未批复变更令金额' AFTER `un_tz_qc_qty`;
 
+-- 项目大屏:记录不展示的标段,与决策大屏的排除记录独立。
+CREATE TABLE `zh_project_screen_tender` (
+  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `pid` int(11) NOT NULL COMMENT '总项目 ID',
+  `spid` varchar(36) NOT NULL COMMENT '具体项目 ID',
+  `tid` int(11) NOT NULL COMMENT '不展示的标段 ID',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_project_screen_tender_pid_tid` (`pid`, `tid`),
+  KEY `idx_project_screen_tender_pid_spid` (`pid`, `spid`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目大屏排除标段';
+
 ------------------------------------
 -- 表数据
 ------------------------------------