Explorar o código

feat(filing): 封装分类目录操作并支持批量删除

- 新增 FilingDirectory 类封装分类的添加与删除逻辑
- 支持多选分类批量删除及删除前预览确认弹窗
- 删除操作在事务中执行,包含权限校验与子分类级联处理
- 添加分类时支持自定义名称并校验同级排序节点
- 增强分类树重载:保留展开状态、焦点节点与滚动位置
- 控制器层改为通过 FilingDirectory 调用,移除冗余权限校验
- 分类名称列增加 tooltip 显示完整名称
- 新增 _directorySaving 标志防止重复提交操作
caipin hai 6 días
pai
achega
487c49b9b0

+ 5 - 4
app/controller/file_controller.js

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

+ 118 - 0
app/lib/filing_directory.js

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

+ 145 - 34
app/public/js/filesjs.js

@@ -41,7 +41,7 @@ $(document).ready(function() {
             this.spreadSetting = {
                 cols: [
                     {
-                        title: '分类名称', colSpan: '1', rowSpan: '1', field: 'name', hAlign: 0, width: 220, formatter: '@', cellType: 'tree',
+                        title: '分类名称', colSpan: '1', rowSpan: '1', field: 'name', hAlign: 0, width: 220, formatter: '@', cellType: 'tree', showTips: true,
                         getValue: function(data) {
                             return data.name;
                         },
@@ -53,6 +53,7 @@ $(document).ready(function() {
                                 size: 18,
                             };
                         },
+                        getTip: function(data) { return _.escape(data.name || ''); },
                     },
                     { title: '文件数', colSpan: '1', rowSpan: '1', field: 'total_file_count', hAlign: 1, width: 70, formatter: '@' },
                 ],
@@ -74,7 +75,9 @@ $(document).ready(function() {
             this.curPage = 1;
             this.curTotalPage = 1;
             this.curFiling = null;
-            this.selectRemoveNode = null;
+            this.selectRemoveNodes = [];
+            this._directorySaving = false;
+            this._reloadingFiling = false;
             this.fileOrderKey = 'fileOrderSjs';
             this.fileOrder = getLocalCache(this.fileOrderKey) || 'create_time|desc';
             this.curFilingKey = 'cur-filing-sjs-' + window.location.pathname.split('/')[2];
@@ -229,7 +232,7 @@ $(document).ready(function() {
 
             // 选中行变化事件
             this.spread.bind(spreadNS.Events.SelectionChanged, async function(e, info) {
-                if (self._isRenaming) return;
+                if (self._isRenaming || self._reloadingFiling) return;
                 const node = SpreadJsObj.getSelectObject(self.sheet);
                 if (node && (!self.curFiling || self.curFiling.id !== node.id)) {
                     await self.setCurFiling(node);
@@ -285,7 +288,7 @@ $(document).ready(function() {
             // 双击行内重命名分类
             $('#filing-spread').dblclick(function() {
                 if (!canFiling) return;
-                if (self._isRenaming) return;
+                if (self._isRenaming || self._directorySaving) return;
                 const node = SpreadJsObj.getSelectObject(self.sheet);
                 if (!node || node.is_fixed) return;
 
@@ -363,14 +366,14 @@ $(document).ready(function() {
                         name: '删除',
                         icon: 'fa-trash-o',
                         callback: function () {
-                            const node = SpreadJsObj.getSelectObject(self.sheet);
-                            if (!node || node.is_fixed) return;
-                            self.selectRemoveNode = node;
-                            $('#del-filing').modal('show');
+                            self._showDeletePreview();
                         },
                         disabled: function () {
-                            const node = SpreadJsObj.getSelectObject(self.sheet);
-                            return !canFiling || !node || node.is_fixed;
+                            const nodes = self._getSelectedFilings();
+                            return self._directorySaving || projectFileLocked || !nodes.length || nodes.some(node => {
+                                const permission = self._getFilingPermission(node.id);
+                                return Number(node.is_fixed) || !permission.can_view || !permission.can_edit_dir;
+                            });
                         },
                     },
                 },
@@ -596,47 +599,79 @@ $(document).ready(function() {
          * 添加同级分类
          */
         addSiblingFiling(node) {
-            const self = this;
             const treePid = node.tree_pid === -1 ? '-1' : this._findOriginalId(node.tree_pid);
-            postData('filing/add', { tree_pid: treePid, tree_pre_id: node.id }, function(result) {
-                filing.push(result);
-                self._reloadFilingTree();
-            });
+            this._addFiling({ tree_pid: treePid, tree_pre_id: node.id, name: '' });
         }
 
         /**
          * 添加子级分类
          */
         addChildFiling(node) {
-            const self = this;
-            postData('filing/add', { tree_pid: node.id }, function(result) {
-                filing.push(result);
-                self._reloadFilingTree();
-            });
+            this._addFiling({ tree_pid: node.id, name: '' });
         }
 
         /**
          * 重新加载归档树
          */
-        _reloadFilingTree() {
-            const convertedData = this._convertData(filing);
-            this.filingTree.loadDatas(convertedData);
-            SpreadJsObj.loadSheetData(this.sheet, SpreadJsObj.DataType.Tree, this.filingTree);
-            this.refreshFileCountHint();
+        _reloadFilingTree(focusId) {
+            const expanded = new Map(this.filingTree.nodes.map(node => [node.id, node.expanded]));
+            const currentId = focusId || (this.curFiling && this.curFiling.id);
+            const topRow = this.sheet.getViewportTopRow(1);
+            const topNode = this.filingTree.nodes[topRow];
+            this._reloadingFiling = true;
+            let currentNode;
+            try {
+                this.filingTree.loadDatas(this._convertData(filing));
+                this.filingTree.nodes.forEach(node => {
+                    if (expanded.has(node.id)) node.expanded = expanded.get(node.id);
+                });
+                currentNode = this.filingTree.nodes.find(node => node.id === currentId);
+                if (focusId && currentNode) {
+                    let parent = this.filingTree.getParent(currentNode);
+                    while (parent) {
+                        parent.expanded = true;
+                        parent = this.filingTree.getParent(parent);
+                    }
+                }
+                this.filingTree.refreshNodeVisible();
+                SpreadJsObj.loadSheetData(this.sheet, SpreadJsObj.DataType.Tree, this.filingTree);
+                this.resizeSpreadColumns();
+                this.sheet.clearSelection();
+                if (currentNode) this.sheet.setSelection(this.filingTree.nodes.indexOf(currentNode), 0, 1, 1);
+                const targetRow = focusId && currentNode ? this.filingTree.nodes.indexOf(currentNode)
+                    : this.filingTree.nodes.findIndex(node => topNode && node.id === topNode.id);
+                if (targetRow >= 0) this.sheet.showRow(targetRow, focusId ? spreadNS.VerticalPosition.center : spreadNS.VerticalPosition.top);
+                this.refreshFileCountHint();
+            } finally {
+                this._reloadingFiling = false;
+            }
+            this.curFiling = null;
+            if (currentNode) {
+                this.setCurFiling(currentNode).catch(() => { $('#file-view').hide(); });
+            } else {
+                canFiling = false;
+                $('.js-can-edit-dir').hide();
+                $('#file-view').hide();
+            }
         }
 
         /**
          * 删除分类
          */
-        delFiling(node, callback) {
+        delFiling(nodes, callback) {
+            if (this._directorySaving || !nodes.length) return;
             const self = this;
-            const parent = this.filingTree.getParent(node);
-            postData('filing/del', { id: node.id }, function(result) {
+            const modal = $('#filesjs-del-filing');
+            this._directorySaving = true;
+            $('button', modal).prop('disabled', true);
+            $('#filesjs-del-filing-error').text('');
+            postData('filing/del', { ids: nodes.map(node => node.id) }, function(result) {
                 // 更新 filing 数组:移除已删除的节点
                 if (result && result.delete) {
                     for (const delId of result.delete) {
                         const idx = filing.findIndex(x => x.id === delId);
                         if (idx >= 0) filing.splice(idx, 1);
+                        delete filingPermissionMap[delId];
                     }
                 }
                 // 更新兄弟节点的 tree_order
@@ -648,9 +683,14 @@ $(document).ready(function() {
                 }
                 // 重新加载树
                 self._reloadFilingTree();
-                $('#file-view').hide();
-                self.curFiling = null;
+                self._directorySaving = false;
+                $('button', modal).prop('disabled', false);
                 if (callback) callback();
+                toastr.success(`已删除 ${result.delete.length} 个分类`);
+            }, function(error) {
+                self._directorySaving = false;
+                $('button', modal).prop('disabled', false);
+                $('#filesjs-del-filing-error').text(error || '删除失败,请重试');
             });
         }
 
@@ -1192,6 +1232,71 @@ $(document).ready(function() {
                 $('#filing-name-paste-error').text(error || '保存失败,请重试;若名称已变化,请刷新页面后重新粘贴。');
             });
         }
+
+        _addFiling(data) {
+            if (this._directorySaving || !canFiling || this._isRenaming) return;
+            this._directorySaving = true;
+            postData('filing/add', data, result => {
+                for (const update of result.update || []) {
+                    const source = filing.find(node => node.id === update.id);
+                    if (source) Object.assign(source, update);
+                }
+                for (const node of result.create || []) {
+                    if (!filing.some(source => source.id === node.id)) filing.push(node);
+                }
+                Object.assign(filingPermissionMap, result.permissionMap || {});
+                this._directorySaving = false;
+                const created = (result.create || [])[0];
+                this._reloadFilingTree(created && created.id);
+            }, () => { this._directorySaving = false; });
+        }
+
+        _getSelectedFilings() {
+            const rows = new Set();
+            for (const selection of this.sheet.getSelections()) {
+                const first = Math.max(0, selection.row);
+                const last = selection.row < 0 ? this.filingTree.nodes.length
+                    : Math.min(first + selection.rowCount, this.filingTree.nodes.length);
+                for (let row = first; row < last; row++) {
+                    if (this.sheet.getRowVisible(row)) rows.add(row);
+                }
+            }
+            return [...rows].sort((a, b) => a - b).map(row => this.filingTree.nodes[row]);
+        }
+
+        _showDeletePreview() {
+            if (this._directorySaving || projectFileLocked) return;
+            const nodes = this._getSelectedFilings();
+            if (!nodes.length) return;
+            const included = new Set(nodes.map(node => node.id));
+            // 树节点按父子顺序排列,检查选中的分类以及折叠的子分类。
+            const scope = this.filingTree.nodes.filter(node => {
+                const parent = this.filingTree.getParent(node);
+                if (parent && included.has(parent.id)) included.add(node.id);
+                return included.has(node.id);
+            });
+            if (scope.some(node => Number(node.file_count || 0) !== 0 || Number(node.total_file_count || 0) !== 0)) {
+                toastr.error('此分类已存在文件,请先删除所有文件。');
+                return;
+            }
+            if (scope.some(node => Number(node.is_fixed))) {
+                toastr.error('固定分类不可删除');
+                return;
+            }
+            if (scope.some(node => {
+                const permission = this._getFilingPermission(node.id);
+                return !permission.can_view || !permission.can_edit_dir;
+            })) {
+                toastr.error('部分分类或子分类没有编辑目录权限,无法删除');
+                return;
+            }
+            this.selectRemoveNodes = nodes;
+            const list = $('#filesjs-del-filing-list').empty();
+            nodes.forEach(node => { $('<li>').text(node.name || '(未命名分类)').appendTo(list); });
+            $('#filesjs-del-filing-summary').text(`已选择 ${nodes.length} 个分类,确认删除?`);
+            $('#filesjs-del-filing-error').text('');
+            $('#filesjs-del-filing').modal('show');
+        }
     }
 
     // 初始化
@@ -2496,13 +2601,19 @@ $(document).ready(function() {
     });
 
     // 删除分类确认弹窗
-    $('#del-filing-ok').off('click').on('click', function() {
-        if (filingSjs.selectRemoveNode) {
-            filingSjs.delFiling(filingSjs.selectRemoveNode, function() {
-                $('#del-filing').modal('hide');
+    $('#filesjs-del-filing-ok').on('click', function() {
+        if (filingSjs.selectRemoveNodes.length) {
+            filingSjs.delFiling(filingSjs.selectRemoveNodes, function() {
+                $('#filesjs-del-filing').modal('hide');
             });
         }
     });
+    $('#filesjs-del-filing').on('hide.bs.modal', function(e) {
+        if (filingSjs._directorySaving) e.preventDefault();
+    }).on('hidden.bs.modal', function() {
+        filingSjs.selectRemoveNodes = [];
+        filingSjs.spread.focus();
+    });
 
     // 批量删除确认
     $('#batch-del-file-ok').off('click').on('click', function() {

+ 16 - 3
app/service/filing.js

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

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

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