Browse Source

资料管理,分类树支持粘贴修改名称

caipin 2 days ago
parent
commit
710bf7495e

+ 34 - 1
app/controller/file_controller.js

@@ -294,7 +294,7 @@ module.exports = app => {
                     );
                     );
                 }
                 }
                 renderData.fileReferenceList = await ctx.service.subProject.getFileReference(ctx.subProject, ctx.service.subProject.FileReferenceType.file);
                 renderData.fileReferenceList = await ctx.service.subProject.getFileReference(ctx.subProject, ctx.service.subProject.FileReferenceType.file);
-                await this.layout('file/filesjs.ejs', renderData, 'file/file_modal.ejs');
+                await this.layout('file/filesjs.ejs', renderData, 'file/filesjs_modal.ejs');
             } catch (err) {
             } catch (err) {
                 ctx.log(err);
                 ctx.log(err);
             }
             }
@@ -1233,6 +1233,39 @@ module.exports = app => {
             }
             }
             throw '未知的修改类型';
             throw '未知的修改类型';
         }
         }
+
+        async saveFilingNames(ctx) {
+            try {
+                this.checkUnlock(ctx);
+                const data = JSON.parse(ctx.request.body.data);
+                if (!data || !Array.isArray(data.items) || data.items.length === 0) throw '请选择需要替换名称的分类';
+                const ids = data.items.map(item => item && item.id);
+                if (ids.some(id => typeof id !== 'string' || !id) || new Set(ids).size !== ids.length) throw '分类数据格式错误';
+                const filings = await ctx.service.filing.getAllDataByCondition({
+                    where: { id: ids, spid: ctx.subProject.id, is_deleted: 0 },
+                });
+                if (filings.length !== ids.length) throw '部分分类不存在,请刷新页面后重新粘贴';
+                const permissionMap = await ctx.service.subProjectFilingPermission.getPermissionMap(
+                    ctx.subProject.id,
+                    ctx.session.sessionUser.accountId,
+                    filings,
+                    ctx.subProject.permission.filing_type,
+                    ctx.subProject.permission.file_permission,
+                    this.isAdmin(ctx)
+                );
+                this.applyFileConfigViewPermission(ctx, permissionMap);
+                for (const filing of filings) {
+                    const permission = permissionMap[filing.id];
+                    if (!permission || !permission.can_view || !permission.can_edit_dir) throw '部分分类没有编辑目录权限';
+                    if (Number(filing.is_fixed)) throw '固定分类不可修改名称';
+                }
+                const result = await ctx.service.filing.saveNames(ctx.subProject.id, data.items);
+                ctx.body = { err: 0, msg: '', data: result };
+            } catch (err) {
+                ctx.log(err);
+                ctx.body = this.ajaxErrorBody(err, '批量替换分类名称失败');
+            }
+        }
     }
     }
 
 
     return FileController;
     return FileController;

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

@@ -2895,3 +2895,11 @@ button.gy-stat:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
     #gy-preview{scrollbar-width:auto;scrollbar-color:auto}
     #gy-preview{scrollbar-width:auto;scrollbar-color:auto}
     #gy-preview::-webkit-scrollbar{height:12px}
     #gy-preview::-webkit-scrollbar{height:12px}
 }
 }
+
+.filesjs-name-paste-modal .filesjs-name-paste-scroll{max-height:55vh;overflow:auto}
+.filesjs-name-paste-modal table{table-layout:fixed;width:100%}
+.filesjs-name-paste-modal th{position:sticky;top:0;background:#f4f6f8;z-index:1}
+.filesjs-name-paste-modal td{white-space:pre-wrap;overflow-wrap:anywhere;vertical-align:top}
+.filesjs-name-paste-modal .filesjs-name-paste-row{width:65px}
+.filesjs-name-paste-modal .filesjs-name-paste-status{width:150px}
+.filesjs-name-paste-modal .filesjs-name-paste-new{background:#eff8ff}

+ 212 - 1
app/public/js/filesjs.js

@@ -81,6 +81,7 @@ $(document).ready(function() {
             this._isRenaming = false;
             this._isRenaming = false;
 
 
             this._bindEvents();
             this._bindEvents();
+            this._initNameClipboard();
             this._loadFilingData();
             this._loadFilingData();
             this.refreshFileOrderButton();
             this.refreshFileOrderButton();
         }
         }
@@ -341,14 +342,22 @@ $(document).ready(function() {
                 $input.focus().select();
                 $input.focus().select();
             });
             });
 
 
