瀏覽代碼

Merge branch 'dev' of http://192.168.1.41:3000/maixinrong/Calculation into dev

MaiXinRong 2 天之前
父節點
當前提交
02782799b2

+ 10 - 18
app/controller/file_controller.js

@@ -12,6 +12,7 @@ const sendToWormhole = require('stream-wormhole');
 const path = require('path');
 const advanceConst = require('../const/advance');
 const FilingDirectory = require('../lib/filing_directory');
+const FilingTemplate = require('../lib/filing_template');
 
 const FILE_MANAGEMENT_EXTRA_EXTENSIONS = [
     '.sbp', '.jygs', '.gkgs', '.sjgs', '.sgys', '.qdys', '.gcjs', '.sjys',
@@ -282,11 +283,7 @@ module.exports = app => {
                 renderData.filingInitializationTemplates = [];
                 renderData.filingInitializationProjects = [];
                 if (renderData.needFilingInitialization) {
-                    renderData.filingInitializationTemplates = await ctx.service.filingTemplateList.getAllDataByCondition({
-                        columns: ['id', 'name'],
-                        where: { ft_type: ctx.service.filingTemplateList.FtType.org },
-                        orders: [['create_time', 'asc']],
-                    });
+                    renderData.filingInitializationTemplates = await ctx.service.filingTemplateList.getInitializationTemplates();
                     renderData.filingInitializationProjects = await ctx.service.subProject.getManageDirProjects(
                         ctx.session.sessionProject.id,
                         ctx.session.sessionUser.accountId,
@@ -308,19 +305,11 @@ module.exports = app => {
                 if (!data || !data.init_type) throw '请选择初始化方式';
 
                 let sourceData = [];
-                let templateData = null;
+                const templateData = null;
                 if (data.init_type === 'template') {
-                    if (!data.template_id) throw '请选择系统模板库';
-                    templateData = await ctx.service.filingTemplateList.getDataByCondition({
-                        id: data.template_id,
-                        ft_type: ctx.service.filingTemplateList.FtType.org,
-                    });
-                    if (!templateData) throw '选择的系统模板不存在';
-                    sourceData = await ctx.service.filingTemplate.getAllDataByCondition({
-                        where: { temp_id: templateData.id },
-                        orders: [['tree_level', 'asc'], ['tree_order', 'asc']],
-                    });
-                    if (sourceData.length === 0) throw '选择的系统模板没有目录数据';
+                    const result = await new FilingTemplate(ctx).initialize(data.template_id);
+                    ctx.body = { err: 0, msg: '', data: result };
+                    return;
                 } else if (data.init_type === 'project') {
                     if (!data.source_spid) throw '请选择来源项目';
                     const sourceProjects = await ctx.service.subProject.getManageDirProjects(
@@ -1065,6 +1054,7 @@ module.exports = app => {
                 const name = ctx.request.body.name;
                 const is_share = ctx.request.body.is_share ? parseInt(ctx.request.body.is_share) : undefined;
                 const share_id = ctx.request.body.share_id;
+                if (id && !share_id) await new FilingTemplate(ctx).assertEditable(id);
                 const [save, templateId] = share_id ? await ctx.service.filingTemplateList.copy(share_id) : await ctx.service.filingTemplateList.save(name, is_share, id);
                 if (!save) throw '保存数据失败';
                 ctx.redirect('/file/template/' + templateId);
@@ -1077,6 +1067,7 @@ module.exports = app => {
         async resetTemplate(ctx) {
             try {
                 const id = ctx.query.id;
+                await new FilingTemplate(ctx).assertEditable(id);
                 await ctx.service.filingTemplateList.reset(id);
                 ctx.redirect('/file/template/' + id);
             } catch (err) {
@@ -1088,6 +1079,7 @@ module.exports = app => {
         async delTemplate(ctx) {
             try {
                 const id = ctx.query.id;
+                await new FilingTemplate(ctx).assertEditable(id);
                 await ctx.service.filingTemplateList.delete(id);
                 if (ctx.request.headers.referer.indexOf(id) > 0) {
                     ctx.redirect('/file/template');
@@ -1103,7 +1095,7 @@ module.exports = app => {
         async updateTemplate(ctx) {
             try {
                 const data = JSON.parse(ctx.request.body.data);
-                if (!data.updateType) throw '数据错误';
+                await new FilingTemplate(ctx).checkUpdate(ctx.params.id, data);
                 let result;
                 if (data.updateType === 'add') {
                     result = await ctx.service.filingTemplate.add(ctx.params.id, data);

+ 83 - 0
app/lib/filing_template.js

@@ -0,0 +1,83 @@
+'use strict';
+
+class FilingTemplate {
+    constructor(ctx) {
+        this.ctx = ctx;
+    }
+
+    async assertEditable(id) {
+        const ctx = this.ctx;
+        const project = ctx.session.sessionProject;
+        const user = ctx.session.sessionUser;
+        if (!project || !user || !project.userAccount || user.account !== project.userAccount) throw '您没有管理资料模板的权限';
+        if (typeof id !== 'string' || !id) throw '请选择需要修改的资料模板';
+        const template = await ctx.service.filingTemplateList.getDataById(id);
+        if (!template) throw '资料模板不存在';
+        if (Number(template.ft_type) === ctx.service.filingTemplateList.FtType.org) throw '系统资料模板只能在SAAS后台维护';
+        if (Number(template.project_id) !== Number(project.id)) throw '您无权修改其他项目的资料模板';
+        return template;
+    }
+
+    async checkUpdate(id, data) {
+        await this.assertEditable(id);
+        if (!data || !['add', 'del', 'save', 'move', 'import', 'multi'].includes(data.updateType)) throw '模板操作类型错误';
+        const rows = await this.ctx.service.filingTemplate.getData(id);
+        const map = new Map(rows.map(row => [row.id, row]));
+        const checkNode = nodeId => {
+            if (typeof nodeId !== 'string' || !map.has(nodeId)) throw '分类不属于当前资料模板';
+            return map.get(nodeId);
+        };
+        if (['del', 'save', 'move'].includes(data.updateType)) checkNode(data.id);
+        if (data.updateType === 'add' || data.updateType === 'move') {
+            const parentId = String(data.tree_pid);
+            if (parentId !== '-1') checkNode(parentId);
+            if (data.tree_pre_id) {
+                const previous = checkNode(data.tree_pre_id);
+                if (String(previous.tree_pid) !== parentId) throw '所选分类不是同级分类';
+            }
+            if (data.updateType === 'move') {
+                const seen = new Set([data.id]);
+                let current = parentId;
+                while (current !== '-1') {
+                    if (seen.has(current)) throw '不能将分类移动到自身或其子分类中';
+                    seen.add(current);
+                    current = String(checkNode(current).tree_pid);
+                }
+            }
+        }
+        if (data.updateType === 'multi') {
+            if (!Array.isArray(data.data) || !data.data.length) throw '请选择需要更新的分类';
+            for (const row of data.data) checkNode(row && row.id);
+        }
+    }
+
+    async initialize(id) {
+        if (typeof id !== 'string' || !id) throw '请选择系统模板库';
+        const ctx = this.ctx;
+        const list = ctx.service.filingTemplateList;
+        const transaction = await list.beginTransaction();
+        try {
+            // SAAS 的编辑、停用和删除也锁定这条模板记录;复制提交前保持锁定。
+            const templates = await list.getAllDataForUpdate({ id, project_id: 0 }, transaction);
+            const template = templates[0];
+            if (!template) throw '选择的系统模板不存在';
+            if (Number(template.is_init) !== 1) throw '该模板已停用初始化,请重新选择';
+            const source = await ctx.service.filingTemplate.getAllDataByCondition({
+                where: { temp_id: id },
+                orders: [['tree_level', 'asc'], ['tree_order', 'asc'], ['id', 'asc']],
+            }, transaction);
+            if (!source.length) throw '选择的系统模板没有目录数据';
+            // 模板读取和项目目录写入共用连接,由此处统一提交或回滚。
+            const result = await ctx.service.filing.initializeDirectory(
+                ctx.subProject.id, 'template', source, ctx.session.sessionUser.accountId, template, transaction
+            );
+            await transaction.commit();
+            return result;
+        } catch (err) {
+            await transaction.rollback();
+            throw err;
+        }
+    }
+}
+
+module.exports = FilingTemplate;

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

@@ -4013,3 +4013,31 @@ button.gy-stat:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
 #approval-editor .approval-saving { pointer-events: none; opacity: .65; }
 .approval-child-modal { z-index: 1060; }
 .modal-backdrop.approval-child-backdrop { z-index: 1055; }
+
+.setting-approval-page .approval-heading {
+    font-weight: 500;
+}
+.setting-approval-page .approval-layout {
+    background-color: #fff;
+    overflow: hidden;
+}
+.setting-approval-page .approval-tenders,
+.setting-approval-page .approval-content {
+    overflow: auto;
+}
+.setting-approval-page .tender-info {
+    cursor: pointer;
+}
+
+.setting-approval-page .approval-summary-table thead th { text-align: center; }
+.setting-approval-page .approval-column-name { width: 17%; min-width: 120px; }
+.setting-approval-page .approval-column-actions { width: 110px; }
+.setting-approval-page .approval-actions { text-align: center; white-space: nowrap; }
+.setting-approval-page .approval-mode { background: #ddd; font-weight: normal; font-size: 12px; }
+.setting-approval-page .approval-summary-table .dropdown-menu { min-width: 140px; }
+#approval-editor .modal-body { min-height: 300px; max-height: 70vh; overflow: auto; }
+#approval-editor .card { border: 0; margin-bottom: 0 !important; }
+#approval-editor .card-body { padding: 0; }
+#approval-editor .approval-saving { pointer-events: none; opacity: .65; }
+.approval-child-modal { z-index: 1060; }
+.modal-backdrop.approval-child-backdrop { z-index: 1055; }

+ 137 - 49
app/public/js/config_dir.js

@@ -5,6 +5,7 @@ $(document).ready(function() {
         constructor() {
             this.spread = SpreadJsObj.createNewSpread($('#config-dir-spread')[0]);
             this.spread.options.showHorizontalScrollbar = false;
+            this.spread.options.allowUndo = false;
             this.sheet = this.spread.getActiveSheet();
             this.treeIcons = {
                 closed: new Image(),
@@ -47,24 +48,27 @@ $(document).ready(function() {
                         getValue: function(data) { return data.is_fixed ? '\u{1F512}' : ''; },
                     },
                     { 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: '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' },
                 ],
                 emptyRows: 0, headRows: 1, headRowHeight: [32], defaultRowHeight: 24,
                 headColWidth: [25], headerFont: '12px 微软雅黑', font: '12px 微软雅黑',
-                readOnly: true, selectedBackColor: '#e8f4fd',
+                readOnly: !configDirLocked || !canManageConfigDir, selectedBackColor: '#e8f4fd',
             };
 
-            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.editableTextColumnIndexes = [];
+            this.spreadSetting.cols.forEach((col, index) => {
+                col.readOnly = col.field !== 'file_company' && col.field !== 'upload_tips';
+                if (!col.readOnly) {
+                    this.editableTextColumnIndexes.push(index);
+                    col.getTip = node => _.escape(node[col.field] || '');
+                }
+            });
+            SpreadJsObj.initSheet(this.sheet, this.spreadSetting);
             this._textCellEditing = null;
+            this._pendingTextCells = new Map();
             this._editableTextCellType = null;
             this._authUserSaving = false;
             this._authTargetSaving = false;
@@ -273,15 +277,18 @@ $(document).ready(function() {
                 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) {
+                const editing = self._getTextCellEdit(info.row, info.col);
+                if (!editing) {
                     info.cancel = true;
+                    return;
                 }
+                self._textCellEditing = editing;
+                self._showTextCellStatus();
             });
             this.spread.bind(spreadNS.Events.EditEnded, function(e, info) {
                 self._saveTextCellEdit(info);
             });
+            this._bindTextCellShortcuts();
 
             $('#cd-add-sibling').click(function() {
                 var node = self._getSelectedNode();
@@ -452,57 +459,42 @@ $(document).ready(function() {
                 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;
-                }
+                if (selection && self._isEditableTextColumn(selection.col)) 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),
+        _getTextCellEdit(row, col) {
+            const node = this.filingTree.nodes[row];
+            if (!configDirLocked || !canManageConfigDir || !node || !this._isEditableTextColumn(col)) return null;
+            const { field, title } = this.spreadSetting.cols[col];
+            const key = node.id + ':' + field;
+            if (this._pendingTextCells.has(key)) return null;
+            return {
+                row, col, field, title, key, filingId: 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;
+            const editing = this._textCellEditing;
+            if (!editing || editing.row !== info.row || editing.col !== info.col) return;
             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);
+            const node = this.filingTree.nodes[info.row];
+            if (!node || node.id !== editing.filingId) {
+                this._showTextCellStatus();
                 return;
             }
-
-            var textValue = String(info.editingText || '').trim();
-            if (textValue === editing.originalValue) {
-                SpreadJsObj.reLoadRowData(info.sheet, info.row);
+            // Esc 时 editingText 仍可能是取消前的草稿,读取实际提交到单元格的值。
+            const value = info.sheet.getValue(info.row, info.col);
+            const raw = value == null ? '' : String(value);
+            const textValue = raw.trim();
+            if (raw === editing.originalValue || textValue === editing.originalValue) {
+                if (raw !== editing.originalValue) info.sheet.setValue(info.row, info.col, editing.originalValue);
+                this._showTextCellStatus();
                 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);
-            });
+            this._persistTextCellValue(editing, textValue);
         }
 
         _getSubtreeTreeIds(node) {
@@ -1563,6 +1555,102 @@ $(document).ready(function() {
                 return self._getAuthUserOptionHtml(user, isTarget);
             }).join('');
         }
+
+        _bindTextCellShortcuts() {
+            const commands = this.spread.commandManager();
+            const keys = GC.Spread.Commands.Key;
+            // 接管表格的清空命令;文字编辑器内仍由浏览器处理退格和删除。
+            const clear = () => {
+                if (this.sheet.isEditing()) return false;
+                this._replaceSelectedTextCell('');
+                return true;
+            };
+            [keys.backspace, keys.del].forEach(key => {
+                commands.setShortcutKey(null, key, false, false, false, false);
+                commands.register('configDirClearText' + key, clear, key, false, false, false, false);
+            });
+            commands.setShortcutKey(null, keys.enter, true, true, false, false);
+            const host = this.spread.getHost();
+            host.addEventListener('copy', e => {
+                if (this.sheet.isEditing() || !e.clipboardData) return;
+                const cell = this._getSelectedTextCell();
+                if (!cell) return;
+                e.clipboardData.setData('text/plain', this.sheet.getText(cell.row, cell.col));
+                e.preventDefault();
+                e.stopImmediatePropagation();
+            }, true);
+            host.addEventListener('paste', e => {
+                if (this.sheet.isEditing() || !e.clipboardData) return;
+                e.preventDefault();
+                e.stopImmediatePropagation();
+                // 直接取原始文本,避免 SpreadJS 把说明中的换行、制表符拆成多个单元格。
+                if (Array.from(e.clipboardData.types).includes('text/plain')) {
+                    this._replaceSelectedTextCell(e.clipboardData.getData('text/plain'));
+                }
+            }, true);
+            this.sheet.bind(spreadNS.Events.ClipboardPasting, (e, info) => {
+                if (this.sheet.isEditing()) return;
+                info.cancel = true;
+                const text = info.pasteData && info.pasteData.text;
+                if (typeof text === 'string' && text !== '') this._replaceSelectedTextCell(text);
+            });
+        }
+
+        _persistTextCellValue(editing, value) {
+            this._pendingTextCells.set(editing.key, editing);
+            this._showTextCellStatus();
+            const finish = saved => {
+                this._pendingTextCells.delete(editing.key);
+                const row = this.filingTree.nodes.findIndex(node => node.id === editing.filingId);
+                if (saved) {
+                    const source = filingData.find(node => node.id === editing.filingId);
+                    if (source) source[editing.field] = value;
+                    if (row >= 0) this.filingTree.nodes[row][editing.field] = value;
+                }
+                // 紧接着编辑同一行的另一列时,只刷新原单元格。
+                if (row >= 0) {
+                    this.sheet.setValue(row, editing.col, saved ? value : editing.originalValue);
+                    if (this.sheet.getRowVisible(row)) this.sheet.autoFitRow(row);
+                }
+                this._showTextCellStatus(editing.title + (saved ? ':已保存' : ':保存失败,请重新编辑'),
+                    saved ? 'text-success' : 'text-danger');
+            };
+            postData('config-dir/update', { updateType: 'save', id: editing.filingId, [editing.field]: value },
+                () => finish(true), () => finish(false), false);
+        }
+
+        _showTextCellStatus(message = '', color = 'text-muted') {
+            if (color !== 'text-danger') {
+                if (this._textCellEditing) {
+                    message = this._textCellEditing.title + ':编辑中';
+                    color = 'text-muted';
+                } else if (this._pendingTextCells.size) {
+                    message = this._pendingTextCells.values().next().value.title + ':保存中…';
+                    color = 'text-muted';
+                }
+            }
+            $('#config-dir-text-status').removeClass('text-muted text-success text-danger').addClass(color).text(message);
+        }
+
+        _getSelectedTextCell() {
+            const selections = this.sheet.getSelections();
+            const cell = selections[0];
+            if (selections.length !== 1 || cell.rowCount !== 1 || cell.colCount !== 1 ||
+                !this._isEditableTextColumn(cell.col) || !this.filingTree.nodes[cell.row]) return null;
+            return cell;
+        }
+
+        _replaceSelectedTextCell(text) {
+            const cell = this._getSelectedTextCell();
+            if (!cell) return;
+            const editing = this._getTextCellEdit(cell.row, cell.col);
+            if (!editing) return;
+            const raw = text.replace(/\r\n?/g, '\n');
+            const value = raw.trim();
+            if (raw === editing.originalValue || value === editing.originalValue) return;
+            this.sheet.setValue(cell.row, cell.col, value);
+            this._persistTextCellValue(editing, value);
+        }
     }
 
     new ConfigDir();

+ 37 - 7
app/public/js/filesjs.js

@@ -656,18 +656,20 @@ $(document).ready(function() {
          */
         renameFiling(node, newName) {
             this._nameSaving = true;
-            const finishSave = () => {
+            this._setFilingNameStatus('保存中…');
+            const finishSave = (message, color) => {
                 this._nameSaving = false;
                 const row = this.filingTree.nodes.findIndex(item => item.id === node.id);
                 if (row >= 0) SpreadJsObj.reLoadRowsData(this.sheet, [row]);
+                this._setFilingNameStatus(message, color);
             };
             postData('filing/save', { id: node.id, name: newName }, () => {
                 const source = filing.find(item => item.id === node.id);
                 if (source) source.name = newName;
                 const currentNode = this.filingTree.nodes.find(item => item.id === node.id);
                 if (currentNode) currentNode.name = newName;
-                finishSave();
-            }, finishSave, false);
+                finishSave('已保存', 'text-success');
+            }, () => finishSave('保存失败,请重新编辑', 'text-danger'), false);
         }
 
         /**
@@ -1262,6 +1264,15 @@ $(document).ready(function() {
         }
 
         _bindFilingNameEditing() {
+            // SpreadJS 会先于容器的 keydown 处理退格键;在这张表的快捷键配置中接管非编辑态退格。
+            const commands = this.spread.commandManager();
+            const backspace = GC.Spread.Commands.Key.backspace;
+            commands.setShortcutKey(null, backspace, false, false, false, false);
+            commands.register('clearSelectedFilingName', () => {
+                if (this.sheet.isEditing()) return false;
+                this._clearSelectedFilingName();
+                return true;
+            }, backspace, false, false, false, false);
             this.sheet.bind(spreadNS.Events.EditStarting, (e, info) => {
                 const node = this.filingTree.nodes[info.row];
                 if (info.col !== 0 || !this._canEditFilingName(node) || this._directorySaving
@@ -1271,6 +1282,7 @@ $(document).ready(function() {
                 }
                 this._isRenaming = true;
                 this._nameEdit = { node, row: info.row, oldName: String(node.name || '') };
+                this._setFilingNameStatus('编辑中');
             });
             this.sheet.bind(spreadNS.Events.EditEnded, (e, info) => { this._finishFilingNameEdit(info); });
             $('#filing-spread')[0].addEventListener('keydown', e => {
@@ -1293,15 +1305,33 @@ $(document).ready(function() {
             this._isRenaming = false;
             if (!editing || info.col !== 0 || info.row !== editing.row) return;
             // Esc 取消时 editingText 仍是输入内容;读取实际单元格值,避免误保存。
-            const value = String(this.sheet.getValue(info.row, 0) || '');
-            if (value === editing.oldName) return;
+            const cellValue = this.sheet.getValue(info.row, 0);
+            const value = cellValue == null ? '' : String(cellValue);
             const newName = value.trim();
-            if (!newName || newName === editing.oldName) {
-                this.sheet.setValue(info.row, 0, editing.oldName);
+            if (value === editing.oldName || newName === editing.oldName) {
+                if (value !== editing.oldName) this.sheet.setValue(info.row, 0, editing.oldName);
+                this._setFilingNameStatus();
                 return;
             }
             this.renameFiling(editing.node, newName);
         }
+
+        _setFilingNameStatus(message = '', color = 'text-muted') {
+            $('#filing-name-save-status').removeClass('text-muted text-success text-danger').addClass(color)
+                .text(message ? `分类名称:${message}` : '');
+        }
+
+        _clearSelectedFilingName() {
+            if (this._directorySaving || this._nameSaving || this._namePasteSaving || this._reloadingFiling) return;
+            const selections = this.sheet.getSelections();
+            if (selections.length !== 1) return;
+            const range = selections[0];
+            if (range.col !== 0 || range.colCount !== 1 || range.rowCount !== 1 || range.row < 0) return;
+            const node = this.filingTree.nodes[range.row];
+            if (!this._canEditFilingName(node) || !node.name) return;
+            this.sheet.setValue(range.row, 0, '');
+            this.renameFiling(node, '');
+        }
     }
 
     // 初始化

+ 5 - 4
app/service/filing.js

@@ -160,9 +160,10 @@ module.exports = app => {
          * @param {Array} sourceData 来源模板或项目的有效目录
          * @param {Number} operatorUid 初始化用户ID
          * @param {Object|null} templateData 选用的系统模板
+         * @param {Object|null} transaction 模板校验与初始化共用的事务
          * @return {Object} 初始化结果
          */
-        async initializeDirectory(spid, initType, sourceData, operatorUid, templateData = null) {
+        async initializeDirectory(spid, initType, sourceData, operatorUid, templateData = null, transaction = null) {
             let directorySource = sourceData || [];
             if (initType === 'blank') {
                 directorySource = [{
@@ -177,7 +178,7 @@ module.exports = app => {
             if (directorySource.length === 0) throw '来源数据没有可复制的资料目录';
             const insertData = this._buildInitializedFiling(spid, directorySource, operatorUid);
 
-            const conn = await this.db.beginTransaction();
+            const conn = transaction || await this.db.beginTransaction();
             try {
                 await conn.query('SELECT id FROM ?? WHERE id = ? FOR UPDATE', [
                     this.ctx.service.subProject.tableName, spid,
@@ -192,10 +193,10 @@ module.exports = app => {
                         filing_template_name: templateData.name,
                     });
                 }
-                await conn.commit();
+                if (!transaction) await conn.commit();
                 return { count: insertData.length };
             } catch (err) {
-                await conn.rollback();
+                if (!transaction) await conn.rollback();
                 throw err;
             }
         }

+ 2 - 2
app/service/filing_template.js

@@ -63,7 +63,7 @@ module.exports = app => {
                 insertData.push({
                     id: this.uuid.v4(), temp_id: templateId, add_user_id: this.ctx.session.sessionUser.accountId,
                     tree_pid: parent ? parent.id : rootId, tree_level: parent ? parent.tree_level + 1 : 1, tree_order: d.tree_order,
-                    name: d.name, file_company: d.file_company, tips: d.tips, filing_type: d.filing_type, is_fixed: parent ? d.is_fixed : 1, org_id: d.id,
+                    name: d.name, code: d.code || '', file_company: d.file_company, tips: d.tips, filing_type: d.filing_type, is_fixed: parent ? d.is_fixed : 1, org_id: d.id,
                 });
             }
             insertData.forEach(x => { delete x.org_id; });
@@ -256,7 +256,7 @@ module.exports = app => {
                 insertData.push({
                     id: this.uuid.v4(), temp_id: templateId, add_user_id: this.ctx.session.sessionUser.accountId,
                     tree_pid: parent ? parent.id : rootId, tree_level: parent ? parent.tree_level + 1 : 1, tree_order: d.tree_order,
-                    name: d.name, file_company: d.file_company, tips: d.tips, filing_type: d.filing_type, is_fixed: parent ? d.is_fixed : 1, org_id: d.id,
+                    name: d.name, code: d.code || '', file_company: d.file_company, tips: d.tips, filing_type: d.filing_type, is_fixed: parent ? d.is_fixed : 1, org_id: d.id,
                 });
             }
             insertData.forEach(x => { delete x.org_id; });

+ 7 - 0
app/service/filing_template_list.js

@@ -142,6 +142,13 @@ module.exports = app => {
             }
         }
 
+        async getInitializationTemplates() {
+            return await this.getAllDataByCondition({
+                columns: ['id', 'name'],
+                where: { project_id: 0, is_init: 1 },
+                orders: [['sort_order', 'desc'], ['create_time', 'asc'], ['id', 'asc']],
+            });
+        }
     }
 
     return FilingTemplateList;

+ 3 - 1
app/view/file/config_dir.ejs

@@ -4,7 +4,9 @@
 <div class="panel-content">
     <div class="panel-title fluid">
         <div class="title-main d-flex justify-content-between">
-            <div>资料管理-目录配置/<%- ctx.subProject.name %></div>
+            <div>资料管理-目录配置/<%- ctx.subProject.name %>
+                <small id="config-dir-text-status" class="ml-3 text-muted" role="status" aria-live="polite"></small>
+            </div>
             <div class="ml-auto">
                 <a href="/sp/<%- ctx.subProject.id %>/filesjs" class="btn btn-outline-primary btn-sm">返回资料管理</a>
             </div>

+ 3 - 1
app/view/file/filesjs.ejs

@@ -42,7 +42,9 @@
 <div class="panel-content">
     <div class="panel-title fluid">
         <div class="title-main  d-flex justify-content-between">
-            <div>资料管理/<%- ctx.subProject.name %><span class="ml-4" id="file-count"></span></div>
+            <div>资料管理/<%- ctx.subProject.name %><span class="ml-4" id="file-count"></span>
+                <small id="filing-name-save-status" class="ml-3 text-muted" role="status" aria-live="polite"></small>
+            </div>
             <div class="ml-auto">
                 <% if (canManageDir) { %>
                 <a href="/sp/<%- ctx.subProject.id %>/config-dir" class="btn btn-outline-primary btn-sm ml-1">目录配置</a>

+ 44 - 0
sql/update.sql

@@ -218,3 +218,47 @@ ALTER TABLE `zh_safe_inspection_attachment`
 ALTER TABLE `zh_quality_inspection_attachment`
   MODIFY COLUMN `flow_stage` tinyint unsigned NOT NULL DEFAULT 1
     COMMENT '附件上传阶段:1上报(含历史附件),2整改,3批复';
+
+-- SAAS 工具:资料模板。以下结构变更仅首次执行;已更新的数据库不要重复执行。
+ALTER TABLE `zh_filing_template_list`
+  ADD COLUMN `is_init` tinyint unsigned NOT NULL DEFAULT 0
+    COMMENT '资料目录初始化可用:0否,1是' AFTER `memo`,
+  ADD COLUMN `sort_order` int NOT NULL DEFAULT 0
+    COMMENT '模板排序,数字越大越靠前' AFTER `is_init`,
+  ADD INDEX `idx_ft_init_sort` (`ft_type`, `is_init`, `sort_order`);
+
+-- 仅首次迁移保留历史系统模板的初始化能力,之后不可重复启用已停用模板。
+UPDATE `zh_filing_template_list` SET `is_init` = 1 WHERE `ft_type` = 0;
+
+ALTER TABLE `zh_filing_template`
+  ADD COLUMN `code` varchar(100) CHARACTER SET utf8 COLLATE utf8_unicode_ci
+    NOT NULL DEFAULT '' COMMENT '分类编号或序号' AFTER `tree_level`,
+  ADD INDEX `idx_temp_parent_order` (`temp_id`, `tree_pid`, `tree_order`);
+
+INSERT INTO `zh_permission`
+  (`name`, `controller`, `action`, `pid`, `icon_class`, `create_time`, `isshow`)
+SELECT '资料模板', 'filing-template', 'all', tool.`id`, '', 0, 1
+FROM `zh_permission` AS tool
+WHERE tool.`name` = '工具' AND tool.`pid` = 0
+  AND NOT EXISTS (
+    SELECT 1 FROM `zh_permission` AS existing
+    WHERE existing.`controller` = 'filing-template'
+  )
+LIMIT 1;
+
+-- 菜单直接归属“工具”,仅显示位置排在参考文件后、报表前。
+UPDATE `zh_permission` AS menu
+JOIN `zh_permission` AS tool ON tool.`name` = '工具' AND tool.`pid` = 0
+SET menu.`pid` = tool.`id`, menu.`name` = '资料模板', menu.`action` = 'all', menu.`isshow` = 1
+WHERE menu.`controller` = 'filing-template';
+
+UPDATE `zh_permission` AS menu
+JOIN `zh_permission` AS tool
+  ON menu.`pid` = tool.`id` AND tool.`name` = '工具' AND tool.`pid` = 0
+JOIN `zh_permission` AS ref
+  ON ref.`pid` = tool.`id` AND ref.`controller` = 'fileReference'
+SET menu.`create_time` = CASE menu.`controller`
+  WHEN 'filing-template' THEN ref.`create_time` - 1
+  WHEN 'report' THEN ref.`create_time` - 2
+END
+WHERE menu.`controller` IN ('filing-template', 'report');