Просмотр исходного кода

项目大屏功能静态页部分功能

ellisran 1 день назад
Родитель
Сommit
32c3badfd8

+ 1 - 0
app/base/base_controller.js

@@ -110,6 +110,7 @@ class BaseController extends Controller {
             }
         } else {
             menuList.management.display = ctx.session && ctx.session.sessionProject ? ctx.session.sessionProject.page_show.openManagement : false;
+            menuList.projectscreen.display = !!ctx.showProjectScreen;
         }
         ctx.menuList = menuList;
     }

+ 18 - 1
app/const/account_permission.js

@@ -73,6 +73,14 @@ const permission = {
             { title: '查阅所有施工标段', value: 1 },
         ],
     },
+    projectscreen: {
+        class: 'fa fa-television',
+        title: '项目大屏',
+        type: 'checkbox',
+        children: [
+            { title: '查看', value: 1 },
+        ],
+    },
     // cooperation: {
     //     class: '',
     //     title: '协作办公',
@@ -100,6 +108,12 @@ const PermissionCheck = {
         switch (key) {
             case 'viewPmData': return permission && permission.tender ? permission.tender.indexOf('6') >= 0 : false;
             case 'delFile': return permission && permission.tender ? permission.tender.indexOf('7') >= 0 : false;
+            case 'projectScreen': {
+                if (!permission || !permission.projectscreen) return false;
+                const projectScreenPermission = permission.projectscreen instanceof Array
+                    ? permission.projectscreen : [permission.projectscreen];
+                return projectScreenPermission.map(String).indexOf('1') >= 0;
+            }
             default: return false;
         }
     },