-            // 右键菜单 - 删除分类
+            // 分类右键菜单
             $.contextMenu({
             $.contextMenu({
                 selector: '#filing-spread',
                 selector: '#filing-spread',
                 build: function ($trigger, e) {
                 build: function ($trigger, e) {
+                    const hit = SpreadJsObj.getHitTest($trigger, e, self.sheet);
+                    if (!hit || hit.row < 0 || hit.row >= self.filingTree.nodes.length) return false;
                     const target = SpreadJsObj.safeRightClickSelection($trigger, e, self.spread);
                     const target = SpreadJsObj.safeRightClickSelection($trigger, e, self.spread);
                     return target.hitTestType === spreadNS.SheetArea.viewport || target.hitTestType === spreadNS.SheetArea.rowHeader;
                     return target.hitTestType === spreadNS.SheetArea.viewport || target.hitTestType === spreadNS.SheetArea.rowHeader;
                 },
                 },
                 items: {
                 items: {
+                    'paste-name': {
+                        name: '粘贴名称',
+                        icon: 'fa-paste',
+                        callback: function() { self._showNamePastePreview(); },
+                        disabled: function() { return !self._referenceNameClipboard || self._namePasteSaving; },
+                    },
                     'delete': {
                     'delete': {
                         name: '删除',
                         name: '删除',
                         icon: 'fa-trash-o',
                         icon: 'fa-trash-o',
@@ -981,6 +990,207 @@ $(document).ready(function() {
             });
             });
             return result;
             return result;
         }
         }
+
+        _initNameClipboard() {
+            this._referenceNameClipboard = null;
+            this._namePasteItems = [];
+            this._namePasteSaving = false;
+            const container = $('#filing-spread')[0];
+            // 先于 SpreadJS 的只读单元格处理,保留浏览器原生粘贴事件以读取本次剪贴板。
+            container.addEventListener('keydown', e => {
+                if (!(e.ctrlKey || e.metaKey) || e.altKey || e.keyCode !== 86 || this._isRenaming) return;
+                e.stopImmediatePropagation();
+                if (!this._referenceNameClipboard || this._namePasteSaving) {
+                    e.preventDefault();
+                    if (!this._namePasteSaving) toastr.warning('请先从右侧参考文件复制名称');
+                }
+            }, true);
+            container.addEventListener('paste', e => {
+                if (this._isRenaming) return;
+                e.preventDefault();
+                e.stopImmediatePropagation();
+                const text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
+                this._pasteReferenceText(text);
+            }, true);
+            this.sheet.bind(spreadNS.Events.ClipboardPasting, (e, info) => {
+                info.cancel = true;
+                this._pasteReferenceText(info.pasteData ? info.pasteData.text : '');
+            });
+            $('#filing-name-paste-ok').on('click.filesjsNames', () => { this._savePastedNames(); });
+            $('#filing-name-paste').on('hide.bs.modal.filesjsNames', e => {
+                if (this._namePasteSaving) e.preventDefault();
+            }).on('hidden.bs.modal.filesjsNames', () => {
+                this._namePasteItems = [];
+                this.spread.focus();
+            });
+        }
+
+        initReferenceNameClipboard(spread) {
+            const container = $('#std-reference-spread')[0];
+            const sheet = spread.getActiveSheet();
+            container.addEventListener('keydown', e => {
+                if (!(e.ctrlKey || e.metaKey) || e.altKey || e.keyCode !== 67 || sheet.isEditing()) return;
+                e.preventDefault();
+                e.stopImmediatePropagation();
+                this._copyReferenceNames(spread);
+            }, true);
+            container.addEventListener('copy', e => {
+                if (sheet.isEditing()) return;
+                e.preventDefault();
+                e.stopImmediatePropagation();
+                this._copyReferenceNames(spread, e.clipboardData);
+            }, true);
+            $.contextMenu({
+                selector: '#std-reference-spread',
+                build: function($trigger, e) {
+                    const hit = SpreadJsObj.getHitTest($trigger, e, sheet);
+                    if (!hit || hit.row < 0 || !sheet.zh_tree || hit.row >= sheet.zh_tree.nodes.length) return false;
+                    if (hit.hitTestType !== spreadNS.SheetArea.viewport && hit.hitTestType !== spreadNS.SheetArea.rowHeader) return false;
+                    SpreadJsObj.safeRightClickSelection($trigger, e, spread);
+                    return true;
+                },
+                items: {
+                    'copy-name': {
+                        name: '复制名称',
+                        icon: 'fa-copy',
+                        callback: () => { this._copyReferenceNames(spread); },
+                    },
+                },
+            });
+        }
+
+        _copyReferenceNames(spread, clipboardData) {
+            const sheet = spread.getActiveSheet();
+            const tree = sheet.zh_tree;
+            const rows = new Set();
+            for (const selection of sheet.getSelections()) {
+                if (selection.col > 0) continue;
+                const first = Math.max(0, selection.row);
+                const last = selection.row < 0 ? sheet.getRowCount() : first + selection.rowCount;
+                for (let row = first; row < last; row++) {
+                    if (tree && tree.nodes[row] && sheet.getRowVisible(row)) rows.add(row);
+                }
+            }
+            if (!rows.size) {
+                this._referenceNameClipboard = null;
+                toastr.warning('请先选中参考文件的名称列');
+                return;
+            }
+            const names = [...rows].sort((a, b) => a - b).map(row => String(tree.nodes[row].name || ''));
+            const text = names.join('\r\n');
+            // 保留原始数组,名称内的换行仍属于同一个分类。
+            this._referenceNameClipboard = { names, text };
+            SpreadJsObj.Clipboard.setCopyData(text);
+            if (clipboardData) {
+                clipboardData.setData('text/plain', text);
+            } else {
+                SpreadJsObj.Clipboard.setSysClipboard(text);
+            }
+            spread.focus();
+            toastr.success(`已复制 ${names.length} 条名称`);
+        }
+
+        _pasteReferenceText(text) {
+            const clipboard = this._referenceNameClipboard;
+            if (!clipboard || String(text || '').replace(/\r\n?/g, '\n') !== clipboard.text.replace(/\r\n?/g, '\n')) {
+                toastr.warning('请重新从右侧参考文件复制名称后粘贴');
+                return;
+            }
+            this._showNamePastePreview();
+        }
+
+        _buildNamePasteItems(names, startRow) {
+            const targets = [];
+            for (let row = startRow; row < this.filingTree.nodes.length && targets.length < names.length; row++) {
+                if (this.sheet.getRowVisible(row)) targets.push({ row, node: this.filingTree.nodes[row] });
+            }
+            return names.map((value, index) => {
+                const target = targets[index];
+                const node = target && target.node;
+                const name = String(value).trim();
+                const errors = [];
+                if (!node) {
+                    errors.push('目标行数不足');
+                } else {
+                    const permission = this._getFilingPermission(node.id);
+                    if (projectFileLocked) errors.push('项目资料已锁定');
+                    if (Number(node.is_fixed)) errors.push('固定分类不可改名');
+                    if (!Number(permission.can_view) || !Number(permission.can_edit_dir)) errors.push('无编辑目录权限');
+                }
+                if (!name) errors.push('名称不能为空');
+                if (name.length > 100) errors.push('名称不能超过100个字符');
+                return {
+                    id: node ? node.id : null,
+                    row: target ? target.row : null,
+                    old_name: node ? String(node.name || '') : '',
+                    name,
+                    errors,
+                };
+            });
+        }
+
+        _showNamePastePreview() {
+            if (this._namePasteSaving || $('#filing-name-paste').hasClass('show')) return;
+            const clipboard = this._referenceNameClipboard;
+            if (!clipboard) { toastr.warning('请先从右侧参考文件复制名称'); return; }
+            if (this._isRenaming) return;
+            const row = this.sheet.getActiveRowIndex();
+            if (row < 0 || !this.filingTree.nodes[row] || !this.sheet.getRowVisible(row)) {
+                toastr.warning('请选择粘贴的起始分类');
+                return;
+            }
+            if (this.sheet.getActiveColumnIndex() > 0) {
+                toastr.warning('请在分类名称列粘贴');
+                return;
+            }
+            this._namePasteItems = this._buildNamePasteItems(clipboard.names, row);
+            const tbody = $('#filing-name-paste-rows').empty();
+            for (const item of this._namePasteItems) {
+                const tr = $('<tr>').toggleClass('table-danger', item.errors.length > 0);
+                $('<td>').text(item.row === null ? '—' : item.row + 1).appendTo(tr);
+                $('<td>').text(item.old_name).appendTo(tr);
+                $('<td>').text(item.name).toggleClass('filesjs-name-paste-new', !item.errors.length && item.name !== item.old_name).appendTo(tr);
+                $('<td>').text(item.errors.length ? item.errors.join(';') : (item.name === item.old_name ? '名称相同,不修改' : '可替换')).appendTo(tr);
+                tbody.append(tr);
+            }
+            const invalid = this._namePasteItems.filter(item => item.errors.length).length;
+            const changed = this._namePasteItems.filter(item => item.name !== item.old_name).length;
+            $('#filing-name-paste-summary').text(`共 ${this._namePasteItems.length} 条,${changed} 条名称有变化,${invalid} 条校验未通过。`);
+            $('#filing-name-paste-error').text(invalid ? '请调整复制范围或起始分类后重新粘贴,校验全部通过后才能替换。' : '');
+            $('#filing-name-paste-ok').prop('disabled', invalid > 0 || changed === 0);
+            $('#filing-name-paste').modal('show');
+        }
+
+        _savePastedNames() {
+            if (this._namePasteSaving || !this._namePasteItems.length || this._namePasteItems.some(item => item.errors.length)) return;
+            const items = this._namePasteItems.map(item => ({ id: item.id, old_name: item.old_name, name: item.name }));
+            if (!items.some(item => item.name !== item.old_name)) return;
+            const modal = $('#filing-name-paste');
+            this._namePasteSaving = true;
+            $('button', modal).prop('disabled', true);
+            $('#filing-name-paste-error').text('');
+            postData('filing/names/save', { items }, result => {
+                const rows = [];
+                for (const update of result.update) {
+                    const source = filing.find(node => node.id === update.id);
+                    if (source) source.name = update.name;
+                    const row = this.filingTree.nodes.findIndex(node => node.id === update.id);
+                    if (row >= 0) {
+                        this.filingTree.nodes[row].name = update.name;
+                        rows.push(row);
+                    }
+                }
+                if (rows.length) SpreadJsObj.reLoadRowsData(this.sheet, rows);
+                this._namePasteSaving = false;
+                $('button', modal).prop('disabled', false);
+                modal.modal('hide');
+                toastr.success(`已替换 ${result.update.length} 条分类名称`);
+            }, error => {
+                this._namePasteSaving = false;
+                $('button', modal).prop('disabled', false);
+                $('#filing-name-paste-error').text(error || '保存失败,请重试;若名称已变化,请刷新页面后重新粘贴。');
+            });
+        }
     }
     }
 
 
     // 初始化
     // 初始化
@@ -2035,6 +2245,7 @@ $(document).ready(function() {
                         },
                         },
                         page: 'file',
                         page: 'file',
                     });
                     });
