diff --git a/www/js/zui3/zin.js b/www/js/zui3/zin.js new file mode 100644 index 0000000000..fb39a01f19 --- /dev/null +++ b/www/js/zui3/zin.js @@ -0,0 +1,1164 @@ +(function() +{ + if (config.skipRedirect || window.skipRedirect) return; + + const parent = window.parent; + const currentModule = config.currentModule; + const currentMethod = config.currentMethod; + const isIndexPage = currentModule === 'index' && currentMethod === 'index'; + const isInAppTab = parent.window !== window; + const is18version = config.version.split('.')[0] === '18'; + + const selfOpenList = new Set('index|tutorial|install|upgrade|sso|cron|misc|user-login|user-deny|user-logout|user-reset|user-forgetpassword|user-resetpassword|my-changepassword|my-preference|file-read|file-download|file-uploadimages|report-annualdata|misc-captcha|execution-printkanban|traincourse-playvideo'.split('|')); + const isAllowSelfOpen = isIndexPage + || location.hash === '#_single' + || /(\?|\&)_single/.test(location.search) + || selfOpenList.has(`${currentModule}-${currentMethod}`) + || selfOpenList.has(currentModule) + || $('body').hasClass('allow-self-open'); + + if(!isInAppTab && !isAllowSelfOpen) + { + const shortUrl = location.pathname + location.search + location.hash; + location.href = $.createLink('index', 'index', `open=${btoa(shortUrl)}`); + return; + } + + if(is18version && isInAppTab && parent.$.apps) // 18+zin. + { + if(isIndexPage) parent.location.reload(); + + var name = window.name; + if(name.indexOf('app-') === 0) + { + $.apps = window.apps = parent.$.apps; + var appCode = name.substring(4); + $.appCode = appCode; + $.apps.updateUrl(appCode, location.href, document.title); + } + } +}()); + +(function() +{ + let config = window.config; + const isIndexPage = config.currentModule === 'index' && config.currentMethod === 'index'; + if(isIndexPage) return; + + const DEBUG = config.debug; + const is18version = config.version.split('.')[0] === '18'; // 18+zin. + const currentCode = window.name.substring(4); + const isInAppTab = parent.window !== window; + const fetchTasks = new Map(); + const startTime = performance.now(); + const timers = {timeout: [], interval: []}; + let currentAppUrl = (isInAppTab && !is18version) ? '' : location.href; + let zinbar = null; + + $.apps = $.extend( + { + currentCode: currentCode, + updateApp: function(code, url, title) + { + if(is18version) return; + const state = typeof code === 'object' ? code : {url: url, title: title}; + const oldState = window.history.state; + + if(title) document.title = title; + + if(oldState && oldState.url === url) return; + + window.history.pushState(state, title, url); + if(DEBUG) console.log('[APP]', 'update:', {code, url, title}); + return state; + }, + isOldPage: () => false, + reloadApp: function(_code, url){loadPage(url);}, + openApp: function(url, options){loadPage(url, options);}, + goBack: function(){history.go(-1);}, + changeAppsLang: changeAppLang, + changeAppsTheme: changeAppTheme + }, parent.window.$.apps); + $.apps.openedMap = $.apps.openedApps; + + const renderMap = + { + html: updatePageWithHtml, + body: (data) => $('body').html(data), + title: (data) => document.title = data, + main: (data) => $('#main').html(data), + featureBar: (data) => $('#featureBar').html(data), + pageCSS: (data) => $('style.zin-page-css').html(data), + pageJS: updatePageJS, + configJS: updateConfigJS, + activeFeature: (data) => activeNav(data, '#featureBar'), + activeMenu: (data) => activeNav(data), + navbar: updateNavbar, + heading: updateHeading, + fatal: showFatalError, + zinDebug: (data, _info, options) => showZinDebugInfo(data, options), + zinErrors: (data, _info, options) => showErrors(data, options.id === 'page'), + }; + + function registerRender(name, callback) + { + renderMap[name] = callback; + } + + function showFatalError(data, _info, options) + { + const zinDebug = window.zinDebug; + if(zinDebug && zinDebug.basePath) data = data.split(zinDebug.basePath).join('/'); + if(data.startsWith('
\n')) data = data.replace('
\n', ''); + zui.Modal.alert({message: {html: data}, title: `Fatal error: ${options.url}`, actions: [], size: 'lg', custom: {className: 'backdrop-blur border-2 border-canvas bg-opacity-80 rounded-xl', bodyClass: 'font-mono', headerClass: 'text-danger'}}); + } + + function initZinbar() + { + if(!DEBUG || isIndexPage) return; + let $bar = $('#zinbar'); + if($bar.length) return; + + $bar = $('
').insertAfter('body'); + zinbar = new zui.Zinbar($bar[0]); + } + + function registerTimer(callback, time, type) + { + type = type || 'timeout'; + const id = type === 'interval' ? setInterval(callback, time) : setTimeout(callback, time); + timers[type].push(id); + return id; + } + + function updateConfigJS(data) + { + $('#configJS').replaceWith(data); + config = window.config; + const $body = $(document.body); + const classList = ($body.attr('class') || '').split(' ').filter(x => x.length && !x.startsWith('m-')); + classList.push(`m-${config.currentModule}-${config.currentMethod}`); + $body.attr('class', classList.join(' ')); + } + + function updatePageJS(data) + { + if(window.onPageUnmount) window.onPageUnmount(); + + window.onPageUnmount = null; + window.beforePageUpdate = null; + window.afterPageUpdate = null; + window.onPageRender = null; + + if(timers.interval.length) timers.interval.forEach(clearInterval); + if(timers.timeout.length) timers.timeout.forEach(clearTimeout); + timers.interval = []; + timers.timeout = []; + + $('script.zin-page-js').replaceWith(data); + } + + function updateZinbar(perf, errors, basePath) + { + if(!DEBUG) return; + + if(zinbar && zinbar.$) zinbar.$.update(perf, errors, basePath); + else requestAnimationFrame(() => updateZinbar(perf, errors, basePath)); + } + + function updatePerfInfo(options, stage, info) + { + if(!DEBUG) return; + + const perf = {id: options.id, url: options.url || currentAppUrl}; + perf[stage] = performance.now(); + if(stage === 'requestBegin') $.extend(perf, {requestEnd: undefined, renderBegin: undefined, renderEnd: undefined}); + if(info) + { + if(info.perf) $.extend(perf, info.perf); + if(info.dataSize) perf.dataSize = info.dataSize; + } + updateZinbar(perf); + } + + function showZinDebugInfo(data, options) + { + if(!DEBUG) return; + + updateZinbar({id: options.id, trace: data.trace, xhprof: data.xhprof}, data.errors, data.basePath); + } + + function updatePageWithHtml(data) + { + const html = []; + const skipTags = new Set(['SCRIPT', 'META']); + $(data).each(function(_idx, node) + { + const nodeName = node.nodeName; + if(nodeName === '#text') html.push(node.textContent); + else if(nodeName === 'SCRIPT' && node.innerText.startsWith('window.config={')) html.push(node.outerHTML); + else if(nodeName === 'TITLE') document.title = node.innerText; + else if(skipTags.has(nodeName)) return; + else html.push(node.outerHTML); + }); + $('body').html(html.join('')); + window.zin = {config: window.config}; + if(DEBUG) console.log('[ZIN] ', window.zin); + if(DEBUG) zui.Messager.show({content: 'ZIN: load an old page.', close: false}); + } + + function updateNavbar(data) + { + const $navbar = $('#navbar'); + const $newNav = $(data); + if($newNav.text().trim() !== $navbar.text().trim() || $newNav.find('.nav-item>a').map((_, element) => element.href).get().join(' ') !== $navbar.find('.nav-item>a').map((_, element) => element.href).get().join(' ')) return $navbar.empty().append($newNav); + + activeNav($newNav.find('.nav-item>a.active').data('id'), $navbar); + } + + function updateHeading(data) + { + const $data = $(data); + const $heading = $('#heading'); + const $dropmenu = $heading.find('#dropmenu'); + const $nextDropmenu = $data.filter('#dropmenu'); + if($dropmenu.dataset('fetcher') === $nextDropmenu.dataset('fetcher')) + { + const $toolbar = $heading.find('.toolbar'); + const $nextToolbar = $data.filter('.toolbar'); + if($nextToolbar.text().trim() !== $toolbar.text().trim()) $toolbar.replaceWith($nextToolbar); + } + else + { + $heading.html(data); + } + } + + function activeNav(activeID, nav) + { + const $nav = $(nav || '#navbar'); + const $active = $nav.find('.nav-item>a.active'); + if($active.data('id') === activeID) return; + $active.removeClass('active'); + $nav.find('.nav-item>a[data-id="' + activeID + '"]').addClass('active'); + } + + function beforeUpdate($target, info, options) + { + if($target && $target.length) $target.find('[data-zin-events]').off('.on.zin'); + if(window.beforePageUpdate) window.beforePageUpdate($target, info, options); + } + + function afterUpdate($target, info, options) + { + if(window.afterPageUpdate) window.afterPageUpdate($target, info, options); + } + + function renderPartial(info, options) + { + if(window.onPageRender && window.onPageRender(info, options)) return; + if(options.onRender && options.onRender(info, options)) return; + + const render = (options.renders ? options.renders[info.name] : null) || renderMap[info.name]; + const isHtml = info.type === 'html'; + const selector = parseSelector(info.selector); + const $target = selector ? $(selector.select) : $target; + if(render) + { + if(isHtml) beforeUpdate($target, info, options); + render(info.data, info, options); + if(isHtml) afterUpdate($target, info, options); + return; + } + + /* Common render */ + if(!selector) return console.warn('[APP] ', 'cannot render partial content with data', info); + + if(!$target.length) return console.warn('[APP] ', 'cannot find target element with selector', selector); + if(selector.first) $target = $target.first(); + if(selector.type === 'json') + { + const props = info.data.props; + if(typeof props !== 'object') return; + const component = $target.zui(info.name); + if(typeof component === 'object' && typeof component.render === 'function') component.render(props); + return; + } + + beforeUpdate($target, info, options); + if(selector.inner) $target.html(info.data); + else $target.replaceWith(info.data); + afterUpdate($target, info, options); + } + + function renderPage(list, options) + { + if(DEBUG) console.log('[APP] ', 'render:', list); + list.forEach(item => renderPartial(item, options)); + if(!options.partial) + { + const newState = $.apps.updateApp(currentCode, currentAppUrl, document.title); + if (newState) historyState = newState; + } + } + + function toggleLoading(target, isLoading) + { + const $target = $(target); + const position = $target.css('position'); + if(!['relative', 'absolute', 'fixed'].includes(position)) $target.css('position', 'relative'); + if(!$target.hasClass('load-indicator')) $target.addClass('load-indicator'); + if(isLoading === undefined) isLoading = !$target.hasClass('loading'); + $target.toggleClass('loading', isLoading); + } + + /** + * Request data from remote server + * @param {Object} options + * @param {string} options.id + * @param {string} options.url + * @param {string} options.selector + * @param {string} [options.target] + * @param {string} [options.method] + * @param {FormData|Form|Record} [options.data] + * @param {{selector: string, type: string}} [options.zinOptions] + * @param {function} [options.success] + * @param {function} [options.error] + * @param {function} [options.complete] + * @param {function} [onFinish] + */ + function requestContent(options, onFinish) + { + const target = options.target || '#main'; + const selectors = (Array.isArray(options.selector) ? options.selector : options.selector.split(',')).map(selector => selector.replace(':component', ':type=json&data=props')); + const url = options.url; + + if(DEBUG) console.log('[APP]', 'request', options); + if(DEBUG && !selectors.includes('zinDebug()')) selectors.push('zinDebug()'); + const isDebugRequest = DEBUG && selectors.length === 1 || selectors[0] === 'zinDebug()'; + const ajaxOptions = + { + url: url, + headers: {'X-ZIN-Options': JSON.stringify($.extend({selector: selectors, type: 'list'}, options.zinOptions)), 'X-ZIN-App': currentCode}, + type: options.method || 'GET', + data: options.data, + contentType: options.contentType, + beforeSend: () => + { + updatePerfInfo(options, 'requestBegin'); + if(isDebugRequest) return; + toggleLoading(target, true); + }, + success: (data) => + { + updatePerfInfo(options, 'requestEnd', {dataSize: data.length}); + options.result = 'success'; + let hasFatal = false; + try + { + if(data.includes('RAWJS<')) + { + const func = new Function(`return ${data.split('"RAWJS<').join('').split('>RAWJS"').join('')}`); + data = func(); + } + else + { + data = JSON.parse(data); + } + } + catch(e) + { + if(!isInAppTab && config.zin) return; + hasFatal = data.includes('Fatal error') || data.includes('Uncaught TypeError:'); + data = [{name: hasFatal ? 'fatal' : 'html', data: data}]; + } + if(!options.partial && !hasFatal) currentAppUrl = url; + if(Array.isArray(data)) + { + data.forEach((item, idx) => item.selector = selectors[idx]); + updatePerfInfo(options, 'renderBegin'); + renderPage(data, options); + updatePerfInfo(options, 'renderEnd'); + $(document).trigger('pagerender.app'); + if(options.success) options.success(data); + if(onFinish) onFinish(null, data); + } + else if(data.load) + { + if(data.load === 'dtable') loadTable(); + else if(typeof data.load === 'string') loadPage(data.load); + else if(data.load === true) loadCurrentPage(); + else if(typeof data.load === 'object') + { + if('back' in data.load) + { + openUrl(data.load); + } + else if('confirm' in data.load) + { + const confirmed = confirm(data.load.confirm); + if(confirmed) loadPage(data.load.confirmed); + else loadPage(data.load.canceled); + } + else + { + loadPage(data.load); + } + } + } + }, + error: (xhr, type, error) => + { + updatePerfInfo(options, 'requestEnd', {error: error}); + if(type === 'abort') return console.log('[ZIN] ', 'Abord fetch data from ' + url, {xhr, type, error});; + if(DEBUG) console.error('[ZIN] ', 'Fetch data failed from ' + url, {xhr, type, error}); + zui.Messager.show('ZIN: Fetch data failed from ' + url); + if(options.error) options.error(data, error); + if(onFinish) onFinish(error); + }, + complete: () => + { + toggleLoading(target, false); + if(options.complete) options.complete(); + $(document).trigger('pageload.app'); + const frameElement = window.frameElement; + if(frameElement) + { + frameElement.classList.remove('loading'); + frameElement.classList.add('in'); + } + } + }; + return $.ajax(ajaxOptions); + } + + function fetchContent(url, selectors, options) + { + if(typeof url === 'object') + { + options = url; + url = options.url; + selectors = options.selector; + } + if(typeof options === 'string') options = {id: options}; + else if(typeof options === 'function') options = {success: options}; + + selectors = Array.isArray(selectors) ? selectors.join(',') : selectors; + const id = options.id || selectors; + options = $.extend({}, options, {url: url, selectors: selectors, id: id}); + + const task = fetchTasks.get(id) || {url: url, selectors: selectors, options: options}; + if(task.xhr) + { + if(task.url === url) return; + task.xhr.abort(); + task.xhr = null; + } + fetchTasks.set(id, task); + if(task.timerID) clearTimeout(task.timerID); + task.timerID = setTimeout(() => + { + task.timerID = 0; + task.xhr = requestContent(options, () => + { + const runID = task.xhr.getResponseHeader('Xhprof-RunID'); + updateZinbar({id: id, xhprof: `${config.webRoot}xhprof/xhprof_html/index.php?run=${runID}&source=${config.currentModule}_${config.currentMethod}`}); + task.xhr = null; + fetchTasks.delete(id); + }); + }, options.delayTime || 0); + } + + /** Load an old page. */ + function loadOldPage(url) + { + let $page = $('#oldPage'); + if(!$page.length) + { + $page = $('
') + .append($('