@@ -108,7 +122,10 @@ const PermissionCheck = {
     },
     delFile: function(permission) {
         return this.check(permission, 'delFile');
-    }
+    },
+    projectScreen: function(permission) {
+        return this.check(permission, 'projectScreen');
+    },
 };
 // 用户重新发送权限默认值
 const noticeAgain = {

+ 1 - 0
app/const/page_show.js

@@ -56,6 +56,7 @@ const defaultSetting = {
     maxMultiStageCount: 5,
     openStageStart: 0,
     openDataCollect: 1,
+    openProjectScreen: 0,
     openFile: 1,
     openBudget: 1,
     openPayment: 1,

+ 33 - 0
app/const/project_screen.js

@@ -0,0 +1,33 @@
+'use strict';
+
+/**
+ * 项目大屏模板注册表。
+ * 数据库只保存 code,页面及数据提供器必须在代码中注册,避免动态执行数据库内容。
+ */
+const templates = [
+    {
+        code: 'general1',
+        name: '通用大屏一',
+        description: '通用项目综合展示模板',
+        previewClass: 'project-screen-preview-general1',
+    },
+    {
+        code: 'general3',
+        name: '通用大屏三',
+        description: '通用项目图表展示模板',
+        previewClass: 'project-screen-preview-general3',
+    },
+    {
+        code: 'guangyuan',
+        name: '广元定制',
+        description: '广元项目定制展示模板',
+        previewClass: 'project-screen-preview-guangyuan',
+    },
+];
+
+const getTemplate = code => templates.find(item => item.code === code) || null;
+
+module.exports = {
+    templates,
+    getTemplate,
+};

+ 41 - 0
app/controller/project_screen_controller.js

@@ -0,0 +1,41 @@
+'use strict';
+
+const projectScreenConst = require('../const/project_screen');
+
+module.exports = app => {
+    class ProjectScreenController extends app.BaseController {
+        constructor(ctx) {
+            super(ctx);
+            ctx.showProject = true;
+            ctx.showTitle = true;
+        }
+
+        async index(ctx) {
+            try {
+                if (!ctx.showProjectScreen) throw '项目大屏未开启或没有访问权限';
+                const pid = ctx.session.sessionProject.id;
+                const screens = await ctx.service.projectScreen.getList(pid);
+                const screen = await ctx.service.projectScreen.getViewScreen(pid, ctx.params.sid || '');
+                if (!screen) throw '项目尚未添加大屏';
+                const template = projectScreenConst.getTemplate(screen.template_code);
+                if (!template) throw '大屏模板不存在';
+                const screenList = screens.map(item => {
+                    return Object.assign(item, {
+                        template: projectScreenConst.getTemplate(item.template_code),
+                    });
+                });
+                await this.layout('project_screen/index.ejs', {
+                    screens: screenList,
+                    screen,
+                    template,
+                });
+            } catch (error) {
+                ctx.log(error);
+                ctx.session.postError = error.toString();
+                ctx.redirect('/dashboard');
+            }
+        }
+    }
+
+    return ProjectScreenController;
+};

+ 79 - 0
app/controller/setting_controller.js

@@ -26,6 +26,7 @@ const funSet = require('../const/fun_set');
 const projectSettingConst = require('../const/project_setting');
 const SpreadConst = require('../const/spread');
 const shenpiConst = require('../const/shenpi');
+const projectScreenConst = require('../const/project_screen');
 
 module.exports = app => {
 
@@ -48,6 +49,84 @@ module.exports = app => {
             ctx.subMenu.s2b.display = !!ctx.session.sessionProject.gxby || !!ctx.session.sessionProject.dagl;
         }
 
+        _checkProjectScreenAdmin(ctx) {
+            if (!ctx.session.sessionUser.is_admin) throw '没有访问权限';
+            if (!ctx.session.sessionProject || !ctx.session.sessionProject.id) throw '项目不存在';
+        }
+
+        async module(ctx) {
+            try {
+                this._checkProjectScreenAdmin(ctx);
+                const projectId = ctx.session.sessionProject.id;
+                await this._checkMenu(projectId);
+                const screens = await ctx.service.projectScreen.getList(projectId);
+                await this.layout('setting/module.ejs', {
+                    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');
+            } catch (error) {
+                ctx.log(error);
+                ctx.redirect('/dashboard');
+            }
+        }
+
+        async projectScreenSave(ctx) {
+            let errorMessage = '项目大屏操作失败';
+            try {
+                this._checkProjectScreenAdmin(ctx);
+                const data = JSON.parse(ctx.request.body.data);
+                const projectId = ctx.session.sessionProject.id;
+                const responseData = { err: 0, msg: '', data: null };
+                switch (data.type) {
+                    case 'status': {
+                        errorMessage = '保存项目大屏开关失败';
+                        const enabled = parseInt(data.enabled) === 1 ? 1 : 0;
+                        ctx.session.sessionProject.page_show.openProjectScreen = enabled;
+                        await ctx.service.project.updatePageshow(projectId);
+                        responseData.data = { enabled };
+                        break;
+                    }
+                    case 'add': {
+                        errorMessage = '添加大屏失败';
+                        const screens = await ctx.service.projectScreen.addScreens(projectId, data.codes);
+                        responseData.data = { screens };
+                        break;
+                    }
+                    case 'delete': {
+                        errorMessage = '删除大屏失败';
+                        const screens = await ctx.service.projectScreen.deleteScreen(projectId, data.id);
+                        responseData.data = { screens };
+                        break;
+                    }
+                    case 'default': {
+                        errorMessage = '设置默认大屏失败';
+                        const screens = await ctx.service.projectScreen.setDefault(projectId, data.id);
+                        responseData.data = { screens };
+                        break;
+                    }
+                    case 'permissionload':
+                        errorMessage = '加载项目成员失败';
+                        responseData.data = await ctx.service.projectAccount.getProjectScreenPermissionAccounts(
+                            projectId,
+                            data
+                        );
+                        break;
+                    case 'permissionsave':
+                        errorMessage = '保存项目大屏权限失败';
+                        await ctx.service.projectAccount.saveProjectScreenPermissions(projectId, data.changes);
+                        break;
+                    default:
+                        throw '提交数据错误';
+                }
+                ctx.body = responseData;
+            } catch (error) {
+                ctx.log(error);
+                ctx.ajaxErrorBody(error, errorMessage);
+            }
+        }
+
         /**
          * 项目信息页面(Get)
          *

+ 8 - 0
app/middleware/session_auth.js

@@ -3,6 +3,7 @@
 // 加密类
 const crypto = require('crypto');
 const messageType = require('../const/message_type');
+const accountPermission = require('../const/account_permission');
 
 module.exports = options => {
     /**
@@ -71,6 +72,13 @@ module.exports = options => {
             this.session.sessionProject.custom = projectData.custom;
             this.session.sessionProject.customType = projectData.customType;
             this.session.sessionProject.funSet = projectData.fun_set ? JSON.parse(projectData.fun_set) : null;
+            this.showProjectScreen = false;
+            if (this.session.sessionProject.page_show.openProjectScreen) {
+                const hasProjectScreen = yield this.service.projectScreen.hasScreen(projectData.id);
+                const canViewProjectScreen = this.session.sessionUser.is_admin ||
+                    accountPermission.PermissionCheck.projectScreen(this.session.sessionUser.permission);
+                this.showProjectScreen = hasProjectScreen && canViewProjectScreen;
+            }
 
             // 同步消息
             yield this.service.notify.syncNotifyData();

+ 59 - 0
app/public/css/main.css

@@ -2504,3 +2504,62 @@ animation:shake 1s .2s ease both;}
 .attribute-fields-group .form-group {
     margin-bottom: .75rem;
 }
+
+/* 项目大屏与模块管理 */
+.project-screen-page { overflow: hidden; background: #fff; }
+.project-screen-manage-body { min-height: 0; height: calc(100vh - 142px); max-height: calc(100% - 58px); flex: 0 0 auto !important; }
+.project-screen-panel-card { min-height: 0; }
+.project-screen-card-title { height: 53px; background: #fff; }
+.project-screen-module-title { font-size: 14px; font-weight: 600; }
+.project-screen-detail-title { font-size: 14px; font-weight: 600; }
+.project-screen-panel-card > .card-header { flex-shrink: 0; }
+.project-screen-panel-card > .card-body { min-height: 0; }
+.project-screen-module-tabs .nav-link { height: 100%; display: flex; align-items: center; padding: 0 18px; border: 0; border-bottom: 2px solid transparent; color: #6c757d; background: transparent; font-size: 14px; }
+.project-screen-module-tabs .nav-link:hover { color: #007bff; border-bottom-color: #b8d4ff; }
+.project-screen-module-tabs .nav-link.active { color: #007bff; border-bottom-color: #007bff; background: transparent; font-weight: 600; }
+.project-screen-module-item { width: auto; margin: 4px 10px; border: 1px solid transparent !important; border-radius: 4px !important; font-size: 14px; }
+.project-screen-module-item > span:first-child { font-weight: 600; }
+.project-screen-module-item > span:last-child { font-size: 12px; font-weight: 400; }
+.project-screen-module-item.active { color: #007bff; border-color: #cfe0ff !important; background: #eaf2ff; }
+.project-screen-setting-content { min-height: 0; overflow: hidden; }
+.project-screen-config-columns { position: relative; }
+.project-screen-config-columns:after { content: ""; position: absolute; top: 20px; bottom: 20px; left: 25%; width: 1px; background: #e6e9f0; pointer-events: none; }
+.project-screen-list-item { width: auto; margin: 4px 10px; border: 1px solid transparent !important; border-radius: 4px !important; }
+.project-screen-list-item.active { color: #212529; border-color: #cfe0ff !important; background: #eaf2ff; }
+.project-screen-list-item.active > span:first-child { font-weight: 600; }
+.project-screen-search { width: 280px; }
+.project-screen-sticky-head { position: sticky; top: 0; z-index: 10; background: #fff; }
+
+.project-screen-preview-stage { width: calc(100% - 32px); max-height: calc(100% - 32px); aspect-ratio: 1.05 / 1; margin: 16px; border-radius: 4px; background: #dfe2ec; }
+.project-screen-preview { width: 86%; max-width: 650px; min-width: 360px; height: auto; aspect-ratio: 16 / 9; display: grid; grid-template-columns: 22% 1fr; overflow: hidden; border: 6px solid #f1f2f7; background: #f8f9fa; }
+.project-screen-preview-nav { padding: 16px 12px; background: #4d7881; }
+.project-screen-preview-nav span { display: block; width: 30px; height: 30px; margin-bottom: 24px; border-radius: 4px; background: #6f8cff; }
+.project-screen-preview-nav i { display: block; width: 78%; height: 8px; margin: 14px 0; border-radius: 10px; background: rgba(231,244,247,.7); }
+.project-screen-preview-content { display: grid; grid-template-rows: 38px 1fr; gap: 12px; padding: 14px; }
+.project-screen-preview-content header, .project-screen-preview-content aside, .project-screen-preview-content main { border: 1px solid #dee2e6; background: #fff; }
+.project-screen-preview-content section { display: grid; grid-template-columns: 34% 1fr; gap: 12px; }
+.project-screen-preview-content main { position: relative; overflow: hidden; background: #edf1ff; }
+.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-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-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; }
+.project-screen-view-head strong, .project-screen-view-head span { display: block; }
+.project-screen-view-head span { margin-top: 3px; color: #83a4d7; font-size: 12px; }
+.project-screen-view-tabs { display: flex; gap: 8px; }
+.project-screen-view-tabs a { padding: 7px 12px; border: 1px solid #244b78; border-radius: 5px; color: #8fb4e6; }
+.project-screen-view-tabs a.active { color: #fff; border-color: #3c88e8; background: #153c6b; }
+.project-screen-live { height: calc(100% - 55px); padding: 16px; border: 1px solid #1d4f83; background: linear-gradient(135deg,#081a36,#0c2a4d); }
+.project-screen-live-title { margin-bottom: 15px; text-align: center; font-size: 22px; letter-spacing: 5px; }
+.project-screen-live-grid { height: calc(100% - 48px); display: grid; grid-template-columns: repeat(2,1fr); gap: 14px; }
+.project-screen-live-grid section { padding: 14px; border: 1px solid #1b568f; background: rgba(8,33,65,.76); }
+.project-screen-live-grid h6 { color: #78bafa; }
+.project-screen-live-grid section div { height: calc(100% - 25px); background: linear-gradient(180deg,rgba(30,99,156,.22),rgba(9,42,77,.06)); }

Разница между файлами не показана из-за своего большого размера
+ 61 - 0
app/public/css/project_screen_guangyuan.css


+ 78 - 0
app/public/css/project_screen_setting.css

@@ -0,0 +1,78 @@
+.project-screen-setting { padding: 20px; background: #f5f7fb; }
+.project-screen-title-row { display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; }
+.project-screen-title-row h5 { margin:0 0 4px; font-weight:600; }
+.project-screen-title-row > div > span { color:#8993a5; font-size:12px; }
+.project-screen-switch-box { display:flex; align-items:center; gap:14px; margin:0; padding:9px 12px; border:1px solid #e1e5ee; border-radius:8px; background:#fff; font-size:13px; }
+.project-screen-switch-box .custom-control { min-height:20px; }
+.project-screen-panel { display:grid; grid-template-columns:220px 1fr; height:calc(100vh - 185px); min-height:540px; overflow:hidden; border:1px solid #e1e5ee; border-radius:10px; background:#fff; }
+.project-screen-module-list { padding:15px 12px; border-right:1px solid #e6e9f0; }
+.project-screen-module-list h6 { margin:0 8px 14px; font-weight:600; }
+.project-screen-module-active { display:flex; align-items:center; gap:10px; padding:13px 12px; border-radius:7px; color:#3870f6; background:#edf3ff; font-size:13px; font-weight:600; }
+.project-screen-workarea { min-width:0; }
+.project-screen-workhead { height:62px; display:flex; align-items:center; gap:24px; padding:0 18px; border-bottom:1px solid #e6e9f0; }
+.project-screen-worktitle { margin-right:auto; }
+.project-screen-worktitle strong, .project-screen-worktitle small { display:block; }
+.project-screen-worktitle small { margin-top:3px; color:#8a94a6; }
+.project-screen-tab { align-self:stretch; padding:0 7px; border:0; border-bottom:2px solid transparent; color:#7d8798; background:transparent; cursor:pointer; }
+.project-screen-tab.active { color:#3870f6; border-bottom-color:#4f7cff; }
+.project-screen-config { display:grid; grid-template-columns:280px 1fr; height:calc(100% - 62px); }
+.project-screen-list-panel { padding:14px; border-right:1px solid #e6e9f0; overflow:auto; }
+.project-screen-list-tools { display:flex; justify-content:space-between; align-items:center; margin-bottom:10px; color:#8b95a6; font-size:12px; }
+.project-screen-list-item { display:grid; grid-template-columns:1fr auto; align-items:center; min-height:48px; padding:8px 10px; margin-bottom:5px; border:1px solid transparent; border-radius:7px; cursor:pointer; font-size:13px; }
+.project-screen-list-item:hover { background:#f7f9fc; }
+.project-screen-list-item.active { border-color:#cddaff; background:#edf3ff; }
+.project-screen-default-mark { margin-left:7px; color:#3970f6; font-size:11px; }
+.project-screen-list-delete { opacity:0; border:0; color:#dc5965; background:transparent; cursor:pointer; font-size:12px; }
+.project-screen-list-item:hover .project-screen-list-delete, .project-screen-list-item.active .project-screen-list-delete { opacity:1; }
+.project-screen-preview-panel { padding:16px; min-width:0; }
+.project-screen-preview-head { display:flex; justify-content:space-between; align-items:center; min-height:32px; margin-bottom:10px; }
+.project-screen-preview-wrap { height:calc(100% - 42px); min-height:360px; display:grid; place-items:center; padding:28px; border-radius:8px; background:#e7e9f1; }
+.project-screen-preview { width:min(100%, 650px); aspect-ratio:16/9; display:grid; grid-template-columns:22% 1fr; overflow:hidden; border:8px solid #d6d9e4; border-radius:5px; background:#f7f8fd; }
+.project-screen-preview-nav { padding:12px 10px; background:#4d7881; }
+.project-screen-preview-nav span { display:block; width:28px; height:28px; margin-bottom:22px; border-radius:6px; background:#6f8cff; }
+.project-screen-preview-nav i { display:block; width:78%; height:8px; margin:14px 0; border-radius:20px; background:rgba(231,244,247,.7); }
+.project-screen-preview-content { display:grid; grid-template-rows:32px 1fr; gap:12px; padding:13px; }
+.project-screen-preview-content header, .project-screen-preview-content aside, .project-screen-preview-content main { border-radius:5px; background:#fff; }
+.project-screen-preview-content section { display:grid; grid-template-columns:34% 1fr; gap:12px; }
+.project-screen-preview-content main { position:relative; overflow:hidden; background:#edf1ff; }
+.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:90px; height:90px; right:36px; top:25px; }
+.project-screen-preview-content main:after { width:170px; height:170px; right:62px; bottom:-90px; }
+.project-screen-preview-general3 .project-screen-preview-content main { background:#eef2ff; }
+.project-screen-preview-guangyuan .project-screen-preview-nav { background:#295f70; }
+.project-screen-empty { display:none; color:#8a94a6; }
+.project-screen-template-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:14px; }
+.project-screen-template-item { position:relative; padding:10px; border:1px solid #dfe3eb; border-radius:8px; cursor:pointer; }
+.project-screen-template-item:hover { border-color:#91aaf8; }
+.project-screen-template-item.selected { padding:9px; border:2px solid #4f7cff; background:#f6f8ff; }
+.project-screen-template-item.added { opacity:.5; cursor:not-allowed; }
+.project-screen-template-check { position:absolute; top:17px; right:17px; width:20px; height:20px; display:grid; place-items:center; border:1px solid #c9d0dd; border-radius:4px; color:transparent; background:#fff; z-index:2; }
+.project-screen-template-item.selected .project-screen-template-check { color:#fff; border-color:#4f7cff; background:#4f7cff; }
+.project-screen-template-preview { height:135px; display:grid; place-items:center; border-radius:6px; background:#e8ebf3; }
+.project-screen-template-preview > div { width:82%; aspect-ratio:16/9; display:grid; grid-template-columns:22% 1fr; border:4px solid #d5d9e5; background:#4d7881; }
+.project-screen-template-preview > div:after { content:""; margin:7px; background:#f4f6fc; }
+.project-screen-template-name { display:flex; justify-content:space-between; margin-top:9px; font-size:13px; }
+.project-screen-template-name span { color:#8a94a6; font-size:11px; }
+.project-screen-permission { height:calc(100% - 62px); padding:16px; }
+.project-screen-permission-tools { display:flex; align-items:center; margin-bottom:10px; font-size:12px; }
+.project-screen-permission-tools > span { margin-right:auto; color:#7d8798; }
+.project-screen-search { width:280px; }
+.project-screen-permission-table { height:calc(100% - 82px); overflow:auto; border:1px solid #e0e4ec; }
+.project-screen-permission-table thead th { position:sticky; top:0; z-index:2; text-align:center; background:#f1f3f9; }
+.project-screen-permission-table tbody td { text-align:center; vertical-align:middle; }
+.project-screen-permission-empty { display:none; padding:45px; text-align:center; color:#8a94a6; }
+.project-screen-page { height:42px; display:flex; justify-content:center; align-items:end; gap:12px; font-size:12px; }
+.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; }
+.project-screen-view-head strong, .project-screen-view-head span { display:block; }
+.project-screen-view-head span { margin-top:3px; color:#83a4d7; font-size:12px; }
+.project-screen-view-tabs { display:flex; gap:8px; }
+.project-screen-view-tabs a { padding:7px 12px; border:1px solid #244b78; border-radius:5px; color:#8fb4e6; }
+.project-screen-view-tabs a.active { color:#fff; border-color:#3c88e8; background:#153c6b; }
+.project-screen-live { height:calc(100% - 55px); padding:16px; border:1px solid #1d4f83; background:linear-gradient(135deg,#081a36,#0c2a4d); }
+.project-screen-live-title { margin-bottom:15px; text-align:center; font-size:22px; letter-spacing:5px; }
+.project-screen-live-grid { height:calc(100% - 48px); display:grid; grid-template-columns:repeat(2,1fr); gap:14px; }
+.project-screen-live-grid section { padding:14px; border:1px solid #1b568f; background:rgba(8,33,65,.76); }
+.project-screen-live-grid h6 { color:#78bafa; }
+.project-screen-live-grid section div { height:calc(100% - 25px); background:linear-gradient(180deg,rgba(30,99,156,.22),rgba(9,42,77,.06)); }
+@media(max-width:900px){.project-screen-panel{grid-template-columns:160px 1fr}.project-screen-config{grid-template-columns:220px 1fr}.project-screen-template-grid{grid-template-columns:1fr}.project-screen-search{width:210px}}

Разница между файлами не показана из-за своего большого размера
+ 64 - 0
app/public/js/project_screen_guangyuan.js


Разница между файлами не показана из-за своего большого размера
+ 69 - 0
app/public/js/project_screen_guangyuan_building.js


Разница между файлами не показана из-за своего большого размера
+ 67 - 0
app/public/js/project_screen_guangyuan_finished.js


Разница между файлами не показана из-за своего большого размера
+ 108 - 0
app/public/js/project_screen_guangyuan_planned.js


+ 275 - 0
app/public/js/project_screen_setting.js

@@ -0,0 +1,275 @@
+/* global $, postData, toastr, window */
+'use strict';
+
+$(function() {
+    const pageData = window.projectScreenPageData || { screens: [], templates: [] };
+    let screens = pageData.screens || [];
+    const templates = pageData.templates || [];
+    let selectedId = screens.length ? String((screens.find(x => parseInt(x.is_default) === 1) || screens[0]).id) : '';
+    let selectedCodes = [];
+    let permissionPage = 1;
+    const permissionPageSize = 50;
+    let permissionChanges = {};
+    let permissionOriginal = {};
+
+    const escapeHtml = function(value) {
+        return $('<div>').text(value === undefined || value === null ? '' : value).html();
+    };
+
+    const selectedScreen = function() {
+        return screens.find(x => String(x.id) === selectedId);
+    };
+
+    const postProjectScreen = function(type, data, successCallback, errorCallback) {
+        postData(
+            '/setting/module/projectscreen',
+            $.extend({ type }, data),
+            successCallback,
+            errorCallback
+        );
+    };
+
+    const renderScreens = function() {
+        if (selectedId && !screens.some(x => String(x.id) === selectedId)) {
+            selectedId = screens.length ? String(screens[0].id) : '';
+        }
+        if (!selectedId && screens.length) {
+            selectedId = String((screens.find(x => parseInt(x.is_default) === 1) || screens[0]).id);
+        }
+        const html = [];
+        screens.forEach(screen => {
+            const active = String(screen.id) === selectedId ? ' active' : '';
+            html.push(`<div class="list-group-item list-group-item-action d-flex align-items-center project-screen-list-item${active}" data-id="${screen.id}">`);
+            html.push(`<span>${escapeHtml(screen.name)}</span>`);
+            if (parseInt(screen.is_default) === 1) html.push('<span class="ml-auto text-primary">默认</span>');
+            html.push('</div>');
+        });
+        $('#project-screen-list').html(html.join(''));
+
+        const screen = selectedScreen();
+        if (!screen) {
+            $('#project-screen-selected-name').text('大屏预览');
+            $('#project-screen-default, #project-screen-delete, #project-screen-preview').hide();
+            $('#project-screen-empty').show();
+            return;
+        }
+        $('#project-screen-selected-name').text(screen.name);
+        $('#project-screen-delete, #project-screen-preview').show();
+        $('#project-screen-empty').hide();
+        $('#project-screen-default').toggle(parseInt(screen.is_default) !== 1);
+        const previewClass = screen.template && screen.template.previewClass ? screen.template.previewClass : '';
+        const preview = $('#project-screen-preview');
+        preview.removeClass(function(index, className) {
+            return (className.match(/project-screen-preview-\S+/g) || []).join(' ');
+        });
+        if (previewClass) preview.addClass(previewClass);
+    };
+
+    const renderTemplates = function() {
+        const html = [];
+        templates.forEach(template => {
+            const added = screens.some(screen => screen.template_code === template.code);
+            const selected = selectedCodes.indexOf(template.code) >= 0;
+            const cardClass = selected ? ' border-primary' : '';
+            const statusClass = selected ? 'badge-primary' : 'badge-secondary';
+            const statusText = added ? '已添加' : (selected ? '已选择' : '可选择');
+            html.push('<div class="col-6 mb-3">');
+            html.push(`<div class="card h-100 project-screen-template-item${added ? ' added' : ''}${cardClass}" data-code="${escapeHtml(template.code)}">`);
+            html.push(`<div class="project-screen-template-preview ${escapeHtml(template.previewClass)}"><div></div></div>`);
+            html.push('<div class="card-body py-2 d-flex align-items-center">');
+            html.push(`<strong>${escapeHtml(template.name)}</strong><span class="badge ${statusClass} ml-auto">${statusText}</span>`);
+            html.push('</div></div></div>');
+        });
+        $('#project-screen-template-grid').html(html.join(''));
+        $('#project-screen-selected-count').text(`已选择 ${selectedCodes.length} 个`);
+        $('#project-screen-add').text(selectedCodes.length ? `添加(${selectedCodes.length})` : '添加');
+    };
+
+    const openDeleteModal = function(id) {
+        selectedId = id || selectedId;
+        renderScreens();
+        const screen = selectedScreen();
+        if (!screen) return;
+        const remaining = screens.filter(x => x.id !== screen.id);
+        let hint = '删除后该大屏配置会被移除,已有业务数据不会受到影响。';
+        if (parseInt(screen.is_default) === 1 && remaining.length) {
+            hint += ` “${remaining[0].name}”将自动成为默认大屏。`;
+        } else if (parseInt(screen.is_default) === 1) {
+            hint += ' 这是最后一个大屏,删除后左侧项目大屏入口将隐藏。';
+        }
+        $('#project-screen-delete-name').text(screen.name);
+        $('#project-screen-delete-hint').text(hint);
+        $('#project-screen-delete-modal').modal('show');
+    };
+
+    $('#project-screen-status').change(function() {
+        const input = this;
+        const enabled = input.checked ? 1 : 0;
+        input.disabled = true;
+        postProjectScreen('status', { enabled }, function() {
+            $('#project-screen-module-status')
+                .toggleClass('text-success', enabled === 1)
+                .text(enabled ? '已开启' : '');
+            input.disabled = false;
+        }, function() {
+            input.checked = !input.checked;
+            input.disabled = false;
+        });
+    });
+
+    $('.project-screen-tab').click(function(event) {
+        event.preventDefault();
+        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-select').click(function() {
+        selectedCodes = [];
+        renderTemplates();
+        $('#project-screen-select-modal').modal('show');
+    });
+
+    $('#project-screen-template-grid').on('click', '.project-screen-template-item:not(.added)', function() {
+        const code = $(this).attr('data-code');
+        const index = selectedCodes.indexOf(code);
+        if (index >= 0) selectedCodes.splice(index, 1);
+        else selectedCodes.push(code);
+        renderTemplates();
+    });
+
+    $('#project-screen-add').click(function() {
+        if (!selectedCodes.length) {
+            toastr.warning('请至少选择一个大屏');
+            return;
+        }
+        postProjectScreen('add', { codes: selectedCodes }, function(data) {
+            screens = data.screens;
+            const firstAdded = screens.find(x => selectedCodes[0] === x.template_code);
+            selectedId = firstAdded ? String(firstAdded.id) : (screens.length ? String(screens[0].id) : '');
+            renderScreens();
+            $('#project-screen-select-modal').modal('hide');
+            toastr.success(`已添加 ${selectedCodes.length} 个大屏`);
+        });
+    });
+
+    $('#project-screen-list').on('click', '.project-screen-list-item', function() {
+        selectedId = $(this).attr('data-id');
+        renderScreens();
+    });
+
+    $('#project-screen-delete').click(function() { openDeleteModal(selectedId); });
+
+    $('#project-screen-delete-confirm').click(function() {
+        const screen = selectedScreen();
+        if (!screen) return;
+        postProjectScreen('delete', { id: screen.id }, function(data) {
+            screens = data.screens;
+            selectedId = screens.length ? String(screens[0].id) : '';
+            renderScreens();
+            $('#project-screen-delete-modal').modal('hide');
+            toastr.success('大屏删除成功');
+        });
+    });
+
+    $('#project-screen-default').click(function() {
+        const screen = selectedScreen();
+        if (!screen) return;
+        postProjectScreen('default', { id: screen.id }, function(data) {
+            screens = data.screens;
+            renderScreens();
+            toastr.success('默认大屏设置成功');
+        });
+    });
+
+    const renderPermissionRows = function(result) {
+        const html = [];
+        permissionOriginal = {};
+        result.list.forEach(account => {
+            const disabled = account.is_admin ? 'disabled' : '';
+            const authorized = !!(account.is_admin || account.authorized);
+            const checked = authorized ? 'checked' : '';
+            const adminIcon = account.is_admin ? ' <span class="ml-1" title="管理员"><i class="fa fa-user-circle-o"></i></span>' : '';
+            permissionOriginal[account.id] = authorized;
+            html.push(`<tr data-id="${account.id}"><td>${escapeHtml(account.account)}${adminIcon}</td><td>${escapeHtml(account.name)}</td><td>${escapeHtml(account.company)}</td><td>${escapeHtml(account.role)}</td>`);
+            html.push(`<td><input type="checkbox" class="project-screen-permission-check" ${checked} ${disabled}></td></tr>`);
+        });
+        $('#project-screen-permission-body').html(html.join(''));
+        $('#project-screen-permission-empty').toggle(result.list.length === 0);
+        $('#project-screen-permission-count').text(`已授权 ${result.authorizedTotal} / 共 ${result.allTotal} 人`);
+        const totalPage = Math.max(Math.ceil(result.total / result.pageSize), 1);
+        $('#project-screen-page-info').text(`${result.page} / ${totalPage}`);
+        $('#project-screen-prev').prop('disabled', result.page <= 1);
+        $('#project-screen-next').prop('disabled', result.page >= totalPage);
+        $('#project-screen-permission-all').prop('checked', false);
+    };
+
+    const updatePermissionSaveButton = function() {
+        $('#project-screen-permission-save').toggle(Object.keys(permissionChanges).length > 0);
+    };
+
+    const setPermissionChange = function(id, authorized) {
+        if (permissionOriginal[id] === authorized) delete permissionChanges[id];
+        else permissionChanges[id] = { id, authorized };
+    };
+
+    function loadPermissions(page) {
+        permissionPage = Math.max(page || 1, 1);
+        postProjectScreen('permissionload', {
+            page: permissionPage,
+            pageSize: permissionPageSize,
+            keyword: $('#project-screen-keyword').val(),
+            authorized: $('#project-screen-authorized').prop('checked') ? 1 : 0,
+        }, function(result) {
+            permissionPage = result.page;
+            permissionChanges = {};
+            updatePermissionSaveButton();
+            renderPermissionRows(result);
+        });
+    }
+
+    $('#project-screen-permission-body').on('change', '.project-screen-permission-check', function() {
+        const id = parseInt($(this).closest('tr').attr('data-id'));
+        setPermissionChange(id, this.checked);
+        updatePermissionSaveButton();
+    });
+
+    $('#project-screen-permission-all').change(function() {
+        const checked = this.checked;
+        $('#project-screen-permission-body .project-screen-permission-check:not(:disabled)').each(function() {
+            this.checked = checked;
+            const id = parseInt($(this).closest('tr').attr('data-id'));
+            setPermissionChange(id, checked);
+        });
+        updatePermissionSaveButton();
+    });
+
+    $('#project-screen-permission-save').click(function() {
+        const changes = Object.values(permissionChanges);
+        if (!changes.length) return;
+        postProjectScreen('permissionsave', { changes }, function() {
+            permissionChanges = {};
+            updatePermissionSaveButton();
+            toastr.success('项目大屏权限保存成功');
+            loadPermissions(permissionPage);
+        });
+    });
+
+    $('#project-screen-search').click(function() { loadPermissions(1); });
+    $('#project-screen-keyword').keydown(function(event) {
+        if (event.keyCode === 13) loadPermissions(1);
+    });
+    $('#project-screen-authorized').change(function() { loadPermissions(1); });
+    $('#project-screen-prev').click(function() { loadPermissions(permissionPage - 1); });
+    $('#project-screen-next').click(function() { loadPermissions(permissionPage + 1); });
+
+    renderScreens();
+});

+ 1 - 1
app/public/js/shares/drag_tree.js

@@ -453,4 +453,4 @@ const createDragTree = function (setting) {
         }
     }
     return new DragTree(setting);
-};
+};

+ 1 - 1
app/public/js/sub_project.js

@@ -459,4 +459,4 @@ $(document).ready(function() {
             $('#sm-management').attr('tree_id', '');
         });
     });
-});
+});

+ 6 - 0
app/router.js

@@ -205,8 +205,14 @@ module.exports = app => {
     app.post('/setting/spread/save', sessionAuth, projectManagerCheck, 'settingController.saveSpread');
     app.post('/setting/spread/preview', sessionAuth, projectManagerCheck, 'settingController.previewSpread');
     app.post('/setting/spread/reset', sessionAuth, projectManagerCheck, 'settingController.resetSpread');
+    // 模块管理-项目大屏
+    app.get('/setting/module', sessionAuth, 'settingController.module');
+    app.post('/setting/module/projectscreen', sessionAuth, 'settingController.projectScreenSave');
     // ------------------------- 项目相关 -----------------------------
 
+    app.get('/projectscreen', sessionAuth, 'projectScreenController.index');
+    app.get('/projectscreen/:sid', sessionAuth, 'projectScreenController.index');
+
 
     // ------------------------- 项目内部相关 -----------------------------
     // **项目设置

+ 113 - 0
app/service/project_account.js

@@ -1303,6 +1303,119 @@ module.exports = app => {
             }
             return info.sign_path ? info.sign_path.split('&%&') : []; // 多个签章用&%&分隔
         }
+
+        /**
+         * 解析项目大屏权限
+         *
+         * @param {String|Object} permission - 账号权限
+         * @return {Object} 权限对象
+         */
+        parseProjectScreenPermission(permission) {
+            if (!permission) return {};
+            try {
+                const result = typeof permission === 'string' ? JSON.parse(permission) : permission;
+                return result && typeof result === 'object' && !(result instanceof Array) ? result : {};
+            } catch (error) {
+                return {};
+            }
+        }
+
+        /**
+         * 获取项目大屏权限账号
+         *
+         * @param {Number} pid - 项目id
+         * @param {Object} options - 查询条件
+         * @return {Object} 账号列表及分页信息
+         */
+        async getProjectScreenPermissionAccounts(pid, options = {}) {
+            const keyword = (options.keyword || '').trim();
+            const authorized = parseInt(options.authorized) === 1;
+            const page = Math.max(parseInt(options.page) || 1, 1);
+            const pageSize = Math.min(Math.max(parseInt(options.pageSize) || 50, 10), 100);
+            const whereSql = ['project_id = ?', 'enable = 1'];
+            const whereSqlParam = [pid];
+            if (keyword) {
+                const keywordValue = '%' + keyword + '%';
+                whereSql.push('(account LIKE ? OR name LIKE ? OR company LIKE ? OR role LIKE ? OR mobile LIKE ?)');
+                whereSqlParam.push(keywordValue, keywordValue, keywordValue, keywordValue, keywordValue);
+            }
+            const permissionWhereSql = `(is_admin = 1
+                OR permission LIKE '%"projectscreen":["1"]%'
+                OR permission LIKE '%"projectscreen":[1]%'
+                OR permission LIKE '%"projectscreen":"1"%'
+                OR permission LIKE '%"projectscreen":1%')`;
+            const baseWhereSql = whereSql.join(' AND ');
+            const listWhereSql = authorized ? baseWhereSql + ' AND ' + permissionWhereSql : baseWhereSql;
+
+            const totalSql = `SELECT COUNT(*) AS count FROM ?? WHERE ${listWhereSql}`;
+            const totalSqlParam = [this.tableName].concat(whereSqlParam);
+            const totalResult = await this.db.queryOne(totalSql, totalSqlParam);
+
+            const allTotalSql = `SELECT COUNT(*) AS count FROM ?? WHERE ${baseWhereSql}`;
+            const allTotalSqlParam = [this.tableName].concat(whereSqlParam);
+            const allTotalResult = await this.db.queryOne(allTotalSql, allTotalSqlParam);
+
+            const authorizedTotalSql = `SELECT COUNT(*) AS count FROM ?? WHERE ${baseWhereSql} AND ${permissionWhereSql}`;
+            const authorizedTotalSqlParam = [this.tableName].concat(whereSqlParam);
+            const authorizedTotalResult = await this.db.queryOne(authorizedTotalSql, authorizedTotalSqlParam);
+
+            const sql = `SELECT id, account, name, company, role, mobile, is_admin, permission
+                FROM ?? WHERE ${listWhereSql}
+                ORDER BY is_admin DESC, id DESC LIMIT ?, ?`;
+            const sqlParam = [this.tableName].concat(whereSqlParam, [(page - 1) * pageSize, pageSize]);
+            const list = await this.db.query(sql, sqlParam);
+            list.forEach(account => {
+                const accountPermission = this.parseProjectScreenPermission(account.permission);
+                const screenPermission = accountPermission.projectscreen instanceof Array
+                    ? accountPermission.projectscreen : [accountPermission.projectscreen];
+                account.authorized = account.is_admin === 1 || screenPermission.map(String).indexOf('1') >= 0;
+                delete account.permission;
+            });
+            return {
+                list,
+                page,
+                pageSize,
+                total: totalResult ? totalResult.count : 0,
+                allTotal: allTotalResult ? allTotalResult.count : 0,
+                authorizedTotal: authorizedTotalResult ? authorizedTotalResult.count : 0,
+            };
+        }
+
+        async saveProjectScreenPermissions(pid, changes) {
+            if (!(changes instanceof Array) || changes.length === 0) return true;
+            const permissionMap = {};
+            changes.forEach(item => {
+                const id = parseInt(item.id);
+                if (id > 0) permissionMap[id] = !!item.authorized;
+            });
+            const ids = Object.keys(permissionMap).map(id => this._.toInteger(id));
+            if (ids.length === 0) return true;
+
+            const transaction = await this.db.beginTransaction();
+            try {
+                const accounts = await transaction.select(this.tableName, {
+                    where: { id: ids, project_id: pid, enable: 1 },
+                });
+                const updateData = accounts.filter(account => account.is_admin !== 1).map(account => {
+                    const accountPermission = this.parseProjectScreenPermission(account.permission);
+                    if (permissionMap[account.id]) {
+                        accountPermission.projectscreen = ['1'];
+                    } else {
+                        delete accountPermission.projectscreen;
+                    }
+                    return {
+                        id: account.id,
+                        permission: JSON.stringify(accountPermission),
+                    };
+                });
+                if (updateData.length > 0) await transaction.updateRows(this.tableName, updateData);
+                await transaction.commit();
+                return true;
+            } catch (error) {
+                await transaction.rollback();
+                throw error;
+            }
+        }
     }
 
     return ProjectAccount;

+ 134 - 0
app/service/project_screen.js

@@ -0,0 +1,134 @@
+'use strict';
+
+const projectScreenConst = require('../const/project_screen');
+
+module.exports = app => {
+    class ProjectScreen extends app.BaseService {
+        constructor(ctx) {
+            super(ctx);
+            this.tableName = 'project_screen';
+        }
+
+        async getList(pid, transaction = null) {
+            const db = transaction || this.db;
+            const result = await db.select(this.tableName, {
+                where: { pid },
+                orders: [['id', 'asc']],
+            });
+            return result.map(item => {
+                const template = projectScreenConst.getTemplate(item.template_code);
+                return Object.assign(item, {
+                    template: template || {
+                        code: item.template_code,
+                        name: item.name,
+                        description: '',
+                        previewClass: '',
+                    },
+                });
+            });
+        }
+
+        async hasScreen(pid) {
+            return (await this.count({ pid })) > 0;
+        }
+
+        async getViewScreen(pid, id = '') {
+            const screens = await this.getList(pid);
+            if (screens.length === 0) return null;
+            if (id) {
+                const selected = screens.find(item => item.id === parseInt(id));
+                if (selected) return selected;
+            }
+            return screens.find(item => item.is_default === 1) || screens[0];
+        }
+
+        async addScreens(pid, codes) {
+            const selectedCodes = [];
+            for (const code of codes || []) {
+                if (!projectScreenConst.getTemplate(code)) throw '大屏模板不存在';
+                if (selectedCodes.indexOf(code) < 0) selectedCodes.push(code);
+            }
+            if (selectedCodes.length === 0) throw '请至少选择一个大屏';
+
+            const transaction = await this.db.beginTransaction();
+            try {
+                const activeList = await transaction.select(this.tableName, {
+                    where: { pid },
+                    orders: [['id', 'asc']],
+                });
+                const needDefault = activeList.length === 0;
+                const existingCodes = activeList.map(item => item.template_code);
+                const insertCodes = selectedCodes.filter(code => existingCodes.indexOf(code) < 0);
+                const insertData = insertCodes.map((code, index) => {
+                    const template = projectScreenConst.getTemplate(code);
+                    return {
+                        pid,
+                        template_code: code,
+                        name: template.name,
+                        is_default: needDefault && index === 0 ? 1 : 0,
+                    };
+                });
+                if (insertData.length > 0) await transaction.insert(this.tableName, insertData);
+
+                await transaction.commit();
+                return await this.getList(pid);
+            } catch (error) {
+                await transaction.rollback();
+                throw error;
+            }
+        }
+
+        async setDefault(pid, id) {
+            const transaction = await this.db.beginTransaction();
+            try {
+                const target = await transaction.get(this.tableName, {
+                    id, pid,
+                });
+                if (!target) throw '大屏不存在';
+                await transaction.update(this.tableName, { is_default: 0 }, {
+                    where: { pid },
+                });
+                await transaction.update(this.tableName, {
+                    is_default: 1,
+                }, {
+                    where: { id, pid },
+                });
+                await transaction.commit();
+                return await this.getList(pid);
+            } catch (error) {
+                await transaction.rollback();
+                throw error;
+            }
+        }
+
+        async deleteScreen(pid, id) {
+            const transaction = await this.db.beginTransaction();
+            try {
+                const target = await transaction.get(this.tableName, { id, pid });
+                if (!target) throw '大屏不存在或已删除';
+                await transaction.delete(this.tableName, { id, pid });
+                if (target.is_default === 1) {
+                    const remaining = await transaction.select(this.tableName, {
+                        where: { pid },
+                        orders: [['id', 'asc']],
+                    });
+                    if (remaining.length > 0) {
+                        await transaction.update(this.tableName, { is_default: 0 }, {
+                            where: { pid },
+                        });
+                        await transaction.update(this.tableName, { is_default: 1 }, {
+                            where: { id: remaining[0].id, pid },
+                        });
+                    }
+                }
+                await transaction.commit();
+                return await this.getList(pid);
+            } catch (error) {
+                await transaction.rollback();
+                throw error;
+            }
+        }
+    }
+
+    return ProjectScreen;
+};

Разница между файлами не показана из-за своего большого размера
+ 37 - 0
app/view/project_screen/guangyuan.ejs


+ 11 - 0
app/view/project_screen/guangyuan_building.ejs

@@ -0,0 +1,11 @@
+<div id="gy-building-page" class="gy-planned-page" hidden>
+    <section class="gy-card gy-project-list"><div class="gy-title"><h2>项目列表</h2></div><div class="gy-list-heading">项目名称</div><div id="gy-building-tree" class="gy-scroll"></div></section>
+    <div class="gy-planned-detail">
+        <div class="gy-detail-top">
+            <section class="gy-card"><div class="gy-title"><h2>项目进度</h2></div><div id="gy-building-progress" class="gy-detail-text"></div></section>
+            <section class="gy-card"><div class="gy-title"><h2>项目概况</h2></div><div id="gy-building-intro" class="gy-detail-text gy-scroll"></div></section>
+        </div>
+        <section class="gy-card"><div class="gy-title"><h2>全过程造价趋势</h2><span>单位:万元</span></div><div id="gy-building-chart" class="gy-chart"></div></section>
+        <section class="gy-card"><div class="gy-title"><h2>批复变更信息</h2><span>单位:万元</span></div><div class="gy-scroll"><table class="gy-table gy-change-table"><thead><tr><th>标段</th><th>合同金额</th><th>一般设计变更</th><th>较大设计变更</th><th>重大设计变更</th><th>累计变更金额</th><th>变更占合同净价</th></tr></thead><tbody id="gy-building-changes"></tbody></table></div></section>
+    </div>
+</div>

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

@@ -0,0 +1,13 @@
+<div id="gy-finished-page" class="gy-finished-page" hidden>
+    <section class="gy-card"><div class="gy-title"><h2>项目完工投资</h2><span>单位:万元</span></div><div id="gy-finished-investment-chart" class="gy-chart"></div></section>
+    <div class="gy-finished-layout">
+        <section class="gy-card gy-project-list"><div class="gy-title"><h2>项目列表</h2></div><div class="gy-list-heading">项目名称</div><div id="gy-finished-tree" class="gy-scroll"></div></section>
+        <div class="gy-finished-detail">
+            <div class="gy-detail-top">
+                <section class="gy-card"><div class="gy-title"><h2>项目进度</h2></div><div id="gy-finished-progress" class="gy-detail-text"></div></section>
+                <section class="gy-card"><div class="gy-title"><h2>项目概况</h2></div><div id="gy-finished-intro" class="gy-detail-text gy-scroll"></div></section>
+            </div>
+            <section class="gy-card"><div class="gy-title"><h2>全过程造价趋势</h2><span>单位:万元</span></div><div id="gy-finished-cost-chart" class="gy-chart"></div></section>
+        </div>
+    </div>
+</div>

+ 8 - 0
app/view/project_screen/guanyuan_modal.ejs

@@ -0,0 +1,8 @@
+<dialog id="gy-stage-dialog" class="gy-stage-dialog">
+    <div class="gy-title">
+        <h2>阶段进度详情</h2>
+        <button id="gy-close-details" class="gy-detail-button" type="button" aria-label="关闭阶段详情">关闭 ×</button>
+    </div>
+    <p id="gy-dialog-project"></p>
+    <div id="gy-dialog-content" class="gy-scroll"></div>
+</dialog>

+ 19 - 0
app/view/project_screen/index.ejs

@@ -0,0 +1,19 @@
+<% if (template.code === 'guangyuan') { %>
+<% include ./guangyuan.ejs %>
+<% } else { %>
+<div class="panel-content project-screen-view-page">
+    <div class="project-screen-view-head">
+        <div><strong><%- ctx.session.sessionProject.name %></strong><span><%- screen.name %></span></div>
+        <% include ./switcher.ejs %>
+    </div>
+    <div class="project-screen-live <%- template.previewClass %>">
+        <div class="project-screen-live-title"><%- screen.name %></div>
+        <div class="project-screen-live-grid">
+            <section><h6>项目概况</h6><div></div></section>
+            <section><h6>进度趋势</h6><div></div></section>
+            <section><h6>合同执行</h6><div></div></section>
+            <section><h6>资金情况</h6><div></div></section>
+        </div>
+    </div>
+</div>
+<% } %>

+ 10 - 0
app/view/project_screen/switcher.ejs

@@ -0,0 +1,10 @@
+<% if (screens.length > 1) { %>
+<div class="dropdown project-screen-switcher">
+    <button type="button" id="project-screen-switcher" class="btn btn-sm btn-outline-secondary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"><%= screen.name %></button>
+    <div class="dropdown-menu dropdown-menu-right" aria-labelledby="project-screen-switcher">
+        <% for (const item of screens) { %>
+        <a class="dropdown-item<%= String(item.id) === String(screen.id) ? ' active' : '' %>" href="/projectscreen/<%= encodeURIComponent(item.id) %>"<% if (String(item.id) === String(screen.id)) { %> aria-current="page"<% } %>><%= item.name %></a>
+        <% } %>
+    </div>
+</div>
+<% } %>

+ 129 - 0
app/view/setting/module.ejs

@@ -0,0 +1,129 @@
+<% include ./sub_menu.ejs %>
+<div class="panel-content">
+    <div class="panel-title">
+        <div class="title-main">
+            <h2>模块管理</h2>
+        </div>
+    </div>
+    <div class="content-wrap">
+        <div class="c-body">
+            <div class="sjs-height-0 d-flex flex-column p-3 project-screen-page">
+                <div class="d-flex align-items-center mb-3 flex-shrink-0">
+                    <div>
+                        <h5 class="mb-1">模块管理</h5>
+                        <span class="text-muted">管理平台可用模块及其功能配置</span>
+                    </div>
+                    <div class="ml-auto d-flex align-items-center">
+                        <span class="mr-2">项目大屏模块</span>
+                        <div class="custom-control custom-switch">
+                            <input type="checkbox" class="custom-control-input" id="project-screen-status" <%- projectScreenEnabled ? 'checked' : '' %>>
+                            <label class="custom-control-label" for="project-screen-status"><span class="sr-only">项目大屏模块</span></label>
+                        </div>
+                    </div>
+                </div>
+
+                <div class="row flex-fill project-screen-manage-body">
+                    <div class="col-3 h-100">
+                        <div class="card h-100">
+                            <div class="card-header d-flex align-items-center project-screen-card-title project-screen-module-title">模块列表</div>
+                            <div class="list-group list-group-flush">
+                                <a href="javascript:void(0);" class="list-group-item list-group-item-action active d-flex align-items-center project-screen-module-item">
+                                    <span><i class="fa fa-television mr-1"></i>项目大屏</span>
+                                    <span id="project-screen-module-status" class="ml-auto <%- projectScreenEnabled ? 'text-success' : '' %>"><%- projectScreenEnabled ? '已开启' : '' %></span>
+                                </a>
+                            </div>
+                        </div>
+                    </div>
+
+                    <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">
+                                    <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>
+                                </nav>
+                            </div>
+
+                            <div class="card-body p-0 tab-content project-screen-setting-content">
+                                <div id="project-screen-config" class="tab-pane active h-100">
+                                    <div class="row no-gutters h-100 project-screen-config-columns">
+                                        <div class="col-3 h-100 d-flex flex-column">
+                                            <div class="p-3 d-flex align-items-center">
+                                                <button type="button" class="btn btn-link btn-sm p-0" id="project-screen-select"><i class="fa fa-plus"></i> 选择大屏</button>
+                                            </div>
+                                            <div class="list-group list-group-flush overflow-auto flex-fill" id="project-screen-list"></div>
+                                        </div>
+                                        <div class="col-9 h-100 d-flex flex-column">
+                                            <div class="p-3 d-flex align-items-center">
+                                                <span id="project-screen-selected-name">大屏预览</span>
+                                                <div class="ml-auto">
+                                                    <button type="button" class="btn btn-outline-primary btn-sm" id="project-screen-default">设为默认</button>
+                                                    <button type="button" class="btn btn-outline-danger btn-sm ml-1" id="project-screen-delete">删除</button>
+                                                </div>
+                                            </div>
+                                            <div class="project-screen-preview-stage d-flex align-items-center justify-content-center overflow-auto">
+                                                <div class="project-screen-preview" id="project-screen-preview">
+                                                    <div class="project-screen-preview-nav"><span></span><i></i><i></i><i></i></div>
+                                                    <div class="project-screen-preview-content"><header></header><section><aside></aside><main></main></section></div>
+                                                </div>
+                                                <div class="text-muted" id="project-screen-empty">尚未添加大屏</div>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </div>
+
+                                <div id="project-screen-permission" class="tab-pane h-100" style="display:none;">
+                                    <div class="h-100 d-flex flex-column">
+                                        <div class="p-3 border-bottom d-flex align-items-center">
+                                            <span id="project-screen-permission-count"></span>
+                                            <div class="input-group input-group-sm ml-auto project-screen-search">
+                                                <input type="text" class="form-control" id="project-screen-keyword" placeholder="账号/姓名/单位/手机 搜索">
+                                                <div class="input-group-append">
+                                                    <button type="button" class="btn btn-outline-primary" id="project-screen-search"><i class="fa fa-search"></i></button>
+                                                </div>
+                                            </div>
+                                            <div class="custom-control custom-switch ml-3">
+                                                <input type="checkbox" class="custom-control-input" id="project-screen-authorized">
+                                                <label class="custom-control-label" for="project-screen-authorized">筛选已授权</label>
+                                            </div>
+                                            <button type="button" class="btn btn-warning btn-sm ml-3" id="project-screen-permission-save" style="display:none;">保存</button>
+                                        </div>
+                                        <div class="overflow-auto flex-fill">
+                                            <table class="table table-hover table-bordered table-sm mb-0">
+                                                <thead>
+                                                <tr class="text-center project-screen-sticky-head">
+                                                    <th>账号</th>
+                                                    <th>用户</th>
+                                                    <th>公司</th>
+                                                    <th>职位</th>
+                                                    <th width="90">查看 <input type="checkbox" class="ml-2 align-middle" id="project-screen-permission-all"></th>
+                                                </tr>
+                                                </thead>
+                                                <tbody class="text-center" id="project-screen-permission-body"></tbody>
+                                            </table>
+                                            <div class="text-center text-muted p-5" id="project-screen-permission-empty">没有匹配的项目成员</div>
+                                        </div>
+                                        <div class="border-top p-2 d-flex justify-content-center align-items-center">
+                                            <button type="button" class="btn btn-outline-secondary btn-sm" id="project-screen-prev">上一页</button>
+                                            <span class="mx-3" id="project-screen-page-info"></span>
+                                            <button type="button" class="btn btn-outline-secondary btn-sm" id="project-screen-next">下一页</button>
+                                        </div>
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+
+<script>
+    window.projectScreenPageData = JSON.parse(unescape('<%- escape(JSON.stringify({ screens, templates })) %>'));
+    autoFlashHeight();
+</script>

+ 35 - 0
app/view/setting/module_modal.ejs

@@ -0,0 +1,35 @@
+<div class="modal fade" id="project-screen-select-modal" 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="row" id="project-screen-template-grid"></div>
+            </div>
+            <div class="modal-footer">
+                <span class="mr-auto text-muted" id="project-screen-selected-count">已选择 0 个</span>
+                <button type="button" class="btn btn-secondary btn-sm" data-dismiss="modal">关闭</button>
+                <button type="button" class="btn btn-primary btn-sm" id="project-screen-add">添加</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<div class="modal fade" id="project-screen-delete-modal" 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">
+                <p class="mb-1">确定删除“<strong id="project-screen-delete-name"></strong>”吗?</p>
+                <p class="text-muted mb-0" id="project-screen-delete-hint"></p>
+            </div>
+            <div class="modal-footer">
+                <button type="button" class="btn btn-secondary btn-sm" data-dismiss="modal">关闭</button>
+                <button type="button" class="btn btn-danger btn-sm" id="project-screen-delete-confirm">确认删除</button>
+            </div>
+        </div>
+    </div>
+</div>

+ 15 - 0
config/menu.js

@@ -19,6 +19,15 @@ const projectMenu = {
         caption: '工作台',
         controller: 'dashboard',
     },
+    projectscreen: {
+        name: '项目大屏',
+        icon: 'fa-television',
+        display: false,
+        url: '/projectscreen',
+        children: null,
+        caption: '项目大屏',
+        controller: 'projectscreen',
+    },
     dataview: {
         name: '数据大屏',
         icon: 'fa-th-large',
@@ -489,6 +498,12 @@ const settingMenu = {
         url: '/setting/show',
         caption: '显示设置',
     },
+    module: {
+        name: '模块管理',
+        display: true,
+        url: '/setting/module',
+        caption: '模块管理',
+    },
     manage: {
         name: '计量管理',
         display: false,

+ 15 - 0
sql/update.sql

@@ -98,6 +98,21 @@ ALTER TABLE `zh_file`
 ADD COLUMN `is_locked` tinyint(1) UNSIGNED NOT NULL DEFAULT 0
 COMMENT '文件锁定状态:0未锁定,1已锁定'
 AFTER `rela_info`;
+
+CREATE TABLE `zh_project_screen` (
+  `id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增ID',
+  `pid` int(11) UNSIGNED NOT NULL COMMENT '项目ID(zh_project.id)',
+  `template_code` varchar(50) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL COMMENT '代码内注册的大屏模板编码',
+  `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '大屏名称',
+  `is_default` tinyint(1) UNSIGNED NOT NULL DEFAULT 0 COMMENT '是否默认大屏',
+  `config_json` json NULL COMMENT '大屏个性配置',
+  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后修改时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_project_screen_template` (`pid`, `template_code`),
+  KEY `idx_project_screen_list` (`pid`, `create_time`)
+) ENGINE=InnoDB COMMENT='项目大屏配置';
+
 ------------------------------------
 -- 表数据
 ------------------------------------