+                    filingSjs.initReferenceNameClipboard(fileReference.spread);
                 }
                 }
                 fileReference.spread.refresh();
                 fileReference.spread.refresh();
             }
             }

+ 1 - 0
app/router.js

@@ -417,6 +417,7 @@ module.exports = app => {
     app.post('/sp/:id/filing/init', sessionAuth, subProjectCheck, 'fileController.initializeFiling');
     app.post('/sp/:id/filing/init', sessionAuth, subProjectCheck, 'fileController.initializeFiling');
     app.post('/sp/:id/filing/add', sessionAuth, subProjectCheck, 'fileController.addFiling');
     app.post('/sp/:id/filing/add', sessionAuth, subProjectCheck, 'fileController.addFiling');
     app.post('/sp/:id/filing/save', sessionAuth, subProjectCheck, 'fileController.saveFiling');
     app.post('/sp/:id/filing/save', sessionAuth, subProjectCheck, 'fileController.saveFiling');
+    app.post('/sp/:id/filing/names/save', sessionAuth, subProjectCheck, 'fileController.saveFilingNames');
     app.post('/sp/:id/filing/del', sessionAuth, subProjectCheck, 'fileController.delFiling');
     app.post('/sp/:id/filing/del', sessionAuth, subProjectCheck, 'fileController.delFiling');
     app.post('/sp/:id/filing/move', sessionAuth, subProjectCheck, 'fileController.moveFiling');
     app.post('/sp/:id/filing/move', sessionAuth, subProjectCheck, 'fileController.moveFiling');
     app.post('/sp/:id/file/load', sessionAuth, subProjectCheck, 'fileController.loadFile');
     app.post('/sp/:id/file/load', sessionAuth, subProjectCheck, 'fileController.loadFile');

+ 37 - 0
app/service/filing.js

@@ -481,6 +481,43 @@ module.exports = app => {
             const result = await this.db.queryOne(`SELECT SUM(file_count) AS file_count FROM ${this.tableName} WHERE spid = '${spid}' and is_deleted = 0`);
             const result = await this.db.queryOne(`SELECT SUM(file_count) AS file_count FROM ${this.tableName} WHERE spid = '${spid}' and is_deleted = 0`);
             return result.file_count;
             return result.file_count;
         }
         }
