Przeglądaj źródła

feat: 资料管理 相关

caipin 21 godzin temu
rodzic
commit
335c1e4ceb

+ 3 - 0
app/base/base_controller.js

@@ -90,6 +90,9 @@ class BaseController extends Controller {
                         case 'financial':
                             im.url = `/sp/${ctx.subProject.id}/${im.controller}/${ctx.subProject.financialToUrl}`;
                             break;
+                        case 'file':
+                            im.url = `/sp/${ctx.subProject.id}/filesjs`;
+                            break;
                         case 'budget':
                             for (const child of im.children) {
                                 if (child.msg === 'budget') {

+ 611 - 35
app/controller/file_controller.js

@@ -23,6 +23,131 @@ module.exports = app => {
             if (!ctx.subProject.lock_file) throw '请先锁定,再管理分类数据';
         }
 
+        isAdmin(ctx) {
+            return Number(ctx.session.sessionUser.is_admin) === 1;
+        }
+
+        hasFilePermission(ctx, permissionKey) {
+            if (this.isAdmin(ctx)) return true;
+            const permission = ctx.service.subProjPermission.PermissionConst.file[permissionKey];
+            const filePermission = ctx.subProject.permission.file_permission || [];
+            return permission && filePermission.indexOf(permission.value) >= 0;
+        }
+
+        hasFileConfigPermission(ctx) {
+            return this.hasFilePermission(ctx, 'manage_dir') || this.hasFilePermission(ctx, 'auth_user');
+        }
+
+        applyFileConfigViewPermission(ctx, permissionMap) {
+            if (!this.hasFileConfigPermission(ctx)) return permissionMap;
+            Object.keys(permissionMap || {}).forEach(filingId => {
+                permissionMap[filingId].can_view = 1;
+            });
+            return permissionMap;
+        }
+
+        checkFilePermission(ctx, permissionKey) {
+            if (!this.hasFilePermission(ctx, permissionKey)) throw '您无权进行该操作';
+        }
+
+        hasLegacyFilePermission(ctx, value) {
+            if (this.isAdmin(ctx)) return true;
+            return (ctx.subProject.permission.file_permission || []).indexOf(value) >= 0;
+        }
+
+        async getProjectFiling(ctx, filingId) {
+            const filing = await ctx.service.filing.getDataById(filingId);
+            if (!filing || filing.is_deleted || filing.spid !== ctx.subProject.id) throw '分类不存在';
+            return filing;
+        }
+
+        async fillFilingAddUserNames(ctx, filingList) {
+            const rows = filingList || [];
+            const userIds = this.app._.uniq(rows.map(x => Number(x.create_uid)).filter(x => x > 0));
+            const users = userIds.length > 0 ? await ctx.service.projectAccount.getAllDataByCondition({
+                columns: ['id', 'name'],
+                where: { id: userIds },
+            }) : [];
+            const userNameMap = {};
+            users.forEach(user => { userNameMap[Number(user.id)] = user.name || ''; });
+            rows.forEach(filing => {
+                filing.add_user = userNameMap[Number(filing.create_uid)] || '';
+            });
+            return rows;
+        }
+
+        async fillFilingPermissionCreatorNames(ctx, filingList, permissionRows) {
+            const rows = filingList || [];
+            const exactPermissionRows = (permissionRows || []).filter(permission => {
+                return permission.filing_id && Number(permission.create_uid) > 0;
+            });
+            const creatorIds = this.app._.uniq(exactPermissionRows
+                .map(permission => Number(permission.create_uid)).filter(uid => uid > 0));
+            const creators = creatorIds.length > 0 ? await ctx.service.projectAccount.getAllDataByCondition({
+                columns: ['id', 'name'],
+                where: { id: creatorIds },
+            }) : [];
+            const creatorNameMap = {};
+            creators.forEach(creator => { creatorNameMap[Number(creator.id)] = creator.name || ''; });
+            rows.forEach(filing => {
+                const creatorNames = this.app._.uniq(exactPermissionRows.filter(permission => {
+                    return String(permission.filing_id) === String(filing.id);
+                }).map(permission => creatorNameMap[Number(permission.create_uid)]).filter(Boolean));
+                if (creatorNames.length > 0) filing.add_user = creatorNames.join('、');
+            });
+            return rows;
+        }
+
+        async checkFilingView(ctx, filing) {
+            const permission = await ctx.service.subProjectFilingPermission.getResolvedPermission(
+                ctx.subProject.id,
+                ctx.session.sessionUser.accountId,
+                filing.id,
+                filing.filing_type,
+                ctx.subProject.permission.file_permission,
+                ctx.subProject.permission.filing_type,
+                this.isAdmin(ctx)
+            );
+            if (this.hasFileConfigPermission(ctx)) permission.can_view = 1;
+            if (!permission.can_view) throw '您无权查看该资料目录';
+            return permission;
+        }
+
+        async getFilingOperationPermission(ctx, filing) {
+            return await this.checkFilingView(ctx, filing);
+        }
+
+        async checkFilingOperation(ctx, filing, permissionField) {
+            const permission = await this.getFilingOperationPermission(ctx, filing);
+            if (!permission[permissionField]) throw '您无权进行该操作';
+            return permission;
+        }
+
+        async getFilingFromDirectoryData(ctx, data) {
+            const filingId = data.id || data.tree_pre_id || (data.tree_pid && data.tree_pid !== '-1' ? data.tree_pid : '');
+            if (!filingId) throw '请先选择资料类别';
+            return await this.getProjectFiling(ctx, filingId);
+        }
+
+        filterVisibleFiling(filingList, permissionMap) {
+            const filingMap = {};
+            (filingList || []).forEach(filing => { filingMap[String(filing.id)] = filing; });
+            const visibleIds = new Set();
+            (filingList || []).forEach(filing => {
+                const permission = permissionMap[filing.id];
+                if (!permission || !permission.can_view) return;
+                let current = filing;
+                while (current) {
+                    const currentId = String(current.id);
+                    if (visibleIds.has(currentId)) break;
+                    visibleIds.add(currentId);
+                    if (current.tree_pid === '-1' || current.tree_pid === -1) break;
+                    current = filingMap[String(current.tree_pid)];
+                }
+            });
+            return (filingList || []).filter(filing => visibleIds.has(String(filing.id)));
+        }
+
         /**
          * 概算投资
          *
@@ -54,14 +179,15 @@ module.exports = app => {
 
         async file(ctx) {
             try {
+                if (!this.hasLegacyFilePermission(ctx, 1)) throw '您无权查看旧版资料管理';
                 const renderData = {
                     jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.file.file),
                 };
                 renderData.filing = await ctx.service.filing.getValidFiling(ctx.params.id, ctx.subProject.permission.filing_type);
                 renderData.categoryData = await ctx.service.category.getAllCategory(ctx.subProject);
-                renderData.canFiling = ctx.subProject.permission.file_permission.indexOf(ctx.service.subProjPermission.PermissionConst.file.filing.value) >= 0;
-                renderData.canUpload = ctx.subProject.permission.file_permission.indexOf(ctx.service.subProjPermission.PermissionConst.file.upload.value) >= 0;
-                renderData.canEdit = ctx.subProject.permission.file_permission.indexOf(ctx.service.subProjPermission.PermissionConst.file.editfile.value) >= 0;
+                renderData.canFiling = !ctx.subProject.lock_file && this.hasLegacyFilePermission(ctx, 3);
+                renderData.canUpload = !ctx.subProject.lock_file && this.hasLegacyFilePermission(ctx, 2);
+                renderData.canEdit = !ctx.subProject.lock_file && this.hasLegacyFilePermission(ctx, 4);
                 renderData.fileReferenceList = await ctx.service.subProject.getFileReference(ctx.subProject, ctx.service.subProject.FileReferenceType.file);
                 await this.layout('file/file.ejs', renderData, 'file/file_modal.ejs');
             } catch (err) {
@@ -69,6 +195,399 @@ module.exports = app => {
             }
         }
 
+        /**
+         * SpreadJS 替代 zTree 展示资料分类树 Demo
+         *
+         * @param {Object} ctx - egg context
+         */
+        async fileSjsDemo(ctx) {
+            try {
+                const renderData = {
+                    jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.file.sjs_demo),
+                };
+                renderData.filing = await ctx.service.filing.getValidFiling(ctx.params.id, ctx.subProject.permission.filing_type);
+                renderData.categoryData = await ctx.service.category.getAllCategory(ctx.subProject);
+                // 根据 lock_file 判断是否可编辑
+                const canEdit = !ctx.subProject.lock_file;
+                renderData.canFiling = canEdit;
+                renderData.canUpload = canEdit;
+                renderData.canEdit = canEdit;
+                renderData.fileReferenceList = await ctx.service.subProject.getFileReference(ctx.subProject, ctx.service.subProject.FileReferenceType.file);
+                await this.layout('file/file_sjs_demo.ejs', renderData);
+            } catch (err) {
+                ctx.log(err);
+            }
+        }
+
+        /**
+         * 资料管理 SpreadJS 版本
+         * 使用 SpreadJS 替代 zTree 展示文件分类树
+         *
+         * @param {Object} ctx - egg context
+         */
+        async filesjs(ctx) {
+            try {
+                const renderData = {
+                    jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.file.filesjs),
+                };
+                const allFiling = await ctx.service.filing.getValidFiling(ctx.params.id, 'all');
+                const canManageDir = this.hasFilePermission(ctx, 'manage_dir');
+                renderData.categoryData = await ctx.service.category.getAllCategory(ctx.subProject);
+                renderData.filingPermissionMap = await ctx.service.subProjectFilingPermission.getPermissionMap(
+                    ctx.subProject.id,
+                    ctx.session.sessionUser.accountId,
+                    allFiling,
+                    ctx.subProject.permission.filing_type,
+                    ctx.subProject.permission.file_permission,
+                    this.isAdmin(ctx)
+                );
+                this.applyFileConfigViewPermission(ctx, renderData.filingPermissionMap);
+                renderData.filing = this.filterVisibleFiling(allFiling, renderData.filingPermissionMap);
+                // 共用 file_modal.ejs 需要渲染全部弹窗,实际显示与操作由 filingPermissionMap 控制。
+                renderData.canFiling = true;
+                renderData.canUpload = true;
+                renderData.canEdit = false;
+                renderData.canManageDir = this.hasFileConfigPermission(ctx);
+                renderData.needFilingInitialization = canManageDir && allFiling.length === 0;
+                renderData.filingInitializationTemplates = [];
+                renderData.filingInitializationProjects = [];
+                if (renderData.needFilingInitialization) {
+                    renderData.filingInitializationTemplates = await ctx.service.filingTemplateList.getAllDataByCondition({
+                        columns: ['id', 'name'],
+                        where: { ft_type: ctx.service.filingTemplateList.FtType.org },
+                        orders: [['create_time', 'asc']],
+                    });
+                    renderData.filingInitializationProjects = await ctx.service.subProject.getManageDirProjects(
+                        ctx.session.sessionProject.id,
+                        ctx.session.sessionUser.accountId,
+                        this.isAdmin(ctx),
+                        ctx.subProject.id
+                    );
+                }
+                renderData.fileReferenceList = await ctx.service.subProject.getFileReference(ctx.subProject, ctx.service.subProject.FileReferenceType.file);
+                await this.layout('file/filesjs.ejs', renderData, 'file/file_modal.ejs');
+            } catch (err) {
+                ctx.log(err);
+            }
+        }
+
+        async initializeFiling(ctx) {
+            try {
+                this.checkFilePermission(ctx, 'manage_dir');
+                const data = JSON.parse(ctx.request.body.data);
+                if (!data || !data.init_type) throw '请选择初始化方式';
+                if (data.init_type === 'template') throw '模板库初始化功能暂未开放';
+
+                let sourceData = [];
+                if (data.init_type === 'project') {
+                    if (!data.source_spid) throw '请选择来源项目';
+                    const sourceProjects = await ctx.service.subProject.getManageDirProjects(
+                        ctx.session.sessionProject.id,
+                        ctx.session.sessionUser.accountId,
+                        this.isAdmin(ctx),
+                        ctx.subProject.id
+                    );
+                    const sourceProject = sourceProjects.find(project => {
+                        return String(project.id) === String(data.source_spid);
+                    });
+                    if (!sourceProject) throw '来源项目不存在或您没有管理目录权限';
+                    sourceData = await ctx.service.filing.getValidFiling(sourceProject.id, 'all');
+                }
+                const result = await ctx.service.filing.initializeDirectory(
+                    ctx.subProject.id,
+                    data.init_type,
+                    sourceData,
+                    ctx.session.sessionUser.accountId
+                );
+                ctx.body = { err: 0, msg: '', data: result };
+            } catch (err) {
+                ctx.log(err);
+                ctx.ajaxErrorBody(err, '初始化资料目录失败');
+            }
+        }
+
+        async configDir(ctx) {
+            try {
+                const canManageDir = this.hasFilePermission(ctx, 'manage_dir');
+                const canAuthUser = this.hasFilePermission(ctx, 'auth_user');
+                if (!canManageDir && !canAuthUser) throw '您无权查看配置目录';
+                const renderData = {
+                    jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.file.config_dir),
+                };
+                renderData.canManageDir = canManageDir;
+                renderData.canAuthUser = canAuthUser;
+                renderData.filing = await ctx.service.filing.getValidFiling(ctx.params.id, 'all');
+                await this.fillFilingAddUserNames(ctx, renderData.filing);
+                renderData.permissionData = renderData.canAuthUser ? await ctx.service.subProjPermission.getPermission(ctx.params.id) : [];
+                renderData.filingPermissionData = renderData.canAuthUser
+                    ? await ctx.service.subProjectFilingPermission.getConfigPermissionRows(ctx.params.id, renderData.filing) : [];
+                if (renderData.canAuthUser) {
+                    await this.fillFilingPermissionCreatorNames(
+                        ctx, renderData.filing, renderData.filingPermissionData
+                    );
+                }
+                renderData.accountList = renderData.canAuthUser
+                    ? await ctx.service.subProjDataRange.getSelectableAccounts(ctx.subProject, 'file') : [];
+                await this.layout('file/config_dir.ejs', renderData);
+            } catch (err) {
+                ctx.log(err);
+                ctx.session.postError = err.toString();
+                ctx.redirect(this.menu.menu.dashboard.url);
+            }
+        }
+
+        async configDirLock(ctx) {
+            const redirectUrl = `/sp/${ctx.subProject.id}/config-dir`;
+            try {
+                this.checkFilePermission(ctx, 'manage_dir');
+                const lock = Number(ctx.request.body.lock);
+                if (lock !== 0 && lock !== 1) throw '锁定状态参数错误';
+                await ctx.service.subProject.save({ id: ctx.subProject.id, lock_file: lock });
+                ctx.redirect(redirectUrl);
+            } catch (err) {
+                ctx.log(err);
+                ctx.postError(err, '资料管理锁定状态修改失败');
+                ctx.redirect(redirectUrl);
+            }
+        }
+
+        async configDirUpdate(ctx) {
+            try {
+                this.checkFilePermission(ctx, 'manage_dir');
+                this.checkLock(ctx);
+                const data = JSON.parse(ctx.request.body.data);
+                const result = await this.updateFiling(ctx, data);
+                if (result && result.create) await this.fillFilingAddUserNames(ctx, result.create);
+                ctx.body = { err: 0, msg: '', data: result };
+            } catch (err) {
+                ctx.log(err);
+                ctx.ajaxErrorBody(err, '修改失败');
+            }
+        }
+
+        async getFilingNodePermission(ctx) {
+            try {
+                this.checkFilePermission(ctx, 'auth_user');
+                const filingId = ctx.request.body.filing_id;
+                const filing = await this.getProjectFiling(ctx, filingId);
+                const selectableAccounts = await ctx.service.subProjDataRange.getSelectableAccounts(ctx.subProject, 'file');
+                const projectAccounts = await ctx.service.subProjPermission.getPermission(ctx.subProject.id);
+                const permissionRows = await ctx.service.subProjectFilingPermission.getConfigPermissionRows(
+                    ctx.subProject.id, [filing]
+                );
+                const authorizedIds = permissionRows.filter(row => {
+                    return String(row.filing_id || '') === String(filing.id);
+                }).map(row => Number(row.uid));
+                const accountMap = {};
+                projectAccounts.forEach(permission => {
+                    const userId = Number(permission.uid);
+                    accountMap[userId] = {
+                        id: userId,
+                        name: permission.name,
+                        company: permission.company,
+                        role: permission.role,
+                    };
+                });
+                selectableAccounts.forEach(account => {
+                    accountMap[Number(account.id)] = account;
+                });
+                const authorizedUsers = authorizedIds.map(uid => accountMap[uid]).filter(Boolean).map(account => {
+                    return {
+                        id: Number(account.id),
+                        name: account.name,
+                        company: account.company,
+                        role: account.role,
+                    };
+                }).filter((account, index, list) => {
+                    return list.findIndex(item => Number(item.id) === Number(account.id)) === index;
+                });
+                /*
+                 * 下拉选择仍只使用数据范围内账号;这里额外合并项目账号,
+                 * 是为了让已经存在的旧授权不会因数据范围调整而从弹窗消失。
+                 */
+                const selectableIds = selectableAccounts.map(account => Number(account.id));
+                authorizedUsers.forEach(account => {
+                    account.selectable = selectableIds.indexOf(Number(account.id)) >= 0;
+                });
+                ctx.body = { err: 0, msg: '', data: authorizedUsers };
+            } catch (err) {
+                ctx.log(err);
+                ctx.ajaxErrorBody(err, '获取授权用户失败');
+            }
+        }
+
+        async saveFilingNodePermission(ctx) {
+            try {
+                this.checkFilePermission(ctx, 'auth_user');
+                this.checkLock(ctx);
+                const data = JSON.parse(ctx.request.body.data);
+                if (!data || !data.filing_id) throw '请选择需要授权的资料目录';
+                if (!(data.user_permissions instanceof Array)) throw '授权用户权限格式错误';
+                if (data.user_permissions.find(x => !x || typeof x !== 'object')) throw '授权用户权限格式错误';
+                const filing = await this.getProjectFiling(ctx, data.filing_id);
+                const allFiling = await ctx.service.filing.getValidFiling(ctx.subProject.id, 'all');
+                const filingMap = {};
+                allFiling.forEach(item => { filingMap[String(item.id)] = item; });
+                const ancestorIds = [];
+                let parentId = String(filing.tree_pid);
+                while (parentId !== '-1') {
+                    const parent = filingMap[parentId];
+                    if (!parent) break;
+                    ancestorIds.push(String(parent.id));
+                    parentId = String(parent.tree_pid);
+                }
+                if (ancestorIds.length > 0) {
+                    const ancestorPermissionRows = await ctx.service.subProjectFilingPermission.getAllDataByCondition({
+                        columns: ['filing_id'],
+                        where: { spid: ctx.subProject.id, filing_id: ancestorIds },
+                        limit: 1,
+                    });
+                    if (ancestorPermissionRows.length > 0) throw '父级目录已授权,当前目录不能单独授权';
+                }
+                const userPermissions = data.user_permissions;
+                const requestedUserIds = userPermissions.map(x => Number(x.uid !== undefined ? x.uid : x.id));
+                if (requestedUserIds.find(uid => !Number.isInteger(uid) || uid <= 0)) throw '授权用户数据错误';
+                if (new Set(requestedUserIds).size !== requestedUserIds.length) throw '授权用户数据重复';
+                const selectableAccounts = await ctx.service.subProjDataRange.getSelectableAccounts(ctx.subProject, 'file');
+                const selectableIds = selectableAccounts.map(account => Number(account.id));
+                const currentPermissionRows = await ctx.service.subProjectFilingPermission.getConfigPermissionRows(
+                    ctx.subProject.id, [filing]
+                );
+                const currentUserIds = currentPermissionRows.filter(row => {
+                    return String(row.filing_id || '') === String(filing.id);
+                }).map(row => Number(row.uid));
+                const invalidUserId = requestedUserIds.find(uid => {
+                    return selectableIds.indexOf(uid) < 0 && currentUserIds.indexOf(uid) < 0;
+                });
+                if (invalidUserId) throw '选择的用户超出资料管理数据范围';
+                const result = await ctx.service.subProjectFilingPermission.savePermissionsToSubtree(
+                    ctx.subProject,
+                    filing,
+                    allFiling,
+                    userPermissions,
+                    ctx.session.sessionUser.accountId
+                );
+                const savedPermissionRows = await ctx.service.subProjectFilingPermission.getRows(ctx.subProject.id);
+                await this.fillFilingAddUserNames(ctx, result.filings);
+                await this.fillFilingPermissionCreatorNames(ctx, result.filings, savedPermissionRows);
+                ctx.body = {
+                    err: 0,
+                    msg: '',
+                    data: {
+                        permissions: result.permissions,
+                        filings: result.filings.map(item => ({
+                            id: item.id,
+                            add_user: item.add_user || '',
+                        })),
+                    },
+                };
+            } catch (err) {
+                ctx.log(err);
+                ctx.ajaxErrorBody(err, '保存授权用户失败');
+            }
+        }
+
+        async addFilingNodePermissions(ctx) {
+            await this._saveFilingNodePermissionsToOther(ctx, false);
+        }
+
+        async coverFilingNodePermissions(ctx) {
+            await this._saveFilingNodePermissionsToOther(ctx, true);
+        }
+
+        async _saveFilingNodePermissionsToOther(ctx, replaceExisting) {
+            try {
+                this.checkFilePermission(ctx, 'auth_user');
+                this.checkLock(ctx);
+                const data = JSON.parse(ctx.request.body.data);
+                if (!data || !data.source_filing_id) throw '请选择来源资料目录';
+                if (!(data.target_filing_ids instanceof Array) || data.target_filing_ids.length === 0) {
+                    throw '请选择目标资料目录';
+                }
+                if (data.target_filing_ids.length > 500) throw '一次最多选择500个目标资料目录';
+                if (!(data.user_permissions instanceof Array) || data.user_permissions.length === 0) {
+                    throw '请选择需要添加的授权用户';
+                }
+                if (data.user_permissions.find(x => !x || typeof x !== 'object')) {
+                    throw '授权用户权限格式错误';
+                }
+
+                const sourceFiling = await this.getProjectFiling(ctx, data.source_filing_id);
+                const targetFilingIds = data.target_filing_ids.map(id => String(id || '').trim());
+                if (targetFilingIds.find(id => !id)) throw '目标资料目录数据错误';
+                if (new Set(targetFilingIds).size !== targetFilingIds.length) throw '目标资料目录重复';
+                if (targetFilingIds.indexOf(String(sourceFiling.id)) >= 0) throw '当前目录不能作为目标目录';
+
+                const targetFilingRows = await ctx.service.filing.getAllDataByCondition({
+                    where: {
+                        id: targetFilingIds,
+                        spid: ctx.subProject.id,
+                        is_deleted: 0,
+                    },
+                });
+                const targetFilingMap = {};
+                targetFilingRows.forEach(filing => { targetFilingMap[String(filing.id)] = filing; });
+                if (targetFilingIds.find(filingId => !targetFilingMap[filingId])) throw '目标资料目录不存在';
+                const targetFilings = targetFilingIds.map(filingId => targetFilingMap[filingId]);
+
+                const userPermissions = data.user_permissions;
+                const requestedUserIds = userPermissions.map(x => Number(x.uid !== undefined ? x.uid : x.id));
+                if (requestedUserIds.find(uid => !Number.isInteger(uid) || uid <= 0)) throw '授权用户数据错误';
+                if (new Set(requestedUserIds).size !== requestedUserIds.length) throw '授权用户数据重复';
+
+                const selectableAccounts = await ctx.service.subProjDataRange.getSelectableAccounts(ctx.subProject, 'file');
+                const selectableIds = selectableAccounts.map(account => Number(account.id));
+                const sourcePermissionRows = await ctx.service.subProjectFilingPermission.getConfigPermissionRows(
+                    ctx.subProject.id, [sourceFiling]
+                );
+                const sourceUserIds = sourcePermissionRows.filter(row => {
+                    return String(row.filing_id || '') === String(sourceFiling.id);
+                }).map(row => Number(row.uid));
+                const invalidUserId = requestedUserIds.find(uid => {
+                    return selectableIds.indexOf(uid) < 0 && sourceUserIds.indexOf(uid) < 0;
+                });
+                if (invalidUserId) throw '选择的用户超出资料管理数据范围';
+
+                const permissionService = ctx.service.subProjectFilingPermission;
+                if (replaceExisting) {
+                    await permissionService.coverPermissionsToFilings(
+                        ctx.subProject,
+                        targetFilings,
+                        userPermissions,
+                        ctx.session.sessionUser.accountId
+                    );
+                } else {
+                    await permissionService.addPermissionsToFilings(
+                        ctx.subProject,
+                        targetFilings,
+                        userPermissions,
+                        ctx.session.sessionUser.accountId
+                    );
+                }
+
+                const savedPermissionRows = await ctx.service.subProjectFilingPermission.getRows(ctx.subProject.id);
+                const targetPermissionRows = await ctx.service.subProjectFilingPermission.getConfigPermissionRows(
+                    ctx.subProject.id, targetFilings
+                );
+                await this.fillFilingAddUserNames(ctx, targetFilings);
+                await this.fillFilingPermissionCreatorNames(ctx, targetFilings, savedPermissionRows);
+                ctx.body = {
+                    err: 0,
+                    msg: '',
+                    data: {
+                        permissions: targetPermissionRows,
+                        filings: targetFilings.map(filing => ({
+                            id: filing.id,
+                            add_user: filing.add_user || '',
+                        })),
+                    },
+                };
+            } catch (err) {
+                ctx.log(err);
+                ctx.ajaxErrorBody(err, replaceExisting ? '覆盖授权用户至其他目录失败' : '添加授权用户至其他目录失败');
+            }
+        }
+
         async getFilingTypePermission(ctx) {
             try {
                 if (ctx.subProject.project_id !== this.ctx.session.sessionProject.id) throw '您无权操作该数据';
@@ -95,6 +614,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.add(data);
                 ctx.body = { err: 0, msg: '', data: result };
             } catch (err) {
@@ -106,6 +627,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);
                 ctx.body = { err: 0, msg: '', data: result };
             } catch (err) {
@@ -117,6 +640,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.save(data);
                 ctx.body = { err: 0, msg: '', data: result };
             } catch (err) {
@@ -130,6 +655,8 @@ module.exports = app => {
                 this.checkUnlock(ctx);
                 const data = JSON.parse(ctx.request.body.data);
                 if (!data.id || !(data.tree_order >= 0)) throw '数据错误';
+                const filing = await this.getFilingFromDirectoryData(ctx, data);
+                await this.checkFilingOperation(ctx, filing, 'can_edit_dir');
                 const result = await ctx.service.filing.move(data);
                 ctx.body = { err: 0, msg: '', data: result };
             } catch (err) {
@@ -141,6 +668,8 @@ module.exports = app => {
         async loadFile(ctx) {
             try {
                 const data = JSON.parse(ctx.request.body.data);
+                const filing = await this.getProjectFiling(ctx, data.filing_id);
+                await this.checkFilingView(ctx, filing);
                 const order = data.order.split('|');
                 if (order.length !== 2) throw '加载文件错误';
                 if (order[0] !== 'filename' && order[0] !== 'create_time') throw '加载文件错误';
@@ -159,10 +688,9 @@ module.exports = app => {
             }
         }
 
-        async checkCanUpload(ctx) {
-            if (ctx.subProject.permission.file_permission.indexOf(ctx.service.subProjPermission.PermissionConst.file.upload.value) < 0) {
-                throw '您无权上传、导入、删除文件';
-            }
+        async checkCanUpload(ctx, filing) {
+            this.checkUnlock(ctx);
+            await this.checkFilingOperation(ctx, filing, 'can_upload');
         }
 
         async checkFiling(filing) {
@@ -174,6 +702,8 @@ module.exports = app => {
             try{
                 const data = JSON.parse(ctx.request.body.data);
                 if (!data.filing_id || !data.files) throw '缺少参数';
+                const filing = await this.getProjectFiling(ctx, data.filing_id);
+                await this.checkCanUpload(ctx, filing);
                 const result = await ctx.service.file.checkFiles(data.filing_id, data.files);
                 ctx.body = { err: 0, msg: '', data: result };
             } catch(error) {
@@ -185,16 +715,14 @@ module.exports = app => {
         async uploadFile(ctx){
             let stream;
             try {
-                await this.checkCanUpload(ctx);
-
                 const parts = ctx.multipart({ autoFields: true });
 
                 let index = 0;
                 const create_time = Date.parse(new Date()) / 1000;
-                let stream = await parts();
+                stream = await parts();
                 const user = await ctx. service.projectAccount.getDataById(ctx.session.sessionUser.accountId);
-                const filing = await ctx.service.filing.getDataById(parts.field.filing_id);
-                if (!filing || filing.is_deleted) throw '分类不存在,请刷新页面后重试';
+                const filing = await this.getProjectFiling(ctx, parts.field.filing_id);
+                await this.checkCanUpload(ctx, filing);
                 await this.checkFiling(filing);
 
                 const uploadfiles = [];
@@ -234,6 +762,7 @@ module.exports = app => {
         }
         async delFile(ctx) {
             try{
+                this.checkUnlock(ctx);
                 const data = JSON.parse(ctx.request.body.data);
                 if (!data.del) throw '缺少参数';
                 const result = await ctx.service.file.delFiles(data.del);
@@ -245,6 +774,7 @@ module.exports = app => {
         }
         async saveFile(ctx) {
             try {
+                this.checkUnlock(ctx);
                 const data = JSON.parse(ctx.request.body.data);
                 if (!data.id) throw '缺少参数';
                 const result = await ctx.service.file.saveFile(data.id, data.filename);
@@ -254,10 +784,25 @@ module.exports = app => {
                 ctx.ajaxErrorBody(error, '编辑附件失败');
             }
         }
+        async lockFile(ctx) {
+            try {
+                this.checkUnlock(ctx);
+                const data = JSON.parse(ctx.request.body.data);
+                if (!data || !data.id) throw '缺少参数';
+                const result = await ctx.service.file.setLocked(data.id, data.is_locked);
+                ctx.body = { err: 0, msg: '', data: result };
+            } catch (error) {
+                this.log(error);
+                ctx.ajaxErrorBody(error, '修改文件锁定状态失败');
+            }
+        }
         async moveFile(ctx) {
             try {
+                this.checkUnlock(ctx);
                 const data = JSON.parse(ctx.request.body.data);
                 if (!data.id || !data.filingId) throw '缺少参数';
+                const targetFiling = await this.getProjectFiling(ctx, data.filingId);
+                await this.checkFilingOperation(ctx, targetFiling, 'can_upload');
                 const result = await ctx.service.file.moveFile(data.id, data.filingId);
                 ctx.body = { err: 0, msg: '', data: result };
             } catch (error) {
@@ -267,11 +812,10 @@ module.exports = app => {
         }
         async uploadBigFile(ctx) {
             try {
-                await this.checkCanUpload(ctx);
                 const data = JSON.parse(ctx.request.body.data);
                 if (!data.type || !data.filing_id || !data.fileInfo) throw '缺少参数';
-                const filing = await ctx.service.filing.getDataById(data.filing_id);
-                if (!filing || filing.is_deleted) throw '分类不存在,请刷新页面后重试';
+                const filing = await this.getProjectFiling(ctx, data.filing_id);
+                await this.checkCanUpload(ctx, filing);
 
                 let result;
                 const fileInfo = path.parse(data.fileInfo.filename);
@@ -443,8 +987,8 @@ module.exports = app => {
                 if (!data.filing_id || !data.files) throw '缺少参数';
 
                 const user = await ctx. service.projectAccount.getDataById(ctx.session.sessionUser.accountId);
-                const filing = await ctx.service.filing.getDataById(data.filing_id);
-                if (!filing || filing.is_deleted) throw '分类不存在,请刷新页面后重试';
+                const filing = await this.getProjectFiling(ctx, data.filing_id);
+                await this.checkCanUpload(ctx, filing);
                 await this.checkFiling(filing);
 
                 const result = await ctx.service.file.relaFiles(filing, data.files, user);
@@ -549,10 +1093,36 @@ module.exports = app => {
             try {
                 const limit = 1000;
                 const data = JSON.parse(ctx.request.body.data);
-                if (!data.filing_type || !data.keyword) throw '数据错误';
+                if (!data.keyword) throw '数据错误';
+                if (data.filing_id instanceof Array) {
+                    const filingIds = this.app._.uniq(data.filing_id.map(id => String(id || '')).filter(Boolean));
+                    if (filingIds.length === 0) throw '数据错误';
+                    const filings = await ctx.service.filing.getAllDataByCondition({
+                        where: { id: filingIds, spid: ctx.subProject.id, is_deleted: 0 },
+                    });
+                    const permissionMap = await ctx.service.subProjectFilingPermission.getPermissionMap(
+                        ctx.subProject.id,
+                        ctx.session.sessionUser.accountId,
+                        filings,
+                        ctx.subProject.permission.filing_type,
+                        ctx.subProject.permission.file_permission,
+                        this.isAdmin(ctx)
+                    );
+                    this.applyFileConfigViewPermission(ctx, permissionMap);
+                    const validFilingIds = filings.filter(filing => {
+                        return permissionMap[filing.id] && permissionMap[filing.id].can_view;
+                    }).map(filing => filing.id);
+                    const result = await ctx.service.file.searchByFilingIds(validFilingIds, data.keyword, limit);
+                    ctx.body = { err: 0, msg: '', data: { list: result, limit } };
+                    return;
+                }
+                if (!data.filing_type) throw '数据错误';
                 const validFilingType = [];
                 for (const f of data.filing_type) {
-                    if (ctx.subProject.permission.filing_type === 'all' || ctx.subProject.permission.filing_type.indexOf(f) >= 0) validFilingType.push(f);
+                    const filingType = Number(f);
+                    if (!Number.isInteger(filingType) || filingType <= 0) continue;
+                    if (ctx.subProject.permission.filing_type === 'all' ||
+                        ctx.subProject.permission.filing_type.indexOf(filingType) >= 0) validFilingType.push(filingType);
                 }
                 const result = await ctx.service.file.search(validFilingType, data.keyword, limit);
                 ctx.body = { err: 0, msg: '', data: { list: result, limit } };
@@ -599,28 +1169,34 @@ module.exports = app => {
             try {
                 this.checkLock(ctx);
                 const data = JSON.parse(ctx.request.body.data);
-                if (!data.updateType) throw '数据错误';
-                let result;
-                const updateData = JSON.parse(JSON.stringify(data));
-                delete updateData.updateType;
-                if (data.updateType === 'add') {
-                    result = await ctx.service.filing.add(updateData);
-                } else if (data.updateType === 'del') {
-                    result = await ctx.service.filing.del(updateData);
-                } else if (data.updateType === 'save') {
-                    result = await ctx.service.filing.save(updateData);
-                } else if (data.updateType === 'move') {
-                    if (!data.id || !(data.tree_order >= 0)) throw '数据错误';
-                    result = await ctx.service.filing.move(updateData);
-                } else if (data.updateType === 'multi' ) {
-                    result = await ctx.service.filing.multiUpdate(ctx.subProject.id, data.data);
-                }
+                const result = await this.updateFiling(ctx, data);
                 ctx.body = { err: 0, msg: '', data: result };
             } catch (err) {
                 ctx.log(err);
                 ctx.ajaxErrorBody(err, '修改失败');
             }
         }
+
+        async updateFiling(ctx, data) {
+            if (!data.updateType) throw '数据错误';
+            const updateData = JSON.parse(JSON.stringify(data));
+            delete updateData.updateType;
+            if (data.updateType === 'add') {
+                return await ctx.service.filing.add(updateData);
+            } else if (data.updateType === 'del') {
+                return await ctx.service.filing.del(updateData);
+            } else if (data.updateType === 'save') {
+                return await ctx.service.filing.save(updateData);
+            } else if (data.updateType === 'edit') {
+                return await ctx.service.filing.editDirectory(updateData);
+            } else if (data.updateType === 'move') {
+                if (!data.id || !(data.tree_order >= 0)) throw '数据错误';
+                return await ctx.service.filing.move(updateData);
+            } else if (data.updateType === 'multi') {
+                return await ctx.service.filing.multiUpdate(ctx.subProject.id, data.data);
+            }
+            throw '未知的修改类型';
+        }
     }
 
     return FileController;

+ 45 - 0
app/controller/safe_controller.js

@@ -603,6 +603,51 @@ module.exports = app => {
             }
         }
 
+        // 巡检概况
+        async inspectionOverview(ctx) {
+            try {
+                if (!ctx.subProject.page_show.safeInspection) throw '该功能已关闭';
+                const tender = await this.service.tender.getDataById(ctx.tender.id);
+                const renderData = {
+                    moment,
+                    tender,
+                    permission: ctx.permission.safe_inspection,
+                    jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.safe.inspection),
+                };
+                await this.layout('safe/inspection_overview.ejs', renderData);
+            } catch (err) {
+                ctx.log(err);
+                ctx.postError(err, '无法查看巡检概况数据');
+                ctx.redirect(`/sp/${ctx.subProject.id}/safe/tender/${ctx.tender.id}/inspection`);
+            }
+        }
+
+        // 巡检整改
+        async inspectionRectification(ctx) {
+            try {
+                if (!ctx.subProject.page_show.safeInspection) throw '该功能已关闭';
+                const status = auditConst.filter.status.rectification; // 整改中状态
+                await this._filterInspection(ctx, status);
+            } catch (err) {
+                ctx.log(err);
+                ctx.postError(err, '无法查看巡检整改数据');
+                ctx.redirect(`/sp/${ctx.subProject.id}/safe/tender/${ctx.tender.id}/inspection`);
+            }
+        }
+
+        // 巡检批复
+        async inspectionApproval(ctx) {
+            try {
+                if (!ctx.subProject.page_show.safeInspection) throw '该功能已关闭';
+                const status = auditConst.filter.status.checked; // 已完成状态
+                await this._filterInspection(ctx, status);
+            } catch (err) {
+                ctx.log(err);
+                ctx.postError(err, '无法查看巡检批复数据');
+                ctx.redirect(`/sp/${ctx.subProject.id}/safe/tender/${ctx.tender.id}/inspection`);
+            }
+        }
+
         // 质量巡检单功能
         async _filterInspection(ctx, status = 0) {
             try {

+ 51 - 0
app/controller/sub_proj_setting_controller.js

@@ -273,6 +273,13 @@ module.exports = app => {
             }
         }
 
+
+        /*
+        view	查看	1	file_permission
+        upload	上传/引用	2	file_permission
+        editfile	编辑文件	4	file_permission
+        filing	文件类别编辑	3	file_permission
+        */
         async permission(ctx) {
             try {
                 this.defaultCheck(ctx);
@@ -335,6 +342,50 @@ module.exports = app => {
             }
         }
 
+        async dataRange(ctx) {
+            try {
+                this.defaultCheck(ctx);
+                const moduleKey = ctx.query.module || 'file';
+                const moduleInfo = ctx.service.subProjDataRange.getModule(moduleKey);
+                const result = await Promise.all([
+                    ctx.service.subProjDataRange.getRangeTree(ctx.subProject),
+                    ctx.service.subProjDataRange.getRangeConfig(ctx.subProject.id, moduleKey),
+                ]);
+                const renderData = {
+                    moduleKey,
+                    moduleInfo,
+                    moduleList: ctx.service.subProjDataRange.getModuleList(),
+                    rangeTree: result[0].tree,
+                    unassignedUsers: result[0].unassignedUsers,
+                    rangeConfig: result[1],
+                    jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.setting.sp_data_range),
+                };
+                await this.layout('sp_setting/data_range.ejs', renderData, 'sp_setting/data_range_modal.ejs');
+            } catch (error) {
+                ctx.log(error);
+                ctx.session.postError = error.toString();
+                ctx.redirect(`/sp/${ctx.subProject.id}/setting/user`);
+            }
+        }
+
+        async dataRangeUpdate(ctx) {
+            try {
+                this.defaultCheck(ctx);
+                const data = JSON.parse(ctx.request.body.data);
+                const moduleKey = data.module_key || 'file';
+                const result = await ctx.service.subProjDataRange.saveRange(
+                    ctx.subProject,
+                    moduleKey,
+                    data.ranges,
+                    ctx.session.sessionUser.accountId
+                );
+                ctx.body = { err: 0, msg: '', data: result };
+            } catch (err) {
+                ctx.log(err);
+                ctx.ajaxErrorBody(err, '保存数据范围失败');
+            }
+        }
+
         async logs(ctx) {
             try {
                 this.defaultCheck(ctx);

Plik diff jest za duży
+ 1167 - 0
app/public/js/config_dir.js


Plik diff jest za duży
+ 2192 - 0
app/public/js/filesjs.js


+ 353 - 0
app/public/js/sp_setting_data_range.js

@@ -0,0 +1,353 @@
+$(document).ready(function() {
+    autoFlashHeight();
+
+    const RangeType = {
+        unitType: 1,
+        unit: 2,
+        user: 3,
+    };
+    const typeIndex = {};
+    const unitIndex = {};
+    const userIndex = {};
+
+    dataRangeTree.forEach(type => {
+        typeIndex[Number(type.id)] = type;
+        type.units.forEach(unit => {
+            unit.parentTypeId = Number(type.id);
+            unitIndex[Number(unit.id)] = unit;
+            unit.users.forEach(user => {
+                user.parentTypeId = Number(type.id);
+                user.parentUnitId = Number(unit.id);
+                userIndex[Number(user.id)] = user;
+            });
+        });
+    });
+    dataRangeUnassignedUsers.forEach(user => {
+        user.parentTypeId = Number(user.account_group) || 0;
+        userIndex[Number(user.id)] = user;
+    });
+
+    const makeSelection = function(config) {
+        return {
+            unitTypeIds: new Set((config.unit_type_ids || []).map(Number)),
+            unitIds: new Set((config.unit_ids || []).map(Number)),
+            userIds: new Set((config.user_ids || []).map(Number)),
+        };
+    };
+    const cloneSelection = function(source) {
+        return {
+            unitTypeIds: new Set(Array.from(source.unitTypeIds)),
+            unitIds: new Set(Array.from(source.unitIds)),
+            userIds: new Set(Array.from(source.userIds)),
+        };
+    };
+    let savedSelection = makeSelection(dataRangeConfig);
+    let savedConfigured = Boolean(dataRangeConfig.configured);
+    let currentSelection = cloneSelection(savedSelection);
+
+    const escapeHtml = function(value) {
+        return $('<div>').text(value === undefined || value === null ? '' : value).html();
+    };
+    const containsKeyword = function(value, keyword) {
+        return !keyword || String(value || '').toLowerCase().indexOf(keyword) >= 0;
+    };
+    const getSelectionSet = function(rangeType) {
+        if (rangeType === RangeType.unitType) return currentSelection.unitTypeIds;
+        if (rangeType === RangeType.unit) return currentSelection.unitIds;
+        return currentSelection.userIds;
+    };
+    const getSelectionSetFrom = function(selection, rangeType) {
+        if (rangeType === RangeType.unitType) return selection.unitTypeIds;
+        if (rangeType === RangeType.unit) return selection.unitIds;
+        return selection.userIds;
+    };
+    const getRangeName = function(rangeType, targetId) {
+        let node;
+        if (rangeType === RangeType.unitType) node = typeIndex[targetId];
+        if (rangeType === RangeType.unit) node = unitIndex[targetId];
+        if (rangeType === RangeType.user) node = userIndex[targetId];
+        return node ? node.name : '';
+    };
+    const getRangeTitle = function(rangeType) {
+        if (rangeType === RangeType.unitType) return '单位类型';
+        if (rangeType === RangeType.unit) return '单位';
+        return '用户';
+    };
+
+    const removeTypeDescendants = function(typeId) {
+        const type = typeIndex[typeId];
+        if (!type) return;
+        type.units.forEach(unit => {
+            currentSelection.unitIds.delete(Number(unit.id));
+            unit.users.forEach(user => currentSelection.userIds.delete(Number(user.id)));
+        });
+        dataRangeUnassignedUsers.forEach(user => {
+            if (Number(user.parentTypeId) === Number(typeId)) currentSelection.userIds.delete(Number(user.id));
+        });
+    };
+    const removeUnitDescendants = function(unitId) {
+        const unit = unitIndex[unitId];
+        if (!unit) return;
+        unit.users.forEach(user => currentSelection.userIds.delete(Number(user.id)));
+    };
+
+    const renderCheckbox = function(rangeType, targetId, disabled) {
+        const checked = getSelectionSet(rangeType).has(Number(targetId)) ? ' checked' : '';
+        const disabledAttr = disabled ? ' disabled' : '';
+        return `<input type="checkbox" class="mr-1 data-range-check" data-range-type="${rangeType}" data-target-id="${targetId}"${checked}${disabledAttr}>`;
+    };
+    const renderNode = function(options) {
+        const indent = options.level * 20;
+        const toggle = options.hasChildren
+            ? '<span class="data-range-toggle"><i class="fa fa-caret-down"></i></span>'
+            : '<span class="data-range-toggle empty"></span>';
+        const checkbox = options.selectable === false ? '' : renderCheckbox(options.rangeType, options.targetId, options.disabled);
+        const icon = options.rangeType === RangeType.user
+            ? '<i class="fa fa-user text-warning mr-1"></i>'
+            : '<i class="fa fa-folder text-warning mr-1"></i>';
+        const extra = options.extra ? ` <small class="text-muted">${escapeHtml(options.extra)}</small>` : '';
+        return `<div class="data-range-node" style="padding-left:${indent}px">${toggle}${checkbox}${icon}<span>${escapeHtml(options.name)}</span>${extra}</div>`;
+    };
+
+    const renderTree = function() {
+        const keyword = $('#data-range-keyword').val().trim().toLowerCase();
+        const html = [];
+        dataRangeTree.forEach(type => {
+            const typeMatch = containsKeyword(type.name, keyword);
+            const visibleUnits = [];
+            type.units.forEach(unit => {
+                const unitMatch = containsKeyword(unit.name, keyword);
+                const users = typeMatch || unitMatch ? unit.users : unit.users.filter(user => {
+                    return containsKeyword(user.name, keyword) || containsKeyword(user.role, keyword);
+                });
+                if (!keyword || typeMatch || unitMatch || users.length > 0) visibleUnits.push({ unit, users });
+            });
+            if (keyword && !typeMatch && visibleUnits.length === 0) return;
+
+            html.push('<div class="data-range-tree-group">');
+            html.push(renderNode({
+                level: 0,
+                hasChildren: visibleUnits.length > 0,
+                rangeType: RangeType.unitType,
+                targetId: Number(type.id),
+                name: type.name,
+            }));
+            html.push('<div class="data-range-children">');
+            visibleUnits.forEach(item => {
+                const typeSelected = currentSelection.unitTypeIds.has(Number(type.id));
+                html.push('<div class="data-range-tree-group">');
+                html.push(renderNode({
+                    level: 1,
+                    hasChildren: item.users.length > 0,
+                    rangeType: RangeType.unit,
+                    targetId: Number(item.unit.id),
+                    name: item.unit.name,
+                    disabled: typeSelected,
+                }));
+                html.push('<div class="data-range-children">');
+                item.users.forEach(user => {
+                    const unitSelected = currentSelection.unitIds.has(Number(item.unit.id));
+                    html.push(renderNode({
+                        level: 2,
+                        hasChildren: false,
+                        rangeType: RangeType.user,
+                        targetId: Number(user.id),
+                        name: user.name,
+                        extra: user.role,
+                        disabled: typeSelected || unitSelected,
+                    }));
+                });
+                html.push('</div></div>');
+            });
+            html.push('</div></div>');
+        });
+
+        const otherUsers = keyword ? dataRangeUnassignedUsers.filter(user => {
+            return containsKeyword(user.name, keyword) || containsKeyword(user.company, keyword) || containsKeyword(user.role, keyword);
+        }) : dataRangeUnassignedUsers;
+        if (otherUsers.length > 0) {
+            html.push('<div class="data-range-tree-group">');
+            html.push(renderNode({ level: 0, hasChildren: true, selectable: false, name: '未关联单位用户' }));
+            html.push('<div class="data-range-children">');
+            otherUsers.forEach(user => {
+                const typeSelected = currentSelection.unitTypeIds.has(Number(user.parentTypeId));
+                html.push(renderNode({
+                    level: 1,
+                    hasChildren: false,
+                    rangeType: RangeType.user,
+                    targetId: Number(user.id),
+                    name: user.name,
+                    extra: user.company || user.role,
+                    disabled: typeSelected,
+                }));
+            });
+            html.push('</div></div>');
+        }
+        if (html.length === 0) html.push('<div class="text-muted text-center py-4">没有匹配的数据</div>');
+        $('#data-range-tree').html(html.join(''));
+    };
+
+    const renderSelected = function() {
+        const html = [];
+        const appendItems = function(rangeType, ids) {
+            Array.from(ids).sort((a, b) => a - b).forEach(targetId => {
+                html.push('<div class="data-range-selected-item d-flex align-items-center">');
+                html.push(`<span class="badge badge-light mr-2">${getRangeTitle(rangeType)}</span>`);
+                html.push(`<span>${escapeHtml(getRangeName(rangeType, targetId))}</span>`);
+                html.push(`<a href="javascript:void(0);" class="ml-auto text-danger remove-data-range" data-range-type="${rangeType}" data-target-id="${targetId}" title="移除"><i class="fa fa-times"></i></a>`);
+                html.push('</div>');
+            });
+        };
+        appendItems(RangeType.unitType, currentSelection.unitTypeIds);
+        appendItems(RangeType.unit, currentSelection.unitIds);
+        appendItems(RangeType.user, currentSelection.userIds);
+        const total = currentSelection.unitTypeIds.size + currentSelection.unitIds.size + currentSelection.userIds.size;
+        if (total === 0) html.push('<div class="text-muted text-center py-4">暂未选择范围</div>');
+        $('#data-range-selected-count').text(total);
+        $('#selected-unit-type-count').text(currentSelection.unitTypeIds.size);
+        $('#selected-unit-count').text(currentSelection.unitIds.size);
+        $('#selected-user-count').text(currentSelection.userIds.size);
+        $('#data-range-selected-list').html(html.join(''));
+    };
+
+    const renderSummary = function() {
+        if (!savedConfigured) {
+            $('#data-range-summary').text('当前未配置数据范围,默认可选择当前子项目全部启用成员。');
+            return;
+        }
+        $('#data-range-summary').text(
+            `已设置:单位类型 ${savedSelection.unitTypeIds.size} 项,单位 ${savedSelection.unitIds.size} 项,具体用户 ${savedSelection.userIds.size} 项。`
+        );
+    };
+
+    const renderMainSelection = function() {
+        const html = [];
+        const appendItems = function(rangeType, ids) {
+            Array.from(ids).sort((a, b) => a - b).forEach(targetId => {
+                const rangeName = getRangeName(rangeType, targetId);
+                if (!rangeName) return;
+                html.push(`<span class="data-range-value-item" title="${escapeHtml(getRangeTitle(rangeType))}">`);
+                html.push(`<span class="data-range-value-name">${escapeHtml(rangeName)}</span>`);
+                html.push(`<a href="javascript:void(0);" class="remove-main-data-range" data-range-type="${rangeType}" data-target-id="${targetId}" title="移除${escapeHtml(rangeName)}" aria-label="移除${escapeHtml(rangeName)}"><i class="fa fa-times"></i></a>`);
+                html.push('</span>');
+            });
+        };
+        appendItems(RangeType.unitType, savedSelection.unitTypeIds);
+        appendItems(RangeType.unit, savedSelection.unitIds);
+        appendItems(RangeType.user, savedSelection.userIds);
+        $('#data-range-value-list').html(html.join(''));
+    };
+
+    const selectionToRanges = function(selection) {
+        const ranges = [];
+        const appendRanges = function(rangeType, ids) {
+            ids.forEach(targetId => ranges.push({ range_type: rangeType, target_id: targetId }));
+        };
+        appendRanges(RangeType.unitType, selection.unitTypeIds);
+        appendRanges(RangeType.unit, selection.unitIds);
+        appendRanges(RangeType.user, selection.userIds);
+        return ranges;
+    };
+
+    let saving = false;
+    const saveSelection = function(selection, successCallback) {
+        if (saving) return;
+        saving = true;
+        $('#save-data-range').prop('disabled', true);
+        postData(`/sp/${dataRangeSpid}/setting/user/data-range/update`, {
+            module_key: dataRangeModuleKey,
+            ranges: selectionToRanges(selection),
+        }, function(result) {
+            const config = result || {
+                configured: selectionToRanges(selection).length > 0,
+                unit_type_ids: Array.from(selection.unitTypeIds),
+                unit_ids: Array.from(selection.unitIds),
+                user_ids: Array.from(selection.userIds),
+            };
+            savedSelection = makeSelection(config);
+            savedConfigured = Boolean(config.configured);
+            saving = false;
+            $('#save-data-range').prop('disabled', false);
+            renderMainSelection();
+            renderSummary();
+            if (successCallback) successCallback();
+        }, function() {
+            saving = false;
+            $('#save-data-range').prop('disabled', false);
+        });
+    };
+
+    const refreshModal = function() {
+        renderTree();
+        renderSelected();
+    };
+
+    $('#open-data-range-modal').on('click keydown', function(event) {
+        if ($(event.target).closest('.remove-main-data-range').length > 0) return;
+        if (event.type === 'keydown' && event.keyCode !== 13 && event.keyCode !== 32) return;
+        event.preventDefault();
+        currentSelection = cloneSelection(savedSelection);
+        $('#data-range-keyword').val('');
+        refreshModal();
+        $('#data-range-modal').modal('show');
+    });
+
+    $('body').on('click', '.remove-main-data-range', function(event) {
+        event.preventDefault();
+        event.stopPropagation();
+        if (saving) return;
+        const rangeType = Number($(this).attr('data-range-type'));
+        const targetId = Number($(this).attr('data-target-id'));
+        const nextSelection = cloneSelection(savedSelection);
+        getSelectionSetFrom(nextSelection, rangeType).delete(targetId);
+        saveSelection(nextSelection, function() {
+            toastr.success('授权用户范围已移除');
+        });
+    });
+
+    $('#data-range-search').click(renderTree);
+    $('#data-range-keyword').on('keydown', function(event) {
+        if (event.keyCode === 13) {
+            event.preventDefault();
+            renderTree();
+        }
+    });
+    $('#data-range-keyword').on('input', renderTree);
+
+    $('body').on('click', '.data-range-toggle:not(.empty)', function() {
+        const children = $(this).parent().next('.data-range-children');
+        children.toggleClass('collapsed');
+        $('i', this).toggleClass('fa-caret-down fa-caret-right');
+    });
+
+    $('body').on('change', '.data-range-check', function() {
+        const rangeType = Number($(this).attr('data-range-type'));
+        const targetId = Number($(this).attr('data-target-id'));
+        const selection = getSelectionSet(rangeType);
+        if (this.checked) {
+            selection.add(targetId);
+            if (rangeType === RangeType.unitType) removeTypeDescendants(targetId);
+            if (rangeType === RangeType.unit) removeUnitDescendants(targetId);
+        } else {
+            selection.delete(targetId);
+        }
+        refreshModal();
+    });
+
+    $('body').on('click', '.remove-data-range', function() {
+        const rangeType = Number($(this).attr('data-range-type'));
+        const targetId = Number($(this).attr('data-target-id'));
+        getSelectionSet(rangeType).delete(targetId);
+        refreshModal();
+    });
+
+    $('#save-data-range').click(function() {
+        saveSelection(currentSelection, function() {
+            $('#data-range-modal').modal('hide');
+            toastr.success('数据范围保存成功');
+        });
+    });
+
+    renderMainSelection();
+    renderSummary();
+});

+ 94 - 0
app/public/js/spreadjs_rela/spreadjs_zh.js

@@ -1588,6 +1588,84 @@ const SpreadJsObj = {
                 }
             };
 
+            /**
+             * 绘制文件夹图标(使用arcTo兼容旧浏览器)
+             * @param {Object} canvas - 画布
+             * @param {Number} x - 左上角x坐标
+             * @param {Number} y - 左上角y坐标
+             * @param {Number} s - 图标尺寸
+             */
+            const drawFolderIcon = function (canvas, x, y, s) {
+                canvas.save();
+                // 文件夹整体(含顶部标签页)
+                canvas.fillStyle = '#FFC107';
+                canvas.strokeStyle = '#F57F17';
+                canvas.lineWidth = 0.8;
+                canvas.beginPath();
+                canvas.moveTo(x, y + 3);
+                canvas.lineTo(x, y);
+                canvas.lineTo(x + s * 0.4, y);
+                canvas.lineTo(x + s * 0.5, y + 3);
+                canvas.lineTo(x + s, y + 3);
+                canvas.arcTo(x + s, y + s, x, y + s, 1.5);
+                canvas.arcTo(x, y + s, x, y, 1.5);
+                canvas.closePath();
+                canvas.fill();
+                canvas.stroke();
+                // 前面板(浅色,营造层次感)
+                canvas.fillStyle = '#FFD54F';
+                canvas.beginPath();
+                canvas.moveTo(x, y + 5);
+                canvas.arcTo(x + s, y + 5, x + s, y + s, 1.5);
+                canvas.arcTo(x + s, y + s, x, y + s, 1.5);
+                canvas.closePath();
+                canvas.fill();
+                canvas.restore();
+            };
+            /**
+             * 绘制文件图标(带折角和文字线条)
+             * @param {Object} canvas - 画布
+             * @param {Number} x - 左上角x坐标
+             * @param {Number} y - 左上角y坐标
+             * @param {Number} s - 图标尺寸
+             */
+            const drawFileIcon = function (canvas, x, y, s) {
+                const fold = s * 0.3;
+                canvas.save();
+                // 文件主体(带折角轮廓)
+                canvas.fillStyle = '#E3F2FD';
+                canvas.strokeStyle = '#42A5F5';
+                canvas.lineWidth = 0.8;
+                canvas.beginPath();
+                canvas.moveTo(x, y);
+                canvas.lineTo(x + s - fold, y);
+                canvas.lineTo(x + s, y + fold);
+                canvas.lineTo(x + s, y + s);
+                canvas.arcTo(x, y + s, x, y, 1.5);
+                canvas.closePath();
+                canvas.fill();
+                canvas.stroke();
+                // 折角三角形
+                canvas.fillStyle = '#90CAF9';
+                canvas.beginPath();
+                canvas.moveTo(x + s - fold, y);
+                canvas.lineTo(x + s - fold, y + fold);
+                canvas.lineTo(x + s, y + fold);
+                canvas.closePath();
+                canvas.fill();
+                canvas.stroke();
+                // 文字线条
+                canvas.strokeStyle = '#90CAF9';
+                canvas.lineWidth = 0.6;
+                canvas.beginPath();
+                canvas.moveTo(x + 2, y + fold + 3);
+                canvas.lineTo(x + s - 3, y + fold + 3);
+                canvas.moveTo(x + 2, y + fold + 5.5);
+                canvas.lineTo(x + s - 5, y + fold + 5.5);
+                canvas.stroke();
+                canvas.restore();
+            };
+
             let TreeNodeCellType = function (){};
             TreeNodeCellType.prototype = new spreadNS.CellTypes.Text();
             const proto = TreeNodeCellType.prototype;
@@ -1693,6 +1771,22 @@ const SpreadJsObj = {
                         const move = (node[setting.level]) * indent + (node[setting.level]) * levelIndent + xOffset;
                         x = x + move;
                         w = w - move;
+                        // 绘制节点图标(文件夹/文件等)
+                        const col = options.sheet.zh_setting.cols[options.col];
+                        if (col && col.getIcon) {
+                            const iconType = col.getIcon(node);
+                            if (iconType) {
+                                const iconSize = 14;
+                                const iconY = y + Math.floor((h - iconSize) / 2);
+                                if (iconType === 'folder') {
+                                    drawFolderIcon(canvas, x, iconY, iconSize);
+                                } else if (iconType === 'file') {
+                                    drawFileIcon(canvas, x, iconY, iconSize);
+                                }
+                                x += iconSize + 4;
+                                w -= iconSize + 4;
+                            }
+                        }
                     }
                 }
                 // Drawing Text

+ 17 - 0
app/router.js

@@ -223,6 +223,8 @@ module.exports = app => {
     app.get('/sp/:id/setting/user', sessionAuth, subProjectCheck, 'subProjSettingController.user');
     app.get('/sp/:id/setting/user/permission', sessionAuth, subProjectCheck, 'subProjSettingController.permission');
     app.post('/sp/:id/setting/user/permission/update', sessionAuth, subProjectCheck, 'subProjSettingController.permissionUpdate');
+    app.get('/sp/:id/setting/user/data-range', sessionAuth, subProjectCheck, 'subProjSettingController.dataRange');
+    app.post('/sp/:id/setting/user/data-range/update', sessionAuth, subProjectCheck, 'subProjSettingController.dataRangeUpdate');
     // 操作日志
     app.get('/sp/:id/setting/logs', sessionAuth, subProjectCheck, 'subProjSettingController.logs');
     app.get('/sp/:id/setting/logs/type/:type', sessionAuth, subProjectCheck, 'subProjSettingController.logs');
@@ -392,8 +394,19 @@ module.exports = app => {
     app.post('/file/template/:id/update', sessionAuth, projectManagerCheck, 'fileController.updateTemplate');
     // 资料归集-文件
     app.get('/sp/:id/file', sessionAuth, subProjectCheck, 'fileController.file');
+    // 资料归集-SpreadJS 版本(第二版)
+    app.get('/sp/:id/filesjs', sessionAuth, subProjectCheck, 'fileController.filesjs');
+    // 配置目录(SpreadJS树管理+授权用户)
+    app.get('/sp/:id/config-dir', sessionAuth, subProjectCheck, 'fileController.configDir');
+    app.post('/sp/:id/config-dir/lock', sessionAuth, subProjectCheck, 'fileController.configDirLock');
+    app.post('/sp/:id/config-dir/update', sessionAuth, subProjectCheck, 'fileController.configDirUpdate');
+    app.post('/sp/:id/config-dir/permission', sessionAuth, subProjectCheck, 'fileController.getFilingNodePermission');
+    app.post('/sp/:id/config-dir/permission/save', sessionAuth, subProjectCheck, 'fileController.saveFilingNodePermission');
+    app.post('/sp/:id/config-dir/permission/add-other', sessionAuth, subProjectCheck, 'fileController.addFilingNodePermissions');
+    app.post('/sp/:id/config-dir/permission/cover-other', sessionAuth, subProjectCheck, 'fileController.coverFilingNodePermissions');
     app.post('/sp/:id/permission', sessionAuth, projectManagerCheck, subProjectCheck, 'fileController.getFilingTypePermission');
     app.post('/sp/:id/permission/save', sessionAuth, projectManagerCheck, subProjectCheck, 'fileController.saveFilingTypePermission');
+    app.post('/sp/:id/filing/init', sessionAuth, subProjectCheck, 'fileController.initializeFiling');
     app.post('/sp/:id/filing/add', sessionAuth, subProjectCheck, 'fileController.addFiling');
     app.post('/sp/:id/filing/save', sessionAuth, subProjectCheck, 'fileController.saveFiling');
     app.post('/sp/:id/filing/del', sessionAuth, subProjectCheck, 'fileController.delFiling');
@@ -404,6 +417,7 @@ module.exports = app => {
     app.post('/sp/:id/file/upload/big', sessionAuth, subProjectCheck, 'fileController.uploadBigFile');
     app.post('/sp/:id/file/del', sessionAuth, subProjectCheck, 'fileController.delFile');
     app.post('/sp/:id/file/save', sessionAuth, subProjectCheck, 'fileController.saveFile');
+    app.post('/sp/:id/file/lock', sessionAuth, subProjectCheck, 'fileController.lockFile');
     app.post('/sp/:id/file/move', sessionAuth, subProjectCheck, 'fileController.moveFile');
     app.post('/sp/:id/file/rela', sessionAuth, subProjectCheck, 'fileController.relaFile');
     app.post('/sp/:id/file/rela/tender', sessionAuth, subProjectCheck, 'fileController.loadValidRelaTender');
@@ -618,6 +632,9 @@ module.exports = app => {
     // 安全巡检
     app.get('/sp/:id/safe/inspection', sessionAuth, subProjectCheck, 'safeController.inspectionTender');
     app.get('/sp/:id/safe/tender/:tid/inspection', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, 'safeController.inspection');
+    app.get('/sp/:id/safe/tender/:tid/inspection/overview', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, 'safeController.inspectionOverview');
+    app.get('/sp/:id/safe/tender/:tid/inspection/rectification', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, 'safeController.inspectionRectification');
+    app.get('/sp/:id/safe/tender/:tid/inspection/approval', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, 'safeController.inspectionApproval');
     app.post('/sp/:id/safe/tender/:tid/inspection/save', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, 'safeController.inspectionSave');
     app.get('/sp/:id/safe/tender/:tid/inspection/:qiid/information', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, safeInspectionCheck, 'safeController.inspectionInformation');
     app.post('/sp/:id/safe/tender/:tid/inspection/:qiid/information/save', sessionAuth, subProjectCheck, tenderCheck, tenderPermissionCheck, safeInspectionCheck, 'safeController.inspectionInformationSave');

+ 62 - 12
app/service/file.js

@@ -50,6 +50,44 @@ module.exports = app => {
             return files.filter(x => { return existFilesName.indexOf(x) >= 0; });
         }
 
+        async _checkCanEditFile(file) {
+            if (!file || file.spid !== this.ctx.subProject.id || file.is_deleted) throw '文件不存在';
+            if (Number(file.is_locked) === 1) throw '文件已锁定,无法编辑、移动或删除';
+            const isAdmin = Number(this.ctx.session.sessionUser.is_admin) === 1;
+            if (isAdmin) return;
+            const permission = await this.ctx.service.subProjectFilingPermission.getResolvedPermission(
+                this.ctx.subProject.id,
+                this.ctx.session.sessionUser.accountId,
+                file.filing_id,
+                file.filing_type,
+                this.ctx.subProject.permission.file_permission,
+                this.ctx.subProject.permission.filing_type,
+                isAdmin
+            );
+            if (!permission.can_edit_file) throw '您没有该目录的编辑文件权限';
+        }
+
+        async setLocked(id, isLocked) {
+            const locked = Number(isLocked);
+            if (locked !== 0 && locked !== 1) throw '文件锁定状态错误';
+            const file = await this.getDataById(id);
+            if (!file || file.spid !== this.ctx.subProject.id || file.is_deleted) throw '文件不存在';
+            const isAdmin = Number(this.ctx.session.sessionUser.is_admin) === 1;
+            const permission = await this.ctx.service.subProjectFilingPermission.getResolvedPermission(
+                this.ctx.subProject.id,
+                this.ctx.session.sessionUser.accountId,
+                file.filing_id,
+                file.filing_type,
+                this.ctx.subProject.permission.file_permission,
+                this.ctx.subProject.permission.filing_type,
+                isAdmin
+            );
+            if (!permission.can_lock_file) throw '您没有该目录的锁定文件权限';
+            const updateData = { id: file.id, is_locked: locked };
+            await this.defaultUpdate(updateData);
+            return updateData;
+        }
+
         async addFiles(filing, fileInfo, user) {
             const conn = await this.db.beginTransaction();
             const result = {};
@@ -79,11 +117,11 @@ module.exports = app => {
             if (files.length === 0) return;
 
             const fileDatas = await this.getAllDataByCondition({ where: { id: files } });
+            if (fileDatas.length !== files.length) throw '文件不存在';
             const filing = await this.ctx.service.filing.getDataById(fileDatas[0].filing_id);
-            if (this.ctx.subProject.permission.file_permission.indexOf(this.ctx.service.subProjPermission.PermissionConst.file.editfile.value) < 0) {
-                for (const file of fileDatas) {
-                    if (file.user_id !== this.ctx.session.sessionUser.accountId && this.ctx.subProject.permission.file_permission.indexOf(this.ctx.service.subProjPermission.PermissionConst.file.editfile.value) < 0) throw '无权删除文件';
-                }
+            for (const file of fileDatas) {
+                if (file.filing_id !== filing.id) throw '请选择同一资料类别下的文件';
+                await this._checkCanEditFile(file);
             }
             const result = {};
 
@@ -129,10 +167,10 @@ module.exports = app => {
             return result;
         }
 
-        async saveFile(id, filename){
+        async saveFile(id, filename) {
             const file = await this.getDataById(id);
             if (!file) throw '文件不存在';
-            if (file.user_id !== this.ctx.session.sessionUser.accountId && this.ctx.subProject.permission.file_permission.indexOf(this.ctx.service.subProjPermission.PermissionConst.file.editfile.value) < 0) throw '您无权编辑该文件';
+            await this._checkCanEditFile(file);
 
             const info = path.parse(filename);
             const updateData = { id, filename: info.name, fileext: info.ext};
@@ -143,10 +181,10 @@ module.exports = app => {
         async moveFile(id, filing_id) {
             const file = await this.getDataById(id);
             if (!file) throw '文件不存在';
-            if (file.user_id !== this.ctx.session.sessionUser.accountId && this.ctx.subProject.permission.file_permission.indexOf(this.ctx.service.subProjPermission.PermissionConst.file.editfile.value) < 0) throw '您无权编辑该文件';
+            await this._checkCanEditFile(file);
             const orgFiling = await this.ctx.service.filing.getDataById(file.filing_id);
             const filing = await this.ctx.service.filing.getDataById(filing_id);
-            if (!filing) throw '目标分类不存在';
+            if (!filing || filing.spid !== this.ctx.subProject.id || filing.is_deleted) throw '目标分类不存在';
 
             const conn = await this.db.beginTransaction();
             try {
@@ -165,13 +203,25 @@ module.exports = app => {
         async search(filing_type, keyword, limit = 1000) {
             if (!filing_type || filing_type.length === 0 || !keyword) return [];
             const sql = `SELECT * FROM ${this.tableName}` +
-                `  WHERE spid = ? and is_deleted = 0 and filing_type in (${filing_type.join(',')}) and filename like '%${keyword}%'`+
-                `  ORDER BY update_time DESC LIMIT 0, ${limit}`;
-            const result = await this.db.query(sql, [this.ctx.subProject.id]);
+                '  WHERE spid = ? and is_deleted = 0 and filing_type in (?) and filename like ?' +
+                '  ORDER BY update_time DESC LIMIT 0, ?';
+            const result = await this.db.query(sql, [this.ctx.subProject.id, filing_type, `%${keyword}%`, Number(limit)]);
+            this.analysisFiles(result);
+            return result;
+        }
+
+        async searchByFilingIds(filingIds, keyword, limit = 1000) {
+            if (!filingIds || filingIds.length === 0 || !keyword) return [];
+            const sql = `SELECT * FROM ${this.tableName}` +
+                '  WHERE spid = ? and is_deleted = 0 and filing_id in (?) and filename like ?' +
+                '  ORDER BY update_time DESC LIMIT 0, ?';
+            const result = await this.db.query(sql, [
+                this.ctx.subProject.id, filingIds, `%${keyword}%`, Number(limit),
+            ]);
             this.analysisFiles(result);
             return result;
         }
     }
 
     return File;
-};
+};

+ 173 - 21
app/service/filing.js

@@ -92,9 +92,10 @@ module.exports = app => {
                 f.newId = this.uuid.v4();
                 const parent = f.tree_pid !== rootId ? templateFiling.find(x => { return x.id === f.tree_pid; }) : null;
                 const newData = {
-                    id: f.newId, tree_pid : parent ? parent.newId : rootId, tree_level: f.tree_level, tree_order: f.tree_order,
-                    spid, add_user_id: this.ctx.session.sessionUser.accountId, is_fixed: f.is_fixed,
-                    filing_type: f.filing_type, name: f.name, tips: f.tips, file_company: f.file_company,
+                    id: f.newId, tree_pid: parent ? parent.newId : rootId, tree_level: f.tree_level, tree_order: f.tree_order,
+                    spid, add_user_id: this.ctx.session.sessionUser.accountId,
+                    create_uid: this.ctx.session.sessionUser.accountId, is_fixed: f.is_fixed,
+                    filing_type: f.filing_type, name: f.name, tips: f.tips, upload_tips: f.upload_tips, file_company: f.file_company,
                 };
                 insertData.push(newData);
             }
@@ -105,6 +106,91 @@ module.exports = app => {
             }
         }
 
+        _buildInitializedFiling(spid, sourceData, operatorUid) {
+            const source = (sourceData || []).slice().sort((x, y) => {
+                const levelDiff = Number(x.tree_level) - Number(y.tree_level);
+                return levelDiff || Number(x.tree_order) - Number(y.tree_order);
+            });
+            const sourceNodeMap = {};
+            const insertData = [];
+            for (const node of source) {
+                const sourceId = String(node.id || '');
+                if (!sourceId || sourceNodeMap[sourceId]) throw '来源项目目录数据错误';
+                const sourceParentId = String(node.tree_pid);
+                const isRoot = sourceParentId === rootId;
+                const parent = isRoot ? null : sourceNodeMap[sourceParentId];
+                if (!isRoot && !parent) throw '来源项目目录结构不完整';
+
+                const newNode = {
+                    id: this.uuid.v4(),
+                    spid,
+                    tree_pid: parent ? parent.id : rootId,
+                    tree_level: parent ? Number(parent.tree_level) + 1 : 1,
+                    tree_order: Number(node.tree_order),
+                    name: String(node.name || ''),
+                    filing_type: Number(node.filing_type),
+                    add_user_id: Number(operatorUid),
+                    create_uid: Number(operatorUid),
+                    is_fixed: Number(node.is_fixed) === 1 ? 1 : 0,
+                    is_deleted: 0,
+                    file_count: 0,
+                    tips: node.tips || '',
+                    upload_tips: node.upload_tips || '',
+                    file_company: node.file_company || '',
+                    is_rela: Number(node.is_rela) === 1 ? 1 : 0,
+                };
+                if (!newNode.name || !Number.isInteger(newNode.filing_type) || newNode.filing_type <= 0 ||
+                    !Number.isInteger(newNode.tree_order) || newNode.tree_order <= 0) {
+                    throw '来源项目目录数据错误';
+                }
+                sourceNodeMap[sourceId] = newNode;
+                insertData.push(newNode);
+            }
+            return insertData;
+        }
+
+        /**
+         * 首次初始化项目资料目录。
+         *
+         * 空白模式创建一个可继续编辑的根目录;项目模式只复制目录字段,
+         * 不复制文件与目录授权用户配置。
+         *
+         * @param {String} spid 目标子项目ID
+         * @param {String} initType 初始化方式(blank/project)
+         * @param {Array} sourceData 来源项目有效目录
+         * @param {Number} operatorUid 初始化用户ID
+         * @return {Object} 初始化结果
+         */
+        async initializeDirectory(spid, initType, sourceData, operatorUid) {
+            let directorySource = sourceData || [];
+            if (initType === 'blank') {
+                directorySource = [{
+                    id: 'blank-root', tree_pid: rootId, tree_level: 1, tree_order: 1,
+                    name: '新建文件夹', filing_type: maxFilingType + 1, is_fixed: 1,
+                    tips: '', upload_tips: '', file_company: '', is_rela: 0,
+                }];
+            } else if (initType !== 'project') {
+                throw '初始化方式错误';
+            }
+            if (directorySource.length === 0) throw '来源项目没有可复制的资料目录';
+            const insertData = this._buildInitializedFiling(spid, directorySource, operatorUid);
+
+            const conn = await this.db.beginTransaction();
+            try {
+                await conn.query('SELECT id FROM ?? WHERE id = ? FOR UPDATE', [
+                    this.ctx.service.subProject.tableName, spid,
+                ]);
+                const activeCount = await conn.count(this.tableName, { spid, is_deleted: 0 });
+                if (activeCount > 0) throw '当前项目已经存在资料目录,无需重复初始化';
+                await conn.insert(this.tableName, insertData);
+                await conn.commit();
+                return { count: insertData.length };
+            } catch (err) {
+                await conn.rollback();
+                throw err;
+            }
+        }
+
         _filterValidFiling(filing, filingType) {
             const validFiling = filing.filter(x => { return filingType.indexOf(x.filing_type) > -1;});
             const checkParent = function(child) {
@@ -180,6 +266,7 @@ module.exports = app => {
                 const name = await this.getNewName(this.ctx.subProject.id);
                 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
                 };
@@ -230,6 +317,9 @@ module.exports = app => {
                 await conn.updateRows(this.tableName, delData);
                 if (updateData.length > 0) conn.updateRows(this.tableName, updateData);
                 await conn.update(this.ctx.service.file.tableName, { is_deleted: 1}, { where: {filing_id: delData.map(x => { return x.id; })} });
+                await this.ctx.service.subProjectFilingPermission.removeFilingNodes(
+                    filing.spid, delData.map(x => x.id), conn
+                );
 
                 await conn.commit();
                 return { delete: delData.map(x => { return x.id }), update: updateData };
@@ -240,30 +330,57 @@ module.exports = app => {
         }
         async move(data) {
             const filing = await this.getDataById(data.id);
-            if (!filing) throw '移动的分类不存在,请刷新页面后重试';
-            const parent = await this.getDataById(data.tree_pid);
-            if (!parent && filing.tree_pid !== data.tree_pid) throw '移动后的分类不存在,请刷新页面后重试';
-            const sibling = await this.getAllDataByCondition({ where: { spid: filing.spid, tree_pid: data.tree_pid, is_deleted: 0 } });
+            if (!filing || filing.is_deleted) throw '移动的分类不存在,请刷新页面后重试';
+            if (this.ctx.subProject && filing.spid !== this.ctx.subProject.id) throw '移动的分类不属于当前项目';
+            if (Number(filing.is_fixed)) throw '固定分类不可移动';
+            if (data.tree_pid === undefined || data.tree_pid === null || data.tree_pid === '') throw '请选择移动后的目录';
+            const treePid = String(data.tree_pid);
+            const treeOrder = Number(data.tree_order);
+            if (!Number.isInteger(treeOrder) || treeOrder < 0) throw '移动后的目录顺序错误';
             const posterity = await this.getPosterityData(filing.id);
-            const updateData = { id: filing.id, tree_order: data.tree_order, tree_pid: data.tree_pid, tree_level: (parent ? parent.tree_level : 0) + 1 };
+            const filingWithFiles = [filing, ...posterity].find(x => !x.is_deleted && Number(x.file_count) > 0);
+            if (filingWithFiles) throw `分类【${filingWithFiles.name}】下存在文件,不可移动目录`;
+            if (treePid === String(filing.id) || posterity.find(x => String(x.id) === treePid)) {
+                throw '不能将目录移动到自身或其子目录下';
+            }
+            const parent = treePid === rootId ? null : await this.getDataById(treePid);
+            if (treePid !== rootId && (!parent || parent.is_deleted || parent.spid !== filing.spid)) {
+                throw '移动后的分类不存在,请刷新页面后重试';
+            }
+            if (parent && String(filing.tree_pid) !== treePid && Number(parent.file_count) > 0) {
+                throw `分类【${parent.name}】下存在文件,不可添加子分类`;
+            }
+            const sibling = await this.getAllDataByCondition({ where: { spid: filing.spid, tree_pid: treePid, is_deleted: 0 } });
+            const updateData = { id: filing.id, tree_order: treeOrder, tree_pid: treePid, tree_level: (parent ? parent.tree_level : 0) + 1 };
+            if (data.name !== undefined) {
+                const name = String(data.name).trim();
+                if (!name) throw '目录名称不能为空';
+                if (name.length > 100) throw '目录名称不能超过100个字符';
+                updateData.name = name;
+            }
+            if (data.is_fixed !== undefined) {
+                const isFixed = Number(data.is_fixed);
+                if (isFixed !== 0 && isFixed !== 1) throw '固定目录状态错误';
+                updateData.is_fixed = isFixed;
+            }
             const posterityUpdateData = posterity.map(x => {
-               return { id: x.id,  tree_level: (parent ? parent.tree_level : 0) + 1 - filing.tree_level + x.tree_level };
+                return { id: x.id, tree_level: (parent ? parent.tree_level : 0) + 1 - filing.tree_level + x.tree_level };
             });
             const siblingUpdateData = [];
-            if (data.tree_pid === filing.tree_pid) {
-                if (data.tree_order < filing.tree_order) {
+            if (treePid === String(filing.tree_pid)) {
+                if (treeOrder < filing.tree_order) {
                     sibling.forEach(x => {
                         if (x.id === filing.id) return;
-                        if (x.tree_order < data.tree_order) return;
+                        if (x.tree_order < treeOrder) return;
                         if (x.tree_order > filing.tree_order) return;
-                        siblingUpdateData.push({id: x.id, tree_order: x.tree_order + 1});
+                        siblingUpdateData.push({ id: x.id, tree_order: x.tree_order + 1 });
                     });
                 } else {
                     sibling.forEach(x => {
                         if (x.id === filing.id) return;
                         if (x.tree_order < filing.tree_order) return;
-                        if (x.tree_order > data.tree_order) return;
-                        siblingUpdateData.push({id: x.id, tree_order: x.tree_order - 1});
+                        if (x.tree_order > treeOrder) return;
+                        siblingUpdateData.push({ id: x.id, tree_order: x.tree_order - 1 });
                     });
                 }
             } else {
@@ -271,13 +388,13 @@ module.exports = app => {
                 orgSibling.forEach(x => {
                     if (x.id === filing.id) return;
                     if (x.tree_order < filing.tree_order) return;
-                    siblingUpdateData.push({id: x.id, tree_order: x.tree_order - 1});
+                    siblingUpdateData.push({ id: x.id, tree_order: x.tree_order - 1 });
                 });
                 sibling.forEach(x => {
                     if (x.id === filing.id) return;
-                    if (x.tree_order < data.tree_order) return;
-                    siblingUpdateData.push({id: x.id, tree_order: x.tree_order + 1});
-                })
+                    if (x.tree_order < treeOrder) return;
+                    siblingUpdateData.push({ id: x.id, tree_order: x.tree_order + 1 });
+                });
             }
 
             const conn = await this.db.beginTransaction();
@@ -293,12 +410,47 @@ module.exports = app => {
             return { update: [updateData, ...posterityUpdateData, ...siblingUpdateData] };
         }
 
+        async editDirectory(data) {
+            if (!data || !data.id) throw '请选择需要编辑的目录';
+            const filing = await this.getDataById(data.id);
+            if (!filing || filing.is_deleted) throw '编辑的分类不存在,请刷新页面后重试';
+            if (this.ctx.subProject && filing.spid !== this.ctx.subProject.id) throw '编辑的分类不属于当前项目';
+            const name = String(data.name || '').trim();
+            if (!name) throw '目录名称不能为空';
+            if (name.length > 100) throw '目录名称不能超过100个字符';
+            const isFixed = Number(data.is_fixed);
+            if (isFixed !== 0 && isFixed !== 1) throw '固定目录状态错误';
+            if (data.tree_pid === undefined || data.tree_pid === null || data.tree_pid === '') throw '请选择目录';
+            const treePid = String(data.tree_pid);
+            if (treePid === String(filing.tree_pid)) {
+                const updateData = { id: filing.id, name, is_fixed: isFixed };
+                const result = await this.db.update(this.tableName, updateData);
+                if (result.affectedRows === 0) throw '更新目录失败';
+                return { update: [updateData] };
+            }
+
+            const parent = treePid === rootId ? null : await this.getDataById(treePid);
+            if (treePid !== rootId && (!parent || parent.is_deleted || parent.spid !== filing.spid)) {
+                throw '移动后的分类不存在,请刷新页面后重试';
+            }
+            const targetChildren = await this.getAllDataByCondition({
+                where: { spid: filing.spid, tree_pid: treePid, is_deleted: 0 },
+                orders: [['tree_order', 'asc']],
+            });
+            const lastChild = targetChildren.length > 0 ? targetChildren[targetChildren.length - 1] : null;
+            const treeOrder = lastChild ? Number(lastChild.tree_order) + 1 : 1;
+            return await this.move({
+                id: filing.id, tree_pid: treePid, tree_order: treeOrder,
+                name, is_fixed: isFixed,
+            });
+        }
+
         async multiUpdate(spid, data) {
             if (!data || data.length === 0) throw '提交数据格式错误';
 
             const sourceData = await this.getAllDataByCondition({ where: { spid } });
 
-            const validFields = ['id', 'is_fixed', 'name', 'filing_type', 'tree_order', 'tips', 'file_company'];
+            const validFields = ['id', 'is_fixed', 'name', 'filing_type', 'tree_order', 'tips', 'upload_tips', 'file_company'];
             const updateData = [];
             for (const d of data) {
                 if (!d.id) throw '提交数据格式错误';
@@ -323,4 +475,4 @@ module.exports = app => {
     }
 
     return Filing;
-};
+};

+ 223 - 0
app/service/sub_proj_data_range.js

@@ -0,0 +1,223 @@
+'use strict';
+
+const accountGroup = require('../const/account_group').group;
+
+/**
+ * 子项目功能栏目数据范围
+ *
+ * range_type: 1-单位类型,2-单位,3-具体用户
+ *
+ * @param {Object} app - Egg应用实例
+ * @return {Object} 数据范围Service
+ */
+module.exports = app => {
+    class SubProjDataRange extends app.BaseService {
+
+        constructor(ctx) {
+            super(ctx);
+            this.tableName = 'sub_project_data_range';
+            this.Module = {
+                file: { key: 'file', name: '资料管理' },
+            };
+            this.RangeType = {
+                unitType: 1,
+                unit: 2,
+                user: 3,
+            };
+        }
+
+        getModuleList() {
+            return Object.keys(this.Module).map(key => this.Module[key]);
+        }
+
+        getModule(moduleKey) {
+            const moduleInfo = this.Module[moduleKey];
+            if (!moduleInfo) throw '数据范围功能栏目不存在';
+            return moduleInfo;
+        }
+
+        _toId(value) {
+            const id = Number(value);
+            return Number.isInteger(id) && id > 0 ? id : 0;
+        }
+
+        _getRangeKey(rangeType) {
+            if (rangeType === this.RangeType.unitType) return 'unit_type_ids';
+            if (rangeType === this.RangeType.unit) return 'unit_ids';
+            if (rangeType === this.RangeType.user) return 'user_ids';
+            return '';
+        }
+
+        async getRangeRows(spid, moduleKey) {
+            this.getModule(moduleKey);
+            return await this.getAllDataByCondition({
+                where: { spid, module_key: moduleKey },
+                orders: [['range_type', 'asc'], ['target_id', 'asc']],
+            });
+        }
+
+        async getRangeConfig(spid, moduleKey) {
+            const rows = await this.getRangeRows(spid, moduleKey);
+            const result = {
+                configured: rows.length > 0,
+                unit_type_ids: [],
+                unit_ids: [],
+                user_ids: [],
+            };
+            rows.forEach(row => {
+                const key = this._getRangeKey(Number(row.range_type));
+                const targetId = this._toId(row.target_id);
+                if (key && targetId && result[key].indexOf(targetId) < 0) result[key].push(targetId);
+            });
+            return result;
+        }
+
+        async _getScopeSource(subProject) {
+            const result = await Promise.all([
+                this.ctx.service.constructionUnit.getAllDataByCondition({
+                    where: { pid: subProject.project_id },
+                    orders: [['type', 'asc'], ['id', 'asc']],
+                }),
+                this.ctx.service.projectAccount.getAllSubProjectAccount(subProject, [
+                    'id', 'name', 'company', 'company_id', 'role', 'account_group', 'mobile',
+                ]),
+            ]);
+            return { unitList: result[0], accountList: result[1] };
+        }
+
+        async getRangeTree(subProject) {
+            const source = await this._getScopeSource(subProject);
+            const unitList = source.unitList;
+            const accountList = source.accountList;
+            const assignedUserIds = [];
+            const typeIds = this._.uniq(unitList.map(unit => Number(unit.type))).sort((a, b) => a - b);
+            const tree = typeIds.map(typeId => {
+                const units = unitList.filter(unit => Number(unit.type) === typeId).map(unit => {
+                    const users = accountList.filter(account => {
+                        const sameId = Number(account.company_id) === Number(unit.id);
+                        const oldDataMatch = !account.company_id && account.company === unit.name;
+                        if (sameId || oldDataMatch) assignedUserIds.push(Number(account.id));
+                        return sameId || oldDataMatch;
+                    }).map(account => ({
+                        id: Number(account.id),
+                        name: account.name,
+                        role: account.role || '',
+                    }));
+                    return { id: Number(unit.id), name: unit.name, users };
+                });
+                return {
+                    id: typeId,
+                    name: accountGroup[typeId] || `单位类型${typeId}`,
+                    units,
+                };
+            });
+
+            const unassignedUsers = accountList.filter(account => assignedUserIds.indexOf(Number(account.id)) < 0).map(account => ({
+                id: Number(account.id),
+                name: account.name,
+                company: account.company || '',
+                role: account.role || '',
+                account_group: Number(account.account_group) || 0,
+            }));
+            return { tree, unassignedUsers };
+        }
+
+        _normalizeRanges(ranges) {
+            if (!(ranges instanceof Array)) throw '数据范围格式错误';
+            const result = [];
+            const exists = {};
+            ranges.forEach(range => {
+                const rangeType = Number(range.range_type);
+                const targetId = this._toId(range.target_id);
+                if (!this._getRangeKey(rangeType) || !targetId) throw '数据范围包含无效数据';
+                const key = `${rangeType}_${targetId}`;
+                if (!exists[key]) {
+                    exists[key] = true;
+                    result.push({ range_type: rangeType, target_id: targetId });
+                }
+            });
+            return result;
+        }
+
+        async _validateRanges(subProject, ranges) {
+            const normalized = this._normalizeRanges(ranges);
+            const source = await this._getScopeSource(subProject);
+            const valid = {};
+            valid[this.RangeType.unitType] = this._.uniq(source.unitList.map(unit => Number(unit.type)));
+            valid[this.RangeType.unit] = source.unitList.map(unit => Number(unit.id));
+            valid[this.RangeType.user] = source.accountList.map(account => Number(account.id));
+            normalized.forEach(range => {
+                if (valid[range.range_type].indexOf(range.target_id) < 0) throw '数据范围包含不属于当前项目的数据';
+            });
+            return normalized;
+        }
+
+        async saveRange(subProject, moduleKey, ranges, createUid) {
+            this.getModule(moduleKey);
+            const normalized = await this._validateRanges(subProject, ranges);
+            const insertData = normalized.map(range => ({
+                id: this.uuid.v4(),
+                pid: subProject.project_id,
+                spid: subProject.id,
+                module_key: moduleKey,
+                range_type: range.range_type,
+                target_id: range.target_id,
+                create_uid: createUid,
+            }));
+            const transaction = await this.db.beginTransaction();
+            try {
+                await transaction.delete(this.tableName, { spid: subProject.id, module_key: moduleKey });
+                if (insertData.length > 0) await transaction.insert(this.tableName, insertData);
+                await transaction.commit();
+                return await this.getRangeConfig(subProject.id, moduleKey);
+            } catch (err) {
+                await transaction.rollback();
+                throw err;
+            }
+        }
+
+        async getSelectableAccounts(subProject, moduleKey) {
+            const source = await this._getScopeSource(subProject);
+            const rows = await this.getRangeRows(subProject.id, moduleKey);
+            // 历史项目未配置数据范围时,保持原有的“全部子项目成员”行为。
+            if (rows.length === 0) return source.accountList;
+
+            const unitTypeIds = [];
+            const unitIds = [];
+            const userIds = [];
+            rows.forEach(row => {
+                const targetId = Number(row.target_id);
+                if (Number(row.range_type) === this.RangeType.unitType) unitTypeIds.push(targetId);
+                if (Number(row.range_type) === this.RangeType.unit) unitIds.push(targetId);
+                if (Number(row.range_type) === this.RangeType.user) userIds.push(targetId);
+            });
+            const unitIndex = {};
+            const unitNameIndex = {};
+            source.unitList.forEach(unit => {
+                unitIndex[Number(unit.id)] = unit;
+                unitNameIndex[unit.name] = unit;
+            });
+            return source.accountList.filter(account => {
+                const accountId = Number(account.id);
+                const unit = unitIndex[Number(account.company_id)] || unitNameIndex[account.company];
+                const companyId = unit ? Number(unit.id) : Number(account.company_id);
+                const unitType = unit ? Number(unit.type) : Number(account.account_group);
+                return userIds.indexOf(accountId) >= 0 ||
+                    unitIds.indexOf(companyId) >= 0 ||
+                    unitTypeIds.indexOf(unitType) >= 0;
+            });
+        }
+
+        async checkSelectableUserIds(subProject, moduleKey, userIds) {
+            if (!(userIds instanceof Array)) throw '授权用户数据格式错误';
+            const normalized = this._.uniq(userIds.map(id => this._toId(id)));
+            if (normalized.indexOf(0) >= 0) throw '授权用户数据错误';
+            const selectableAccounts = await this.getSelectableAccounts(subProject, moduleKey);
+            const selectableIds = selectableAccounts.map(account => Number(account.id));
+            if (normalized.find(id => selectableIds.indexOf(id) < 0)) throw '选择的用户超出资料管理数据范围';
+            return normalized;
+        }
+    }
+
+    return SubProjDataRange;
+};

+ 35 - 7
app/service/sub_proj_permission.js

@@ -31,10 +31,12 @@ module.exports = app => {
                     edit: { title: '编辑', value: 2 },
                 },
                 file: {
-                    view: { title: '查看', value: 1 },
-                    upload: { title: '上传/引用', value: 2 },
-                    editfile: { title: '编辑文件', value: 4 },
-                    filing: { title: '文件类别编辑', value: 3 },
+                    // view: { title: '查看', value: 1 },
+                    // upload: { title: '上传/引用', value: 2 },
+                    // editfile: { title: '编辑文件', value: 4 },
+                    // filing: { title: '文件类别编辑', value: 3 },
+                    manage_dir: { title: '管理目录', value: 5 },
+                    auth_user: { title: '授权用户', value: 6 },
                 },
                 manage: {
                     rela: { title: '关联标段', value: 1 },
@@ -144,7 +146,8 @@ module.exports = app => {
         get touristPermission () {
             return {
                 budget_permission: [this.PermissionConst.budget.view.value],
-                file_permission: [this.PermissionConst.file.view.value],
+                // 旧数据兼容:游客仍保留只读的资料查看权限。
+                file_permission: [1],
                 manage_permission: [],
                 filing_type: 'all',
                 info_permission: [this.PermissionConst.info.view.value],
@@ -287,6 +290,15 @@ module.exports = app => {
         async _updateUserPermission(data) {
             const datas = data instanceof Array ? data : [data];
             const updateData = [];
+            const filePermissionIds = datas.filter(x => x.file_permission !== undefined).map(x => x.id);
+            const oldFilePermissionMap = {};
+            if (filePermissionIds.length > 0) {
+                const oldPermissions = await this.getAllDataByCondition({
+                    columns: ['id', 'file_permission'],
+                    where: { id: filePermissionIds },
+                });
+                oldPermissions.forEach(x => { oldFilePermissionMap[x.id] = x.file_permission || ''; });
+            }
             // const contractData = [];
             for (const x of datas) {
                 const ud = { id: x.id };
@@ -296,7 +308,18 @@ module.exports = app => {
                             if (x[c.field] !== undefined) ud[c.field] = x[c.field] || '';
                         }
                     } else {
-                        if (x[p.field] !== undefined) ud[p.field] = x[p.field] || '';
+                        if (x[p.field] !== undefined) {
+                            if (p.field === 'file_permission') {
+                                // 新权限页只配置 5/6,保留旧项目已有的 1-4,避免隐藏列被覆盖。
+                                const oldValues = String(oldFilePermissionMap[x.id] || '').split(',')
+                                    .map(value => Number(value)).filter(value => value > 0 && value <= 4);
+                                const newValues = String(x[p.field] || '').split(',')
+                                    .map(value => Number(value)).filter(value => value >= 5);
+                                ud[p.field] = this._.uniq(oldValues.concat(newValues)).join(',');
+                            } else {
+                                ud[p.field] = x[p.field] || '';
+                            }
+                        }
                         // if (p.field === 'contract_permission') {
                         //     const spAudit = await this.getDataById(x.id);
                         //     if (spAudit) {
@@ -396,7 +419,12 @@ module.exports = app => {
         }
 
         async getFilingType(subProjectId) {
-            const permissionConst = {}, prefix = 'f';
+            const permissionConst = {
+                f1: '查看',
+                f2: '上传/引用',
+                f3: '文件类别编辑',
+                f4: '编辑文件',
+            }, prefix = 'f';
             for (const p in this.PermissionConst.file) {
                 const fp = this.PermissionConst.file[p];
                 permissionConst[prefix + fp.value] = fp.title;

+ 37 - 0
app/service/sub_project.js

@@ -281,6 +281,43 @@ module.exports = app => {
             return this._filterEmptyFolder(result);
         }
 
+        /**
+         * 获取当前账号可用于复制资料目录的项目。
+         *
+         * 仅返回账号拥有“管理目录”权限、且已经存在有效资料目录的非文件夹项目。
+         * 管理员默认拥有管理目录权限。
+         *
+         * @param {Number} pid 主项目ID
+         * @param {Number} uid 当前账号ID
+         * @param {Boolean} admin 是否管理员
+         * @param {String} excludeSpid 需要排除的当前子项目ID
+         * @return {Array} 可复制目录的项目
+         */
+        async getManageDirProjects(pid, uid, admin, excludeSpid = '') {
+            let projects = await this.getAllDataByCondition({
+                columns: ['id', 'name', 'tree_order'],
+                where: { project_id: pid, is_folder: 0, is_delete: 0 },
+                orders: [['tree_order', 'asc']],
+            });
+            projects = projects.filter(project => String(project.id) !== String(excludeSpid));
+            if (!admin && projects.length > 0) {
+                const permissionRows = await this.ctx.service.subProjPermission.getUserPermission(pid, uid);
+                const manageDirValue = this.ctx.service.subProjPermission.PermissionConst.file.manage_dir.value;
+                projects = projects.filter(project => {
+                    const permission = permissionRows.find(row => String(row.spid) === String(project.id));
+                    return permission && permission.file_permission.indexOf(manageDirValue) >= 0;
+                });
+            }
+            if (projects.length === 0) return [];
+
+            const filingRows = await this.ctx.service.filing.getAllDataByCondition({
+                columns: ['spid'],
+                where: { spid: projects.map(project => project.id), is_deleted: 0 },
+            });
+            const filingSpids = new Set(filingRows.map(filing => String(filing.spid)));
+            return projects.filter(project => filingSpids.has(String(project.id)));
+        }
+
         async getLastChild(tree_pid) {
             const result = await this.getAllDataByCondition({ where: { tree_pid, project_id: this.ctx.session.sessionProject.id }, orders: [['tree_order', 'desc']], limit: 1, offset: 0 });
             return result[0];

+ 587 - 0
app/service/sub_project_filing_permission.js

@@ -0,0 +1,587 @@
+'use strict';
+
+/**
+ * 资料目录用户权限。
+ *
+ * 新数据按 filing_id 精确到目录节点;filing_id 为 NULL 的记录是旧版
+ * filing_type 权限。用户在某个 filing_type 下只要存在节点权限,就只按
+ * 节点权限判断,避免同类型的父子、兄弟目录互相继承;没有节点权限时
+ * 才回退旧版 filing_type 与 file_permission。
+ *
+ * @param {Object} app Egg 应用
+ * @return {Object} Service
+ */
+module.exports = app => {
+    class SubProjectFilingPermission extends app.BaseService {
+
+        constructor(ctx) {
+            super(ctx);
+            this.tableName = 'sub_project_filing_permission';
+            this.PermissionKey = {
+                upload: 'can_upload',
+                editFile: 'can_edit_file',
+                editDir: 'can_edit_dir',
+                lockFile: 'can_lock_file',
+            };
+        }
+
+        _toPermissionList(value) {
+            if (value instanceof Array) return value.map(x => Number(x)).filter(x => x > 0);
+            if (!value) return [];
+            return String(value).split(',').map(x => Number(x)).filter(x => x > 0);
+        }
+
+        _toFlag(value) {
+            return Number(value) === 1 ? 1 : 0;
+        }
+
+        _legacyFlags(filePermission) {
+            const permissions = this._toPermissionList(filePermission);
+            return {
+                can_view: 1,
+                can_upload: permissions.indexOf(2) >= 0 ? 1 : 0,
+                can_edit_file: permissions.indexOf(4) >= 0 ? 1 : 0,
+                can_edit_dir: permissions.indexOf(3) >= 0 ? 1 : 0,
+                can_lock_file: 0,
+                explicit: false,
+                scope: 'legacy',
+            };
+        }
+
+        _rowFlags(row) {
+            return {
+                can_view: 1,
+                can_upload: this._toFlag(row.can_upload),
+                can_edit_file: this._toFlag(row.can_edit_file),
+                can_edit_dir: this._toFlag(row.can_edit_dir),
+                can_lock_file: this._toFlag(row.can_lock_file),
+                explicit: true,
+                scope: row.filing_id ? 'node' : 'legacy',
+            };
+        }
+
+        _emptyFlags() {
+            return {
+                can_view: 0,
+                can_upload: 0,
+                can_edit_file: 0,
+                can_edit_dir: 0,
+                can_lock_file: 0,
+                explicit: true,
+                scope: 'node',
+            };
+        }
+
+        _adminFlags() {
+            return {
+                can_view: 1,
+                can_upload: 1,
+                can_edit_file: 1,
+                can_edit_dir: 1,
+                can_lock_file: 1,
+                explicit: true,
+                scope: 'admin',
+            };
+        }
+
+        _hasLegacyView(filingType, legacyFilingTypes) {
+            if (legacyFilingTypes === 'all') return true;
+            return this._toPermissionList(legacyFilingTypes).indexOf(Number(filingType)) >= 0;
+        }
+
+        _resolvePermissionFromRows(rows, filingId, filingType, legacyFilePermission, legacyFilingTypes) {
+            const type = Number(filingType);
+            const nodeId = String(filingId || '');
+            const exact = rows.find(row => row.filing_id && String(row.filing_id) === nodeId);
+            if (exact) return this._rowFlags(exact);
+
+            // 同类型已有任一节点权限时,未明确授权的兄弟或父子节点不可查看。
+            const hasExactType = rows.some(row => row.filing_id && Number(row.filing_type) === type);
+            if (hasExactType) return this._emptyFlags();
+
+            if (!this._hasLegacyView(type, legacyFilingTypes)) return this._emptyFlags();
+            const legacyRow = rows.find(row => !row.filing_id && Number(row.filing_type) === type);
+            return legacyRow ? this._rowFlags(legacyRow) : this._legacyFlags(legacyFilePermission);
+        }
+
+        async getRows(spid, uid) {
+            const where = { spid };
+            if (uid !== undefined && uid !== null) where.uid = Number(uid);
+            return await this.getAllDataByCondition({ where });
+        }
+
+        /**
+         * 生成配置目录页使用的节点授权数据。
+         *
+         * 新记录直接按 filing_id 返回;旧记录仅映射到固定目录,并使用每个
+         * 固定目录自身的 filing_type。某用户在相同 filing_type 下已有精确
+         * 节点记录时,不再展开该用户的旧类型权限,避免权限扩散到其他节点。
+         *
+         * @param {String} spid 子项目ID
+         * @param {Array} filingList 项目资料目录
+         * @return {Array} 节点授权数据
+         */
+        async getConfigPermissionRows(spid, filingList) {
+            const rows = await this.getRows(spid);
+            const legacyPermissionRows = await this.ctx.service.subProjPermission.getAllDataByCondition({
+                where: { spid },
+            });
+            const result = rows.filter(row => row.filing_id).map(row => {
+                return Object.assign({}, row, { is_legacy: 0 });
+            });
+
+            (filingList || []).forEach(filing => {
+                if (Number(filing.is_fixed) !== 1) return;
+                const type = Number(filing.filing_type);
+                if (!Number.isInteger(type) || type <= 0) return;
+                legacyPermissionRows.forEach(permissionRow => {
+                    const uid = Number(permissionRow.uid);
+                    if (!uid || !this._hasLegacyView(type, permissionRow.filing_type)) return;
+                    const userRows = rows.filter(row => Number(row.uid) === uid);
+                    const hasExactType = userRows.some(row => {
+                        return row.filing_id && Number(row.filing_type) === type;
+                    });
+                    if (hasExactType) return;
+                    const legacyRow = userRows.find(row => {
+                        return !row.filing_id && Number(row.filing_type) === type;
+                    });
+                    const flags = legacyRow ? this._rowFlags(legacyRow) : this._legacyFlags(permissionRow.file_permission);
+                    result.push({
+                        id: legacyRow ? legacyRow.id : null,
+                        pid: permissionRow.pid,
+                        spid,
+                        uid,
+                        filing_type: type,
+                        filing_id: String(filing.id),
+                        can_upload: flags.can_upload,
+                        can_edit_file: flags.can_edit_file,
+                        can_edit_dir: flags.can_edit_dir,
+                        can_lock_file: flags.can_lock_file,
+                        create_time: legacyRow ? legacyRow.create_time : permissionRow.create_time,
+                        is_legacy: 1,
+                    });
+                });
+            });
+            return result;
+        }
+
+        /**
+         * 取得某用户在指定资料节点下的最终权限。
+         *
+         * @param {String} spid 子项目ID
+         * @param {Number} uid 项目用户ID
+         * @param {String} filingId 资料目录ID
+         * @param {Number} filingType 兼容用资料类型
+         * @param {Array|String} legacyFilePermission 旧模块权限
+         * @param {Array|String} legacyFilingTypes 旧查看范围
+         * @param {Boolean} isAdmin 是否管理员
+         * @return {Object} 权限结果
+         */
+        async getResolvedPermission(spid, uid, filingId, filingType, legacyFilePermission, legacyFilingTypes, isAdmin) {
+            if (isAdmin) return this._adminFlags();
+            const rows = await this.getRows(spid, uid);
+            return this._resolvePermissionFromRows(
+                rows, filingId, filingType, legacyFilePermission, legacyFilingTypes
+            );
+        }
+
+        /**
+         * 一次生成新资料管理页需要的 filing_id -> 权限映射。
+         *
+         * @param {String} spid 子项目ID
+         * @param {Number} uid 项目用户ID
+         * @param {Array} filingList 项目资料目录
+         * @param {Array|String} legacyFilingTypes 旧查看范围
+         * @param {Array|String} legacyFilePermission 旧模块权限
+         * @param {Boolean} isAdmin 是否管理员
+         * @return {Object} 权限映射
+         */
+        async getPermissionMap(spid, uid, filingList, legacyFilingTypes, legacyFilePermission, isAdmin) {
+            const result = {};
+            const rows = isAdmin ? [] : await this.getRows(spid, uid);
+            for (const filing of filingList || []) {
+                result[filing.id] = isAdmin ? this._adminFlags() : this._resolvePermissionFromRows(
+                    rows, filing.id, filing.filing_type, legacyFilePermission, legacyFilingTypes
+                );
+            }
+            return result;
+        }
+
+        _normalizeUsers(users) {
+            if (!(users instanceof Array)) throw '授权用户权限格式错误';
+            const result = [];
+            const exists = {};
+            users.forEach(user => {
+                const uid = Number(user.uid !== undefined ? user.uid : user.id);
+                if (!Number.isInteger(uid) || uid <= 0) throw '授权用户数据错误';
+                if (exists[uid]) return;
+                exists[uid] = true;
+                result.push({
+                    uid,
+                    can_upload: this._toFlag(user.can_upload),
+                    can_edit_file: this._toFlag(user.can_edit_file),
+                    can_edit_dir: this._toFlag(user.can_edit_dir),
+                    can_lock_file: this._toFlag(user.can_lock_file),
+                });
+            });
+            return result;
+        }
+
+        async _getAllProjectFilingTypes(spid, connection = this.db) {
+            const rows = await connection.select(this.ctx.service.filing.tableName, {
+                columns: ['filing_type'],
+                where: { spid, is_deleted: 0 },
+            });
+            return this._.uniq(rows.map(x => Number(x.filing_type)).filter(x => x > 0));
+        }
+
+        _getRawFilingTypes(value, allFilingTypes) {
+            if (value === 'all') return allFilingTypes.slice();
+            return this._toPermissionList(value);
+        }
+
+        _createLegacyRow(subProject, permissionRow, filingType, operatorUid) {
+            const flags = this._legacyFlags(permissionRow.file_permission);
+            return {
+                id: this.uuid.v4(),
+                pid: subProject.project_id,
+                spid: subProject.id,
+                uid: Number(permissionRow.uid),
+                filing_type: Number(filingType),
+                filing_id: null,
+                can_upload: flags.can_upload,
+                can_edit_file: flags.can_edit_file,
+                can_edit_dir: flags.can_edit_dir,
+                can_lock_file: flags.can_lock_file,
+                create_uid: Number(operatorUid) || 0,
+                update_uid: Number(operatorUid) || 0,
+            };
+        }
+
+        /**
+         * 事务内同步查看范围与四项操作权限。
+         *
+         * @param {Object} subProject 子项目
+         * @param {Object} filing 资料目录
+         * @param {Array} users 已授权用户及四项权限
+         * @param {Number} operatorUid 操作人ID
+         * @param {Object} options 保存方式及外部事务
+         * @return {Array} 保存后的权限
+         */
+        async savePermissions(subProject, filing, users, operatorUid, options = {}) {
+            if (!filing || !filing.id) throw '资料目录错误';
+            const type = Number(filing.filing_type);
+            if (!Number.isInteger(type) || type <= 0) throw '资料类型错误';
+            const filingId = String(filing.id);
+            const normalized = this._normalizeUsers(users);
+            const permissionService = this.ctx.service.subProjPermission;
+            const ownsTransaction = !options.transaction;
+            const transaction = options.transaction || await this.db.beginTransaction();
+            try {
+                // 锁定项目权限行,避免同时保存不同目录时相互覆盖 filing_type。
+                const permissionRows = await transaction.query(
+                    'SELECT * FROM ?? WHERE spid = ? FOR UPDATE',
+                    [permissionService.tableName, subProject.id]
+                );
+                const permissionUidList = permissionRows.map(x => Number(x.uid));
+                if (normalized.find(x => permissionUidList.indexOf(x.uid) < 0)) throw '授权用户不属于当前子项目';
+
+                const selectedIds = normalized.map(x => x.uid);
+                const allFilingTypes = await this._getAllProjectFilingTypes(subProject.id, transaction);
+                // 锁定同类型全部权限,既防止节点重复,也用于正确维护旧 filing_type 查看范围。
+                const currentTypeRows = await transaction.query(
+                    'SELECT * FROM ?? WHERE spid = ? AND filing_type = ? FOR UPDATE',
+                    [this.tableName, subProject.id, type]
+                );
+                const currentNodeRows = currentTypeRows.filter(row => String(row.filing_id || '') === filingId);
+                const deleteIds = options.replaceExisting === false ? [] : currentNodeRows
+                    .filter(x => selectedIds.indexOf(Number(x.uid)) < 0)
+                    .map(x => x.id);
+                const updateRows = [];
+                const insertRows = [];
+                const sameTypeFixedFilings = await transaction.select(this.ctx.service.filing.tableName, {
+                    columns: ['id'],
+                    where: { spid: subProject.id, filing_type: type, is_fixed: 1, is_deleted: 0 },
+                });
+                const legacyAuthorizedPermissionRows = permissionRows.filter(permissionRow => {
+                    const uid = Number(permissionRow.uid);
+                    const hasExactType = currentTypeRows.some(row => row.filing_id && Number(row.uid) === uid);
+                    return !hasExactType && this._hasLegacyView(type, permissionRow.filing_type);
+                });
+
+                const ensureLegacyRow = permissionRow => {
+                    const uid = Number(permissionRow.uid);
+                    const hasLegacyRow = currentTypeRows.some(row => !row.filing_id && Number(row.uid) === uid) ||
+                        insertRows.some(row => !row.filing_id && Number(row.uid) === uid);
+                    if (!hasLegacyRow) {
+                        insertRows.push(this._createLegacyRow(subProject, permissionRow, type, operatorUid));
+                    }
+                };
+
+                /*
+                 * 旧权限按 filing_type 覆盖同类型固定节点。首次编辑其中一个节点时,
+                 * 先把其他固定节点的旧授权固化为精确记录,再应用当前节点的新名单,
+                 * 避免一次无关保存导致其他同类型目录的旧授权丢失。
+                 */
+                const migratedExactRows = [];
+                legacyAuthorizedPermissionRows.forEach(permissionRow => {
+                    ensureLegacyRow(permissionRow);
+                    const uid = Number(permissionRow.uid);
+                    const legacyRow = currentTypeRows.find(row => !row.filing_id && Number(row.uid) === uid);
+                    const flags = legacyRow ? this._rowFlags(legacyRow) : this._legacyFlags(permissionRow.file_permission);
+                    sameTypeFixedFilings.forEach(legacyFiling => {
+                        const legacyFilingId = String(legacyFiling.id);
+                        if (legacyFilingId === filingId &&
+                            (options.replaceExisting !== false || selectedIds.indexOf(uid) >= 0)) return;
+                        const hasExactNode = currentTypeRows.some(row => {
+                            return row.filing_id && Number(row.uid) === uid &&
+                                String(row.filing_id) === legacyFilingId;
+                        }) || migratedExactRows.some(row => {
+                            return Number(row.uid) === uid && String(row.filing_id) === legacyFilingId;
+                        });
+                        if (hasExactNode) return;
+                        const migratedRow = {
+                            id: this.uuid.v4(),
+                            pid: subProject.project_id,
+                            spid: subProject.id,
+                            uid,
+                            filing_type: type,
+                            filing_id: legacyFilingId,
+                            can_upload: flags.can_upload,
+                            can_edit_file: flags.can_edit_file,
+                            can_edit_dir: flags.can_edit_dir,
+                            can_lock_file: flags.can_lock_file,
+                            create_uid: Number(operatorUid) || 0,
+                            update_uid: Number(operatorUid) || 0,
+                        };
+                        migratedExactRows.push(migratedRow);
+                        insertRows.push(migratedRow);
+                    });
+                });
+
+                // 用户原本拥有旧类型查看范围时,首次切换到节点授权前保存一条 NULL 兼容记录。
+                // NULL 记录只保留旧操作权限,不再代表节点查看授权。
+                normalized.forEach(user => {
+                    const permissionRow = permissionRows.find(row => Number(row.uid) === user.uid);
+                    const hasExactType = currentTypeRows.some(row => row.filing_id && Number(row.uid) === user.uid);
+                    const oldTypes = permissionRow ? this._getRawFilingTypes(permissionRow.filing_type, allFilingTypes) : [];
+                    if (!hasExactType && oldTypes.indexOf(type) >= 0) {
+                        ensureLegacyRow(permissionRow);
+                    }
+                });
+
+                normalized.forEach(user => {
+                    const current = currentNodeRows.find(x => Number(x.uid) === user.uid);
+                    const data = {
+                        can_upload: user.can_upload,
+                        can_edit_file: user.can_edit_file,
+                        can_edit_dir: user.can_edit_dir,
+                        can_lock_file: user.can_lock_file,
+                        update_uid: Number(operatorUid) || 0,
+                    };
+                    if (current) {
+                        updateRows.push(Object.assign({ id: current.id }, data));
+                    } else {
+                        insertRows.push(Object.assign({
+                            id: this.uuid.v4(),
+                            pid: subProject.project_id,
+                            spid: subProject.id,
+                            uid: user.uid,
+                            filing_type: type,
+                            filing_id: filingId,
+                            create_uid: Number(operatorUid) || 0,
+                        }, data));
+                    }
+                });
+
+                const otherExactUserIds = currentTypeRows
+                    .filter(row => row.filing_id && String(row.filing_id) !== filingId)
+                    .map(row => Number(row.uid));
+                const retainedCurrentUserIds = options.replaceExisting === false
+                    ? currentNodeRows.map(row => Number(row.uid)) : [];
+                const finalExactUserIds = this._.uniq(otherExactUserIds
+                    .concat(migratedExactRows.map(row => Number(row.uid)))
+                    .concat(retainedCurrentUserIds)
+                    .concat(selectedIds));
+                const affectedUserIds = this._.uniq(selectedIds
+                    .concat(currentNodeRows.map(row => Number(row.uid)))
+                    .concat(legacyAuthorizedPermissionRows.map(row => Number(row.uid))));
+                const viewUpdates = [];
+                permissionRows.forEach(row => {
+                    const uid = Number(row.uid);
+                    if (affectedUserIds.indexOf(uid) < 0) return;
+                    let types = this._getRawFilingTypes(row.filing_type, allFilingTypes);
+                    const shouldHave = finalExactUserIds.indexOf(uid) >= 0;
+                    const hasType = types.indexOf(type) >= 0;
+                    if (shouldHave && !hasType) types.push(type);
+                    if (!shouldHave && hasType) types = types.filter(value => value !== type);
+                    types = this._.uniq(types);
+                    if (types.join(',') !== String(row.filing_type || '')) {
+                        viewUpdates.push({ id: row.id, filing_type: types.join(',') });
+                    }
+                });
+
+                if (viewUpdates.length > 0) await transaction.updateRows(permissionService.tableName, viewUpdates);
+                if (deleteIds.length > 0) await transaction.delete(this.tableName, { id: deleteIds });
+                if (updateRows.length > 0) await transaction.updateRows(this.tableName, updateRows);
+                if (insertRows.length > 0) await transaction.insert(this.tableName, insertRows);
+                if (ownsTransaction) await transaction.commit();
+                return normalized.map(user => Object.assign({ filing_id: filingId, filing_type: type }, user));
+            } catch (err) {
+                if (ownsTransaction) await transaction.rollback();
+                throw err;
+            }
+        }
+
+        /**
+         * 将用户权限合并添加到多个资料目录,保留目标目录已有的其他用户。
+         *
+         * @param {Object} subProject 子项目
+         * @param {Array} filings 目标资料目录
+         * @param {Array} users 需要添加的用户及四项权限
+         * @param {Number} operatorUid 操作人ID
+         * @return {Array} 本次写入的节点权限
+         */
+        async addPermissionsToFilings(subProject, filings, users, operatorUid) {
+            return this._savePermissionsToFilings(subProject, filings, users, operatorUid, false);
+        }
+
+        /**
+         * 将用户权限覆盖到多个资料目录,目标目录原有授权名单会被替换。
+         *
+         * @param {Object} subProject 子项目
+         * @param {Array} filings 目标资料目录
+         * @param {Array} users 覆盖后的用户及四项权限
+         * @param {Number} operatorUid 操作人ID
+         * @return {Array} 本次写入的节点权限
+         */
+        async coverPermissionsToFilings(subProject, filings, users, operatorUid) {
+            return this._savePermissionsToFilings(subProject, filings, users, operatorUid, true);
+        }
+
+        /**
+         * 父目录授权后,将相同授权覆盖到父目录及其全部子孙目录。
+         *
+         * @param {Object} subProject 子项目
+         * @param {Object} filing 授权来源目录
+         * @param {Array} filingList 当前项目全部有效目录
+         * @param {Array} users 授权用户及四项权限
+         * @param {Number} operatorUid 操作人ID
+         * @return {Object} 受影响目录与保存后的权限
+         */
+        async savePermissionsToSubtree(subProject, filing, filingList, users, operatorUid) {
+            const filingMap = {};
+            const childMap = {};
+            (filingList || []).forEach(node => {
+                filingMap[String(node.id)] = node;
+                const parentId = String(node.tree_pid);
+                if (!childMap[parentId]) childMap[parentId] = [];
+                childMap[parentId].push(node);
+            });
+            if (!filingMap[String(filing.id)]) throw '资料目录不存在';
+
+            const filings = [];
+            const pending = [filing];
+            const visited = new Set();
+            while (pending.length > 0) {
+                const current = pending.shift();
+                const currentId = String(current.id);
+                if (visited.has(currentId)) continue;
+                visited.add(currentId);
+                filings.push(current);
+                (childMap[currentId] || []).forEach(child => pending.push(child));
+            }
+
+            const permissions = await this._savePermissionsToFilings(
+                subProject, filings, users, operatorUid, true, true
+            );
+            return { permissions, filings };
+        }
+
+        async _savePermissionsToFilings(subProject, filings, users, operatorUid, replaceExisting, allowEmpty = false) {
+            if (!(filings instanceof Array) || filings.length === 0) throw '请选择目标资料目录';
+            const normalized = this._normalizeUsers(users);
+            if (!allowEmpty && normalized.length === 0) throw '请选择需要配置的授权用户';
+            const filingIds = filings.map(filing => String(filing && filing.id || ''));
+            if (filingIds.find(id => !id) || this._.uniq(filingIds).length !== filingIds.length) {
+                throw '目标资料目录数据错误';
+            }
+
+            // 固定处理顺序,减少并发批量授权时产生数据库死锁的概率。
+            const sortedFilings = filings.slice().sort((a, b) => {
+                const typeDiff = Number(a.filing_type) - Number(b.filing_type);
+                return typeDiff || String(a.id).localeCompare(String(b.id));
+            });
+            const transaction = await this.db.beginTransaction();
+            const result = [];
+            try {
+                for (const filing of sortedFilings) {
+                    const rows = await this.savePermissions(
+                        subProject,
+                        filing,
+                        normalized,
+                        operatorUid,
+                        { transaction, replaceExisting }
+                    );
+                    result.push.apply(result, rows);
+                }
+                await transaction.commit();
+                return result;
+            } catch (err) {
+                await transaction.rollback();
+                throw err;
+            }
+        }
+
+        /**
+         * 删除目录时同步清理精确节点权限,并维护旧 filing_type 查看字段。
+         *
+         * @param {String} spid 子项目ID
+         * @param {Array} filingIds 被删除目录ID
+         * @param {Object} transaction 当前事务
+         */
+        async removeFilingNodes(spid, filingIds, transaction) {
+            if (!filingIds || filingIds.length === 0) return;
+            const permissionService = this.ctx.service.subProjPermission;
+            const permissionRows = await transaction.query(
+                'SELECT * FROM ?? WHERE spid = ? FOR UPDATE',
+                [permissionService.tableName, spid]
+            );
+            const rows = await transaction.query(
+                'SELECT * FROM ?? WHERE spid = ? AND filing_id IN (?) FOR UPDATE',
+                [this.tableName, spid, filingIds]
+            );
+            if (rows.length === 0) return;
+
+            await transaction.delete(this.tableName, { id: rows.map(row => row.id) });
+            const affectedTypes = this._.uniq(rows.map(row => Number(row.filing_type)).filter(value => value > 0));
+            const remainingRows = await transaction.query(
+                'SELECT * FROM ?? WHERE spid = ? AND filing_type IN (?) FOR UPDATE',
+                [this.tableName, spid, affectedTypes]
+            );
+            const allFilingTypes = await this._getAllProjectFilingTypes(spid, transaction);
+            const affectedKeys = this._.uniq(rows.map(row => `${Number(row.uid)}:${Number(row.filing_type)}`));
+            const updates = [];
+            permissionRows.forEach(permissionRow => {
+                let types = this._getRawFilingTypes(permissionRow.filing_type, allFilingTypes);
+                let changed = false;
+                affectedTypes.forEach(type => {
+                    const key = `${Number(permissionRow.uid)}:${type}`;
+                    if (affectedKeys.indexOf(key) < 0) return;
+                    const stillHasPermission = remainingRows.some(row => {
+                        return row.filing_id && Number(row.uid) === Number(permissionRow.uid) &&
+                            Number(row.filing_type) === type;
+                    });
+                    if (!stillHasPermission && types.indexOf(type) >= 0) {
+                        types = types.filter(value => value !== type);
+                        changed = true;
+                    }
+                });
+                if (changed) updates.push({ id: permissionRow.id, filing_type: this._.uniq(types).join(',') });
+            });
+            if (updates.length > 0) await transaction.updateRows(permissionService.tableName, updates);
+        }
+    }
+
+    return SubProjectFilingPermission;
+};

+ 3 - 1
app/view/dashboard/index.ejs

@@ -443,7 +443,9 @@
                                             <% 
                                                 const isCheckNo = item.status === acContract.status.checkNo;
                                                 const isUncheckReSubmit = item.status === acContract.status.uncheck && item.times > 1;
-                                                const showResubmitBtn = isCheckNo || isUncheckReSubmit;
+                                                const isContractOwnerReturned = item.itemType === 'contract' &&
+                                                    item.contract_status === acContract.status.checkNo && item.uid === uid;
+                                                const showResubmitBtn = isCheckNo || isUncheckReSubmit || isContractOwnerReturned;
                                             %>
                                             <% const isTenderContract = item.tid && item.tid !== 0 && !item.is_project_contract;
                                                const incomePath = item.contract_type === 2 ? '/income' : '';

+ 232 - 0
app/view/file/config_dir.ejs

@@ -0,0 +1,232 @@
+
+<% include ../shares/delete_hint_modal.ejs %>
+
+<div class="panel-content">
+    <div class="panel-title fluid">
+        <div class="title-main d-flex justify-content-between">
+            <div>配置目录/<%- ctx.subProject.name %></div>
+            <div class="ml-auto">
+                <a href="/sp/<%- ctx.subProject.id %>/filesjs" class="btn btn-outline-primary btn-sm">返回资料管理</a>
+            </div>
+        </div>
+    </div>
+    <div class="content-wrap">
+        <div class="c-body">
+            <div class="sjs-height-0">
+                <div class="d-flex align-items-center mb-2 p-1">
+                    <span>资料管理/<%- ctx.subProject.name %>(当前状态:<span class="<%- ctx.subProject.lock_file ? 'text-success' : 'text-warning' %>"><%- ctx.subProject.lock_file ? '锁定' : '未锁定' %></span>)</span>
+                    <% if (canManageDir) { %>
+                    <form class="mb-0 ml-2" method="POST" action="/sp/<%- ctx.subProject.id %>/config-dir/lock">
+                        <input type="hidden" name="_csrf_j" value="<%= ctx.csrf %>" />
+                        <input type="hidden" name="lock" value="1" />
+                        <button class="btn btn-sm <%- ctx.subProject.lock_file ? 'btn-outline-secondary' : 'btn-primary' %>" <%- ctx.subProject.lock_file ? 'disabled' : '' %>>锁定</button>
+                    </form>
+                    <form class="mb-0 ml-1" method="POST" action="/sp/<%- ctx.subProject.id %>/config-dir/lock">
+                        <input type="hidden" name="_csrf_j" value="<%= ctx.csrf %>" />
+                        <input type="hidden" name="lock" value="0" />
+                        <button class="btn btn-sm <%- ctx.subProject.lock_file ? 'btn-primary' : 'btn-outline-secondary' %>" <%- ctx.subProject.lock_file ? '' : 'disabled' %>>解锁</button>
+                    </form>
+                    <% } %>
+                    <div class="alert alert-warning py-1 px-2 mb-0 ml-2">
+                        <i class="fa fa-exclamation-circle"></i>
+                        <%- ctx.subProject.lock_file ? '当前为“锁定”状态,用户仅可查看资料管理,无法上传文件。' : '请先“锁定”资料管理,禁止用户上传文件,避免引起编辑冲突。' %>
+                    </div>
+                </div>
+                <div class="d-flex flex-row mb-2">
+                    <div class="p-1">
+                        <% if (ctx.subProject.lock_file && canManageDir) { %>
+                        <a href="javascript: void(0);" class="btn btn-sm btn-outline-primary" id="cd-add-sibling"><i class="fa fa-plus fa-fw"></i> 同层</a>
+                        <a href="javascript: void(0);" class="btn btn-sm btn-outline-primary" id="cd-add-child"><i class="fa fa-plus fa-fw"></i> 子项</a>
+                        <a href="javascript: void(0);" class="btn btn-sm btn-outline-primary" id="cd-move-up"><i class="fa fa-arrow-up fa-fw"></i> 上移</a>
+                        <a href="javascript: void(0);" class="btn btn-sm btn-outline-primary" id="cd-move-down"><i class="fa fa-arrow-down fa-fw"></i> 下移</a>
+                        <a href="javascript: void(0);" class="btn btn-sm btn-outline-primary" id="cd-move"><i class="fa fa-exchange fa-fw"></i> 移动</a>
+                        <a href="javascript: void(0);" class="btn btn-sm btn-outline-danger" id="cd-delete"><i class="fa fa-trash-o fa-fw"></i> 删除</a>
+                        <a href="javascript: void(0);" class="btn btn-sm btn-outline-primary" id="cd-edit-name"><i class="fa fa-pencil fa-fw"></i> 编辑目录</a>
+                        <a href="javascript: void(0);" class="btn btn-sm btn-outline-primary" id="cd-edit-tips"><i class="fa fa-file-text-o fa-fw"></i> 上报说明</a>
+                        <% } %>
+                        <% if (ctx.subProject.lock_file && canAuthUser) { %>
+                        <a href="javascript: void(0);" class="btn btn-sm btn-outline-primary" id="cd-auth-user"><i class="fa fa-user-plus fa-fw"></i> 授权用户</a>
+                        <% } %>
+                    </div>
+                </div>
+                <div id="config-dir-spread" style="width: 100%; height: calc(100% - 90px);"></div>
+            </div>
+        </div>
+    </div>
+</div>
+
+<!-- 授权用户 Modal -->
+<div class="modal fade" id="auth-user-modal" data-backdrop="static">
+    <div class="modal-dialog modal-lgx" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title">授权用户</h5>
+            </div>
+            <div class="modal-body">
+                <div class="mb-3">当前目录:<strong id="auth-user-filing-name"></strong></div>
+                <div class="d-flex align-items-center mb-3">
+                    <div class="dropdown mr-4">
+                        <button class="btn btn-outline-primary btn-sm dropdown-toggle" type="button" id="cd-auth-user-dropdown"
+                                data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">添加用户</button>
+                        <div class="dropdown-menu" aria-labelledby="cd-auth-user-dropdown"
+                             style="width:260px; max-height:330px; overflow-y:auto;">
+                            <dl class="list-unstyled book-list mb-0" id="cd-auth-user-options"></dl>
+                        </div>
+                    </div>
+                    <a href="javascript:void(0);" class="cd-auth-list-action text-muted mr-4" id="cd-auth-batch-delete"
+                       aria-disabled="true" tabindex="-1">批量删除</a>
+                    <a href="javascript:void(0);" class="cd-auth-list-action text-muted mr-4 d-none" id="cd-auth-add-other"
+                       aria-disabled="true" aria-hidden="true" tabindex="-1">添加至其他目录</a>
+                    <a href="javascript:void(0);" class="cd-auth-list-action text-muted d-none" id="cd-auth-cover-other"
+                       aria-disabled="true" aria-hidden="true" tabindex="-1">覆盖至其他目录</a>
+                </div>
+                <div class="modal-height-400 scroll-y">
+                    <table class="table table-bordered table-sm">
+                        <thead>
+                        <tr class="text-center">
+                            <th width="65px" class="align-middle"><input type="checkbox" id="cd-auth-user-select-all" class="mr-1">选择</th>
+                            <th class="align-middle">用户</th>
+                            <th width="70px" class="align-middle">查看</th>
+                            <th width="100px" class="align-middle"><input type="checkbox" class="cd-auth-permission-all" data-permission="upload_reference"><br>上传/引用</th>
+                            <th width="90px" class="align-middle"><input type="checkbox" class="cd-auth-permission-all" data-permission="edit_file"><br>编辑文件</th>
+                            <th width="90px" class="align-middle"><input type="checkbox" class="cd-auth-permission-all" data-permission="edit_dir"><br>编辑目录</th>
+                            <th width="90px" class="align-middle"><input type="checkbox" class="cd-auth-permission-all" data-permission="lock_file"><br>锁定文件</th>
+                            <th width="65px" class="align-middle">移除</th>
+                        </tr>
+                        </thead>
+                        <tbody id="auth-user-list"></tbody>
+                    </table>
+                </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="auth-user-save">确定</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<!-- 批量配置至其他目录 Modal -->
+<div class="modal fade" id="cd-auth-target-modal" data-backdrop="static" style="z-index: 1060;">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title" id="cd-auth-target-modal-title">添加至其他目录</h5>
+            </div>
+            <div class="modal-body">
+                <div class="border border-danger text-danger px-2 py-2 mb-2" id="cd-auth-cover-warning" style="display:none;">
+                    将覆盖目标目录已配置的用户,请谨慎操作
+                </div>
+                <div class="modal-height-300 scroll-y">
+                    <table class="table table-bordered table-sm mb-0">
+                        <thead>
+                        <tr class="text-center">
+                            <th class="align-middle">目录</th>
+                            <th width="65px" class="align-middle"><input type="checkbox" id="cd-auth-target-select-all" class="mr-1">选择</th>
+                        </tr>
+                        </thead>
+                        <tbody id="cd-auth-target-list"></tbody>
+                    </table>
+                </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="cd-auth-target-save" disabled>确定</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<!-- 编辑目录 Modal -->
+<div class="modal fade" id="cd-edit-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 px-5 py-4">
+                <div class="form-group row align-items-center">
+                    <label class="col-2 col-form-label text-right" for="cd-edit-parent">目录:</label>
+                    <div class="col-10">
+                        <select class="form-control form-control-sm" id="cd-edit-parent"></select>
+                        <small class="form-text text-warning d-none" id="cd-edit-move-hint"></small>
+                    </div>
+                </div>
+                <div class="form-group row align-items-center">
+                    <label class="col-2 col-form-label text-right" for="cd-edit-dir-name">名称:</label>
+                    <div class="col-10">
+                        <input type="text" class="form-control form-control-sm" id="cd-edit-dir-name" maxlength="100">
+                    </div>
+                </div>
+                <div class="form-group row mb-0">
+                    <label class="col-2 col-form-label text-right" for="cd-edit-fixed">固定目录:</label>
+                    <div class="col-10 pt-2">
+                        <div class="custom-control custom-checkbox">
+                            <input type="checkbox" class="custom-control-input" id="cd-edit-fixed">
+                            <label class="custom-control-label" for="cd-edit-fixed">固定</label>
+                        </div>
+                        <small class="form-text text-muted">固定后,目录无法被用户删除</small>
+                    </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="cd-edit-save">确定</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<!-- 移动分类 Modal -->
+<div class="modal fade" id="cd-move-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>将 <strong id="cd-move-source-name"></strong> 移动到:</p>
+                <div class="modal-height-300 scroll-y">
+                    <table class="table table-bordered table-sm">
+                        <thead><tr class="text-center"><th width="40px">选择</th><th>目标分类</th></tr></thead>
+                        <tbody id="cd-move-target-list"></tbody>
+                    </table>
+                </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="cd-move-ok">确定移动</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<!-- 编辑上报说明 Modal -->
+<div class="modal fade" id="cd-tips-modal" data-backdrop="static">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title">编辑上报说明 - <span id="cd-tips-filing-name"></span></h5>
+            </div>
+            <div class="modal-body">
+                <textarea class="form-control" id="cd-tips-input" rows="4" placeholder="请输入上报说明"></textarea>
+            </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="cd-tips-save">保存</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<script>
+    // 将模态框移到 body 下,避免 backdrop 遮挡问题
+    $('#auth-user-modal, #cd-auth-target-modal, #cd-edit-modal, #cd-move-modal, #cd-tips-modal').appendTo('body');
+
+    const filingData = JSON.parse(unescape('<%- escape(JSON.stringify(filing)) %>'));
+    const permissionData = JSON.parse(unescape('<%- escape(JSON.stringify(permissionData)) %>'));
+    const filingPermissionData = JSON.parse(unescape('<%- escape(JSON.stringify(filingPermissionData)) %>'));
+    const accountList = JSON.parse(unescape('<%- escape(JSON.stringify(accountList)) %>'));
+    const configDirLocked = <%- Boolean(ctx.subProject.lock_file) %>;
+    const canManageConfigDir = <%- Boolean(canManageDir) %>;
+</script>

+ 180 - 0
app/view/file/filesjs.ejs

@@ -0,0 +1,180 @@
+<div class="panel-content">
+    <div class="panel-title fluid">
+        <div class="title-main  d-flex justify-content-between">
+            <div>资料管理/<%- ctx.subProject.name %><span class="ml-4" id="file-count"></span></div>
+            <div class="ml-auto">
+                <% if (canManageDir) { %>
+                <a href="/sp/<%- ctx.subProject.id %>/config-dir" class="btn btn-outline-primary btn-sm ml-1">配置目录</a>
+                <% } %>
+            </div>
+        </div>
+    </div>
+    <div class="content-wrap row pr-46">
+        <div class="d-flex flex-nowrap w-100 sub-content">
+            <div class="c-body" id="left-view" style="width: 100%; min-width: 0; flex: 0 0 auto;">
+                <div class="sjs-height-0 d-flex flex-nowrap w-100" style="margin-left: 0 !important; overflow: hidden;">
+                    <div class="border-right" id="file-left-view" style="width: 22%; min-width: 160px; flex: 0 0 auto; overflow: hidden;">
+                        <div class="d-flex flex-row">
+                            <div class="btn-group">
+                                <button type="button" class="btn btn-sm  text-primary dropdown-toggle" data-toggle="dropdown" id="zhankai" aria-expanded="false">显示层级</button>
+                                <div class="dropdown-menu" aria-labelledby="zhankai" x-placement="bottom-start" style="position: absolute; transform: translate3d(0px, 21px, 0px); top: 0px; left: 0px; will-change: transform;">
+                                    <a class="dropdown-item" name="showLevel" tag="1" href="javascript: void(0);">第一层</a>
+                                    <a class="dropdown-item" name="showLevel" tag="2" href="javascript: void(0);">第二层</a>
+                                    <a class="dropdown-item" name="showLevel" tag="3" href="javascript: void(0);">第三层</a>
+                                    <a class="dropdown-item" name="showLevel" tag="4" href="javascript: void(0);">第四层</a>
+                                    <a class="dropdown-item" name="showLevel" tag="last" href="javascript: void(0);">最底层</a>
+                                </div>
+                            </div>
+                            <div class="p-2 js-can-edit-dir" style="display:none;"><a href="javascript: void(0);" id="add-slibing">添加同级</a></div>
+                            <div class="p-2 js-can-edit-dir" style="display:none;"><a href="javascript: void(0);" id="add-child">添加子级</a></div>
+                        </div>
+                        <div id="filing-spread" style="width: 100%; height: calc(100% - 40px);"></div>
+                    </div>
+                    <div id="file-right-view" style="width: 76%; min-width: 0; flex: 0 0 auto; overflow: hidden;">
+                        <div class="resize-x" id="file-right-spr" r-Type="width" div1="#file-left-view" div2="#file-right-view" title="调整大小" a-type="percent" store-id="file-detail-sjs" store-version="1.0.0" min="20"></div>
+                        <div class="ml-2" id="file-view" style="display: none; min-width: 0; overflow: hidden;">
+                            <div class="d-flex flex-nowrap align-items-stretch w-100" style="min-width: 0;">
+                                <div class="flex-grow-1 pr-2" style="min-width: 0; overflow-x: auto;">
+                                    <div class="d-flex flex-row">
+                                        <div class="py-2 pr-2 js-can-upload" style="display:none;"><a href="#add-file" data-toggle="modal" data-target="#add-file">上传文件</a></div>
+                                        <div class="py-2 pr-2 js-can-upload" style="display:none;"><a href="#add-big-file" data-toggle="modal" data-target="#add-big-file">大文件上传</a></div>
+                                        <div class="p-2 js-can-upload" id="rela-file-btn" style="display:none;"><a href="#rela-file" data-toggle="modal" data-target="#rela-file">引用文件</a></div>
+                                        <div class="p-2"><a href="javascript: void(0)" id="batch-del-file-btn">批量删除</a></div>
+                                        <div class="p-2"><a href="javascript: void(0)" id="batch-download">批量下载</a></div>
+                                        <div class="p-2">
+                                            <span id="showPage">
+                                                <a href="javascript:void(0);" class="page-select ml-3" content="pre"><i class="fa fa-chevron-left"></i></a>
+                                                <span id="curPage">1</span>/<span id="curTotalPage">10</span>
+                                                <a href="javascript:void(0);" class="page-select mr-3" content="next"><i class="fa fa-chevron-right"></i></a>
+                                            </span>
+                                        </div>
+                                    </div>
+                                    <table class="table table-hover table-bordered" style="min-width: 590px;">
+                                        <thead>
+                                        <tr class="text-center">
+                                            <th width="60px">选择</th>
+                                            <th>文件名称 <span name="file-sort" field="filename" tag="filename|desc"><i class="fa fa-sort" aria-hidden="true"></i></span></th>
+                                            <th width="70px">锁定 <i class="fa fa-info-circle text-muted" title="锁定后,任何用户均不能编辑、移动或删除该文件"></i></th>
+                                            <th width="10%">上传人</th>
+                                            <th width="20%">上传时间 <span name="file-sort" field="create_time" tag="create_time|asc"><i class="fa fa-sort-amount-desc" aria-hidden="true"></i></span></th>
+                                            <th width="10%">文件类型</th>
+                                            <th width="60px">操作</th>
+                                        </tr>
+                                        </thead>
+                                        <tbody id="file-list">
+                                        </tbody>
+                                    </table>
+                                </div>
+                                <div class="border ml-2" style="width: 250px; flex: 0 1 250px; min-width: 180px; max-width: 320px; min-height: 260px; overflow: hidden;">
+                                    <div class="font-weight-bold text-center border-bottom py-2">上报说明</div>
+                                    <div id="filing-upload-tips" class="p-3 text-muted"
+                                         style="white-space: pre-wrap; overflow-wrap: anywhere; max-height: calc(100vh - 230px); overflow-y: auto;">暂无上报说明</div>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+            <div class="c-body" id="right-view" style="display: none; width: 33%; min-width: 0; flex: 0 0 auto; overflow: hidden;">
+                <div class="resize-x" id="right-spr" r-Type="width" div1="#left-view" div2="#right-view" title="调整大小" a-type="percent"></div>
+                <div class="tab-content">
+                    <div id="search" class="tab-pane tab-select-show mr-1">
+                        <div class="sjs-bar mt-1">
+                            <div class="input-group input-group-sm pb-1">
+                                <div class="input-group-prepend">
+                                    <select class="input-group-text" id="search-filter">
+                                        <option value="cur">当前分类</option>
+                                        <option value="all">全部分类</option>
+                                    </select>
+                                </div>
+                                <input id="search-keyword" type="text" class="form-control" autocomplete="off" placeholder="输入文件名称查找" aria-label="Recipient\'s username" aria-describedby="button-addon2">
+                                <div class="input-group-append"><button class="btn btn-outline-secondary" type="button">搜索</button></div>
+                            </div>
+                        </div>
+                        <div class="sjs-sh scroll-y">
+                            <table class="table table-bordered">
+                                <tr class="text-center"><th>文件名称</th><th width="15%">上传人</th><th width="15%">操作</th></tr>
+                                <tbody id="search-list"></tbody>
+                            </table>
+                        </div>
+                    </div>
+                    <div id="reference" class="tab-pane tab-select-show">
+                    </div>
+                </div>
+            </div>
+        </div>
+        <!--右侧菜单-->
+        <div class="side-menu">
+            <ul class="nav flex-column right-nav" id="side-menu">
+                <li>
+                    <a class="nav-link" content="#search" href="javascript: void(0);">查找定位</a>
+                </li>
+                <li>
+                    <a class="nav-link" content="#reference" href="javascript: void(0);">参考文件</a>
+                </li>
+            </ul>
+        </div>
+    </div>
+</div>
+
+<% if (needFilingInitialization) { %>
+<div class="modal fade" id="filing-initialization-modal" data-backdrop="static" data-keyboard="false">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title">初始化资料目录</h5>
+            </div>
+            <div class="modal-body px-4 py-4" style="min-height: 205px;">
+                <div class="d-flex flex-wrap justify-content-between mb-3">
+                    <label class="mb-0 mr-3">
+                        <input type="radio" name="filing-initialization-type" value="blank" checked class="mr-1">新创建目录
+                    </label>
+                    <label class="mb-0 mr-3">
+                        <input type="radio" name="filing-initialization-type" value="template" class="mr-1">从模板库中选取
+                    </label>
+                    <label class="mb-0">
+                        <input type="radio" name="filing-initialization-type" value="project" class="mr-1">从其他项目中选取
+                    </label>
+                </div>
+                <div id="filing-initialization-description" class="text-muted mb-3">从空白目录开始创建资料目录</div>
+                <div id="filing-initialization-template-wrap" style="display:none;">
+                    <select class="form-control form-control-sm" id="filing-initialization-template">
+                        <option value="">选择系统模板库</option>
+                        <% for (const template of filingInitializationTemplates) { %>
+                        <option value="<%= template.id %>"><%= template.name %></option>
+                        <% } %>
+                    </select>
+                </div>
+                <div id="filing-initialization-project-wrap" style="display:none;">
+                    <select class="form-control form-control-sm" id="filing-initialization-project">
+                        <option value="">选择项目</option>
+                        <% for (const project of filingInitializationProjects) { %>
+                        <option value="<%= project.id %>"><%= project.name %></option>
+                        <% } %>
+                    </select>
+                    <% if (filingInitializationProjects.length === 0) { %>
+                    <small class="form-text text-warning">当前账号暂无其他可复制目录的项目</small>
+                    <% } %>
+                </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="filing-initialization-ok">确定</button>
+            </div>
+        </div>
+    </div>
+</div>
+<% } %>
+<script>
+    let canFiling = false;
+    let canUpload = false;
+    const filing = JSON.parse(unescape('<%- escape(JSON.stringify(filing)) %>'));
+    const category = JSON.parse(unescape('<%- escape(JSON.stringify(categoryData)) %>'));
+    const whiteList = JSON.parse('<%- JSON.stringify(ctx.app.config.multipart.whitelist) %>');
+    let canEdit = false;
+    let canLockFile = false;
+    const filingPermissionMap = JSON.parse(unescape('<%- escape(JSON.stringify(filingPermissionMap)) %>'));
+    const projectFileLocked = <%- Boolean(ctx.subProject.lock_file) %>;
+    const fileReferenceList = JSON.parse('<%- JSON.stringify(fileReferenceList) %>');
+    const needFilingInitialization = <%- Boolean(needFilingInitialization) %>;
+</script>

+ 5 - 0
app/view/shares/delete_hint_modal.ejs

@@ -17,6 +17,11 @@
     </div>
 </div>
 <script>
+    // 将删除确认模态框移到 body 下,避免 backdrop 遮挡问题
+    $(document).ready(function() {
+        $('#del-node').appendTo('body');
+    });
+
     const deleteAfterHint = function (fun, hint = '', show = true) {
         $('#del-node').modal('show');
         $('#del-node-ok').bind('click', fun);

+ 60 - 0
app/view/sp_setting/data_range.ejs

@@ -0,0 +1,60 @@
+<% 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">
+                <nav class="nav nav-tabs m-3" role="tablist">
+                    <a class="nav-item nav-link" href="/sp/<%- ctx.subProject.id %>/setting/user" aria-selected="false">账号列表</a>
+                    <a class="nav-item nav-link" href="/sp/<%- ctx.subProject.id %>/setting/user/permission?ptype=datacollect" aria-selected="false">模块权限</a>
+                    <a class="nav-item nav-link active" href="/sp/<%- ctx.subProject.id %>/setting/user/data-range?module=<%- moduleKey %>" aria-selected="true">数据范围</a>
+                </nav>
+                <div class="tab-content m-3">
+                    <div class="tab-pane active">
+                        <div class="row">
+                            <div class="col-3">
+                                <div class="list-group">
+                                    <% for (const item of moduleList) { %>
+                                    <a class="list-group-item list-group-item-action <%- item.key === moduleKey ? 'active' : '' %>"
+                                       href="/sp/<%- ctx.subProject.id %>/setting/user/data-range?module=<%- item.key %>"><%- item.name %></a>
+                                    <% } %>
+                                </div>
+                            </div>
+                            <div class="col-9">
+                                <div class="card">
+                                    <div class="card-header"><%- moduleInfo.name %></div>
+                                    <div class="card-body">
+                                        <div class="form-group row mb-2">
+                                            <label class="col-form-label col-auto pr-2">授权用户范围:</label>
+                                            <div class="col pl-0">
+                                                <div class="form-control text-left d-flex align-items-center" id="open-data-range-modal"
+                                                     role="button" tabindex="0" aria-label="选择授权用户范围">
+                                                    <span class="data-range-value-list d-flex flex-wrap align-items-center" id="data-range-value-list"></span>
+                                                    <i class="fa fa-caret-down ml-auto"></i>
+                                                </div>
+                                                <small class="form-text text-muted">设置对目录【授权用户】是可选择的用户范围</small>
+                                                <div class="mt-2 text-muted" id="data-range-summary"></div>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+
+<script>
+    const dataRangeSpid = '<%- ctx.subProject.id %>';
+    const dataRangeModuleKey = '<%- moduleKey %>';
+    const dataRangeTree = JSON.parse(unescape('<%- escape(JSON.stringify(rangeTree)) %>'));
+    const dataRangeUnassignedUsers = JSON.parse(unescape('<%- escape(JSON.stringify(unassignedUsers)) %>'));
+    const dataRangeConfig = JSON.parse(unescape('<%- escape(JSON.stringify(rangeConfig)) %>'));
+</script>

+ 71 - 0
app/view/sp_setting/data_range_modal.ejs

@@ -0,0 +1,71 @@
+<style>
+    #data-range-modal .data-range-column { height: 360px; overflow-y: auto; }
+    #data-range-modal .data-range-node { min-height: 30px; line-height: 30px; white-space: nowrap; }
+    #data-range-modal .data-range-node:hover { background: #f5f7fa; }
+    #data-range-modal .data-range-toggle { display: inline-block; width: 18px; text-align: center; cursor: pointer; }
+    #data-range-modal .data-range-toggle.empty { cursor: default; }
+    #data-range-modal .data-range-children.collapsed { display: none; }
+    #data-range-modal .data-range-selected-item { border-bottom: 1px solid #eee; padding: 6px 8px; }
+    #open-data-range-modal { height: auto; min-height: calc(1.5em + .75rem + 2px); cursor: pointer; }
+    #open-data-range-modal .data-range-value-list { flex: 1; min-width: 0; margin: -2px 0; }
+    #open-data-range-modal .data-range-value-item {
+        display: inline-flex;
+        align-items: center;
+        max-width: 100%;
+        margin: 2px 6px 2px 0;
+        padding: 1px 6px;
+        border: 1px solid #b8c1cc;
+        border-radius: 3px;
+        background: #fff;
+        line-height: 22px;
+    }
+    #open-data-range-modal .data-range-value-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+    #open-data-range-modal .remove-main-data-range { margin-left: 7px; color: #909399; }
+    #open-data-range-modal .remove-main-data-range:hover { color: #f56c6c; }
+    #open-data-range-modal > .fa-caret-down { flex: 0 0 auto; margin-left: 8px !important; }
+</style>
+
+<div class="modal fade" id="data-range-modal" data-backdrop="static" tabindex="-1" role="dialog">
+    <div class="modal-dialog modal-lg" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title">选择范围</h5>
+                <button type="button" class="close" data-dismiss="modal" aria-label="关闭">
+                    <span aria-hidden="true">&times;</span>
+                </button>
+            </div>
+            <div class="modal-body">
+                <div class="row no-gutters border">
+                    <div class="col-6 border-right">
+                        <div class="px-2 py-2 border-bottom">已选择:<span id="data-range-selected-count">0</span>项</div>
+                        <div class="p-1 border-bottom">
+                            <div class="input-group input-group-sm">
+                                <input type="text" class="form-control" id="data-range-keyword" placeholder="请输入关键字">
+                                <div class="input-group-append">
+                                    <button class="btn btn-outline-secondary" type="button" id="data-range-search"><i class="fa fa-search"></i></button>
+                                </div>
+                            </div>
+                        </div>
+                        <div class="data-range-column py-1" id="data-range-tree"></div>
+                    </div>
+                    <div class="col-6">
+                        <div class="px-2 py-2 border-bottom">
+                            单位类型:<span id="selected-unit-type-count">0</span>
+                            <span class="ml-2">单位:<span id="selected-unit-count">0</span></span>
+                            <span class="ml-2">用户:<span id="selected-user-count">0</span></span>
+                        </div>
+                        <div class="data-range-column" id="data-range-selected-list"></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="save-data-range">确定</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<script>
+    $('#data-range-modal').appendTo('body');
+</script>

+ 2 - 1
app/view/sp_setting/permission.ejs

@@ -13,7 +13,8 @@
             <div class="sjs-height-0">
                 <nav class="nav nav-tabs m-3" role="tablist">
                     <a class="nav-item nav-link" href="/sp/<%- ctx.subProject.id %>/setting/user" aria-selected="false">账号列表</a>
-                    <a class="nav-item nav-link active"  href="/sp/<%- ctx.subProject.id %>/setting/user/permission?type=<%- ptype %><%- (keyword ? '&keyword=' + keyword : '')%>" aria-selected="true">模块权限</a>
+                    <a class="nav-item nav-link active" href="/sp/<%- ctx.subProject.id %>/setting/user/permission?ptype=<%- ptype %><%- (keyword ? '&keyword=' + keyword : '')%>" aria-selected="true">模块权限</a>
+                    <a class="nav-item nav-link" href="/sp/<%- ctx.subProject.id %>/setting/user/data-range?module=file" aria-selected="false">数据范围</a>
                     <div class="ml-auto">
                         <form class="input-group input-group-sm" method="get">
                             <input type="hidden" class="form-control" value="<%- ptype %>" name="ptype">

+ 2 - 1
app/view/sp_setting/user.ejs

@@ -13,8 +13,9 @@
         <div class="c-body">
             <div class="sjs-height-0">
                 <nav class="nav nav-tabs m-3" role="tablist">
-                    <a class="nav-item nav-link active" href="/sp/<%- ctx.subProject.id %>/setting/user" aria-selected="false">成员列表</a>
+                    <a class="nav-item nav-link active" href="/sp/<%- ctx.subProject.id %>/setting/user" aria-selected="false">账号列表</a>
                     <a class="nav-item nav-link"  href="/sp/<%- ctx.subProject.id %>/setting/user/permission?ptype=datacollect" aria-selected="true">模块权限</a>
+                    <a class="nav-item nav-link" href="/sp/<%- ctx.subProject.id %>/setting/user/data-range?module=file" aria-selected="false">数据范围</a>
                     <div class="ml-auto">
                         <form class="input-group input-group-sm" method="get">
                             <input type="hidden" class="form-control" value="<%- company %>" name="company">

+ 51 - 0
config/web.js

@@ -1435,6 +1435,50 @@ const JsFiles = {
                 ],
                 mergeFile: 'filing_manage',
             },
+            sjs_demo: {
+                files: [
+                    '/public/js/spreadjs/sheets/v11/gc.spread.sheets.all.11.2.2.min.js',
+                ],
+                mergeFiles: [
+                    '/public/js/div_resizer.js',
+                    '/public/js/path_tree.js',
+                    '/public/js/spreadjs_rela/spreadjs_zh.js',
+                    '/public/js/shares/cs_tools.js',
+                    '/public/js/file_sjs_demo.js',
+                ],
+                mergeFile: 'file_sjs_demo',
+            },
+            filesjs: {
+                files: [
+                    '/public/js/axios/axios.min.js', '/public/js/file-saver/FileSaver.js', '/public/js/js-xlsx/jszip.min.js',
+                    '/public/js/moment/moment.min.js',
+                    '/public/js/shares/aliyun-oss-sdk.min.js',
+                    '/public/js/spreadjs/sheets/v11/gc.spread.sheets.all.11.2.2.min.js',
+                ],
+                mergeFiles: [
+                    '/public/js/div_resizer.js',
+                    '/public/js/shares/ali_oss.js',
+                    '/public/js/path_tree.js',
+                    '/public/js/spreadjs_rela/spreadjs_zh.js',
+                    '/public/js/shares/cs_tools.js',
+                    '/public/js/shares/tenders2tree.js',
+                    '/public/js/filesjs.js',
+                ],
+                mergeFile: 'filesjs',
+            },
+            config_dir: {
+                files: [
+                    '/public/js/moment/moment.min.js',
+                    '/public/js/spreadjs/sheets/v11/gc.spread.sheets.all.11.2.2.min.js',
+                ],
+                mergeFiles: [
+                    '/public/js/sub_menu.js',
+                    '/public/js/path_tree.js',
+                    '/public/js/spreadjs_rela/spreadjs_zh.js',
+                    '/public/js/config_dir.js',
+                ],
+                mergeFile: 'config_dir',
+            },
         },
         drawing: {
             tender: {
@@ -1740,6 +1784,13 @@ const JsFiles = {
                 ],
                 mergeFile: 'sp_setting_permission',
             },
+            sp_data_range: {
+                files: [],
+                mergeFiles: [
+                    '/public/js/sp_setting_data_range.js',
+                ],
+                mergeFile: 'sp_setting_data_range',
+            },
         },
         profile: {
             cert: {

+ 41 - 0
sql/update.sql

@@ -74,6 +74,47 @@ ADD COLUMN `effective_price` decimal(30,8) DEFAULT NULL COMMENT '有效价格指
 
 ALTER TABLE `zh_filing`
 ADD COLUMN `upload_tips` varchar(1000) COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '上传说明' AFTER `tips`;
+
+ALTER TABLE `zh_filing`
+ADD COLUMN `create_uid` int(11) unsigned NOT NULL DEFAULT 0
+COMMENT '新版配置目录添加人ID'
+AFTER `add_user_id`;
+
+CREATE TABLE `zh_sub_project_filing_permission` (
+  `id` varchar(36) COLLATE utf8_unicode_ci NOT NULL COMMENT 'UUID',
+  `pid` int(11) unsigned NOT NULL COMMENT '项目ID',
+  `spid` varchar(36) COLLATE utf8_unicode_ci NOT NULL COMMENT '子项目ID',
+  `uid` int(11) unsigned NOT NULL COMMENT '项目用户ID',
+  `filing_type` int(11) unsigned NOT NULL COMMENT '资料类别类型',
+  `can_upload` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT '上传/引用权限',
+  `can_edit_file` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT '编辑文件权限',
+  `can_edit_dir` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT '编辑目录权限',
+  `can_lock_file` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT '锁定文件权限',
+  `create_uid` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '创建人ID',
+  `update_uid` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '最后修改人ID',
+  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_sp_user_filing_type` (`spid`, `uid`, `filing_type`),
+  KEY `idx_sp_user` (`spid`, `uid`),
+  KEY `idx_sp_filing_type` (`spid`, `filing_type`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci
+COMMENT='子项目资料类别用户操作权限';
+
+ALTER TABLE `zh_sub_project_filing_permission`
+ADD COLUMN `filing_id` varchar(36) COLLATE utf8_unicode_ci NULL DEFAULT NULL
+COMMENT '具体资料目录ID,NULL表示旧filing_type权限'
+AFTER `filing_type`,
+DROP INDEX `uk_sp_user_filing_type`,
+ADD UNIQUE KEY `uk_sp_user_filing_node`
+(`spid`, `uid`, `filing_id`),
+ADD KEY `idx_sp_filing_id`
+(`spid`, `filing_id`);
+
+ALTER TABLE `zh_file`
+ADD COLUMN `is_locked` tinyint(1) UNSIGNED NOT NULL DEFAULT 0
+COMMENT '文件锁定状态:0未锁定,1已锁定'
+AFTER `rela_info`;
 ------------------------------------
 -- 表数据
 ------------------------------------