Quellcode durchsuchen

feat(file): 新增配置目录文件移动功能和批量授权功能

- 添加了配置目录文件移动接口和控制器方法
- 实现了前端配置目录树的可编辑文本单元格功能
- 添加了归档单位字段显示和编辑支持
- 优化了配置目录权限管理逻辑
- 实现了批量新增授权和批量覆盖授权功能
- 添加了目录树展开折叠图标支持
- 重构了权限授权相关代码结构
caipin vor 2 Tagen
Ursprung
Commit
688460ab73

+ 35 - 47
app/controller/file_controller.js

@@ -256,6 +256,7 @@ module.exports = app => {
                 const renderData = {
                     jsFiles: this.app.jsFiles.common.concat(this.app.jsFiles.file.filesjs),
                     fileUploadWhitelist: this.getFileUploadWhitelist(ctx),
+                    useSpreadMoveFile: true,
                 };
                 const allFiling = await ctx.service.filing.getValidFiling(ctx.params.id, 'all');
                 const canManageDir = this.hasFilePermission(ctx, 'manage_dir');
@@ -405,6 +406,22 @@ module.exports = app => {
             }
         }
 
+        async configDirMoveFiles(ctx) {
+            try {
+                this.checkFilePermission(ctx, 'manage_dir');
+                this.checkLock(ctx);
+                const data = JSON.parse(ctx.request.body.data);
+                if (!data.source_filing_id || !data.target_filing_id) throw '缺少参数';
+                const sourceFiling = await this.getProjectFiling(ctx, data.source_filing_id);
+                const targetFiling = await this.getProjectFiling(ctx, data.target_filing_id);
+                const result = await ctx.service.file.moveFilingFiles(sourceFiling, targetFiling);
+                ctx.body = { err: 0, msg: '', data: result };
+            } catch (err) {
+                ctx.log(err);
+                ctx.ajaxErrorBody(err, '批量移动文件失败');
+            }
+        }
+
         async getFilingNodePermission(ctx) {
             try {
                 this.checkFilePermission(ctx, 'auth_user');
@@ -465,25 +482,6 @@ module.exports = app => {
                 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 '授权用户数据错误';
@@ -500,25 +498,25 @@ module.exports = app => {
                     return selectableIds.indexOf(uid) < 0 && currentUserIds.indexOf(uid) < 0;
                 });
                 if (invalidUserId) throw '选择的用户超出资料管理数据范围';
-                const result = await ctx.service.subProjectFilingPermission.savePermissionsToSubtree(
+                await ctx.service.subProjectFilingPermission.savePermissions(
                     ctx.subProject,
                     filing,
-                    allFiling,
                     userPermissions,
-                    ctx.session.sessionUser.accountId
+                    ctx.session.sessionUser.accountId,
+                    { replaceExisting: true }
                 );
                 const savedPermissionRows = await ctx.service.subProjectFilingPermission.getRows(ctx.subProject.id);
-                await this.fillFilingAddUserNames(ctx, result.filings);
-                await this.fillFilingPermissionCreatorNames(ctx, result.filings, savedPermissionRows);
+                const savedPermissions = await ctx.service.subProjectFilingPermission.getConfigPermissionRows(
+                    ctx.subProject.id, [filing]
+                );
+                await this.fillFilingAddUserNames(ctx, [filing]);
+                await this.fillFilingPermissionCreatorNames(ctx, [filing], savedPermissionRows);
                 ctx.body = {
                     err: 0,
                     msg: '',
                     data: {
-                        permissions: result.permissions,
-                        filings: result.filings.map(item => ({
-                            id: item.id,
-                            add_user: item.add_user || '',
-                        })),
+                        permissions: savedPermissions,
+                        filings: [{ id: filing.id, add_user: filing.add_user || '' }],
                     },
                 };
             } catch (err) {
@@ -528,35 +526,33 @@ module.exports = app => {
         }
 
         async addFilingNodePermissions(ctx) {
-            await this._saveFilingNodePermissionsToOther(ctx, false);
+            await this._saveBatchFilingNodePermissions(ctx, false);
         }
 
         async coverFilingNodePermissions(ctx) {
-            await this._saveFilingNodePermissionsToOther(ctx, true);
+            await this._saveBatchFilingNodePermissions(ctx, true);
         }
 
-        async _saveFilingNodePermissionsToOther(ctx, replaceExisting) {
+        async _saveBatchFilingNodePermissions(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) 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 '请选择需要添加的授权用户';
+                    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: {
@@ -577,15 +573,7 @@ module.exports = app => {
 
                 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;
-                });
+                const invalidUserId = requestedUserIds.find(uid => selectableIds.indexOf(uid) < 0);
                 if (invalidUserId) throw '选择的用户超出资料管理数据范围';
 
                 const permissionService = ctx.service.subProjectFilingPermission;
@@ -624,7 +612,7 @@ module.exports = app => {
                 };
             } catch (err) {
                 ctx.log(err);
-                ctx.ajaxErrorBody(err, replaceExisting ? '覆盖授权用户至其他目录失败' : '添加授权用户至其他目录失败');
+                ctx.ajaxErrorBody(err, replaceExisting ? '批量覆盖授权失败' : '批量新增授权失败');
             }
         }
 
@@ -850,7 +838,7 @@ module.exports = app => {
                 ctx.body = { err: 0, msg: '', data: result };
             } catch (error) {
                 this.log(error);
-                ctx.ajaxErrorBody(error, '编辑附件失败');
+                ctx.ajaxErrorBody(error, '移动文件失败');
             }
         }
         async uploadBigFile(ctx) {

BIN
app/public/images/filesjs-tree-closed.png


BIN
app/public/images/filesjs-tree-leaf.png


BIN
app/public/images/filesjs-tree-open.png


+ 497 - 154
app/public/js/config_dir.js

@@ -7,14 +7,17 @@ $(document).ready(function() {
             this.spread.options.showHorizontalScrollbar = false;
             this.sheet = this.spread.getActiveSheet();
             this.treeIcons = {
-                folder: new Image(),
+                closed: new Image(),
+                open: new Image(),
                 leaf: new Image(),
             };
             const refreshTreeIcon = () => { this.spread.refresh(); };
-            this.treeIcons.folder.onload = refreshTreeIcon;
+            this.treeIcons.closed.onload = refreshTreeIcon;
+            this.treeIcons.open.onload = refreshTreeIcon;
             this.treeIcons.leaf.onload = refreshTreeIcon;
-            this.treeIcons.folder.src = '/public/images/filesjs-folder.png';
-            this.treeIcons.leaf.src = '/public/images/filesjs-leaf.png';
+            this.treeIcons.closed.src = '/public/images/filesjs-tree-closed.png';
+            this.treeIcons.open.src = '/public/images/filesjs-tree-open.png';
+            this.treeIcons.leaf.src = '/public/images/filesjs-tree-leaf.png';
             const treeIcons = this.treeIcons;
 
             this.filingTree = createNewPathTree('base', {
@@ -32,7 +35,7 @@ $(document).ready(function() {
                         getIcon: function(data) {
                             const fallback = data.is_leaf ? 'file' : 'folder';
                             return {
-                                image: data.is_leaf ? treeIcons.leaf : treeIcons.folder,
+                                image: data.is_leaf ? treeIcons.leaf : (data.expanded ? treeIcons.open : treeIcons.closed),
                                 fallback,
                                 size: 18,
                             };
@@ -43,7 +46,8 @@ $(document).ready(function() {
                         title: '固定目录', colSpan: '1', rowSpan: '1', field: 'is_fixed', hAlign: 1, width: 75, formatter: '@',
                         getValue: function(data) { return data.is_fixed ? '\u{1F512}' : ''; },
                     },
-                    { title: '上传说明', colSpan: '1', rowSpan: '1', field: 'upload_tips', hAlign: 0, width: 200, formatter: '@', cellType: 'autoTip' },
+                    { title: '归档单位', colSpan: '1', rowSpan: '1', field: 'file_company', hAlign: 0, width: 160, formatter: '@', cellType: 'autoTip', wordWrap: true },
+                    { title: '上传说明', colSpan: '1', rowSpan: '1', field: 'upload_tips', hAlign: 0, width: 200, formatter: '@', cellType: 'autoTip', wordWrap: true },
                     { title: '授权用户', colSpan: '1', rowSpan: '1', field: 'auth_users', hAlign: 0, width: 320, formatter: '@', cellType: 'autoTip' },
                     { title: '添加人', colSpan: '1', rowSpan: '1', field: 'add_user', hAlign: 0, width: 90, formatter: '@', cellType: 'autoTip' },
                 ],
@@ -54,6 +58,14 @@ $(document).ready(function() {
 
             SpreadJsObj.initSheet(this.sheet, this.spreadSetting);
             this.curNode = null;
+            this.editableTextFields = [ 'upload_tips', 'file_company' ];
+            var editableTextFields = this.editableTextFields;
+            this.editableTextColumnIndexes = this.spreadSetting.cols.reduce(function(indexes, col, index) {
+                if (editableTextFields.indexOf(col.field) >= 0) indexes.push(index);
+                return indexes;
+            }, []);
+            this._textCellEditing = null;
+            this._editableTextCellType = null;
             this._authUserSaving = false;
             this._authTargetSaving = false;
             this._buildAuthAccountList();
@@ -138,6 +150,7 @@ $(document).ready(function() {
                     file_count: Number(node.file_count) || 0,
                     is_fixed: node.is_fixed,
                     upload_tips: node.upload_tips || '',
+                    file_company: node.file_company || '',
                     auth_users: authUsers.map(function(x) { return x.name; }).join('、'),
                     auth_user_ids: authUsers.map(function(x) { return Number(x.id); }),
                     add_user: node.add_user || '',
@@ -151,6 +164,7 @@ $(document).ready(function() {
             var convertedData = this._convertData(filingData);
             this.filingTree.loadDatas(convertedData);
             SpreadJsObj.loadSheetData(this.sheet, SpreadJsObj.DataType.Tree, this.filingTree);
+            this._applyEditableTextCellType();
             this._applyLastColumnRightBorder();
         }
 
@@ -158,9 +172,37 @@ $(document).ready(function() {
             var convertedData = this._convertData(filingData);
             this.filingTree.loadDatas(convertedData);
             SpreadJsObj.loadSheetData(this.sheet, SpreadJsObj.DataType.Tree, this.filingTree, true);
+            this._applyEditableTextCellType();
             this._resizeSpreadColumns();
         }
 
+        _isEditableTextColumn(columnIndex) {
+            return this.editableTextColumnIndexes.indexOf(columnIndex) >= 0;
+        }
+
+        _applyEditableTextCellType() {
+            if (!this._editableTextCellType) {
+                this._editableTextCellType = SpreadJsObj.CellType.getAutoTipCellType();
+                var defaultIsReservedKey = this._editableTextCellType.isReservedKey;
+                this._editableTextCellType.isReservedKey = function(event, context) {
+                    var target = event && (event.target || event.srcElement);
+                    var isEditingInput = target && target.getAttribute &&
+                        target.getAttribute('gcUIElement') === 'gcEditingInput';
+                    if (event && event.keyCode === 13 && context && context.isEditing && isEditingInput &&
+                        !event.ctrlKey && !event.altKey && !event.metaKey) {
+                        return true;
+                    }
+                    return defaultIsReservedKey ? defaultIsReservedKey.call(this, event, context) : false;
+                };
+            }
+            var self = this;
+            this.editableTextColumnIndexes.forEach(function(columnIndex) {
+                self.sheet.getRange(-1, columnIndex, -1, 1)
+                    .cellType(self._editableTextCellType)
+                    .wordWrap(true);
+            });
+        }
+
         _applyLastColumnRightBorder() {
             var lastColumnIndex = this.spreadSetting.cols.length - 1;
             var borderLine = this.sheet.borderLine || new spreadNS.LineBorder('#cccccc', spreadNS.LineStyle.thin);
@@ -182,14 +224,15 @@ $(document).ready(function() {
             var containerWidth = Math.floor($('#config-dir-spread').innerWidth() || 0);
             if (containerWidth <= 0) return;
             var rowHeaderWidth = 25;
-            var fixedWidth = 75 + 75 + 113;
+            var fileCompanyWidth = 160;
+            var fixedWidth = 75 + 75 + fileCompanyWidth + 113;
             var minimumFlexWidth = 600;
             var usableWidth = Math.max(containerWidth - rowHeaderWidth, fixedWidth + minimumFlexWidth);
             var flexWidth = usableWidth - fixedWidth;
             var nameWidth = Math.floor(flexWidth * 0.34);
             var tipsWidth = Math.floor(flexWidth * 0.24);
             var authUserWidth = flexWidth - nameWidth - tipsWidth;
-            var widths = [ nameWidth, 75, 75, tipsWidth, authUserWidth, 90 ];
+            var widths = [ nameWidth, 75, 75, fileCompanyWidth, tipsWidth, authUserWidth, 90 ];
             for (var index = 0; index < widths.length; index++) {
                 this.sheet.setColumnWidth(index, widths[index]);
             }
@@ -201,28 +244,10 @@ $(document).ready(function() {
 
         _getSelectedNode() { return SpreadJsObj.getSelectObject(this.sheet); }
 
-        _hasDirectAuthorization(node) {
-            if (!node) return false;
-            return filingPermissionData.some(function(permission) {
-                return permission.filing_id && Number(permission.is_legacy) !== 1 &&
-                    String(permission.filing_id) === String(node.id);
-            });
-        }
-
-        _hasAuthorizedAncestor(node) {
-            if (!node) return false;
-            var parent = this.filingTree.getParent(node);
-            while (parent) {
-                if (this._hasDirectAuthorization(parent)) return true;
-                parent = this.filingTree.getParent(parent);
-            }
-            return false;
-        }
-
         _refreshAuthUserButton(node) {
             var button = $('#cd-auth-user');
             if (button.length === 0) return;
-            var disabled = !node || this._hasAuthorizedAncestor(node);
+            var disabled = !node;
             button
                 .toggleClass('disabled btn-outline-secondary', disabled)
                 .toggleClass('btn-outline-primary', !disabled)
@@ -247,6 +272,16 @@ $(document).ready(function() {
                 if (node) self.curNode = node;
                 self._refreshAuthUserButton(node);
             });
+            this.spread.bind(spreadNS.Events.EditStarting, function(e, info) {
+                var editing = self._textCellEditing;
+                if (!editing || info.row !== editing.row || info.col !== editing.col ||
+                    !configDirLocked || !canManageConfigDir) {
+                    info.cancel = true;
+                }
+            });
+            this.spread.bind(spreadNS.Events.EditEnded, function(e, info) {
+                self._saveTextCellEdit(info);
+            });
 
             $('#cd-add-sibling').click(function() {
                 var node = self._getSelectedNode();
@@ -344,8 +379,7 @@ $(document).ready(function() {
             $('#cd-move').click(function() {
                 var node = self._getSelectedNode();
                 if (!node) { toastr.warning('请先选择一个分类'); return; }
-                if (node.is_fixed) { toastr.error('固定分类不可移动'); return; }
-                if (self._hasSubtreeFiles(node)) { toastr.error('该目录或子目录下已有文件,不可移动'); return; }
+                if (!self._hasSubtreeFiles(node)) { toastr.warning('当前目录及子目录暂无可移动文件'); return; }
                 self._showMoveModal(node);
             });
 
@@ -391,6 +425,14 @@ $(document).ready(function() {
                 self._showAuthUserModal(node);
             });
 
+            $('#cd-auth-batch-add').click(function() {
+                self._showAuthTargetModal('add');
+            });
+
+            $('#cd-auth-batch-cover').click(function() {
+                self._showAuthTargetModal('cover');
+            });
+
             $('[name=cd-show-level]').click(function() {
                 self._showLevel($(this).attr('tag'));
             });
@@ -399,10 +441,60 @@ $(document).ready(function() {
                 if (!configDirLocked || !canManageConfigDir) return;
                 var node = self._getSelectedNode();
                 if (!node) return;
+                var selection = self.sheet.getSelections()[0];
+                if (selection && self._isEditableTextColumn(selection.col)) {
+                    self._startTextCellEdit(selection.row, selection.col, node);
+                    return;
+                }
                 self._showEditModal(node);
             });
         }
 
+        _startTextCellEdit(row, col, node) {
+            if (row < 0 || !node || !this._isEditableTextColumn(col)) return;
+            var field = this.spreadSetting.cols[col].field;
+            this._textCellEditing = {
+                row: row,
+                col: col,
+                field: field,
+                filingId: String(node.id),
+                originalValue: String(node[field] || ''),
+            };
+            this.sheet.getCell(row, col).locked(false);
+            this.sheet.startEdit(true);
+        }
+
+        _saveTextCellEdit(info) {
+            if (!info || !this._isEditableTextColumn(info.col)) return;
+            info.sheet.getCell(info.row, info.col).locked(true);
+            var editing = this._textCellEditing;
+            this._textCellEditing = null;
+            var node = this.filingTree.nodes[info.row];
+            if (!editing || !node || editing.row !== info.row || editing.col !== info.col ||
+                String(node.id) !== editing.filingId) {
+                if (node) SpreadJsObj.reLoadRowData(info.sheet, info.row);
+                return;
+            }
+
+            var textValue = String(info.editingText || '').trim();
+            if (textValue === editing.originalValue) {
+                SpreadJsObj.reLoadRowData(info.sheet, info.row);
+                return;
+            }
+
+            var saveData = { updateType: 'save', id: node.id };
+            saveData[editing.field] = textValue;
+            postData('config-dir/update', saveData, function() {
+                var srcNode = filingData.find(function(item) { return String(item.id) === String(node.id); });
+                if (srcNode) srcNode[editing.field] = textValue;
+                node[editing.field] = textValue;
+                SpreadJsObj.reLoadRowData(info.sheet, info.row);
+            }, function() {
+                node[editing.field] = editing.originalValue;
+                SpreadJsObj.reLoadRowData(info.sheet, info.row);
+            });
+        }
+
         _getSubtreeTreeIds(node) {
             var result = new Set([node.tree_id]);
             var pending = [node.tree_id];
@@ -496,50 +588,102 @@ $(document).ready(function() {
             var self = this;
             var subtreeTreeIds = this._getSubtreeTreeIds(node);
             $('#cd-move-source-name').text(node.name);
+            this._moveTargetExpanded = {};
+            this.filingTree.nodes.forEach(function(item) {
+                if (!item.is_leaf) self._moveTargetExpanded[item.tree_id] = true;
+            });
             var html = [];
+            var hasSelectableTarget = false;
             for (var i = 0; i < this.filingTree.nodes.length; i++) {
                 var n = this.filingTree.nodes[i];
-                if (subtreeTreeIds.has(n.tree_id)) continue;
-                if (Number(n.file_count) > 0) continue;
-                if (n.is_fixed && n.level >= node.level) continue;
-                var indent = '';
-                for (var k = 1; k < n.level; k++) indent += '&nbsp;&nbsp;';
-                var icon = n.is_leaf ? '\u{1F4C4}' : '\u{1F4C1}';
-                html.push('<tr><td class="text-center"><input type="radio" name="cd-move-target" value="' + n.id + '" tree-id="' + n.tree_id + '"></td>');
-                html.push('<td>' + indent + icon + ' ' + n.name + '</td></tr>');
+                var hasChildren = this.filingTree.nodes.some(function(child) { return child.tree_pid === n.tree_id; });
+                var inSourceSubtree = subtreeTreeIds.has(n.tree_id);
+                var selectable = n.is_leaf && !inSourceSubtree;
+                var indent = Math.max(Number(n.level) - 1, 0) * 20;
+                if (selectable) hasSelectableTarget = true;
+                html.push('<tr data-move-tree-id="' + n.tree_id + '" data-move-tree-pid="' + n.tree_pid + '">');
+                html.push('<td class="text-center"><input type="radio" name="cd-move-target" value="' +
+                    self._escapeAuthUserHtml(n.id) + '" tree-id="' + n.tree_id + '"' + (selectable ? '' : ' disabled') + '></td>');
+                html.push('<td><span style="display:inline-block;width:' + indent + 'px;"></span>');
+                if (hasChildren) {
+                    html.push('<a href="javascript:void(0);" class="cd-move-target-toggle mr-1" data-tree-id="' + n.tree_id + '">');
+                    html.push('<i class="fa fa-minus-square-o"></i></a>');
+                    html.push('<i class="fa fa-folder-open text-warning mr-1 cd-move-target-folder" data-tree-id="' + n.tree_id + '"></i>');
+                } else {
+                    html.push('<span class="d-inline-block mr-1" style="width:12px;"></span>');
+                    html.push('<i class="fa fa-file-o text-primary mr-1"></i>');
+                }
+                html.push(self._escapeAuthUserHtml(n.name));
+                if (String(n.id) === String(node.id)) html.push('<span class="text-muted ml-1">(当前目录)</span>');
+                html.push('</td></tr>');
+            }
+            if (!hasSelectableTarget) {
+                html.push('<tr><td colspan="2" class="text-muted text-center py-3">暂无可选择的目标底层目录</td></tr>');
             }
             $('#cd-move-target-list').html(html.join(''));
+            this._refreshMoveTargetTreeVisibility();
+            $('#cd-move-target-list').off('click', '.cd-move-target-toggle')
+                .on('click', '.cd-move-target-toggle', function(event) {
+                    event.preventDefault();
+                    var treeId = Number($(this).attr('data-tree-id'));
+                    self._moveTargetExpanded[treeId] = !self._moveTargetExpanded[treeId];
+                    self._refreshMoveTargetTreeVisibility();
+                });
             $('#cd-move-modal').modal('show');
             $('#cd-move-ok').off('click').on('click', function() {
                 var target = $('input[name=cd-move-target]:checked');
-                if (target.length === 0) { toastr.warning('请选择目标分类'); return; }
+                if (target.length === 0) { toastr.warning('请选择目标底层目录'); return; }
                 var targetOrigId = target.attr('value');
-                var targetTreeId = parseInt(target.attr('tree-id'));
-                var targetNode = self.filingTree.nodes.find(function(x) { return x.tree_id === targetTreeId; });
-                var siblings = self.filingTree.nodes.filter(function(x) { return x.tree_pid === targetTreeId; });
-                var newOrder = siblings.length;
                 var srcOrigId = self._findOriginalId(node.tree_id);
-                var parentOrigId = targetTreeId === -1 ? '-1' : targetOrigId;
-                postData('config-dir/update', { updateType: 'move', id: srcOrigId, tree_pid: parentOrigId, tree_order: newOrder }, function(result) {
-                    // 使用后端返回的更新数据同步 filingData
-                    if (result && result.update) {
-                        for (var i = 0; i < result.update.length; i++) {
-                            var updateItem = result.update[i];
-                            var srcNode = filingData.find(function(x) { return x.id === updateItem.id; });
-                            if (srcNode) {
-                                // 更新所有可能变化的字段
-                                if (updateItem.tree_pid !== undefined) srcNode.tree_pid = updateItem.tree_pid;
-                                if (updateItem.tree_order !== undefined) srcNode.tree_order = updateItem.tree_order;
-                                if (updateItem.tree_level !== undefined) srcNode.tree_level = updateItem.tree_level;
-                            }
+                postData('config-dir/file/move', {
+                    source_filing_id: srcOrigId,
+                    target_filing_id: targetOrigId,
+                }, function(result) {
+                    if (result && result.filings) {
+                        for (var i = 0; i < result.filings.length; i++) {
+                            var updateItem = result.filings[i];
+                            var srcNode = filingData.find(function(x) { return String(x.id) === String(updateItem.id); });
+                            if (srcNode) srcNode.file_count = Number(updateItem.file_count) || 0;
                         }
                     }
                     self._reloadTree();
+                    var selectedRow = self.filingTree.nodes.findIndex(function(item) {
+                        return String(item.id) === String(srcOrigId);
+                    });
+                    if (selectedRow >= 0) self.sheet.setSelection(selectedRow, 0, 1, 1);
                     $('#cd-move-modal').modal('hide');
+                    toastr.success('已移动' + Number(result.moved_count || 0) + '个文件');
                 });
             });
         }
 
+        _refreshMoveTargetTreeVisibility() {
+            var self = this;
+            var nodeMap = {};
+            this.filingTree.nodes.forEach(function(node) { nodeMap[node.tree_id] = node; });
+            this.filingTree.nodes.forEach(function(node) {
+                var visible = true;
+                var parentTreeId = node.tree_pid;
+                while (parentTreeId !== -1) {
+                    if (!self._moveTargetExpanded[parentTreeId]) {
+                        visible = false;
+                        break;
+                    }
+                    var parent = nodeMap[parentTreeId];
+                    if (!parent) break;
+                    parentTreeId = parent.tree_pid;
+                }
+                $('#cd-move-target-list tr[data-move-tree-id="' + node.tree_id + '"]').toggle(visible);
+                var expanded = Boolean(self._moveTargetExpanded[node.tree_id]);
+                $('#cd-move-target-list .cd-move-target-toggle[data-tree-id="' + node.tree_id + '"] i')
+                    .toggleClass('fa-minus-square-o', expanded)
+                    .toggleClass('fa-plus-square-o', !expanded);
+                $('#cd-move-target-list .cd-move-target-folder[data-tree-id="' + node.tree_id + '"]')
+                    .toggleClass('fa-folder-open', expanded)
+                    .toggleClass('fa-folder', !expanded);
+            });
+        }
+
         _showTipsModal(node) {
             var self = this;
             $('#cd-tips-filing-name').text(node.name);
@@ -559,7 +703,6 @@ $(document).ready(function() {
         }
 
         _showAuthUserModal(node) {
-            if (this._hasAuthorizedAncestor(node)) return;
             var self = this;
             this._authUserFiling = node;
             this._authUserIds = (node.auth_user_ids || []).map(function(id) { return Number(id); });
@@ -678,28 +821,6 @@ $(document).ready(function() {
                 self._syncAuthUserDropdownMarks();
             });
 
-            $('#cd-auth-add-other').off('click').on('click', function(event) {
-                event.preventDefault();
-                if ($(this).attr('aria-disabled') === 'true') return;
-                var selectedPermissions = self._getCheckedAuthUserPermissions();
-                if (selectedPermissions.length === 0) {
-                    toastr.warning('请选择需要添加的授权用户');
-                    return;
-                }
-                self._showAuthTargetModal(node, selectedPermissions, 'add');
-            });
-
-            $('#cd-auth-cover-other').off('click').on('click', function(event) {
-                event.preventDefault();
-                if ($(this).attr('aria-disabled') === 'true') return;
-                var selectedPermissions = self._getCheckedAuthUserPermissions();
-                if (selectedPermissions.length === 0) {
-                    toastr.warning('请选择需要覆盖的授权用户');
-                    return;
-                }
-                self._showAuthTargetModal(node, selectedPermissions, 'cover');
-            });
-
             $('#auth-user-save').off('click').on('click', function() {
                 if (self._authUserSaving) return;
                 var userIds = self._authUserIds.slice();
@@ -741,7 +862,7 @@ $(document).ready(function() {
                             can_edit_file: Number(permission.can_edit_file) ? 1 : 0,
                             can_edit_dir: Number(permission.can_edit_dir) ? 1 : 0,
                             can_lock_file: Number(permission.can_lock_file) ? 1 : 0,
-                            is_legacy: 0,
+                            is_legacy: Number(permission.is_legacy) === 1 ? 1 : 0,
                         });
                     });
                     self._buildAuthUserMap();
@@ -775,36 +896,24 @@ $(document).ready(function() {
                 .text(this._authUserSaving ? '保存中...' : '确定');
         }
 
-        _getCheckedAuthUserPermissions() {
+        _showAuthTargetModal(mode) {
             var self = this;
-            var result = [];
-            $('#auth-user-list input[name=cd-auth-user-check]:checked').each(function() {
-                var userId = Number($(this).attr('data-user-id'));
-                var state = self._ensureAuthUserPermissionState(userId);
-                result.push({
-                    uid: userId,
-                    can_upload: state.upload_reference ? 1 : 0,
-                    can_edit_file: state.edit_file ? 1 : 0,
-                    can_edit_dir: state.edit_dir ? 1 : 0,
-                    can_lock_file: state.lock_file ? 1 : 0,
-                });
-            });
-            return result;
-        }
-
-        _showAuthTargetModal(sourceNode, userPermissions, mode) {
-            var self = this;
-            this._authTargetSource = sourceNode;
-            this._authTargetPermissions = userPermissions;
+            var selectedNode = this._getSelectedNode();
+            this._authTargetReturnId = selectedNode ? String(selectedNode.id) : '';
             this._authTargetMode = mode === 'cover' ? 'cover' : 'add';
             this._authTargetIds = [];
             this._authTargetExpanded = {};
+            this._authTargetUserIds = [];
+            this._authTargetPermissionState = {};
             this.filingTree.nodes.forEach(function(node) {
-                if (node.tree_pid === -1) self._authTargetExpanded[node.tree_id] = true;
+                if (!node.is_leaf) self._authTargetExpanded[node.tree_id] = true;
             });
-            $('#cd-auth-target-modal-title').text(this._authTargetMode === 'cover' ? '覆盖至其他目录' : '添加至其他目录');
-            $('#cd-auth-cover-warning').hide();
+            $('#cd-auth-target-modal-title').text(
+                this._authTargetMode === 'cover' ? '批量覆盖授权' : '批量新增授权'
+            );
             this._renderAuthTargetTree();
+            this._renderAuthTargetUserDropdown();
+            this._renderAuthTargetUserList();
             this._setAuthTargetSaveState(false);
 
             $('#cd-auth-target-list').off('click', '.cd-auth-target-toggle')
@@ -817,24 +926,95 @@ $(document).ready(function() {
 
             $('#cd-auth-target-list').off('change', '.cd-auth-target-check')
                 .on('change', '.cd-auth-target-check', function() {
-                    var filingId = String($(this).attr('data-filing-id'));
-                    if (this.checked && self._authTargetIds.indexOf(filingId) < 0) {
-                        self._authTargetIds.push(filingId);
-                    }
-                    if (!this.checked) {
-                        self._authTargetIds = self._authTargetIds.filter(function(id) { return id !== filingId; });
-                    }
+                    self._setAuthTargetBranchChecked(Number($(this).attr('data-tree-id')), this.checked);
                     self._syncAuthTargetSelectionState();
                 });
 
-            $('#cd-auth-target-select-all').off('change').on('change', function() {
+            $('#cd-auth-target-user-options').off('click', '.cd-auth-target-unit-row')
+                .on('click', '.cd-auth-target-unit-row', function(event) {
+                    event.preventDefault();
+                    event.stopPropagation();
+                    var groupId = $(this).attr('data-group-id');
+                    var children = $('#cd-auth-target-user-options').find('[data-group-content="' + groupId + '"]');
+                    children.toggle();
+                    $('i', this).toggleClass('fa-plus-square fa-minus-square-o');
+                });
+
+            $('#cd-auth-target-user-options').off('click', '.cd-auth-target-add-unit')
+                .on('click', '.cd-auth-target-add-unit', function(event) {
+                    event.preventDefault();
+                    event.stopPropagation();
+                    var company = $(this).attr('data-company');
+                    self._addAuthTargetUsers(accountList.filter(function(account) {
+                        return (account.company || '未设置单位') === company;
+                    }).map(function(account) { return Number(account.id); }));
+                });
+
+            $('#cd-auth-target-user-options').off('click', '.cd-auth-target-add-user')
+                .on('click', '.cd-auth-target-add-user', function(event) {
+                    event.preventDefault();
+                    event.stopPropagation();
+                    self._addAuthTargetUsers([Number($(this).attr('data-user-id'))]);
+                });
+
+            $('#cd-auth-target-user-list').off('click', '.cd-auth-target-user-remove')
+                .on('click', '.cd-auth-target-user-remove', function(event) {
+                    event.preventDefault();
+                    self._removeAuthTargetUsers([Number($(this).attr('data-user-id'))]);
+                });
+
+            $('#cd-auth-target-user-select-all').off('change').on('change', function() {
+                var checked = this.checked;
+                var userIds = [];
+                $('#cd-auth-target-user-list input[name=cd-auth-target-user-check]')
+                    .prop('checked', checked).each(function() {
+                        userIds.push(Number($(this).attr('data-user-id')));
+                    });
+                if (!checked) self._setAuthTargetUserPermissions(userIds, false);
+                self._syncAuthTargetUserControls();
+            });
+
+            $('#cd-auth-target-user-list').off('change', 'input[name=cd-auth-target-user-check]')
+                .on('change', 'input[name=cd-auth-target-user-check]', function() {
+                    var userId = Number($(this).attr('data-user-id'));
+                    if (!this.checked) self._setAuthTargetUserPermissions([userId], false);
+                    self._syncAuthTargetUserControls();
+                });
+
+            $('.cd-auth-target-permission-all').off('change').on('change', function() {
+                var permissionKey = $(this).attr('data-permission');
                 var checked = this.checked;
-                var targetChecks = $('#cd-auth-target-list .cd-auth-target-check:not(:disabled)');
-                targetChecks.prop('checked', checked);
-                self._authTargetIds = checked ? targetChecks.map(function() {
-                    return String($(this).attr('data-filing-id'));
-                }).get() : [];
-                self._syncAuthTargetSelectionState();
+                $('#cd-auth-target-user-list .cd-auth-target-permission-check[data-permission="' + permissionKey + '"]')
+                    .each(function() {
+                        var userId = Number($(this).attr('data-user-id'));
+                        $(this).prop('checked', checked);
+                        self._ensureAuthTargetUserPermissionState(userId)[permissionKey] = checked;
+                        if (checked) {
+                            $('#cd-auth-target-user-list input[name=cd-auth-target-user-check][data-user-id="' + userId + '"]')
+                                .prop('checked', true);
+                        }
+                    });
+                self._syncAuthTargetUserControls();
+            });
+
+            $('#cd-auth-target-user-list').off('change', '.cd-auth-target-permission-check')
+                .on('change', '.cd-auth-target-permission-check', function() {
+                    var userId = Number($(this).attr('data-user-id'));
+                    var permissionKey = $(this).attr('data-permission');
+                    self._ensureAuthTargetUserPermissionState(userId)[permissionKey] = this.checked;
+                    if (this.checked) {
+                        $('#cd-auth-target-user-list input[name=cd-auth-target-user-check][data-user-id="' + userId + '"]')
+                            .prop('checked', true);
+                    }
+                    self._syncAuthTargetUserControls();
+                });
+
+            $('#cd-auth-target-user-delete').off('click').on('click', function(event) {
+                event.preventDefault();
+                if ($(this).attr('aria-disabled') === 'true') return;
+                var removeIds = $('#cd-auth-target-user-list input[name=cd-auth-target-user-check]:checked')
+                    .map(function() { return Number($(this).attr('data-user-id')); }).get();
+                self._removeAuthTargetUsers(removeIds);
             });
 
             $('#cd-auth-target-save').off('click').on('click', function() {
@@ -843,56 +1023,51 @@ $(document).ready(function() {
                     toastr.warning('请选择目标资料目录');
                     return;
                 }
+                if (self._authTargetUserIds.length === 0) {
+                    toastr.warning('请选择需要授权的用户');
+                    return;
+                }
                 var isCover = self._authTargetMode === 'cover';
                 self._setAuthTargetSaveState(true);
                 postData(isCover ? 'config-dir/permission/cover-other' : 'config-dir/permission/add-other', {
-                    source_filing_id: sourceNode.id,
                     target_filing_ids: self._authTargetIds.slice(),
-                    user_permissions: self._authTargetPermissions,
+                    user_permissions: self._getAuthTargetUserPermissions(),
                 }, function(result) {
                     self._setAuthTargetSaveState(false);
                     self._applyAuthTargetResult(result || {});
                     $('#cd-auth-target-modal').modal('hide');
-                    toastr.success(isCover ? '覆盖成功' : '添加成功');
+                    toastr.success(isCover ? '批量覆盖授权成功' : '批量新增授权成功');
                 }, function() {
                     self._setAuthTargetSaveState(false);
                 });
             });
 
-            $('#cd-auth-target-modal').off('.configDirAuthTarget')
-                .on('shown.bs.modal.configDirAuthTarget', function() {
-                    $('.modal-backdrop').last().css('z-index', 1055);
-                })
-                .on('hidden.bs.modal.configDirAuthTarget', function() {
-                    if ($('#auth-user-modal').hasClass('show')) $('body').addClass('modal-open');
-                })
-                .modal('show');
+            $('#cd-auth-target-modal').modal('show');
         }
 
         _renderAuthTargetTree() {
             var self = this;
             var nodes = this.filingTree.nodes;
-            var sourceId = String(this._authTargetSource.id);
             var html = [];
             nodes.forEach(function(node) {
                 var hasChildren = nodes.some(function(child) { return child.tree_pid === node.tree_id; });
-                var indent = Math.max(Number(node.level) - 1, 0) * 20;
-                var isSource = String(node.id) === sourceId;
+                var indent = Math.max(Number(node.level) - 1, 0) * 18;
                 html.push('<tr data-target-tree-id="' + node.tree_id + '" data-target-tree-pid="' + node.tree_pid + '">');
+                html.push('<td class="text-center"><input type="checkbox" class="cd-auth-target-check" data-tree-id="' +
+                    node.tree_id + '" data-filing-id="' + self._escapeAuthUserHtml(node.id) + '"></td>');
                 html.push('<td><span style="display:inline-block;width:' + indent + 'px;"></span>');
                 if (hasChildren) {
                     html.push('<a href="javascript:void(0);" class="cd-auth-target-toggle mr-1" data-tree-id="' + node.tree_id + '">');
                     html.push('<i class="fa ' + (self._authTargetExpanded[node.tree_id] ? 'fa-minus-square-o' : 'fa-plus-square-o') + '"></i></a>');
+                    html.push('<i class="fa fa-folder-open text-warning mr-1 cd-auth-target-folder" data-tree-id="' + node.tree_id + '"></i>');
                 } else {
                     html.push('<span class="d-inline-block mr-1" style="width:12px;"></span>');
+                    html.push('<i class="fa fa-file-o text-primary mr-1"></i>');
                 }
-                html.push('<i class="fa fa-folder text-warning mr-1"></i>' + self._escapeAuthUserHtml(node.name));
-                if (isSource) html.push('<span class="text-muted ml-1">(当前目录)</span>');
-                html.push('</td><td class="text-center"><input type="checkbox" class="cd-auth-target-check" data-filing-id="' +
-                    self._escapeAuthUserHtml(node.id) + '"' + (isSource ? ' disabled' : '') + '></td></tr>');
+                html.push(self._escapeAuthUserHtml(node.name) + '</td></tr>');
             });
-            if (nodes.length <= 1) {
-                html.push('<tr><td colspan="2" class="text-muted text-center py-4">暂无可选择的其他目录</td></tr>');
+            if (nodes.length === 0) {
+                html.push('<tr><td colspan="2" class="text-muted text-center py-4">暂无可选择的目录</td></tr>');
             }
             $('#cd-auth-target-list').html(html.join(''));
             this._refreshAuthTargetTreeVisibility();
@@ -916,30 +1091,200 @@ $(document).ready(function() {
                     parentTreeId = parent.tree_pid;
                 }
                 $('#cd-auth-target-list tr[data-target-tree-id="' + node.tree_id + '"]').toggle(visible);
+                var expanded = Boolean(self._authTargetExpanded[node.tree_id]);
                 $('#cd-auth-target-list .cd-auth-target-toggle[data-tree-id="' + node.tree_id + '"] i')
-                    .toggleClass('fa-minus-square-o', Boolean(self._authTargetExpanded[node.tree_id]))
-                    .toggleClass('fa-plus-square-o', !self._authTargetExpanded[node.tree_id]);
+                    .toggleClass('fa-minus-square-o', expanded)
+                    .toggleClass('fa-plus-square-o', !expanded);
+                $('#cd-auth-target-list .cd-auth-target-folder[data-tree-id="' + node.tree_id + '"]')
+                    .toggleClass('fa-folder-open', expanded)
+                    .toggleClass('fa-folder', !expanded);
+            });
+        }
+
+        _setAuthTargetBranchChecked(treeId, checked) {
+            var pending = [treeId];
+            while (pending.length > 0) {
+                var currentId = pending.shift();
+                $('#cd-auth-target-list .cd-auth-target-check[data-tree-id="' + currentId + '"]')
+                    .prop('checked', checked).prop('indeterminate', false);
+                this.filingTree.nodes.filter(function(node) { return node.tree_pid === currentId; })
+                    .forEach(function(child) { pending.push(child.tree_id); });
+            }
+            this._syncAuthTargetParentChecks();
+        }
+
+        _syncAuthTargetParentChecks() {
+            var nodes = this.filingTree.nodes.slice().sort(function(a, b) { return Number(b.level) - Number(a.level); });
+            nodes.forEach(function(node) {
+                var children = nodes.filter(function(child) { return child.tree_pid === node.tree_id; });
+                if (children.length === 0) return;
+                var childChecks = children.map(function(child) {
+                    return $('#cd-auth-target-list .cd-auth-target-check[data-tree-id="' + child.tree_id + '"]')[0];
+                }).filter(Boolean);
+                var allChecked = childChecks.length > 0 && childChecks.every(function(check) { return check.checked; });
+                var hasChecked = childChecks.some(function(check) { return check.checked || check.indeterminate; });
+                $('#cd-auth-target-list .cd-auth-target-check[data-tree-id="' + node.tree_id + '"]')
+                    .prop('checked', allChecked)
+                    .prop('indeterminate', !allChecked && hasChecked);
             });
         }
 
         _syncAuthTargetSelectionState() {
-            var targetChecks = $('#cd-auth-target-list .cd-auth-target-check:not(:disabled)');
-            var checkedCount = targetChecks.filter(':checked').length;
-            $('#cd-auth-target-select-all')
-                .prop('checked', targetChecks.length > 0 && checkedCount === targetChecks.length)
-                .prop('indeterminate', checkedCount > 0 && checkedCount < targetChecks.length)
-                .prop('disabled', targetChecks.length === 0);
-            $('#cd-auth-target-save').prop('disabled', this._authTargetSaving || checkedCount === 0);
-            $('#cd-auth-cover-warning').toggle(this._authTargetMode === 'cover' && checkedCount > 0);
+            this._authTargetIds = $('#cd-auth-target-list .cd-auth-target-check:checked').map(function() {
+                return String($(this).attr('data-filing-id'));
+            }).get();
+            var hasTargets = this._authTargetIds.length > 0;
+            var hasUsers = (this._authTargetUserIds || []).length > 0;
+            $('#cd-auth-target-save').prop('disabled', this._authTargetSaving || !hasTargets || !hasUsers);
+            $('#cd-auth-cover-warning').toggle(this._authTargetMode === 'cover' && hasTargets);
         }
 
         _setAuthTargetSaveState(saving) {
             this._authTargetSaving = Boolean(saving);
-            $('#cd-auth-target-save')
-                .text(this._authTargetSaving ? '保存中...' : '确定');
+            $('#cd-auth-target-save').text(this._authTargetSaving ? '保存中...' : '确定');
+            this._syncAuthTargetSelectionState();
+        }
+
+        _renderAuthTargetUserDropdown() {
+            var self = this;
+            var html = [];
+            this._getAuthUserGroups().forEach(function(group, groupIndex) {
+                html.push('<dt class="px-2 py-1 mb-0 cd-auth-target-unit-row" data-group-id="' + groupIndex + '" style="cursor:pointer;">');
+                html.push('<a href="javascript:void(0);"><i class="fa fa-plus-square mr-1"></i></a>');
+                html.push('<span>' + self._escapeAuthUserHtml(group.company) + '</span></dt>');
+                html.push('<div class="dd-content" data-group-content="' + groupIndex + '" style="display:none;">');
+                html.push('<dd class="border-bottom p-2 mb-0 cd-auth-target-add-unit" data-company="' +
+                    self._escapeAuthUserHtml(group.company) + '"><span class="text-primary">添加单位下全部用户</span></dd>');
+                group.users.forEach(function(user) {
+                    html.push('<dd class="border-bottom p-2 mb-0 cd-auth-target-add-user" data-user-id="' + Number(user.id) + '">');
+                    html.push('<p class="mb-0 d-flex"><span class="text-primary">' + self._escapeAuthUserHtml(user.name) + '</span>');
+                    html.push('<span class="ml-auto">' + self._escapeAuthUserHtml(user.mobile || '') + '</span>');
+                    html.push('<span class="text-success ml-2" data-target-user-mark="' + Number(user.id) + '"><i class="fa fa-check"></i></span></p>');
+                    html.push('<span class="text-muted">' + self._escapeAuthUserHtml(user.role || '') + '</span></dd>');
+                });
+                html.push('</div>');
+            });
+            if (html.length === 0) html.push('<div class="text-muted text-center py-3">数据范围内暂无可选用户</div>');
+            $('#cd-auth-target-user-options').html(html.join(''));
+            this._syncAuthTargetUserDropdownMarks();
+        }
+
+        _syncAuthTargetUserDropdownMarks() {
+            var selectedIds = this._authTargetUserIds || [];
+            $('#cd-auth-target-user-options [data-target-user-mark]').each(function() {
+                var userId = Number($(this).attr('data-target-user-mark'));
+                $(this).toggle(selectedIds.indexOf(userId) >= 0);
+            });
+        }
+
+        _addAuthTargetUsers(userIds) {
+            var selectableIds = accountList.map(function(account) { return Number(account.id); });
+            for (var index = 0; index < userIds.length; index++) {
+                var userId = Number(userIds[index]);
+                if (selectableIds.indexOf(userId) < 0 || this._authTargetUserIds.indexOf(userId) >= 0) continue;
+                this._authTargetUserIds.push(userId);
+                this._ensureAuthTargetUserPermissionState(userId);
+            }
+            this._renderAuthTargetUserList();
+            this._syncAuthTargetUserDropdownMarks();
+        }
+
+        _removeAuthTargetUsers(userIds) {
+            var self = this;
+            this._authTargetUserIds = this._authTargetUserIds.filter(function(userId) {
+                return userIds.indexOf(userId) < 0;
+            });
+            userIds.forEach(function(userId) { delete self._authTargetPermissionState[userId]; });
+            this._renderAuthTargetUserList();
+            this._syncAuthTargetUserDropdownMarks();
+        }
+
+        _ensureAuthTargetUserPermissionState(userId) {
+            if (!this._authTargetPermissionState[userId]) {
+                this._authTargetPermissionState[userId] = {
+                    upload_reference: false,
+                    edit_file: false,
+                    edit_dir: false,
+                    lock_file: false,
+                };
+            }
+            return this._authTargetPermissionState[userId];
+        }
+
+        _renderAuthTargetUserList() {
+            var self = this;
+            var html = [];
+            this.authAccountList.filter(function(account) {
+                return self._authTargetUserIds.indexOf(Number(account.id)) >= 0;
+            }).forEach(function(user) {
+                var userId = Number(user.id);
+                var state = self._ensureAuthTargetUserPermissionState(userId);
+                var selected = state.upload_reference || state.edit_file || state.edit_dir || state.lock_file;
+                html.push('<tr class="text-center" data-user-id="' + userId + '">');
+                html.push('<td><input type="checkbox" name="cd-auth-target-user-check" data-user-id="' + userId + '"' + (selected ? ' checked' : '') + '></td>');
+                html.push('<td>' + self._escapeAuthUserHtml(user.name) + '</td>');
+                html.push('<td><i class="fa fa-check text-primary"></i></td>');
+                [ 'upload_reference', 'edit_file', 'edit_dir', 'lock_file' ].forEach(function(key) {
+                    html.push('<td><input type="checkbox" class="cd-auth-target-permission-check" data-user-id="' + userId +
+                        '" data-permission="' + key + '"' + (state[key] ? ' checked' : '') + '></td>');
+                });
+                html.push('<td><a href="javascript:void(0);" class="text-danger cd-auth-target-user-remove" data-user-id="' + userId + '">移除</a></td></tr>');
+            });
+            if (html.length === 0) {
+                html.push('<tr><td colspan="8" class="text-muted text-center py-4">请添加需要授权的用户</td></tr>');
+            }
+            $('#cd-auth-target-user-list').html(html.join(''));
+            this._syncAuthTargetUserControls();
+        }
+
+        _setAuthTargetUserPermissions(userIds, checked) {
+            var self = this;
+            userIds.forEach(function(userId) {
+                $('#cd-auth-target-user-list .cd-auth-target-permission-check[data-user-id="' + userId + '"]')
+                    .each(function() {
+                        var key = $(this).attr('data-permission');
+                        $(this).prop('checked', checked);
+                        self._ensureAuthTargetUserPermissionState(userId)[key] = checked;
+                    });
+            });
+        }
+
+        _syncAuthTargetUserControls() {
+            var userChecks = $('#cd-auth-target-user-list input[name=cd-auth-target-user-check]');
+            var checkedCount = userChecks.filter(':checked').length;
+            $('#cd-auth-target-user-select-all')
+                .prop('checked', userChecks.length > 0 && checkedCount === userChecks.length)
+                .prop('indeterminate', checkedCount > 0 && checkedCount < userChecks.length);
+            $('.cd-auth-target-permission-all').each(function() {
+                var key = $(this).attr('data-permission');
+                var permissionChecks = $('#cd-auth-target-user-list .cd-auth-target-permission-check[data-permission="' + key + '"]');
+                var permissionCheckedCount = permissionChecks.filter(':checked').length;
+                $(this)
+                    .prop('checked', permissionChecks.length > 0 && permissionCheckedCount === permissionChecks.length)
+                    .prop('indeterminate', permissionCheckedCount > 0 && permissionCheckedCount < permissionChecks.length);
+            });
+            $('#cd-auth-target-user-delete')
+                .attr('aria-disabled', checkedCount > 0 ? 'false' : 'true')
+                .attr('tabindex', checkedCount > 0 ? '0' : '-1')
+                .toggleClass('text-muted', checkedCount === 0)
+                .toggleClass('text-danger', checkedCount > 0);
             this._syncAuthTargetSelectionState();
         }
 
+        _getAuthTargetUserPermissions() {
+            var self = this;
+            return this._authTargetUserIds.map(function(userId) {
+                var state = self._ensureAuthTargetUserPermissionState(userId);
+                return {
+                    uid: userId,
+                    can_upload: state.upload_reference ? 1 : 0,
+                    can_edit_file: state.edit_file ? 1 : 0,
+                    can_edit_dir: state.edit_dir ? 1 : 0,
+                    can_lock_file: state.lock_file ? 1 : 0,
+                };
+            });
+        }
+
         _applyAuthTargetResult(result) {
             var targetIds = this._authTargetIds || [];
             var permissions = result.permissions instanceof Array ? result.permissions : [];
@@ -963,10 +1308,11 @@ $(document).ready(function() {
                 var filingInfo = filingInfos.find(function(item) { return String(item.id) === filingId; });
                 if (filingInfo) filing.add_user = filingInfo.add_user || '';
             });
-            var sourceId = String(this._authTargetSource.id);
+            var returnId = this._authTargetReturnId;
             this._reloadTree();
-            var sourceIndex = this.filingTree.nodes.findIndex(function(node) { return String(node.id) === sourceId; });
-            if (sourceIndex >= 0) this.sheet.setSelection(sourceIndex, 0, 1, 1);
+            var returnIndex = this.filingTree.nodes.findIndex(function(node) { return String(node.id) === returnId; });
+            if (returnIndex >= 0) this.sheet.setSelection(returnIndex, 0, 1, 1);
+            this._refreshAuthUserButton(returnIndex >= 0 ? this.filingTree.nodes[returnIndex] : this._getSelectedNode());
         }
 
         _escapeAuthUserHtml(value) {
@@ -1172,9 +1518,6 @@ $(document).ready(function() {
             $('#cd-auth-batch-delete')
                 .toggleClass('text-muted', !hasUsers)
                 .toggleClass('text-danger', hasUsers);
-            $('#cd-auth-add-other, #cd-auth-cover-other')
-                .toggleClass('text-muted', !hasUsers)
-                .toggleClass('text-primary', hasUsers);
         }
     }
 

+ 119 - 40
app/public/js/filesjs.js

@@ -13,14 +13,17 @@ $(document).ready(function() {
             this.spread.options.showHorizontalScrollbar = false; // 隐藏水平滚动条
             this.sheet = this.spread.getActiveSheet();
             this.treeIcons = {
-                folder: new Image(),
+                closed: new Image(),
+                open: new Image(),
                 leaf: new Image(),
             };
             const refreshTreeIcon = () => { this.spread.refresh(); };
-            this.treeIcons.folder.onload = refreshTreeIcon;
+            this.treeIcons.closed.onload = refreshTreeIcon;
+            this.treeIcons.open.onload = refreshTreeIcon;
             this.treeIcons.leaf.onload = refreshTreeIcon;
-            this.treeIcons.folder.src = '/public/images/filesjs-folder.png';
-            this.treeIcons.leaf.src = '/public/images/filesjs-leaf.png';
+            this.treeIcons.closed.src = '/public/images/filesjs-tree-closed.png';
+            this.treeIcons.open.src = '/public/images/filesjs-tree-open.png';
+            this.treeIcons.leaf.src = '/public/images/filesjs-tree-leaf.png';
             const treeIcons = this.treeIcons;
 
             // 初始化 PathTree,使用 zh_filing 原有字段映射
@@ -45,7 +48,7 @@ $(document).ready(function() {
                         getIcon: function(data) {
                             const fallback = data.is_leaf ? 'file' : 'folder';
                             return {
-                                image: data.is_leaf ? treeIcons.leaf : treeIcons.folder,
+                                image: data.is_leaf ? treeIcons.leaf : (data.expanded ? treeIcons.open : treeIcons.closed),
                                 fallback,
                                 size: 18,
                             };
@@ -885,8 +888,9 @@ $(document).ready(function() {
                 return;
             }
             const self = this;
+            const orgFilingId = file.filing_id;
             postData('file/move', { id: file.id, filingId: filingId }, function(data) {
-                const orgFiling = self.findFiling(file.filing_id);
+                const orgFiling = self.findFiling(orgFilingId);
                 if (orgFiling) {
                     self.updateFilingFileCount(orgFiling, orgFiling.file_count - 1);
                     const fIndex = orgFiling.files.findIndex(x => x.id === file.id);
@@ -895,7 +899,9 @@ $(document).ready(function() {
                 const targetFiling = self.findFiling(filingId);
                 if (targetFiling) {
                     self.updateFilingFileCount(targetFiling, targetFiling.file_count + 1);
+                    delete targetFiling.files;
                 }
+                Object.assign(file, data.file);
                 if (fileSearch) fileSearch.refreshSearchFile(data.file);
                 callback();
             });
@@ -1028,10 +1034,13 @@ $(document).ready(function() {
     class MoveFileObj {
         constructor() {
             const self = this;
-            this.moveTree = null;
             this.spread = null;
             this.sheet = null;
             this.file = null;
+            this.selectedNode = null;
+            this.adjustingSelection = false;
+            this.modal = $('#move-file2');
+            this.okButton = $('#move-file2-ok');
 
             this.filingTree = createNewPathTree('base', {
                 id: 'tree_id', pid: 'tree_pid', order: 'order', level: 'level',
@@ -1046,36 +1055,42 @@ $(document).ready(function() {
                             return data.name;
                         },
                         getIcon: function(data) {
-                            return data.is_leaf ? 'file' : 'folder';
+                            const fallback = data.is_leaf ? 'file' : 'folder';
+                            return {
+                                image: data.is_leaf
+                                    ? filingSjs.treeIcons.leaf
+                                    : (data.expanded ? filingSjs.treeIcons.open : filingSjs.treeIcons.closed),
+                                fallback,
+                                size: 18,
+                            };
+                        },
+                        foreColor: function(data) {
+                            return data.is_leaf && !data.move_selectable ? '#999999' : '#333333';
                         },
                     },
                 ],
                 emptyRows: 0, headRows: 1, headRowHeight: [32], defaultRowHeight: 21,
                 headColWidth: [25], headerFont: '12px 微软雅黑', font: '12px 微软雅黑',
-                readOnly: true, selectedBackColor: '#e8f4fd',
+                readOnly: true,
             };
 
-            $('#move-file2-ok').click(function() {
-                const node = SpreadJsObj.getSelectObject(self.sheet);
-                if (!node) {
+            this.okButton.off('click.filesjsMove').on('click.filesjsMove', function() {
+                const node = self.selectedNode;
+                if (!node || !self._isSelectableNode(node)) {
                     toastr.warning('请选择目标分类');
                     return;
                 }
-                if (!node.is_leaf) {
-                    toastr.warning('请选择最底层目标分类');
-                    return;
-                }
-                if (String(node.id) !== String(self.file.filing_id) && !filingSjs.canUploadFiling(node.id)) {
-                    toastr.warning('您没有目标分类的上传/引用权限');
-                    return;
-                }
                 filingSjs.moveFile(self.file, node.id, function() {
-                    $('#move-file2').modal('hide');
+                    self.modal.modal('hide');
                     filingSjs.refreshPages();
                     filingSjs.refreshFilesTable();
                 });
             });
 
+            this.modal.off('shown.bs.modal.filesjsMove').on('shown.bs.modal.filesjsMove', function() {
+                self.initMoveTree();
+            });
+
             $('a[name=mf2-showLevel]').click(function() {
                 const tag = $(this).attr('tag');
                 setTimeout(() => {
@@ -1086,38 +1101,105 @@ $(document).ready(function() {
             });
         }
 
+        _isSelectableNode(node) {
+            return Boolean(node) && Boolean(node.is_leaf) && Boolean(node.move_selectable);
+        }
+
+        _setSelectionHint(text, warning) {
+            $('#move-file2-selection-hint')
+                .text(text)
+                .toggleClass('text-warning', Boolean(warning))
+                .toggleClass('text-muted', !warning);
+        }
+
+        _clearSelection() {
+            this.selectedNode = null;
+            this.okButton.prop('disabled', true);
+            if (!this.sheet || this.adjustingSelection) return;
+            this.adjustingSelection = true;
+            this.sheet.clearSelection();
+            this.adjustingSelection = false;
+        }
+
+        _handleSelectionChanged() {
+            if (this.adjustingSelection) return;
+            const node = SpreadJsObj.getSelectObject(this.sheet);
+            if (!node) {
+                this._clearSelection();
+                return;
+            }
+            if (!node.is_leaf) {
+                this._setSelectionHint('请展开目录并选择最底层节点', false);
+                this._clearSelection();
+                return;
+            }
+            if (String(node.id) === String(this.file.filing_id)) {
+                this._setSelectionHint('文件已在当前目录,请选择其他最底层节点', true);
+                this._clearSelection();
+                return;
+            }
+            if (!filingSjs.canUploadFiling(node.id)) {
+                this._setSelectionHint('您没有该目录的上传/引用权限', true);
+                this._clearSelection();
+                return;
+            }
+            this.selectedNode = node;
+            this.okButton.prop('disabled', false);
+            this._setSelectionHint(`已选择:${node.name}`, false);
+        }
+
+        _resizeColumn() {
+            if (!this.sheet) return;
+            const width = Math.floor($('#moveFileSjs').innerWidth() || 0);
+            if (width > 0) this.sheet.setColumnWidth(0, Math.max(width - 45, 220));
+        }
+
         _expandByLevel(level) {
-            const nodes = this.filingTree.nodes;
+            if (!this.sheet) return;
             if (level === 'last') {
-                for (const node of nodes) {
-                    node.expanded = !node.is_leaf;
-                }
+                this.filingTree.expandByCustom(node => { return !node.is_leaf; });
             } else {
                 const maxLevel = parseInt(level);
-                for (const node of nodes) {
-                    node.expanded = !node.is_leaf && node.level < maxLevel;
-                }
+                this.filingTree.expandByCustom(node => {
+                    return !node.is_leaf && node.level < maxLevel;
+                });
             }
             SpreadJsObj.loadSheetData(this.sheet, SpreadJsObj.DataType.Tree, this.filingTree);
+            this._clearSelection();
+            this._setSelectionHint('请选择最底层目标目录', false);
         }
 
         initMoveTree() {
-            const convertedData = filingSjs._convertData(filing);
-            this.filingTree.loadDatas(convertedData);
-            if (this.spread) {
-                SpreadJsObj.loadSheetData(this.sheet, SpreadJsObj.DataType.Tree, this.filingTree);
-            } else {
-                this.spread = SpreadJsObj.createNewSpread($('#moveFile')[0]);
+            if (!this.spread) {
+                this.spread = SpreadJsObj.createNewSpread($('#moveFileSjs')[0]);
+                this.spread.options.showHorizontalScrollbar = false;
                 this.sheet = this.spread.getActiveSheet();
                 SpreadJsObj.initSheet(this.sheet, this.spreadSetting);
-                SpreadJsObj.loadSheetData(this.sheet, SpreadJsObj.DataType.Tree, this.filingTree);
+                this.spread.bind(spreadNS.Events.SelectionChanged, () => {
+                    this._handleSelectionChanged();
+                });
             }
+            SpreadJsObj.loadSheetData(this.sheet, SpreadJsObj.DataType.Tree, this.filingTree);
+            this._resizeColumn();
+            this._clearSelection();
+            this.spread.refresh();
         }
 
         show(file) {
+            if (!file) return;
             this.file = file;
-            this.initMoveTree();
-            $('#move-file2').modal('show');
+            this.selectedNode = null;
+            const convertedData = filingSjs._convertData(filing);
+            for (const node of convertedData) {
+                node.move_selectable = node.is_leaf &&
+                    String(node.id) !== String(file.filing_id) &&
+                    filingSjs.canUploadFiling(node.id);
+            }
+            this.filingTree.loadDatas(convertedData);
+            $('#move-file2-description').text(`将“${file.filename + file.fileext}”移动到:`);
+            this._setSelectionHint('请选择最底层目标目录', false);
+            this.okButton.prop('disabled', true);
+            this.modal.modal('show');
         }
     }
     const moveFileObj = new MoveFileObj();
@@ -2212,9 +2294,6 @@ $(document).ready(function() {
         filingSjs.batchDownloadFiles();
     });
 
-    // 覆盖移动文件确认
-    $('#move-file2-ok').off('click');
-
     // 引用文件
     const relaFileLoader = new RelaFileLoader();
     $('#rela-file').on('show.bs.modal', function() {

+ 1 - 0
app/router.js

@@ -407,6 +407,7 @@ module.exports = app => {
     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/file/move', sessionAuth, subProjectCheck, 'fileController.configDirMoveFiles');
     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');

+ 74 - 0
app/service/file.js

@@ -191,6 +191,13 @@ module.exports = app => {
             const orgFiling = await this.ctx.service.filing.getDataById(file.filing_id);
             const filing = await this.ctx.service.filing.getDataById(filing_id);
             if (!filing || filing.spid !== this.ctx.subProject.id || filing.is_deleted) throw '目标分类不存在';
+            if (String(file.filing_id) === String(filing.id)) throw '文件已在目标目录';
+            const childCount = await this.ctx.service.filing.count({
+                spid: this.ctx.subProject.id,
+                tree_pid: filing.id,
+                is_deleted: 0,
+            });
+            if (childCount > 0) throw '目标目录必须是最底层目录';
 
             const conn = await this.db.beginTransaction();
             try {
@@ -206,6 +213,73 @@ module.exports = app => {
             }
         }
 
+        async moveFilingFiles(sourceFiling, targetFiling) {
+            const spid = this.ctx.subProject.id;
+            if (!sourceFiling || sourceFiling.spid !== spid || sourceFiling.is_deleted) {
+                throw '源目录不存在,请刷新页面后重试';
+            }
+            if (!targetFiling || targetFiling.spid !== spid || targetFiling.is_deleted) {
+                throw '目标目录不存在,请刷新页面后重试';
+            }
+
+            const filingService = this.ctx.service.filing;
+            const filingList = await filingService.getValidFiling(spid, 'all');
+            const childrenMap = {};
+            filingList.forEach(filing => {
+                const parentId = String(filing.tree_pid);
+                if (!childrenMap[parentId]) childrenMap[parentId] = [];
+                childrenMap[parentId].push(filing);
+            });
+
+            const sourceId = String(sourceFiling.id);
+            const targetId = String(targetFiling.id);
+            const sourceFilingIds = new Set([sourceId]);
+            const pending = [sourceId];
+            while (pending.length > 0) {
+                const parentId = pending.shift();
+                const children = childrenMap[parentId] || [];
+                children.forEach(child => {
+                    const childId = String(child.id);
+                    if (sourceFilingIds.has(childId)) return;
+                    sourceFilingIds.add(childId);
+                    pending.push(childId);
+                });
+            }
+
+            if (sourceFilingIds.has(targetId)) throw '目标目录不能位于当前目录及其子目录中';
+            if ((childrenMap[targetId] || []).length > 0) throw '目标目录必须是最底层目录';
+
+            const sourceIds = Array.from(sourceFilingIds);
+            const conn = await this.db.beginTransaction();
+            try {
+                const movedCount = await conn.count(this.tableName, {
+                    spid,
+                    filing_id: sourceIds,
+                    is_deleted: 0,
+                });
+                if (movedCount <= 0) throw '当前目录及子目录暂无可移动文件';
+
+                await conn.query(
+                    `UPDATE ${this.tableName} SET filing_id = ?, filing_type = ? ` +
+                    'WHERE spid = ? AND is_deleted = 0 AND filing_id IN (?)',
+                    [targetFiling.id, targetFiling.filing_type, spid, sourceIds]
+                );
+                const targetCount = await conn.count(this.tableName, {
+                    spid,
+                    filing_id: targetFiling.id,
+                    is_deleted: 0,
+                });
+                const filingUpdates = sourceIds.map(id => { return { id, file_count: 0 }; });
+                filingUpdates.push({ id: targetFiling.id, file_count: targetCount });
+                await conn.updateRows(filingService.tableName, filingUpdates);
+                await conn.commit();
+                return { moved_count: movedCount, filings: filingUpdates };
+            } catch (err) {
+                await conn.rollback();
+                throw err;
+            }
+        }
+
         async search(filing_type, keyword, limit = 1000) {
             if (!filing_type || filing_type.length === 0 || !keyword) return [];
             const sql = `SELECT * FROM ${this.tableName}` +

+ 58 - 48
app/service/sub_project_filing_permission.js

@@ -4,9 +4,9 @@
  * 资料目录用户权限。
  *
  * 新数据按 filing_id 精确到目录节点;filing_id 为 NULL 的记录是旧版
- * filing_type 权限。用户在某个 filing_type 下只要存在节点权限,就
- * 节点权限判断,避免同类型的父子、兄弟目录互相继承;没有节点权限时
- * 才回退旧版 filing_type 与 file_permission。
+ * filing_type 权限。用户在某个 filing_type 下只要存在节点权限,就按
+ * 节点精确判断;授权节点的祖先只获得路径查看权限,兄弟与子节点不继承
+ * 操作权限。没有节点权限时才回退旧版 filing_type 与 file_permission。
  *
  * @param {Object} app Egg 应用
  * @return {Object} Service
@@ -72,6 +72,18 @@ module.exports = app => {
             };
         }
 
+        _pathFlags() {
+            return {
+                can_view: 1,
+                can_upload: 0,
+                can_edit_file: 0,
+                can_edit_dir: 0,
+                can_lock_file: 0,
+                explicit: false,
+                scope: 'path',
+            };
+        }
+
         _adminFlags() {
             return {
                 can_view: 1,
@@ -89,12 +101,15 @@ module.exports = app => {
             return this._toPermissionList(legacyFilingTypes).indexOf(Number(filingType)) >= 0;
         }
 
-        _resolvePermissionFromRows(rows, filingId, filingType, legacyFilePermission, legacyFilingTypes) {
+        _resolvePermissionFromRows(rows, filingId, filingType, legacyFilePermission, legacyFilingTypes, pathViewIds) {
             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);
 
+            // 精确授权节点的祖先只获得查看权限,用于展示通往授权节点的完整树路径。
+            if (pathViewIds && pathViewIds.has(nodeId)) return this._pathFlags();
+
             // 同类型已有任一节点权限时,未明确授权的兄弟或父子节点不可查看。
             const hasExactType = rows.some(row => row.filing_id && Number(row.filing_type) === type);
             if (hasExactType) return this._emptyFlags();
@@ -104,6 +119,25 @@ module.exports = app => {
             return legacyRow ? this._rowFlags(legacyRow) : this._legacyFlags(legacyFilePermission);
         }
 
+        _getPathViewIds(rows, filingList) {
+            const filingMap = {};
+            (filingList || []).forEach(filing => { filingMap[String(filing.id)] = filing; });
+            const pathViewIds = new Set();
+            (rows || []).filter(row => row.filing_id).forEach(row => {
+                let current = filingMap[String(row.filing_id)];
+                const visited = new Set();
+                while (current && String(current.tree_pid) !== '-1') {
+                    const parentId = String(current.tree_pid);
+                    if (visited.has(parentId)) break;
+                    visited.add(parentId);
+                    if (!filingMap[parentId]) break;
+                    pathViewIds.add(parentId);
+                    current = filingMap[parentId];
+                }
+            });
+            return pathViewIds;
+        }
+
         async getRows(spid, uid) {
             const where = { spid };
             if (uid !== undefined && uid !== null) where.uid = Number(uid);
@@ -123,10 +157,11 @@ module.exports = app => {
          */
         async getConfigPermissionRows(spid, filingList) {
             const rows = await this.getRows(spid);
+            const filingIds = new Set((filingList || []).map(filing => String(filing.id)));
             const legacyPermissionRows = await this.ctx.service.subProjPermission.getAllDataByCondition({
                 where: { spid },
             });
-            const result = rows.filter(row => row.filing_id).map(row => {
+            const result = rows.filter(row => row.filing_id && filingIds.has(String(row.filing_id))).map(row => {
                 return Object.assign({}, row, { is_legacy: 0 });
             });
 
@@ -180,8 +215,10 @@ module.exports = app => {
         async getResolvedPermission(spid, uid, filingId, filingType, legacyFilePermission, legacyFilingTypes, isAdmin) {
             if (isAdmin) return this._adminFlags();
             const rows = await this.getRows(spid, uid);
+            const filingList = await this.ctx.service.filing.getValidFiling(spid, 'all');
+            const pathViewIds = this._getPathViewIds(rows, filingList);
             return this._resolvePermissionFromRows(
-                rows, filingId, filingType, legacyFilePermission, legacyFilingTypes
+                rows, filingId, filingType, legacyFilePermission, legacyFilingTypes, pathViewIds
             );
         }
 
@@ -199,9 +236,21 @@ module.exports = app => {
         async getPermissionMap(spid, uid, filingList, legacyFilingTypes, legacyFilePermission, isAdmin) {
             const result = {};
             const rows = isAdmin ? [] : await this.getRows(spid, uid);
+            let permissionTree = filingList || [];
+            if (!isAdmin) {
+                const filingMap = {};
+                permissionTree.forEach(filing => { filingMap[String(filing.id)] = filing; });
+                const isIncompleteTree = permissionTree.some(filing => {
+                    return String(filing.tree_pid) !== '-1' && !filingMap[String(filing.tree_pid)];
+                }) || rows.some(row => row.filing_id && !filingMap[String(row.filing_id)]);
+                if (isIncompleteTree) {
+                    permissionTree = await this.ctx.service.filing.getValidFiling(spid, 'all');
+                }
+            }
+            const pathViewIds = isAdmin ? new Set() : this._getPathViewIds(rows, permissionTree);
             for (const filing of filingList || []) {
                 result[filing.id] = isAdmin ? this._adminFlags() : this._resolvePermissionFromRows(
-                    rows, filing.id, filing.filing_type, legacyFilePermission, legacyFilingTypes
+                    rows, filing.id, filing.filing_type, legacyFilePermission, legacyFilingTypes, pathViewIds
                 );
             }
             return result;
@@ -459,49 +508,10 @@ module.exports = app => {
             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) {
+        async _savePermissionsToFilings(subProject, filings, users, operatorUid, replaceExisting) {
             if (!(filings instanceof Array) || filings.length === 0) throw '请选择目标资料目录';
             const normalized = this._normalizeUsers(users);
-            if (!allowEmpty && normalized.length === 0) throw '请选择需要配置的授权用户';
+            if (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 '目标资料目录数据错误';

+ 71 - 28
app/view/file/config_dir.ejs

@@ -39,13 +39,23 @@
                         <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-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 class="btn-group">
+                            <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>
+                            <button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle dropdown-toggle-split"
+                                    id="cd-auth-batch-dropdown" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                                <span class="sr-only">批量授权</span>
+                            </button>
+                            <div class="dropdown-menu" aria-labelledby="cd-auth-batch-dropdown">
+                                <a class="dropdown-item" href="javascript:void(0);" id="cd-auth-batch-add">批量新增授权</a>
+                                <a class="dropdown-item" href="javascript:void(0);" id="cd-auth-batch-cover">批量覆盖授权</a>
+                            </div>
+                        </div>
                         <% } %>
                     </div>
                 </div>
@@ -75,10 +85,6 @@
                     </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">
@@ -106,27 +112,64 @@
     </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">
+<!-- 批量授权 Modal -->
+<div class="modal fade" id="cd-auth-target-modal" data-backdrop="static">
+    <div class="modal-dialog modal-xl" role="document">
         <div class="modal-content">
             <div class="modal-header">
-                <h5 class="modal-title" id="cd-auth-target-modal-title">添加至其他目录</h5>
+                <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 class="modal-body p-3">
+                <div class="row no-gutters">
+                    <div class="col-md-4 pr-md-2">
+                        <div class="mb-2">选择目录</div>
+                        <div class="border modal-height-400 scroll-y">
+                            <table class="table table-bordered table-sm mb-0">
+                                <thead>
+                                <tr class="text-center">
+                                    <th width="55px" class="align-middle">选择</th>
+                                    <th class="align-middle">目标目录</th>
+                                </tr>
+                                </thead>
+                                <tbody id="cd-auth-target-list"></tbody>
+                            </table>
+                        </div>
+                    </div>
+                    <div class="col-md-8 pl-md-2">
+                        <div class="d-flex align-items-center mb-2">
+                            <div class="dropdown mr-3">
+                                <button class="btn btn-outline-primary btn-sm dropdown-toggle" type="button"
+                                        id="cd-auth-target-user-dropdown" data-toggle="dropdown"
+                                        aria-haspopup="true" aria-expanded="false">添加用户</button>
+                                <div class="dropdown-menu" aria-labelledby="cd-auth-target-user-dropdown"
+                                     style="width:260px; max-height:330px; overflow-y:auto;">
+                                    <dl class="list-unstyled book-list mb-0" id="cd-auth-target-user-options"></dl>
+                                </div>
+                            </div>
+                            <a href="javascript:void(0);" class="text-muted" id="cd-auth-target-user-delete"
+                               aria-disabled="true" tabindex="-1">批量删除</a>
+                            <div class="text-danger ml-auto" id="cd-auth-cover-warning" style="display:none;">
+                                所选目录原授权用户将被覆盖,请谨慎操作
+                            </div>
+                        </div>
+                        <div class="border modal-height-400 scroll-y">
+                            <table class="table table-bordered table-sm mb-0">
+                                <thead>
+                                <tr class="text-center">
+                                    <th width="60px" class="align-middle"><input type="checkbox" id="cd-auth-target-user-select-all" class="mr-1">选择</th>
+                                    <th class="align-middle">用户</th>
+                                    <th width="60px" class="align-middle">查看</th>
+                                    <th width="90px" class="align-middle"><input type="checkbox" class="cd-auth-target-permission-all" data-permission="upload_reference"><br>上传/引用</th>
+                                    <th width="80px" class="align-middle"><input type="checkbox" class="cd-auth-target-permission-all" data-permission="edit_file"><br>编辑文件</th>
+                                    <th width="80px" class="align-middle"><input type="checkbox" class="cd-auth-target-permission-all" data-permission="edit_dir"><br>编辑目录</th>
+                                    <th width="80px" class="align-middle"><input type="checkbox" class="cd-auth-target-permission-all" data-permission="lock_file"><br>锁定文件</th>
+                                    <th width="55px" class="align-middle">移除</th>
+                                </tr>
+                                </thead>
+                                <tbody id="cd-auth-target-user-list"></tbody>
+                            </table>
+                        </div>
+                    </div>
                 </div>
             </div>
             <div class="modal-footer">
@@ -177,18 +220,18 @@
     </div>
 </div>
 
-<!-- 移动分类 Modal -->
+<!-- 批量移动文件 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>
+                <h5 class="modal-title">批量移动文件</h5>
             </div>
             <div class="modal-body">
-                <p>将 <strong id="cd-move-source-name"></strong> 移动到:</p>
+                <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>
+                        <thead><tr class="text-center"><th width="40px">选择</th><th>目标目录</th></tr></thead>
                         <tbody id="cd-move-target-list"></tbody>
                     </table>
                 </div>

+ 10 - 2
app/view/file/file_modal.ejs

@@ -198,9 +198,12 @@
     <div class="modal-dialog" role="document">
         <div class="modal-content">
             <div class="modal-header">
-                <h5 class="modal-title">选择分类</h5>
+                <h5 class="modal-title"><%= typeof useSpreadMoveFile !== 'undefined' && useSpreadMoveFile ? '移动文件' : '选择分类' %></h5>
             </div>
             <div class="modal-body">
+                <% if (typeof useSpreadMoveFile !== 'undefined' && useSpreadMoveFile) { %>
+                <div id="move-file2-description" class="mb-2 text-muted"></div>
+                <% } %>
                 <div class="btn-group">
                     <button type="button" class="btn btn-sm  text-primary dropdown-toggle" data-toggle="dropdown" id="mf2-zhankai" aria-expanded="false">显示层级</button>
                     <div class="dropdown-menu" aria-labelledby="mf2-zhankai" x-placement="bottom-start" style="position: absolute; transform: translate3d(0px, 21px, 0px); top: 0px; left: 0px; will-change: transform;">
@@ -211,13 +214,18 @@
                         <a class="dropdown-item" name="mf2-showLevel" tag="last" href="javascript: void(0);">最底层</a>
                     </div>
                 </div>
+                <% if (typeof useSpreadMoveFile !== 'undefined' && useSpreadMoveFile) { %>
+                <div id="move-file2-selection-hint" class="small text-muted mb-1">请选择最底层目标目录</div>
+                <div id="moveFileSjs" style="width: 100%; height: 300px;"></div>
+                <% } else { %>
                 <div class="modal-height-300">
                     <ul id="moveFile" class="ztree"></ul>
                 </div>
+                <% } %>
             </div>
             <div class="modal-footer">
                 <button type="button" class="btn btn-secondary btn-sm" data-dismiss="modal">关闭</button>
-                <button type="button" class="btn btn-primary btn-sm" id="move-file2-ok">确定添加</button>
+                <button type="button" class="btn btn-primary btn-sm" id="move-file2-ok"><%= typeof useSpreadMoveFile !== 'undefined' && useSpreadMoveFile ? '确定移动' : '确定添加' %></button>
             </div>
         </div>
     </div>