+
+        async saveNames(spid, items) {
+            if (!Array.isArray(items) || items.length === 0) throw '请选择需要替换名称的分类';
+            const ids = new Set();
+            const updateData = items.map(item => {
+                if (!item || typeof item.id !== 'string' || !item.id || ids.has(item.id)) throw '分类数据格式错误';
+                ids.add(item.id);
+                if (typeof item.name !== 'string' || typeof item.old_name !== 'string') throw '分类名称格式错误';
+                const name = item.name.trim();
+                if (!name) throw '目录名称不能为空';
+                if (name.length > 100) throw '目录名称不能超过100个字符';
+                return { id: item.id, name, old_name: item.old_name };
+            });
+            const conn = await this.db.beginTransaction();
+            try {
+                const result = [];
+                // 按 ID 更新,保持多批次重叠操作的加锁顺序一致。
+                const sortedData = [...updateData].sort((a, b) => a.id.localeCompare(b.id));
+                for (const item of sortedData) {
+                    const filing = await conn.get(this.tableName, { id: item.id, spid, is_deleted: 0 });
+                    if (!filing) throw '部分分类不存在,请刷新页面后重新粘贴';
+                    if (Number(filing.is_fixed)) throw '固定分类不可修改名称';
+                    if (filing.name !== item.old_name) throw '分类名称已发生变化,请刷新页面后重新粘贴';
+                    if (item.name === item.old_name) continue;
+                    const operation = await conn.update(this.tableName, { name: item.name }, {
+                        where: { id: item.id, spid, is_deleted: 0, is_fixed: 0, name: item.old_name },
+                    });
+                    if (operation.affectedRows !== 1) throw '分类信息已发生变化,请刷新页面后重新粘贴';
+                    result.push({ id: item.id, name: item.name });
+                }
+                await conn.commit();
+                return { update: result };
+            } catch (err) {
+                await conn.rollback();
+                throw err;
+            }
+        }
     }
     }
 
 
     return Filing;
     return Filing;

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

