Explorar o código

资料归集,查找定位相关

MaiXinRong hai 11 meses
pai
achega
56af890c5e

+ 17 - 0
app/controller/file_controller.js

@@ -461,6 +461,23 @@ module.exports = app => {
                 ctx.ajaxErrorBody(err, '修改失败');
             }
         }
+
+        async search(ctx) {
+            try {
+                const limit = 1000;
+                const data = JSON.parse(ctx.request.body.data);
+                if (!data.filing_type || !data.keyword) throw '数据错误';
+                const validFilingType = [];
+                for (const f of data.filing_type) {
+                    if (ctx.subProject.permission.filing_type === 'all' || ctx.subProject.permission.filing_type.indexOf(f) >= 0) validFilingType.push(f);
+                }
+                const result = await ctx.service.file.search(validFilingType, data.keyword, limit);
+                ctx.body = { err: 0, msg: '', data: { list: result, limit } };
+            } catch(err) {
+                ctx.log(err);
+                ctx.ajaxErrorBody(err, '搜索文件失败');
+            }
+        }
     }
 
     return BudgetController;

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

@@ -2155,6 +2155,9 @@ animation:shake 1s .2s ease both;}
 .form-control-width{
     min-width: 450px;
 }
+.form-control-s-width{
+    min-width: 280px;
+}
 .vertical-middle{
     display: flex;
     margin: auto;

+ 221 - 21
app/public/js/file_detail.js

@@ -1,4 +1,5 @@
 $(document).ready(function() {
+    let fileSearch;
     autoFlashHeight();
     $('#filing').height($(".sjs-height-0").height() - $('#add-slibing').parent().parent().height() - 10);
     class FilingObj {
@@ -288,34 +289,35 @@ $(document).ready(function() {
         }
         delFiles(files, callback) {
             postData('file/del', { del: files }, async function(data) {
+                const relaFiling = data.filing.id === filingObj.curFiling.source_node.id
+                    ? filingObj.curFiling : filingObj.findFiling(data.filing.id);
                 for (const id of data.del) {
-                    const fIndex = filingObj.curFiling.source_node.files.findIndex(x => { return x.id === id });
-                    if (fIndex >= 0) filingObj.curFiling.source_node.files.splice(fIndex, 1);
+                    const fIndex = relaFiling.source_node.files.findIndex(x => { return x.id === id });
+                    if (fIndex >= 0) relaFiling.source_node.files.splice(fIndex, 1);
+                    fileSearch.removeSearchFile(id);
+                }
+                filingObj.updateFilingFileCount(relaFiling, data.filing.file_count);
+                await filingObj.loadFiles(relaFiling, filingObj.curPage);
+                if (data.filing.id === filingObj.curFiling.source_node.id) {
+                    filingObj.refreshPages();
+                    filingObj.refreshFilesTable();
                 }
-                filingObj.updateFilingFileCount(filingObj.curFiling, data.filing.file_count);
-                await filingObj.loadFiles(filingObj.curFiling, filingObj.curPage);
-                filingObj.refreshPages();
-                filingObj.refreshFilesTable();
                 if (callback) callback();
             });
         }
-        renameFile(fileId, filename) {
+        renameFile(file, filename) {
             const self = this;
-            const file = filingObj.curFiling.source_node.files.find(x => { return x.id === fileId });
-            if (!file) return;
-
-            const td = $(`td[fid=${fileId}]`);
-            if (filename === file.filename + file.fileext) {
-                td.html(this._getFileNameHtml(file));
-                return;
-            }
-
-            postData('file/save', { id: fileId, filename }, function(data) {
-                file.filename = data.filename;
-                file.fileext = data.fileext;
-                td.html(self._getFileNameHtml(file));
+            const td = $(`td[fid=${file.id}]`);
+            postData('file/save', { id: file.id, filename }, function(data) {
+                const relaFiling = filingObj.findFiling(file.filing_id);
+                const relaFile = relaFiling.source_node.files.find(x => { return x.id === file.id });
+                relaFile.filename = data.filename;
+                relaFile.fileext = data.fileext;
+                td.html(self._getFileNameHtml(relaFile));
+                fileSearch.renameSearchFile(file.id, data);
             }, function() {
                 td.html(self._getFileNameHtml(file));
+                fileSearch.renameSearchFile(file.id);
             });
         }
         relaFiles(files, callback) {
@@ -346,6 +348,9 @@ $(document).ready(function() {
             }
             setLocalCache(this.curFilingKey, filingObj.curFiling.id);
         }
+        findFiling(id) {
+            return filingObj.filingTree.getNodeByParam('id', id);
+        }
         prePage() {
             if (this.curPage === 1) return;
             this.curPage = this.curPage - 1;
@@ -435,6 +440,37 @@ $(document).ready(function() {
             this._clearAllFileCache();
             this.setCurFiling(this.curFiling);
         }
+        getNodeFilingType(node) {
+            if (!node.is_fixed) return [];
+
+            const types = [];
+            if (node.children && node.children.length > 0) {
+                for (const child of node.children) {
+                    const childTypes = this.getNodeFilingType(child);
+                    if (childTypes.length > 0) types.push(...childTypes);
+                }
+            }
+            if (types.length === 0) types.push(node.filing_type);
+            return types;
+        }
+        getParentFilingType(node) {
+            const parent = this.dragTree.getParent(node);
+            if (!parent) return [];
+            if (parent.is_fixed) return [parent.filing_type];
+            return this.getParentFilingType(parent);
+        }
+        getAllFilingType() {
+            const types = [];
+            for (const node of this.dragTree.children) {
+                types.push(...this.getNodeFilingType(node))
+            }
+            return types;
+        }
+        getCurFilingType() {
+            const cur = filingObj.curFiling;
+            const node = this.dragTree.nodes.find(x => { return x.id === cur.source_node.id; });
+            return node.is_fixed ? this.getNodeFilingType(node) : this.getParentFilingType(node);
+        }
     }
     const levelTreeSetting = {
         treeId: 'filing',
@@ -662,7 +698,13 @@ $(document).ready(function() {
         const file = filingObj.curFiling.source_node.files.find(x => { return x.id === fid });
         if (!file) return;
 
-        filingObj.renameFile(fid, $('input', td).val());
+        const filename = $('input', td).val();
+        if (filename === file.filename + file.fileext) {
+            td.html(this._getFileNameHtml(file));
+            return;
+        }
+
+        filingObj.renameFile(file, filename);
     });
     $('body').on('click', "a[name=edit-file-cancel]", function() {
         const td = $(this).parent().parent().parent();
@@ -1218,6 +1260,164 @@ $(document).ready(function() {
         list: '#filing-valid',
     });
 
+    class FileSearch {
+        constructor() {
+            this.searchResult = [];
+            this.initSearch();
+        }
+        getOperateHtml(file) {
+            const locateHtml = `<a href="javascript: void(0);" class="mr-1" name="locate-search-file" fid="${file.id}"><i class="fa fa-crosshairs"></i></a>`;
+            const editHtml = file.canEdit ? `<a href="javascript: void(0);" class="mr-1" name="edit-search-file" fid="${file.id}"><i class="fa fa-pencil fa-fw"></i></a>` : '';
+            const viewHtml = file.viewpath ? `<a href="${file.viewpath}" class="mr-1" target="_blank"><i class="fa fa-eye fa-fw"></i></a>` : '';
+            const downHtml = `<a href="javascript: void(0);" onclick="AliOss.downloadFile('${file.filepath}', '${file.filename + file.fileext}')" class="mr-1"><i class="fa fa-download fa-fw"></i></a>`;
+            const delHtml = file.canEdit ? `<a href="javascript: void(0);" class="mr-1 text-danger" name="del-search-file" fid="${file.id}"><i class="fa fa-trash-o fa-fw"></i></a>` : '';
+            return `<div class="d-flex justify-content-between align-items-center table-file">${locateHtml}${editHtml}${viewHtml}${downHtml}${delHtml}</div>`
+        }
+        getFileHtml(file) {
+            const html = [];
+            html.push(`<tr fid="search_${file.id}">`);
+            html.push(`<td fid="search_${file.id}">${file.filename}${file.fileext}</td>`);
+            html.push(`<td class="text-center">${file.user_name}</td>`);
+            html.push(`<td class="text-center">${this.getOperateHtml(file)}</td>`);
+            html.push('</tr>');
+            return html.join('');
+        }
+        getResultHtml (result) {
+            this.searchResult = result;
+            const html = [];
+            for (const r of result) {
+                html.push(this.getFileHtml(r));
+            }
+            return html.join('');
+        }
+        getSearchFilter() {
+            const filter = $('#search-filter').val();
+            const filing_type = filter === 'all' ? filingObj.getAllFilingType() : filingObj.getCurFilingType();
+            return { filing_type };
+        }
+        getEditFileNameHtml(file) {
+            const inputHtml = `<input type="text" class="form-control form-control-sm form-control-s-width" maxlength="100" value="${file.filename + file.fileext}" fid="${file.id}">`;
+            const btnHtml = `<div class="btn-group-table" style="display: none;"><a href="javascript: void(0)" class="mr-1" name="edit-search-file-ok"><i class="fa fa-check fa-fw"></i></a><a href="javascript: void(0)" class="mr-1" name="edit-search-file-cancel"><i class="fa fa-remove fa-fw"></i></a></div>`;
+            return `<div class="d-flex justify-content-between align-items-center table-file"><div>${inputHtml}</div>${btnHtml}</div>`;
+        }
+        search() {
+            const searchData = this.getSearchFilter();
+            searchData.keyword = $('#search-keyword', '#search').val();
+            if (!searchData.keyword) {
+                toastr.warning('请输入查询的文件名称');
+                this.getResultHtml([]);
+                return;
+            }
+            postData(window.location.pathname + '/search', searchData, function(result) {
+                if (result.list.length === result.limit) toastr.warning(`最多查询${result.limit}个结果,请给出更准确的文件名称`);
+                $('#search-list').html(fileSearch.getResultHtml(result.list));
+            });
+        }
+        removeSearchFile(fid){
+            $(`tr[fid=search_${fid}]`, '#search').remove();
+            const index = this.searchResult.findIndex(x => { return x.id === fid; });
+            this.searchResult.splice(index, 1);
+        }
+        renameSearchFile(fid, data) {
+            const file = this.searchResult.find(x => { return x.id === fid; });
+            if (file) {
+                file.filename = data.filename;
+                file.fileext = data.fileext;
+                const td = $(`td[fid=search_${file.id}]`);
+                td.html(`${file.filename}${file.fileext}`);
+            }
+        }
+        initSearch() {
+            const self = this;
+            $.divResizer({
+                select: '#right-spr',
+                callback: function() {},
+            });
+            $('input', '#search').bind('keydown', function (e) {
+                if (e.keyCode == 13) self.search();
+            });
+            $('button', '#search').bind('click', () => { self.search(); });
+            $('body').on('click', "a[name=del-search-file]", function() {
+                const del = [this.getAttribute('fid')];
+                filingObj.delFiles(del);
+            });
+            $('body').on('click', "a[name=locate-search-file]", function() {
+                const fid = this.getAttribute('fid');
+                const file = self.searchResult.find(x => { return x.id === fid});
+                const filing = filingObj.findFiling(file.filing_id);
+                filingObj.filingTree.selectNode(filing);
+                filingObj.setCurFiling(filing);
+            });
+            $('body').on('click', "a[name=edit-search-file]", function() {
+                const check = $('[name=search-filename] input[fid]');
+                if (check.length > 0 && check[0].getAttribute('fid') === this.getAttribute('fid')) return;
+
+                const id = this.getAttribute('fid');
+                const file = self.searchResult.find(x => { return x.id === id });
+                $(`td[fid=search_${id}]`).html(self.getEditFileNameHtml(file));
+            });
+            $('body').on('click', "a[name=edit-search-file-ok]", function() {
+                const td = $(this).parent().parent().parent();
+                const fid = td.attr('fid').split('_')[1];
+                const file = self.searchResult.find(x => { return x.id === fid });
+                if (!file) return;
+
+                filingObj.renameFile(file, $('input', td).val());
+            });
+            $('body').on('click', "a[name=edit-search-file-cancel]", function() {
+                const td = $(this).parent().parent().parent();
+                const fid = td.attr('fid').split('_')[1];
+                const file = self.searchResult.find(x => { return x.id === fid });
+                if (!file) return;
+
+                td.html(`${file.filename}${file.fileext}`);
+            });
+        }
+    }
+    fileSearch = new FileSearch();
+    const showSideTools = function (show) {
+        const left = $('#left-view'), right = $('#right-view'), parent = left.parent();
+        if (show) {
+            right.show();
+            autoFlashHeight();
+            /**
+             * right.show()后, parent被撑开成2倍left.height, 导致parent.width减少了10px
+             * 第一次left.width调整后,parent的缩回left.height, 此时parent.width又增加了10px
+             * 故需要通过最终的parent.width再计算一次left.width
+             *
+             * Q: 为什么不通过先计算left.width的宽度,以避免计算两次left.width?
+             * A: 右侧工具栏不一定显示,当右侧工具栏显示过一次后,就必须使用parent和right来计算left.width
+             *
+             */
+                //left.css('width', parent.width() - right.outerWidth());
+                //left.css('width', parent.width() - right.outerWidth());
+            const percent = 100 - right.outerWidth() /parent.width() * 100;
+            left.css('width', percent + '%');
+        } else {
+            left.width(parent.width());
+            right.hide();
+        }
+    };
+    // 展开收起标准清单
+    $('a', '#side-menu').bind('click', function (e) {
+        e.preventDefault();
+        const tab = $(this), tabPanel = $(tab.attr('content'));
+        // 展开工具栏、切换标签
+        if (!tab.hasClass('active')) {
+            // const close = $('.active', '#side-menu').length === 0;
+            $('a', '#side-menu').removeClass('active');
+            $('.tab-content .tab-select-show.tab-pane.active').removeClass('active');
+            tab.addClass('active');
+            tabPanel.addClass('active');
+            // $('.tab-content .tab-pane').removeClass('active');
+            showSideTools(tab.hasClass('active'));
+        } else { // 收起工具栏
+            tab.removeClass('active');
+            tabPanel.removeClass('active');
+            showSideTools(tab.hasClass('active'));
+        }
+    });
+
     // 显示层次
     (function (select) {
         $(select).click(function () {

+ 1 - 0
app/router.js

@@ -868,6 +868,7 @@ module.exports = app => {
     app.post('/sp/:id/file/rela', sessionAuth, subProjectCheck, 'fileController.relaFile');
     app.post('/sp/:id/file/rela/tender', sessionAuth, subProjectCheck, 'fileController.loadValidRelaTender');
     app.post('/sp/:id/file/rela/files', sessionAuth, subProjectCheck, 'fileController.loadRelaFiles');
+    app.post('/sp/:id/file/search', sessionAuth, subProjectCheck, 'fileController.search');
 
     // 支付审批
     app.get('/payment', sessionAuth, 'paymentController.index');

+ 10 - 0
app/service/file.js

@@ -134,6 +134,16 @@ module.exports = app => {
             await this.defaultUpdate(updateData);
             return updateData;
         }
+
+        async search(filing_type, keyword, limit = 1000) {
+            if (!filing_type || filing_type.length === 0 || !keyword) return [];
+            const sql = `SELECT * FROM ${this.tableName}` +
+                `  WHERE spid = ? and is_deleted = 0 and filing_type in (${filing_type.join(',')}) and filename like '%${keyword}%'`+
+                `  ORDER BY update_time DESC LIMIT 0, ${limit}`;
+            const result = await this.db.query(sql, [this.ctx.subProject.id]);
+            this.analysisFiles(result);
+            return result;
+        }
     }
 
     return Filing;

+ 2 - 1
app/service/filing_template.js

@@ -106,8 +106,9 @@ module.exports = app => {
                 const insertData = {
                     id: this.uuid.v4(), temp_id: templateId, add_user_id: sessionUser.accountId,
                     tree_pid: parent ? parent.id : rootId, tree_level: parent ? parent.tree_level + 1 : 1, tree_order,
-                    name, filing_type,
+                    name, filing_type
                 };
+                insertData.is_fixed = insertData.tree_level === 1 ? 1 : (preChild ? preChild.is_fixed : 0);
                 const operate = await conn.insert(this.tableName, insertData);
                 if (operate.affectedRows === 0) throw '新增文件夹失败';
 

+ 77 - 42
app/view/file/file.ejs

@@ -9,60 +9,95 @@
             </div>
         </div>
     </div>
-    <div class="content-wrap">
-        <div class="c-body">
-            <div class="sjs-height-0 row">
-                <div class="col-3 border-right pr-0">
-                    <div class="d-flex flex-row">
-                        <div class="btn-group">
-                            <button type="button" class="btn btn-sm  text-primary dropdown-toggle" data-toggle="dropdown" id="zhankai" aria-expanded="false">显示层级</button>
-                            <div class="dropdown-menu" aria-labelledby="zhankai" x-placement="bottom-start" style="position: absolute; transform: translate3d(0px, 21px, 0px); top: 0px; left: 0px; will-change: transform;">
-                                <a class="dropdown-item" name="showLevel" tag="1" href="javascript: void(0);">第一层</a>
-                                <a class="dropdown-item" name="showLevel" tag="2" href="javascript: void(0);">第二层</a>
-                                <a class="dropdown-item" name="showLevel" tag="3" href="javascript: void(0);">第三层</a>
-                                <a class="dropdown-item" name="showLevel" tag="4" href="javascript: void(0);">第四层</a>
-                                <a class="dropdown-item" name="showLevel" tag="last" href="javascript: void(0);">最底层</a>
+    <div class="content-wrap row pr-46">
+        <div class="row w-100 sub-content">
+            <div class="c-body" id="left-view" style="width: 100%">
+                <div class="sjs-height-0 row" style="width: 100%">
+                    <div class="col-3 border-right pr-0">
+                        <div class="d-flex flex-row">
+                            <div class="btn-group">
+                                <button type="button" class="btn btn-sm  text-primary dropdown-toggle" data-toggle="dropdown" id="zhankai" aria-expanded="false">显示层级</button>
+                                <div class="dropdown-menu" aria-labelledby="zhankai" x-placement="bottom-start" style="position: absolute; transform: translate3d(0px, 21px, 0px); top: 0px; left: 0px; will-change: transform;">
+                                    <a class="dropdown-item" name="showLevel" tag="1" href="javascript: void(0);">第一层</a>
+                                    <a class="dropdown-item" name="showLevel" tag="2" href="javascript: void(0);">第二层</a>
+                                    <a class="dropdown-item" name="showLevel" tag="3" href="javascript: void(0);">第三层</a>
+                                    <a class="dropdown-item" name="showLevel" tag="4" href="javascript: void(0);">第四层</a>
+                                    <a class="dropdown-item" name="showLevel" tag="last" href="javascript: void(0);">最底层</a>
+                                </div>
                             </div>
+                            <% if (canFiling) { %>
+                            <div class="p-2"><a href="javascript: void(0);" id="add-slibing">添加同级</a></div>
+                            <div class="p-2"><a href="javascript: void(0);" id="add-child">添加子级</a></div>
+                            <% } %>
                         </div>
-                        <% if (canFiling) { %>
-                        <div class="p-2"><a href="javascript: void(0);" id="add-slibing">添加同级</a></div>
-                        <div class="p-2"><a href="javascript: void(0);" id="add-child">添加子级</a></div>
-                        <% } %>
+                        <ul id="filing" class="ztree" style="overflow: auto"></ul>
                     </div>
-                    <ul id="filing" class="ztree" style="overflow: auto"></ul>
-                </div>
-                <div class="col-9" id="file-view" style="display: none">
-                    <div class="d-flex flex-row">
-                        <% if (canUpload) { %>
-                        <div class="py-2 pr-2"><a href="#add-file" data-toggle="modal" data-target="#add-file">上传文件</a></div>
-                        <div class="py-2 pr-2"><a href="#add-big-file" data-toggle="modal" data-target="#add-big-file">大文件上传</a></div>
-                        <div class="p-2" id="rela-file-btn"><a href="#rela-file" data-toggle="modal" data-target="#rela-file">导入文件</a></div>
-                        <div class="p-2"><a href="javascript: void(0)" id="batch-del-file-btn">批量删除</a></div>
-                        <% } %>
-                        <div class="p-2"><a href="javascript: void(0)" id="batch-download">批量下载</a></div>
-                        <div class="p-2">
+                    <div class="col-9 pr-0" id="file-view" style="display: none">
+                        <div class="d-flex flex-row">
+                            <% if (canUpload) { %>
+                            <div class="py-2 pr-2"><a href="#add-file" data-toggle="modal" data-target="#add-file">上传文件</a></div>
+                            <div class="py-2 pr-2"><a href="#add-big-file" data-toggle="modal" data-target="#add-big-file">大文件上传</a></div>
+                            <div class="p-2" id="rela-file-btn"><a href="#rela-file" data-toggle="modal" data-target="#rela-file">导入文件</a></div>
+                            <div class="p-2"><a href="javascript: void(0)" id="batch-del-file-btn">批量删除</a></div>
+                            <% } %>
+                            <div class="p-2"><a href="javascript: void(0)" id="batch-download">批量下载</a></div>
+                            <div class="p-2">
                             <span id="showPage">
                                 <a href="javascript:void(0);" class="page-select ml-3" content="pre"><i class="fa fa-chevron-left"></i></a>
                                 <span id="curPage">1</span>/<span id="curTotalPage">10</span>
                                 <a href="javascript:void(0);" class="page-select mr-3" content="next"><i class="fa fa-chevron-right"></i></a>
                             </span>
+                            </div>
                         </div>
+                        <table class="table table-bordered">
+                            <thead>
+                            <tr class="text-center">
+                                <th width="60px">选择</th>
+                                <th>文件名称 <span name="file-sort" field="filename" tag="filename|desc"><i class="fa fa-sort" aria-hidden="true"></i></span></th>
+                                <th width="10%">上传人</th>
+                                <th width="20%">上传时间 <span name="file-sort" field="create_time" tag="create_time|asc"><i class="fa fa-sort-amount-desc" aria-hidden="true"></i></span></th>
+                                <th width="10%">文件类型</th>
+                            </tr>
+                            </thead>
+                            <tbody id="file-list">
+                            </tbody>
+                        </table>
                     </div>
-                    <table class="table table-bordered">
-                        <thead>
-                        <tr class="text-center">
-                            <th width="60px">选择</th>
-                            <th>文件名称 <span name="file-sort" field="filename" tag="filename|desc"><i class="fa fa-sort" aria-hidden="true"></i></span></th>
-                            <th width="10%">上传人</th>
-                            <th width="20%">上传时间 <span name="file-sort" field="create_time" tag="create_time|asc"><i class="fa fa-sort-amount-desc" aria-hidden="true"></i></span></th>
-                            <th width="10%">文件类型</th>
-                        </tr>
-                        </thead>
-                        <tbody id="file-list">
-                        </tbody>
-                    </table>
                 </div>
             </div>
+            <div class="c-body" id="right-view" style="display: none; width: 33%;">
+                <div class="resize-x" id="right-spr" r-Type="width" div1="#left-view" div2="#right-view" title="调整大小" a-type="percent"></div>
+                <div class="tab-content">
+                    <div id="search" class="tab-pane tab-select-show mr-1">
+                        <div class="sjs-bar">
+                            <div class="input-group input-group-sm pb-1">
+                                <div class="input-group-prepend">
+                                    <select class="input-group-text" id="search-filter">
+                                        <option value="cur">当前节点</option>
+                                        <option value="all">全部节点</option>
+                                    </select>
+                                </div>
+                                <input id="search-keyword" type="text" class="form-control" autocomplete="off" placeholder="输入文件名称查找" aria-label="Recipient\'s username" aria-describedby="button-addon2">
+                                <div class="input-group-append"><button class="btn btn-outline-secondary" type="button">搜索</button></div>
+                            </div>
+                        </div>
+                        <div class="sjs-sh mt-1">
+                            <table class="table table-bordered">
+                                <tr class="text-center"><th>文件名称</th><th width="15%">上传人</th><th width="15%">操作</th></tr>
+                                <tbody id="search-list"></tbody>
+                            </table>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+        <!--右侧菜单-->
+        <div class="side-menu">
+            <ul class="nav flex-column right-nav" id="side-menu">
+                <li>
+                    <a class="nav-link" content="#search" href="javascript: void(0);">查找定位</a>
+                </li>
+            </ul>
         </div>
     </div>
 </div>

+ 1 - 0
config/web.js

@@ -1204,6 +1204,7 @@ const JsFiles = {
                     '/public/js/shares/aliyun-oss-sdk.min.js',
                 ],
                 mergeFiles: [
+                    '/public/js/div_resizer.js',
                     '/public/js/shares/ali_oss.js',
                     '/public/js/shares/drag_tree.js',
                     '/public/js/path_tree.js',