diff --git a/static/common.css b/static/common.css index edde01c06..a6cc509b3 100644 --- a/static/common.css +++ b/static/common.css @@ -258,3 +258,19 @@ main { text-decoration: none; outline: none; } + +.tooltip[x-placement^="top"] .arrow, .tooltip[x-placement^="bottom"] .arrow { + left: 50%; +} + +.tooltip[x-placement^="left"] .arrow, .tooltip[x-placement^="right"] .arrow { + top: 50%; +} + +.tooltip[x-placement^="top"] .arrow::before, .tooltip[x-placement^="bottom"] .arrow::before { + transform: translateX(-50%); +} + +.tooltip[x-placement^="left"] .arrow::before, .tooltip[x-placement^="right"] .arrow::before { + transform: translateY(-50%); +} \ No newline at end of file diff --git a/static/constant.js b/static/constant.js index 25dc49fed..8632e2227 100644 --- a/static/constant.js +++ b/static/constant.js @@ -166,139 +166,3 @@ const SVG_CODE = { warning: '', error: '' } - - -const I18N_MAP = { - 'en': { - 'Logs': 'Logs', - 'Save': 'Save', - 'Config:': 'Config:', - 'Add': 'Add', - 'Rename': 'Rename', - 'RenameHelp': 'Enter a new name:', - 'Delete': 'Delete', - 'DNS Provider': 'DNS Provider', - 'Create AccessKey': 'Create AccessKey', - 'Auto': 'Auto', - '1s': '1s', - '5s': '5s', - '10s': '10s', - '1m': '1m', - '2m': '2m', - '10m': '10m', - '30m': '30m', - '1h': '1h', - 'ttlHelp': 'You can modify it if the account supports a smaller TTL. The TTL will only be updated when the IP changes', - 'Enabled': 'Enabled', - 'Get IP method': 'Get IP method', - 'By api': 'By api', - 'By network card': 'By network card', - 'By command': 'By command', - 'domainsHelp': ` - Enter one domain per line. - If the domain is unregistrable, manually separate it into a subdomain and a root domain by using a colon. e.g. www:domain.example.com
- - Support for custom parameters (Simplified Chinese) - `, - 'Regular exp.': 'Regular exp.', - 'regHelp': 'You can use @1 to specify the first IPv6 address, @2 to specify the second IPv6 address... You can also use regular expressions to match the specified IPv6 address, leave it blank to disable it', - 'Others': 'Others', - 'Deny from WAN': 'Deny from WAN', - 'NotAllowWanAccessHelp': 'Enable to deny access from the public network', - 'Username': 'Username', - 'accountHelp': 'Username/Password is required', - 'passwordHelp': 'If you need to change the password, please enter it here', - 'Password': 'Password', - 'WebhookURLHelp': ` - Click to get more info
- Support variables #{ipv4Addr}, #{ipv4Result}, - #{ipv4Domains}, #{ipv6Addr}, #{ipv6Result}, #{ipv6Domains} - `, - 'WebhookRequestBodyHelp': 'If RequestBody is empty, it is a GET request, otherwise it is a POST request. Supported variables are the same as above', - 'WebhookHeadersHelp': 'One header per line, such as: Authorization: Bearer API_KEY', - 'Try it': 'Try it', - 'Clear': 'Clear', - 'OK': 'OK', - "Ipv4UrlHelp": "https://api.ipify.org, https://myip.ipip.net, https://ddns.oray.com/checkip, https://ip.3322.net, https://v4.yinghualuo.cn/bejson", - "Ipv6UrlHelp": "https://speed.neu6.edu.cn/getIP.php, https://v6.ident.me, https://6.ipw.cn, https://v6.yinghualuo.cn/bejson", - "Ipv4NetInterfaceHelp": "Get IPv4 address through network card", - "Ipv6NetInterfaceHelp": "If you do not specify a matching regular expression, the first IPv6 address will be used by default", - "Ipv4CmdHelp": "Get IPv4 through command, only use the first matching IPv4 address of standard output(stdout). Such as: ip -4 addr show eth1", - "Ipv6CmdHelp": "Get IPv6 through command, only use the first matching IPv6 address of standard output(stdout). Such as: ip -6 addr show eth1", - "NetInterfaceEmptyHelp": 'No available network card found', - "Login": 'Login', - "LoginInit": 'Login and configure as an administrator account', - "Logout": 'Logout', - }, - 'zh-cn': { - 'Logs': '日志', - 'Save': '保存', - 'Config:': '配置切换:', - 'Add': '添加', - 'Rename': '重命名', - 'RenameHelp': '输入新名称:', - 'Delete': '删除', - 'DNS Provider': 'DNS服务商', - 'Create AccessKey': '创建 AccessKey', - 'Auto': '自动', - '1s': '1秒', - '5s': '5秒', - '10s': '10秒', - '1m': '1分钟', - '2m': '2分钟', - '10m': '10分钟', - '30m': '30分钟', - '1h': '1小时', - 'ttlHelp': '如账号支持更小的 TTL, 可修改。IP 有变化时才会更新TTL', - 'Enabled': '是否启用', - 'Get IP method': '获取 IP 方式', - 'By api': '通过接口获取', - 'By network card': '通过网卡获取', - 'By command': '通过命令获取', - 'domainsHelp': ` - 每行一个域名。 - 如果域名不可注册,请使用冒号手动将其分为子域名和根域名。如 www:domain.example.com
- - 支持自定义参数 - `, - 'Regular exp.': '匹配正则表达式', - 'regHelp': '可使用 @1 指定第一个IPv6地址, @2 指定第二个IPv6地址... 也可使用正则表达式匹配指定的IPv6地址, 留空则不启用', - 'Others': '其他', - 'Deny from WAN': '禁止公网访问', - 'NotAllowWanAccessHelp': '启用后禁止从公网访问此页面', - 'Username': '用户名', - 'accountHelp': '必须输入用户名/密码', - 'passwordHelp': '如需修改密码,请在此处输入新密码', - 'Password': '密码', - 'WebhookURLHelp': ` - 点击参考官方 Webhook 说明 -
- 支持的变量 #{ipv4Addr}, #{ipv4Result}, #{ipv4Domains}, #{ipv6Addr}, #{ipv6Result}, #{ipv6Domains} - `, - 'WebhookRequestBodyHelp': '如果 RequestBody 为空, 则为 GET 请求, 否则为 POST 请求。支持的变量同上', - 'WebhookHeadersHelp': '一行一个Header, 如: Authorization: Bearer API_KEY', - 'Try it': '模拟测试Webhook', - 'Clear': '清空', - 'OK': '确定', - "Ipv4UrlHelp": "https://myip.ipip.net, https://ddns.oray.com/checkip, https://ip.3322.net, https://v4.yinghualuo.cn/bejson", - "Ipv6UrlHelp": "https://speed.neu6.edu.cn/getIP.php, https://v6.ident.me, https://6.ipw.cn, https://v6.yinghualuo.cn/bejson", - "Ipv4NetInterfaceHelp": "通过网卡获取IPv4", - "Ipv6NetInterfaceHelp": "如不指定匹配正则表达式,将默认使用第一个 IPv6 地址", - "Ipv4CmdHelp": ` - 通过命令获取IPv4, 仅使用标准输出(stdout)的第一个匹配的 IPv4 地址。如: ip -4 addr show eth1 - 点击参考更多 - `, - "Ipv6CmdHelp": ` - 通过命令获取IPv6, 仅使用标准输出(stdout)的第一个匹配的 IPv6 地址。如: ip -6 addr show eth1 - 点击参考更多 - `, - "NetInterfaceEmptyHelp": '没有找到可用的网卡', - "Login": '登录', - "LoginInit": '登录并配置为管理员账号', - "Logout": '注销', - } -}; diff --git a/static/i18n.js b/static/i18n.js index 786546069..6a7a7b051 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -1,48 +1,301 @@ +const I18N_MAP = { + 'Logs': { + 'en': 'Logs', + 'zh-cn': '日志' + }, + 'Save': { + 'en': 'Save', + 'zh-cn': '保存' + }, + 'Config:': { + 'en': 'Config:', + 'zh-cn': '配置切换:' + }, + 'Add': { + 'en': 'Add', + 'zh-cn': '添加' + }, + 'Rename': { + 'en': 'Rename', + 'zh-cn': '重命名' + }, + 'RenameHelp': { + 'en': 'Enter a new name:', + 'zh-cn': '输入新名称:' + }, + 'Delete': { + 'en': 'Delete', + 'zh-cn': '删除' + }, + 'DNS Provider': { + 'en': 'DNS Provider', + 'zh-cn': 'DNS服务商' + }, + 'Create AccessKey': { + 'en': 'Create AccessKey', + 'zh-cn': '创建 AccessKey' + }, + 'Auto': { + 'en': 'Auto', + 'zh-cn': '自动' + }, + '1s': { + 'en': '1s', + 'zh-cn': '1秒' + }, + '5s': { + 'en': '5s', + 'zh-cn': '5秒' + }, + '10s': { + 'en': '10s', + 'zh-cn': '10秒' + }, + '1m': { + 'en': '1m', + 'zh-cn': '1分钟' + }, + '2m': { + 'en': '2m', + 'zh-cn': '2分钟' + }, + '10m': { + 'en': '10m', + 'zh-cn': '10分钟' + }, + '30m': { + 'en': '30m', + 'zh-cn': '30分钟' + }, + '1h': { + 'en': '1h', + 'zh-cn': '1小时' + }, + 'ttlHelp': { + 'en': 'You can modify it if the account supports a smaller TTL. The TTL will only be updated when the IP changes', + 'zh-cn': '如账号支持更小的 TTL, 可修改。IP 有变化时才会更新TTL' + }, + 'Enabled': { + 'en': 'Enabled', + 'zh-cn': '是否启用' + }, + 'Get IP method': { + 'en': 'Get IP method', + 'zh-cn': '获取 IP 方式' + }, + 'By api': { + 'en': 'By api', + 'zh-cn': '通过接口获取' + }, + 'By network card': { + 'en': 'By network card', + 'zh-cn': '通过网卡获取' + }, + 'By command': { + 'en': 'By command', + 'zh-cn': '通过命令获取' + }, + 'domainsHelp': { + 'en': ` + Enter one domain per line. + If the domain is unregistrable, manually separate it into a subdomain and a root domain by using a colon. e.g. www:domain.example.com
+ + Support for custom parameters (Simplified Chinese) + `, + 'zh-cn': ` + 每行一个域名。 + 如果域名不可注册,请使用冒号手动将其分为子域名和根域名。如 www:domain.example.com
+ 支持自定义参数 + ` + }, + 'Regular exp.': { + 'en': 'Regular exp.', + 'zh-cn': '匹配正则表达式' + }, + 'regHelp': { + 'en': 'You can use @1 to specify the first IPv6 address, @2 to specify the second IPv6 address... You can also use regular expressions to match the specified IPv6 address, leave it blank to disable it', + 'zh-cn': '可使用 @1 指定第一个IPv6地址, @2 指定第二个IPv6地址... 也可使用正则表达式匹配指定的IPv6地址, 留空则不启用' + }, + 'Others': { + 'en': 'Others', + 'zh-cn': '其他' + }, + 'Deny from WAN': { + 'en': 'Deny from WAN', + 'zh-cn': '禁止公网访问' + }, + 'NotAllowWanAccessHelp': { + 'en': 'Enable to deny access from the public network', + 'zh-cn': '启用后禁止从公网访问此页面' + }, + 'Username': { + 'en': 'Username', + 'zh-cn': '用户名' + }, + 'accountHelp': { + 'en': 'Username/Password is required', + 'zh-cn': '必须输入用户名/密码' + }, + 'passwordHelp': { + 'en': 'If you need to change the password, please enter it here', + 'zh-cn': '如需修改密码,请在此处输入新密码' + }, + 'Password': { + 'en': 'Password', + 'zh-cn': '密码' + }, + 'WebhookURLHelp': { + 'en': ` + Click to get more info
+ Support variables #{ipv4Addr}, #{ipv4Result}, + #{ipv4Domains}, #{ipv6Addr}, #{ipv6Result}, #{ipv6Domains} + `, + 'zh-cn': ` + 点击参考官方 Webhook 说明 +
+ 支持的变量 #{ipv4Addr}, #{ipv4Result}, #{ipv4Domains}, #{ipv6Addr}, #{ipv6Result}, #{ipv6Domains} + ` + }, + 'WebhookRequestBodyHelp': { + 'en': 'If RequestBody is empty, it is a GET request, otherwise it is a POST request. Supported variables are the same as above', + 'zh-cn': '如果 RequestBody 为空, 则为 GET 请求, 否则为 POST 请求。支持的变量同上' + }, + 'WebhookHeadersHelp': { + 'en': 'One header per line, such as: Authorization: Bearer API_KEY', + 'zh-cn': '一行一个Header, 如: Authorization: Bearer API_KEY' + }, + 'Try it': { + 'en': 'Try it', + 'zh-cn': '模拟测试Webhook' + }, + 'Clear': { + 'en': 'Clear', + 'zh-cn': '清空' + }, + 'OK': { + 'en': 'OK', + 'zh-cn': '确定' + }, + "Ipv4UrlHelp": { + 'en': "https://api.ipify.org, https://myip.ipip.net, https://ddns.oray.com/checkip, https://ip.3322.net, https://v4.yinghualuo.cn/bejson", + 'zh-cn': "https://myip.ipip.net, https://ddns.oray.com/checkip, https://ip.3322.net, https://v4.yinghualuo.cn/bejson" + }, + "Ipv6UrlHelp": { + 'en': "https://speed.neu6.edu.cn/getIP.php, https://v6.ident.me, https://6.ipw.cn, https://v6.yinghualuo.cn/bejson", + 'zh-cn': "https://speed.neu6.edu.cn/getIP.php, https://v6.ident.me, https://6.ipw.cn, https://v6.yinghualuo.cn/bejson" + }, + "Ipv4NetInterfaceHelp": { + 'en': "Get IPv4 address through network card", + 'zh-cn': "通过网卡获取IPv4" + }, + "Ipv6NetInterfaceHelp": { + 'en': "If you do not specify a matching regular expression, the first IPv6 address will be used by default", + 'zh-cn': "如不指定匹配正则表达式,将默认使用第一个 IPv6 地址" + }, + "Ipv4CmdHelp": { + 'en': "Get IPv4 through command, only use the first matching IPv4 address of standard output(stdout). Such as: ip -4 addr show eth1", + 'zh-cn': ` + 通过命令获取IPv4, 仅使用标准输出(stdout)的第一个匹配的 IPv4 地址。如: ip -4 addr show eth1 + 点击参考更多 + ` + }, + "Ipv6CmdHelp": { + 'en': "Get IPv6 through command, only use the first matching IPv6 address of standard output(stdout). Such as: ip -6 addr show eth1", + 'zh-cn': ` + 通过命令获取IPv6, 仅使用标准输出(stdout)的第一个匹配的 IPv6 地址。如: ip -6 addr show eth1 + 点击参考更多 + ` + }, + "NetInterfaceEmptyHelp": { + 'en': 'No available network card found', + 'zh-cn': '没有找到可用的网卡' + }, + "Login": { + 'en': 'Login', + 'zh-cn': '登录' + }, + "LoginInit": { + 'en': 'Login and configure as an administrator account', + 'zh-cn': '登录并配置为管理员账号' + }, + "Logout": { + 'en': 'Logout', + 'zh-cn': '注销' + }, + "webhookTestTooltip": { + 'en': 'Send a fake data to the Webhook URL immediately to test if the Webhook is working properly', + 'zh-cn': '立即发送一条假数据到Webhook URL,用于测试Webhook是否正常工作' + }, + "themeTooltip": { + 'en': 'Switch between light and dark themes', + 'zh-cn': '切换明暗主题' + }, +}; + const LANG = localStorage.getItem('lang') || (navigator.language || navigator.browserLanguage).replaceAll('_', '-').toLowerCase(); -// 支持两种调用方式: -// 1. 文本的key + (可选:语言映射字典),{en: {hello: "hello", world: "world"}, zh: {hello: "你好", world: "世界"}} -// 2. 语言字符串字典,{en: "hello", zh: "你好"} -const i18n = (key, langMap = I18N_MAP) => { - if (typeof key !== 'string') { - langMap = key; - key = null; +const getLocalLang = (langs) => { + // 优先取地区语言 + if (langs.includes(LANG)) { + return LANG; } - // 优先取地区语言,否则取表示语言,再否则取表示语言相同的地区语言,最后取英文 - let lang = 'en'; - if (LANG in langMap) { - lang = LANG; - } else if (LANG.split('-')[0] in langMap) { - lang = LANG.split('-')[0]; - } else { - for (const l in langMap) { - if (l.split('-')[0] === LANG.split('-')[0]) { - lang = l; - break; - } + // 其次取表示语言 + if (langs.includes(LANG.split('-')[0])) { + return LANG.split('-')[0]; + } + // 再取表示语言相同的地区语言 + for (const l of langs) { + if (l.split('-')[0] === LANG.split('-')[0]) { + return l; } } - let text = ''; - if (key) { - text = langMap[lang][key]; + // 无法匹配则取英文 + return 'en'; +} + +// 支持两种调用方式: +// 1. 文本在I18N字典中的key,如"hello" +// 2. 语言字符串字典,{en: "hello", zh: "你好"} +const i18n = (keyOrLangDict) => { + let key = keyOrLangDict; + let langDict = keyOrLangDict; + if (typeof keyOrLangDict === 'string') { + langDict = I18N_MAP[keyOrLangDict]; } else { - text = langMap[lang]; + key = null; } - if (text === undefined) { - console.warn(`i18n: No translation for ${key}`); + if (!langDict) { + console.warn(`i18n: No translation for key "${key}"`); return key; } - return text; + const lang = getLocalLang(Object.keys(langDict)); + if (lang in langDict) { + return langDict[lang]; + } + console.warn(`i18n: No such language "${lang}" in langDict ${langDict}`); + return key; } -const convertDom = (dom = document, ...args) => { +const convertDom = (dom = document) => { dom.querySelectorAll('[data-i18n]').forEach(el => { const key = el.dataset.i18n; - el.textContent = i18n(key, ...args); + el.textContent = i18n(key); + }); + dom.querySelectorAll('[data-i18n-html]').forEach(el => { + const key = el.dataset.i18nHtml; + el.innerHTML = i18n(key); }); - dom.querySelectorAll('[data-i18n_html]').forEach(el => { - const key = el.dataset.i18n_html; - el.innerHTML = i18n(key, ...args); + dom.querySelectorAll('[data-i18n-attr]').forEach(el => { + el.dataset.i18nAttr.split(',').forEach(item => { + let [attr, key] = item.split(':'); + attr = attr.trim(); + key = key || el.getAttribute(attr); + el.setAttribute(attr, i18n(key)); + }); }); } diff --git a/static/tooltips.js b/static/tooltips.js new file mode 100644 index 000000000..f94c76d3f --- /dev/null +++ b/static/tooltips.js @@ -0,0 +1,174 @@ +class Tooltip { + constructor(element, triggers) { + this.$element = element; + this.$tooltip = null; + this.originalTitle = ''; + this._bindEvents(triggers); + } + + _createTooltipElement(options) { + const title = options.title || this.$element.dataset.title || this.originalTitle; + if (!title) { + return; + } + const useHtml = options.hasOwnProperty('html') ? options.html : this.$element.dataset.html === 'true'; + let placement = options.placement || this.$element.dataset.placement || 'auto'; + if (placement === 'auto') { + const rect = this.$element.getBoundingClientRect(); + const viewportWidth = window.innerWidth || document.documentElement.clientWidth; + const viewportHeight = window.innerHeight || document.documentElement.clientHeight; + const space = { + top: rect.top, + bottom: viewportHeight - rect.bottom, + left: rect.left, + right: viewportWidth - rect.right + }; + placement = Object.keys(space).reduce((a, b) => space[a] > space[b] ? a : b); + } + this.$tooltip = html2Element(` + + `) + if (useHtml) { + this.$tooltip.querySelector('.tooltip-inner').innerHTML = title + } else { + this.$tooltip.querySelector('.tooltip-inner').textContent = title + } + } + + _updatePosition() { + const elRect = this.$element.getBoundingClientRect() + const bodyRect = document.body.getBoundingClientRect() + const tooltipRect = this.$tooltip.getBoundingClientRect() + const placement = this.$tooltip.getAttribute('x-placement') + + let left, top; + + switch(placement) { + case 'top': + left = elRect.left + (elRect.width - tooltipRect.width) / 2 + top = elRect.top - tooltipRect.height - 8 + break + case 'bottom': + left = elRect.left + (elRect.width - tooltipRect.width) / 2 + top = elRect.bottom + 8 + break + case 'left': + left = elRect.left - tooltipRect.width - 8 + top = elRect.top + (elRect.height - tooltipRect.height) / 2 + break + case 'right': + left = elRect.right + 8 + top = elRect.top + (elRect.height - tooltipRect.height) / 2 + break + } + + // 考虑滚动条的影响 + left = left - bodyRect.left + top = top - bodyRect.top + + this.$tooltip.style.left = `${left}px` + this.$tooltip.style.top = `${top}px` + } + + async show(options = {}) { + if (this.$tooltip) { + this.$tooltip.remove(); + } + if (this.$element.title) { + this.originalTitle = this.$element.title; + this.$element.title = ''; + } + this._createTooltipElement(options); + if (!this.$tooltip) { + return; + } + document.body.appendChild(this.$tooltip); + await delay(0); + if (!this.$tooltip) { + return; + } + this._updatePosition(); + this.$tooltip.classList.add('show'); + } + + async hide() { + if (this.originalTitle && !this.$element.title) { + this.$element.title = this.originalTitle; + } + if (!this.$tooltip) { + return; + } + this.$tooltip.classList.remove('show'); + await delay(200); + if (!this.$tooltip) { + return; + } + this.$tooltip.remove(); + this.$tooltip = null; + } + + _bindEvents(triggers) { + let state = 0; + const _enter = () => { + state += 1; + this.show(); + }; + const _leave = () => { + state -= 1; + if (state <= 0) { + this.hide(); + } + }; + if (!triggers) { + triggers = (this.$element.dataset.trigger || 'hover focus').split(' '); + } + triggers.forEach(trigger => { + switch(trigger) { + case 'hover': + this.$element.addEventListener('mouseenter', _enter); + this.$element.addEventListener('mouseleave', _leave); + break; + case 'focus': + this.$element.addEventListener('focusin', _enter); + this.$element.addEventListener('focusout', _leave); + break; + case 'click': + this.$element.addEventListener('click', () => { + if (this.$tooltip) { + this.hide(); + } else { + this.show(); + } + }); + break; + case 'manual': + break; + default: + console.warn(`Unknown trigger: ${trigger}`); + } + }); + } +} + +// 初始化所有带data-tooltip属性的元素 +const initTooltips = () => { + window.tooltips = {}; + document.querySelectorAll('[data-toggle="tooltip"]').forEach(element => { + let key = element.dataset.tooltipKey || element.id; + if (!key) { + key = crypto.randomUUID(); + element.dataset.tooltipKey = key; + } + window.tooltips[key] = new Tooltip(element); + }); +}; + +// 页面加载完成后初始化 +document.addEventListener('DOMContentLoaded', initTooltips); \ No newline at end of file diff --git a/web/writing.html b/web/writing.html index 812ba267d..13c5ae462 100755 --- a/web/writing.html +++ b/web/writing.html @@ -17,6 +17,7 @@ + @@ -34,11 +35,16 @@ data-i18n="Logs" class="btn btn-info btn-sm" id="logsBtn" + data-toggle="tooltip" + data-placement="bottom" > Logs {{.Version}} @@ -172,7 +178,7 @@ @@ -288,23 +294,23 @@
IPv4
data-visible="cmd" /> IPv4 aria-describedby="ipv4DomainsHelp" > @@ -440,23 +446,23 @@
IPv6
data-visible="cmd" /> IPv6 aria-describedby="Ipv6RegHelp" /> @@ -504,7 +510,7 @@
IPv6
aria-describedby="ipv6_domainsHelp" > @@ -538,7 +544,7 @@
IPv6
{{if .NotAllowWanAccess}}checked{{end}} /> IPv6 aria-describedby="UsernameHelp" /> @@ -587,7 +593,7 @@
IPv6
aria-describedby="passwordHelp" /> @@ -612,7 +618,7 @@
Webhook
aria-describedby="WebhookURLHelp" /> @@ -634,7 +640,7 @@
Webhook
aria-describedby="WebhookRequestBodyHelp" >{{.WebhookRequestBody}} @@ -654,7 +660,7 @@
Webhook
aria-describedby="WebhookHeadersHelp" >{{.WebhookHeaders}} @@ -668,6 +674,8 @@
Webhook
data-i18n="Try it" class="webhook-button btn btn-primary btn-sm" id="webhookTestBtn" + data-toggle="tooltip" + data-i18n-attr="title:webhookTestTooltip" > Try it @@ -681,6 +689,7 @@
Webhook
data-i18n="Save" class="btn btn-primary submit_btn" style="margin-bottom: 16px" + data-placement="top" > Save @@ -1070,7 +1079,12 @@
Webhook
) { return; } - document.getElementById("logsBtn").classList.add("unread"); + const $logsBtn = document.getElementById("logsBtn"); + $logsBtn.classList.add("unread"); + $logsBtn.dataset.title = i18n({ + "en": `${newLogsList.length} new logs`, + "zh-cn": `新增${newLogsList.length}条日志`, + }); // 如果新增日志行数小于等于3,则message显示新增日志,否则显示最后2行并提示剩余行数 if (newLogsList.length <= 3) { for (const line of newLogsList) { @@ -1117,7 +1131,9 @@
Webhook
document.querySelectorAll('#logsBtn, #closeLogBtn, #mask').forEach($el => { $el.addEventListener('click', () => { // 取消未读标记 - document.getElementById("logsBtn").classList.remove("unread"); + const $logsBtn = document.getElementById("logsBtn"); + $logsBtn.classList.remove("unread"); + $logsBtn.dataset.title = "" if (document.getElementById("logs-panel").style.visibility === "hidden") { document.getElementById("logs-panel").style.visibility = ""; document.getElementById("mask").style.visibility = ""; @@ -1161,5 +1177,87 @@
Webhook
}); } }); + + // 测试正则表达式 + const $ipv6Reg = document.getElementById("Ipv6Reg"); + const ipv6RegTooltip = new Tooltip($ipv6Reg, ['manual', 'focus']); + // ipv6网卡信息 + const $ipv6NetInterface = document.getElementById("Ipv6NetInterface"); + const ipv6Dict = Array.from($ipv6NetInterface.options).reduce((acc, option) => { + const ipv6s = option.innerText.match(/([0-9a-fA-F:]{2,})/g); + if (ipv6s) { + acc[option.value] = ipv6s; + } + return acc; + }, {}); + $ipv6Reg.addEventListener('input', e => { + // 为空时不处理 + if (!$ipv6Reg.value) { + ipv6RegTooltip.hide(); + return; + } + const curIpv6s = ipv6Dict[$ipv6NetInterface.value] || []; + // 指定第N个ipv6地址 + const ipv6IndexMatch = $ipv6Reg.value.match(/^@(\d+)$/); + if (ipv6IndexMatch) { + const idx = parseInt(ipv6IndexMatch[1]) - 1; + if (idx < 0 || idx >= curIpv6s.length) { + ipv6RegTooltip.show({ + title: i18n({ + "en": "Index out of range", + "zh-cn": "索引超出范围", + }), + html: true, + placement: "top", + }); + return; + } + ipv6RegTooltip.show({ + title: i18n({ + "en": `Matched: ${curIpv6s[idx]}`, + "zh-cn": `匹配到: ${curIpv6s[idx]}`, + }), + placement: "top", + }); + return; + } + + // 检测正则表达式是否合法 + let reg; + try { + reg = new RegExp($ipv6Reg.value); + } catch (err) { + ipv6RegTooltip.show({ + title: i18n({ + "en": "Invalid regular expression", + "zh-cn": "无效的正则表达式", + }), + html: true, + placement: "top", + }); + return; + } + // 显示正则表达式的匹配结果 + for (const ipv6 of curIpv6s) { + if (reg.test(ipv6)) { + ipv6RegTooltip.show({ + title: i18n({ + "en": `Matched: ${ipv6}`, + "zh-cn": `匹配到: ${ipv6}`, + }), + placement: "top", + }); + return; + } + } + ipv6RegTooltip.show({ + title: i18n({ + "en": "No match found", + "zh-cn": "无匹配项", + }), + html: true, + placement: "top", + }); + });