@@ -0,0 +1,26 @@
+<% include file_modal.ejs %>
+<div class="modal fade filesjs-name-paste-modal" id="filing-name-paste" tabindex="-1" role="dialog" aria-labelledby="filing-name-paste-title" data-backdrop="static">
+    <div class="modal-dialog modal-lg" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title" id="filing-name-paste-title">确认替换分类名称</h5>
+                <button type="button" class="close" data-dismiss="modal" aria-label="关闭"><span aria-hidden="true">&times;</span></button>
+            </div>
+            <div class="modal-body">
+                <p class="text-muted">从选中行开始,按可见行顺序替换分类名称,折叠隐藏的分类不参与。</p>
+                <div id="filing-name-paste-summary" class="mb-2" aria-live="polite"></div>
+                <div class="filesjs-name-paste-scroll">
+                    <table class="table table-bordered table-sm mb-0">
+                        <thead><tr><th class="filesjs-name-paste-row">目标行</th><th>原分类名称</th><th>新名称</th><th class="filesjs-name-paste-status">校验结果</th></tr></thead>
+                        <tbody id="filing-name-paste-rows"></tbody>
+                    </table>
+                </div>
+                <div id="filing-name-paste-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-primary" id="filing-name-paste-ok">确认替换</button>
+            </div>
+        </div>
+    </div>
+</div>