Version 3.0
No files matched your search
@@ -1,5 +1,29 @@
|
||||
.DS_Store
|
||||
bingMapAPIKEY
|
||||
.env
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.env
|
||||
.vscode/extensions.json
|
||||
|
||||
bingMapAPIKEY
|
||||
package-lock.json
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false
|
||||
}
|
||||
@@ -1,7 +1,23 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /usr/src/app
|
||||
# 第一阶段:构建
|
||||
FROM node:20-alpine as build-stage
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
EXPOSE 8966
|
||||
CMD ["node", "server.js"]
|
||||
RUN npm run build
|
||||
|
||||
# 第二阶段:运行
|
||||
# 基于 Node.js 官方镜像
|
||||
FROM node:20-alpine as production-stage
|
||||
WORKDIR /app
|
||||
COPY --from=build-stage /app/node_modules ./node_modules
|
||||
COPY --from=build-stage /app/package.json ./
|
||||
COPY --from=build-stage /app/dist ./dist
|
||||
COPY --from=build-stage /app/server.js ./
|
||||
COPY --from=build-stage /app/static-server.js ./
|
||||
COPY --from=build-stage /app/api ./api
|
||||
|
||||
EXPOSE 18966
|
||||
|
||||
# 启动应用
|
||||
CMD ["npm", "start"]
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Jason Ng
|
||||
Copyright (c) 2024 Jason Ng
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
|
||||
Notes: You can use my demo, but please don't use it for commercial purposes. If you want to use it for commercial purposes, please deploy it yourself.
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fjason5ng32%2FMyIP&project-name=MyIP&repository-name=MyIP)
|
||||
[](https://hub.docker.com/r/jason5ng32/myip)
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fjason5ng32%2FMyIP&project-name=MyIP&repository-name=MyIP)
|
||||
[](https://hub.docker.com/r/jason5ng32/myip)
|
||||
|
||||
## 👀 Main Features
|
||||
|
||||
@@ -51,13 +51,19 @@ Install:
|
||||
npm install
|
||||
```
|
||||
|
||||
Build:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
The program will run on port 8966.
|
||||
The program will run on port 18966.
|
||||
|
||||
If you want to add Bing Maps, make the following changes before starting:
|
||||
|
||||
@@ -102,13 +108,13 @@ IPINFO_API_TOKEN
|
||||
Click the 'Deploy to Docker' button at the top to complete the deployment. Or, use the following shell:
|
||||
|
||||
```bash
|
||||
docker run -d -p 8966:8966 --name myip --restart always jason5ng32/myip:latest
|
||||
docker run -d -p 18966:18966 --name myip --restart always jason5ng32/myip:latest
|
||||
```
|
||||
|
||||
If you wish to display maps, set the Bing Map API Key and allowed domains during deployment:
|
||||
|
||||
```bash
|
||||
docker run -d -p 8966:8966 \
|
||||
docker run -d -p 18966:18966 \
|
||||
-e BING_MAP_API_KEY="YOUR_KEY_HERE" \
|
||||
-e ALLOWED_DOMAINS="example.com" \
|
||||
--name myip \
|
||||
@@ -119,7 +125,7 @@ docker run -d -p 8966:8966 \
|
||||
If you want to use IPInfo.io API, you can also add the following:
|
||||
|
||||
```bash
|
||||
docker run -d -p 8966:8966 \
|
||||
docker run -d -p 18966:18966 \
|
||||
-e BING_MAP_API_KEY="YOUR_KEY_HERE" \
|
||||
-e ALLOWED_DOMAINS="example.com" \
|
||||
-e IPINFO_API_TOKEN="YOUR_TOKEN_HERE" \
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
|
||||
备注:你可以直接用我已经搭建好的服务,也可以自行搭建。
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fjason5ng32%2FMyIP&project-name=MyIP&repository-name=MyIP)
|
||||
[](https://hub.docker.com/r/jason5ng32/myip)
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fjason5ng32%2FMyIP&project-name=MyIP&repository-name=MyIP)
|
||||
[](https://hub.docker.com/r/jason5ng32/myip)
|
||||
|
||||
这是一个完全开源的 IP 信息查看器,可以查询本机 IP、查询任意 IP、查询国内外网站可用性等。这是我第一次用 Vue.js 练手的项目。我……只是一个普通的产品经理。
|
||||
|
||||
@@ -53,13 +53,19 @@ git clone https://github.com/jason5ng32/MyIP.git
|
||||
npm install
|
||||
```
|
||||
|
||||
编译:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
运行:
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
程序会运行在 8966 端口。
|
||||
程序会运行在 18966 端口。
|
||||
|
||||
如果你想添加 Bing 地图,在启动之前,进行如下修改:
|
||||
|
||||
@@ -104,13 +110,13 @@ IPINFO_API_TOKEN
|
||||
点击顶部的部署到 Docker 按钮,即可完成部署,又或者,直接输入下面的命令:
|
||||
|
||||
```bash
|
||||
docker run -d -p 8966:8966 --name myip --restart always jason5ng32/myip:latest
|
||||
docker run -d -p 18966:18966 --name myip --restart always jason5ng32/myip:latest
|
||||
```
|
||||
|
||||
如果你希望展示地图,则在部署的时候,设置 Bing Map API Key 和允许的域名:
|
||||
|
||||
```bash
|
||||
docker run -d -p 8966:8966 \
|
||||
docker run -d -p 18966:18966 \
|
||||
-e BING_MAP_API_KEY="YOUR_KEY_HERE" \
|
||||
-e ALLOWED_DOMAINS="example.com" \
|
||||
--name myip \
|
||||
@@ -121,7 +127,7 @@ docker run -d -p 8966:8966 \
|
||||
如果你希望同时使用 IPinfo.io 的 API:
|
||||
|
||||
```bash
|
||||
docker run -d -p 8966:8966 \
|
||||
docker run -d -p 18966:18966 \
|
||||
-e BING_MAP_API_KEY="YOUR_KEY_HERE" \
|
||||
-e ALLOWED_DOMAINS="example.com" \
|
||||
-e IPINFO_API_TOKEN="YOUR_TOKEN_HERE" \
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
{
|
||||
"AF": "Afghanistan",
|
||||
"AX": "Åland Islands",
|
||||
"AL": "Albania",
|
||||
"DZ": "Algeria",
|
||||
"AS": "American Samoa",
|
||||
"AD": "AndorrA",
|
||||
"AO": "Angola",
|
||||
"AI": "Anguilla",
|
||||
"AQ": "Antarctica",
|
||||
"AG": "Antigua and Barbuda",
|
||||
"AR": "Argentina",
|
||||
"AM": "Armenia",
|
||||
"AW": "Aruba",
|
||||
"AU": "Australia",
|
||||
"AT": "Austria",
|
||||
"AZ": "Azerbaijan",
|
||||
"BS": "Bahamas",
|
||||
"BH": "Bahrain",
|
||||
"BD": "Bangladesh",
|
||||
"BB": "Barbados",
|
||||
"BY": "Belarus",
|
||||
"BE": "Belgium",
|
||||
"BZ": "Belize",
|
||||
"BJ": "Benin",
|
||||
"BM": "Bermuda",
|
||||
"BT": "Bhutan",
|
||||
"BO": "Bolivia",
|
||||
"BA": "Bosnia and Herzegovina",
|
||||
"BW": "Botswana",
|
||||
"BV": "Bouvet Island",
|
||||
"BR": "Brazil",
|
||||
"IO": "British Indian Ocean Territory",
|
||||
"BN": "Brunei Darussalam",
|
||||
"BG": "Bulgaria",
|
||||
"BF": "Burkina Faso",
|
||||
"BI": "Burundi",
|
||||
"KH": "Cambodia",
|
||||
"CM": "Cameroon",
|
||||
"CA": "Canada",
|
||||
"CV": "Cape Verde",
|
||||
"KY": "Cayman Islands",
|
||||
"CF": "Central African Republic",
|
||||
"TD": "Chad",
|
||||
"CL": "Chile",
|
||||
"CN": "China",
|
||||
"CX": "Christmas Island",
|
||||
"CC": "Cocos (Keeling) Islands",
|
||||
"CO": "Colombia",
|
||||
"KM": "Comoros",
|
||||
"CG": "Congo",
|
||||
"CD": "Congo, The Democratic Republic of the",
|
||||
"CK": "Cook Islands",
|
||||
"CR": "Costa Rica",
|
||||
"CI": "Cote D'Ivoire",
|
||||
"HR": "Croatia",
|
||||
"CU": "Cuba",
|
||||
"CY": "Cyprus",
|
||||
"CZ": "Czech Republic",
|
||||
"DK": "Denmark",
|
||||
"DJ": "Djibouti",
|
||||
"DM": "Dominica",
|
||||
"DO": "Dominican Republic",
|
||||
"EC": "Ecuador",
|
||||
"EG": "Egypt",
|
||||
"SV": "El Salvador",
|
||||
"GQ": "Equatorial Guinea",
|
||||
"ER": "Eritrea",
|
||||
"EE": "Estonia",
|
||||
"ET": "Ethiopia",
|
||||
"FK": "Falkland Islands (Malvinas)",
|
||||
"FO": "Faroe Islands",
|
||||
"FJ": "Fiji",
|
||||
"FI": "Finland",
|
||||
"FR": "France",
|
||||
"GF": "French Guiana",
|
||||
"PF": "French Polynesia",
|
||||
"TF": "French Southern Territories",
|
||||
"GA": "Gabon",
|
||||
"GM": "Gambia",
|
||||
"GE": "Georgia",
|
||||
"DE": "Germany",
|
||||
"GH": "Ghana",
|
||||
"GI": "Gibraltar",
|
||||
"GR": "Greece",
|
||||
"GL": "Greenland",
|
||||
"GD": "Grenada",
|
||||
"GP": "Guadeloupe",
|
||||
"GU": "Guam",
|
||||
"GT": "Guatemala",
|
||||
"GG": "Guernsey",
|
||||
"GN": "Guinea",
|
||||
"GW": "Guinea-Bissau",
|
||||
"GY": "Guyana",
|
||||
"HT": "Haiti",
|
||||
"HM": "Heard Island and Mcdonald Islands",
|
||||
"VA": "Holy See (Vatican City State)",
|
||||
"HN": "Honduras",
|
||||
"HK": "Hong Kong",
|
||||
"HU": "Hungary",
|
||||
"IS": "Iceland",
|
||||
"IN": "India",
|
||||
"ID": "Indonesia",
|
||||
"IR": "Iran, Islamic Republic Of",
|
||||
"IQ": "Iraq",
|
||||
"IE": "Ireland",
|
||||
"IM": "Isle of Man",
|
||||
"IL": "Israel",
|
||||
"IT": "Italy",
|
||||
"JM": "Jamaica",
|
||||
"JP": "Japan",
|
||||
"JE": "Jersey",
|
||||
"JO": "Jordan",
|
||||
"KZ": "Kazakhstan",
|
||||
"KE": "Kenya",
|
||||
"KI": "Kiribati",
|
||||
"KP": "Korea, Democratic People'S Republic of",
|
||||
"KR": "Korea, Republic of",
|
||||
"KW": "Kuwait",
|
||||
"KG": "Kyrgyzstan",
|
||||
"LA": "Lao People'S Democratic Republic",
|
||||
"LV": "Latvia",
|
||||
"LB": "Lebanon",
|
||||
"LS": "Lesotho",
|
||||
"LR": "Liberia",
|
||||
"LY": "Libyan Arab Jamahiriya",
|
||||
"LI": "Liechtenstein",
|
||||
"LT": "Lithuania",
|
||||
"LU": "Luxembourg",
|
||||
"MO": "Macao",
|
||||
"MK": "Macedonia, The Former Yugoslav Republic of",
|
||||
"MG": "Madagascar",
|
||||
"MW": "Malawi",
|
||||
"MY": "Malaysia",
|
||||
"MV": "Maldives",
|
||||
"ML": "Mali",
|
||||
"MT": "Malta",
|
||||
"MH": "Marshall Islands",
|
||||
"MQ": "Martinique",
|
||||
"MR": "Mauritania",
|
||||
"MU": "Mauritius",
|
||||
"YT": "Mayotte",
|
||||
"MX": "Mexico",
|
||||
"FM": "Micronesia, Federated States of",
|
||||
"MD": "Moldova, Republic of",
|
||||
"MC": "Monaco",
|
||||
"MN": "Mongolia",
|
||||
"MS": "Montserrat",
|
||||
"MA": "Morocco",
|
||||
"MZ": "Mozambique",
|
||||
"MM": "Myanmar",
|
||||
"NA": "Namibia",
|
||||
"NR": "Nauru",
|
||||
"NP": "Nepal",
|
||||
"NL": "Netherlands",
|
||||
"AN": "Netherlands Antilles",
|
||||
"NC": "New Caledonia",
|
||||
"NZ": "New Zealand",
|
||||
"NI": "Nicaragua",
|
||||
"NE": "Niger",
|
||||
"NG": "Nigeria",
|
||||
"NU": "Niue",
|
||||
"NF": "Norfolk Island",
|
||||
"MP": "Northern Mariana Islands",
|
||||
"NO": "Norway",
|
||||
"OM": "Oman",
|
||||
"PK": "Pakistan",
|
||||
"PW": "Palau",
|
||||
"PS": "Palestinian Territory, Occupied",
|
||||
"PA": "Panama",
|
||||
"PG": "Papua New Guinea",
|
||||
"PY": "Paraguay",
|
||||
"PE": "Peru",
|
||||
"PH": "Philippines",
|
||||
"PN": "Pitcairn",
|
||||
"PL": "Poland",
|
||||
"PT": "Portugal",
|
||||
"PR": "Puerto Rico",
|
||||
"QA": "Qatar",
|
||||
"RE": "Reunion",
|
||||
"RO": "Romania",
|
||||
"RU": "Russian Federation",
|
||||
"RW": "RWANDA",
|
||||
"SH": "Saint Helena",
|
||||
"KN": "Saint Kitts and Nevis",
|
||||
"LC": "Saint Lucia",
|
||||
"PM": "Saint Pierre and Miquelon",
|
||||
"VC": "Saint Vincent and the Grenadines",
|
||||
"WS": "Samoa",
|
||||
"SM": "San Marino",
|
||||
"ST": "Sao Tome and Principe",
|
||||
"SA": "Saudi Arabia",
|
||||
"SN": "Senegal",
|
||||
"CS": "Serbia and Montenegro",
|
||||
"SC": "Seychelles",
|
||||
"SL": "Sierra Leone",
|
||||
"SG": "Singapore",
|
||||
"SK": "Slovakia",
|
||||
"SI": "Slovenia",
|
||||
"SB": "Solomon Islands",
|
||||
"SO": "Somalia",
|
||||
"ZA": "South Africa",
|
||||
"GS": "South Georgia and the South Sandwich Islands",
|
||||
"ES": "Spain",
|
||||
"LK": "Sri Lanka",
|
||||
"SD": "Sudan",
|
||||
"SR": "Suriname",
|
||||
"SJ": "Svalbard and Jan Mayen",
|
||||
"SZ": "Swaziland",
|
||||
"SE": "Sweden",
|
||||
"CH": "Switzerland",
|
||||
"SY": "Syrian Arab Republic",
|
||||
"TW": "Taiwan",
|
||||
"TJ": "Tajikistan",
|
||||
"TZ": "Tanzania, United Republic of",
|
||||
"TH": "Thailand",
|
||||
"TL": "Timor-Leste",
|
||||
"TG": "Togo",
|
||||
"TK": "Tokelau",
|
||||
"TO": "Tonga",
|
||||
"TT": "Trinidad and Tobago",
|
||||
"TN": "Tunisia",
|
||||
"TR": "Turkey",
|
||||
"TM": "Turkmenistan",
|
||||
"TC": "Turks and Caicos Islands",
|
||||
"TV": "Tuvalu",
|
||||
"UG": "Uganda",
|
||||
"UA": "Ukraine",
|
||||
"AE": "United Arab Emirates",
|
||||
"GB": "United Kingdom",
|
||||
"US": "United States",
|
||||
"UM": "United States Minor Outlying Islands",
|
||||
"UY": "Uruguay",
|
||||
"UZ": "Uzbekistan",
|
||||
"VU": "Vanuatu",
|
||||
"VE": "Venezuela",
|
||||
"VN": "Viet Nam",
|
||||
"VG": "Virgin Islands, British",
|
||||
"VI": "Virgin Islands, U.S.",
|
||||
"WF": "Wallis and Futuna",
|
||||
"EH": "Western Sahara",
|
||||
"YE": "Yemen",
|
||||
"ZM": "Zambia",
|
||||
"ZW": "Zimbabwe"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
const http = require('http');
|
||||
import { get } from 'http';
|
||||
|
||||
module.exports = (req, res) => {
|
||||
export default (req, res) => {
|
||||
// 限制只能从指定域名访问
|
||||
const allowedDomains = ['localhost', ...(process.env.ALLOWED_DOMAINS || '').split(',')];
|
||||
const referer = req.headers.referer;
|
||||
@@ -23,7 +23,7 @@ module.exports = (req, res) => {
|
||||
// 构建请求 ip-api.com 的 URL
|
||||
const url = `http://ip-api.com/json/${ipAddress}`;
|
||||
|
||||
http.get(url, apiRes => {
|
||||
get(url, apiRes => {
|
||||
let data = '';
|
||||
apiRes.on('data', chunk => data += chunk);
|
||||
apiRes.on('end', () => {
|
||||
@@ -48,7 +48,7 @@ function modifyJsonForIPAPI(json) {
|
||||
ip: query,
|
||||
city,
|
||||
region: regionName,
|
||||
country,
|
||||
country: countryCode,
|
||||
country_name: country,
|
||||
country_code: countryCode,
|
||||
latitude: lat,
|
||||
|
||||
@@ -1,7 +1,257 @@
|
||||
const https = require('https');
|
||||
const countryCodes = require('./countrycode.json');
|
||||
import { get } from 'https';
|
||||
|
||||
module.exports = (req, res) => {
|
||||
// 国家代码
|
||||
async function loadCountryCodes() {
|
||||
const countryCodes = {
|
||||
"AF": "Afghanistan",
|
||||
"AX": "Åland Islands",
|
||||
"AL": "Albania",
|
||||
"DZ": "Algeria",
|
||||
"AS": "American Samoa",
|
||||
"AD": "AndorrA",
|
||||
"AO": "Angola",
|
||||
"AI": "Anguilla",
|
||||
"AQ": "Antarctica",
|
||||
"AG": "Antigua and Barbuda",
|
||||
"AR": "Argentina",
|
||||
"AM": "Armenia",
|
||||
"AW": "Aruba",
|
||||
"AU": "Australia",
|
||||
"AT": "Austria",
|
||||
"AZ": "Azerbaijan",
|
||||
"BS": "Bahamas",
|
||||
"BH": "Bahrain",
|
||||
"BD": "Bangladesh",
|
||||
"BB": "Barbados",
|
||||
"BY": "Belarus",
|
||||
"BE": "Belgium",
|
||||
"BZ": "Belize",
|
||||
"BJ": "Benin",
|
||||
"BM": "Bermuda",
|
||||
"BT": "Bhutan",
|
||||
"BO": "Bolivia",
|
||||
"BA": "Bosnia and Herzegovina",
|
||||
"BW": "Botswana",
|
||||
"BV": "Bouvet Island",
|
||||
"BR": "Brazil",
|
||||
"IO": "British Indian Ocean Territory",
|
||||
"BN": "Brunei Darussalam",
|
||||
"BG": "Bulgaria",
|
||||
"BF": "Burkina Faso",
|
||||
"BI": "Burundi",
|
||||
"KH": "Cambodia",
|
||||
"CM": "Cameroon",
|
||||
"CA": "Canada",
|
||||
"CV": "Cape Verde",
|
||||
"KY": "Cayman Islands",
|
||||
"CF": "Central African Republic",
|
||||
"TD": "Chad",
|
||||
"CL": "Chile",
|
||||
"CN": "China",
|
||||
"CX": "Christmas Island",
|
||||
"CC": "Cocos (Keeling) Islands",
|
||||
"CO": "Colombia",
|
||||
"KM": "Comoros",
|
||||
"CG": "Congo",
|
||||
"CD": "Congo, The Democratic Republic of the",
|
||||
"CK": "Cook Islands",
|
||||
"CR": "Costa Rica",
|
||||
"CI": "Cote D'Ivoire",
|
||||
"HR": "Croatia",
|
||||
"CU": "Cuba",
|
||||
"CY": "Cyprus",
|
||||
"CZ": "Czech Republic",
|
||||
"DK": "Denmark",
|
||||
"DJ": "Djibouti",
|
||||
"DM": "Dominica",
|
||||
"DO": "Dominican Republic",
|
||||
"EC": "Ecuador",
|
||||
"EG": "Egypt",
|
||||
"SV": "El Salvador",
|
||||
"GQ": "Equatorial Guinea",
|
||||
"ER": "Eritrea",
|
||||
"EE": "Estonia",
|
||||
"ET": "Ethiopia",
|
||||
"FK": "Falkland Islands (Malvinas)",
|
||||
"FO": "Faroe Islands",
|
||||
"FJ": "Fiji",
|
||||
"FI": "Finland",
|
||||
"FR": "France",
|
||||
"GF": "French Guiana",
|
||||
"PF": "French Polynesia",
|
||||
"TF": "French Southern Territories",
|
||||
"GA": "Gabon",
|
||||
"GM": "Gambia",
|
||||
"GE": "Georgia",
|
||||
"DE": "Germany",
|
||||
"GH": "Ghana",
|
||||
"GI": "Gibraltar",
|
||||
"GR": "Greece",
|
||||
"GL": "Greenland",
|
||||
"GD": "Grenada",
|
||||
"GP": "Guadeloupe",
|
||||
"GU": "Guam",
|
||||
"GT": "Guatemala",
|
||||
"GG": "Guernsey",
|
||||
"GN": "Guinea",
|
||||
"GW": "Guinea-Bissau",
|
||||
"GY": "Guyana",
|
||||
"HT": "Haiti",
|
||||
"HM": "Heard Island and Mcdonald Islands",
|
||||
"VA": "Holy See (Vatican City State)",
|
||||
"HN": "Honduras",
|
||||
"HK": "Hong Kong",
|
||||
"HU": "Hungary",
|
||||
"IS": "Iceland",
|
||||
"IN": "India",
|
||||
"ID": "Indonesia",
|
||||
"IR": "Iran, Islamic Republic Of",
|
||||
"IQ": "Iraq",
|
||||
"IE": "Ireland",
|
||||
"IM": "Isle of Man",
|
||||
"IL": "Israel",
|
||||
"IT": "Italy",
|
||||
"JM": "Jamaica",
|
||||
"JP": "Japan",
|
||||
"JE": "Jersey",
|
||||
"JO": "Jordan",
|
||||
"KZ": "Kazakhstan",
|
||||
"KE": "Kenya",
|
||||
"KI": "Kiribati",
|
||||
"KP": "Korea, Democratic People'S Republic of",
|
||||
"KR": "Korea, Republic of",
|
||||
"KW": "Kuwait",
|
||||
"KG": "Kyrgyzstan",
|
||||
"LA": "Lao People'S Democratic Republic",
|
||||
"LV": "Latvia",
|
||||
"LB": "Lebanon",
|
||||
"LS": "Lesotho",
|
||||
"LR": "Liberia",
|
||||
"LY": "Libyan Arab Jamahiriya",
|
||||
"LI": "Liechtenstein",
|
||||
"LT": "Lithuania",
|
||||
"LU": "Luxembourg",
|
||||
"MO": "Macao",
|
||||
"MK": "Macedonia, The Former Yugoslav Republic of",
|
||||
"MG": "Madagascar",
|
||||
"MW": "Malawi",
|
||||
"MY": "Malaysia",
|
||||
"MV": "Maldives",
|
||||
"ML": "Mali",
|
||||
"MT": "Malta",
|
||||
"MH": "Marshall Islands",
|
||||
"MQ": "Martinique",
|
||||
"MR": "Mauritania",
|
||||
"MU": "Mauritius",
|
||||
"YT": "Mayotte",
|
||||
"MX": "Mexico",
|
||||
"FM": "Micronesia, Federated States of",
|
||||
"MD": "Moldova, Republic of",
|
||||
"MC": "Monaco",
|
||||
"MN": "Mongolia",
|
||||
"MS": "Montserrat",
|
||||
"MA": "Morocco",
|
||||
"MZ": "Mozambique",
|
||||
"MM": "Myanmar",
|
||||
"NA": "Namibia",
|
||||
"NR": "Nauru",
|
||||
"NP": "Nepal",
|
||||
"NL": "Netherlands",
|
||||
"AN": "Netherlands Antilles",
|
||||
"NC": "New Caledonia",
|
||||
"NZ": "New Zealand",
|
||||
"NI": "Nicaragua",
|
||||
"NE": "Niger",
|
||||
"NG": "Nigeria",
|
||||
"NU": "Niue",
|
||||
"NF": "Norfolk Island",
|
||||
"MP": "Northern Mariana Islands",
|
||||
"NO": "Norway",
|
||||
"OM": "Oman",
|
||||
"PK": "Pakistan",
|
||||
"PW": "Palau",
|
||||
"PS": "Palestinian Territory, Occupied",
|
||||
"PA": "Panama",
|
||||
"PG": "Papua New Guinea",
|
||||
"PY": "Paraguay",
|
||||
"PE": "Peru",
|
||||
"PH": "Philippines",
|
||||
"PN": "Pitcairn",
|
||||
"PL": "Poland",
|
||||
"PT": "Portugal",
|
||||
"PR": "Puerto Rico",
|
||||
"QA": "Qatar",
|
||||
"RE": "Reunion",
|
||||
"RO": "Romania",
|
||||
"RU": "Russian Federation",
|
||||
"RW": "RWANDA",
|
||||
"SH": "Saint Helena",
|
||||
"KN": "Saint Kitts and Nevis",
|
||||
"LC": "Saint Lucia",
|
||||
"PM": "Saint Pierre and Miquelon",
|
||||
"VC": "Saint Vincent and the Grenadines",
|
||||
"WS": "Samoa",
|
||||
"SM": "San Marino",
|
||||
"ST": "Sao Tome and Principe",
|
||||
"SA": "Saudi Arabia",
|
||||
"SN": "Senegal",
|
||||
"CS": "Serbia and Montenegro",
|
||||
"SC": "Seychelles",
|
||||
"SL": "Sierra Leone",
|
||||
"SG": "Singapore",
|
||||
"SK": "Slovakia",
|
||||
"SI": "Slovenia",
|
||||
"SB": "Solomon Islands",
|
||||
"SO": "Somalia",
|
||||
"ZA": "South Africa",
|
||||
"GS": "South Georgia and the South Sandwich Islands",
|
||||
"ES": "Spain",
|
||||
"LK": "Sri Lanka",
|
||||
"SD": "Sudan",
|
||||
"SR": "Suriname",
|
||||
"SJ": "Svalbard and Jan Mayen",
|
||||
"SZ": "Swaziland",
|
||||
"SE": "Sweden",
|
||||
"CH": "Switzerland",
|
||||
"SY": "Syrian Arab Republic",
|
||||
"TW": "Taiwan",
|
||||
"TJ": "Tajikistan",
|
||||
"TZ": "Tanzania, United Republic of",
|
||||
"TH": "Thailand",
|
||||
"TL": "Timor-Leste",
|
||||
"TG": "Togo",
|
||||
"TK": "Tokelau",
|
||||
"TO": "Tonga",
|
||||
"TT": "Trinidad and Tobago",
|
||||
"TN": "Tunisia",
|
||||
"TR": "Turkey",
|
||||
"TM": "Turkmenistan",
|
||||
"TC": "Turks and Caicos Islands",
|
||||
"TV": "Tuvalu",
|
||||
"UG": "Uganda",
|
||||
"UA": "Ukraine",
|
||||
"AE": "United Arab Emirates",
|
||||
"GB": "United Kingdom",
|
||||
"US": "United States",
|
||||
"UM": "United States Minor Outlying Islands",
|
||||
"UY": "Uruguay",
|
||||
"UZ": "Uzbekistan",
|
||||
"VU": "Vanuatu",
|
||||
"VE": "Venezuela",
|
||||
"VN": "Viet Nam",
|
||||
"VG": "Virgin Islands, British",
|
||||
"VI": "Virgin Islands, U.S.",
|
||||
"WF": "Wallis and Futuna",
|
||||
"EH": "Western Sahara",
|
||||
"YE": "Yemen",
|
||||
"ZM": "Zambia",
|
||||
"ZW": "Zimbabwe"
|
||||
};
|
||||
|
||||
return countryCodes;
|
||||
}
|
||||
|
||||
export default async (req, res) => {
|
||||
// 限制只能从指定域名访问
|
||||
const allowedDomains = ['localhost', ...(process.env.ALLOWED_DOMAINS || '').split(',')];
|
||||
const referer = req.headers.referer;
|
||||
@@ -26,13 +276,17 @@ module.exports = (req, res) => {
|
||||
const token = tokens[Math.floor(Math.random() * tokens.length)];
|
||||
const url = `https://ipinfo.io/${ipAddress}?token=${token}`;
|
||||
|
||||
https.get(url, apiRes => {
|
||||
get(url, apiRes => {
|
||||
let data = '';
|
||||
apiRes.on('data', chunk => data += chunk);
|
||||
apiRes.on('end', () => {
|
||||
apiRes.on('end', async () => {
|
||||
try {
|
||||
const originalJson = JSON.parse(data);
|
||||
const modifiedJson = modifyJson(originalJson);
|
||||
|
||||
// 动态导入国家代码
|
||||
const countryCodes = await loadCountryCodes();
|
||||
const modifiedJson = modifyJson(originalJson, countryCodes);
|
||||
|
||||
res.json(modifiedJson);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Error parsing JSON' });
|
||||
@@ -43,7 +297,7 @@ module.exports = (req, res) => {
|
||||
});
|
||||
};
|
||||
|
||||
function modifyJson(json) {
|
||||
function modifyJson(json, countryCodes) {
|
||||
const { ip, city, region, country, loc, org, timezone } = json;
|
||||
|
||||
const countryName = countryCodes[country] || 'Unknown Country';
|
||||
@@ -65,4 +319,3 @@ function modifyJson(json) {
|
||||
org: modifiedOrg
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const https = require('https');
|
||||
import { get } from 'https';
|
||||
|
||||
module.exports = (req, res) => {
|
||||
export default (req, res) => {
|
||||
// 限制只能从指定域名访问
|
||||
const allowedDomains = ['localhost', ...(process.env.ALLOWED_DOMAINS || '').split(',')];
|
||||
|
||||
@@ -33,7 +33,7 @@ module.exports = (req, res) => {
|
||||
const url = `https://dev.virtualearth.net/REST/v1/Imagery/Map/${CanvasMode}/${latitude},${longitude}/5?mapSize=${mapSize}&pp=${pp}&key=${apiKey}&fmt=${fmt}&dpi=${dpi}&c=${language}`;
|
||||
|
||||
|
||||
https.get(url, apiRes => {
|
||||
get(url, apiRes => {
|
||||
apiRes.pipe(res);
|
||||
}).on('error', (e) => {
|
||||
res.status(500).json({ error: e.message });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module.exports = (req, res) => {
|
||||
export default (req, res) => {
|
||||
if (process.env.BING_MAP_API_KEY) {
|
||||
res.status(200).json({ isValid: true });
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module.exports = (req, res) => {
|
||||
export default (req, res) => {
|
||||
const isIpCheckEnabled = process.env.IS_IPCHECK_ING === 'ipcheck.ing';
|
||||
res.status(200).json({ isIpCheckEnabled });
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="keywords" content="MyIP,IP Tool,My IP,IP check,IP lookup,DNS leak test,WebRTC test,Speed test">
|
||||
<meta name="description"
|
||||
content="A better IP Toolbox. Easy to check what's your IPs, IP informations, check for DNS leaks, examine WebRTC connections, and test website availability">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="theme-color" content="#f8f9fa">
|
||||
<meta name="background-color" content="#ffffff">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<link rel="apple-touch-icon" href="/logo-512.png">
|
||||
<title>Check My IP Address and Geolocation | Check WebRTC Connection IP | DNS Leak Test | Speed Test | Jason Ng Open
|
||||
Source </title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<link rel="icon" href="logo.svg">
|
||||
|
||||
|
||||
<style>
|
||||
.jn-spinner {
|
||||
border: 4px solid rgb(12, 144, 34);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border-left-color: transparent;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.jn-loading {
|
||||
margin-top: 30vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.jn-spinner-text {
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.jn-spinner-text p {
|
||||
font-size: 14px;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="jn-loading" class="jn-loading">
|
||||
<div class="jn-spinner "></div>
|
||||
<div class="jn-spinner-text">
|
||||
<p>首次加载的时间可能会较长,请等待...</p>
|
||||
<p>It may take a while for the first time, please wait...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('service-worker.js').then(function (registration) {
|
||||
}, function (err) {
|
||||
console.log('ServiceWorker Error: ', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,24 +1,33 @@
|
||||
{
|
||||
"name": "myip",
|
||||
"version": "2.1.0",
|
||||
"description": "A better IP Toolbox",
|
||||
"main": "server.js",
|
||||
"private": true,
|
||||
"version": "3.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"dev": "concurrently \"vite\" \"npm run start-server\"",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start-server": "node server.js",
|
||||
"start-static": "node static-server.js",
|
||||
"start": "concurrently \"npm run start-static\" \"npm run start-server\""
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/jason5ng32/MyIP.git"
|
||||
},
|
||||
"author": "Jason Ng",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/jason5ng32/MyIP/issues"
|
||||
},
|
||||
"homepage": "https://github.com/jason5ng32/MyIP#readme",
|
||||
"dependencies": {
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2"
|
||||
"@analytics/google-analytics": "^1.0.7",
|
||||
"@cloudflare/speedtest": "^1.3.0",
|
||||
"analytics": "^0.8.9",
|
||||
"bootstrap": "^5.3.2",
|
||||
"bootstrap-icons": "^1.11.3",
|
||||
"concurrently": "^8.2.2",
|
||||
"dotenv": "^16.4.1",
|
||||
"express": "^4.18.2",
|
||||
"flag-icons": "^7.1.0",
|
||||
"http-proxy-middleware": "^2.0.6",
|
||||
"vue": "^3.3.11",
|
||||
"vue-i18n": "^9.9.0",
|
||||
"vuex": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.5.2",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 945 B After Width: | Height: | Size: 945 B |
@@ -1,58 +0,0 @@
|
||||
export default [
|
||||
{
|
||||
id: "netease",
|
||||
name: "Netease",
|
||||
icon: "globe-americas",
|
||||
url: "https://www.163.com/favicon.ico?",
|
||||
status: "",
|
||||
},
|
||||
{
|
||||
id: "baidu",
|
||||
name: "Baidu",
|
||||
icon: "globe-americas",
|
||||
url: "https://www.baidu.com/favicon.ico?",
|
||||
status: "",
|
||||
},
|
||||
{
|
||||
id: "wechat",
|
||||
name: "WeChat",
|
||||
icon: "wechat",
|
||||
url: "https://res.wx.qq.com/a/wx_fed/assets/res/NTI4MWU5.ico?",
|
||||
status: "",
|
||||
},
|
||||
{
|
||||
id: "google",
|
||||
name: "Google",
|
||||
icon: "google",
|
||||
url: "https://www.google.com/images/errors/robot.png?",
|
||||
status: "",
|
||||
},
|
||||
{
|
||||
id: "cloudflare",
|
||||
name: "Cloudflare",
|
||||
icon: "cloud-fill",
|
||||
url: "https://www.cloudflare.com/favicon.ico?",
|
||||
status: "",
|
||||
},
|
||||
{
|
||||
id: "youtube",
|
||||
name: "Youtube",
|
||||
icon: "youtube",
|
||||
url: "https://i.ytimg.com/vi/GYkq9Rgoj8E/hq720.jpg?",
|
||||
status: "",
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
name: "Github",
|
||||
icon: "github",
|
||||
url: "https://raw.githubusercontent.com/jason5ng32/fulian4/master/background.jpg?",
|
||||
status: "",
|
||||
},
|
||||
{
|
||||
id: "chatgpt",
|
||||
name: "ChatGPT",
|
||||
icon: "chat-quote-fill",
|
||||
url: "https://chat.openai.com/favicon.ico?",
|
||||
status: "",
|
||||
},
|
||||
];
|
||||
@@ -1,98 +0,0 @@
|
||||
export default [
|
||||
{
|
||||
id: "taobao",
|
||||
ip: "",
|
||||
country_name: "",
|
||||
region: "",
|
||||
city: "",
|
||||
latitude: "",
|
||||
longitude: "",
|
||||
isp: "",
|
||||
asn: "",
|
||||
asnlink: "",
|
||||
mapUrl: "res/img/defaultMap.jpg",
|
||||
mapUrl_dark: "res/img/defaultMap_dark.jpg",
|
||||
showMap: false,
|
||||
source: "Taobao",
|
||||
},
|
||||
{
|
||||
id: "special",
|
||||
ip: "",
|
||||
country_name: "",
|
||||
region: "",
|
||||
city: "",
|
||||
latitude: "",
|
||||
longitude: "",
|
||||
isp: "",
|
||||
asn: "",
|
||||
asnlink: "",
|
||||
mapUrl: "res/img/defaultMap.jpg",
|
||||
mapUrl_dark: "res/img/defaultMap_dark.jpg",
|
||||
showMap: false,
|
||||
source: "Special",
|
||||
},
|
||||
{
|
||||
id: "cloudflare_v4",
|
||||
ip: "",
|
||||
country_name: "",
|
||||
region: "",
|
||||
city: "",
|
||||
latitude: "",
|
||||
longitude: "",
|
||||
isp: "",
|
||||
asn: "",
|
||||
asnlink: "",
|
||||
mapUrl: "res/img/defaultMap.jpg",
|
||||
mapUrl_dark: "res/img/defaultMap_dark.jpg",
|
||||
showMap: false,
|
||||
source: "Cloudflare IPv4",
|
||||
},
|
||||
{
|
||||
id: "cloudflare_v6",
|
||||
ip: "",
|
||||
country_name: "",
|
||||
region: "",
|
||||
city: "",
|
||||
latitude: "",
|
||||
longitude: "",
|
||||
isp: "",
|
||||
asn: "",
|
||||
asnlink: "",
|
||||
mapUrl: "res/img/defaultMap.jpg",
|
||||
mapUrl_dark: "res/img/defaultMap_dark.jpg",
|
||||
showMap: false,
|
||||
source: "Cloudflare IPv6",
|
||||
},
|
||||
{
|
||||
id: "ipify_v4",
|
||||
ip: "",
|
||||
country_name: "",
|
||||
region: "",
|
||||
city: "",
|
||||
latitude: "",
|
||||
longitude: "",
|
||||
isp: "",
|
||||
asn: "",
|
||||
asnlink: "",
|
||||
mapUrl: "res/img/defaultMap.jpg",
|
||||
mapUrl_dark: "res/img/defaultMap_dark.jpg",
|
||||
showMap: false,
|
||||
source: "IPify IPv4",
|
||||
},
|
||||
{
|
||||
id: "ipify_v6",
|
||||
ip: "",
|
||||
country_name: "",
|
||||
region: "",
|
||||
city: "",
|
||||
latitude: "",
|
||||
longitude: "",
|
||||
isp: "",
|
||||
asn: "",
|
||||
asnlink: "",
|
||||
mapUrl: "res/img/defaultMap.jpg",
|
||||
mapUrl_dark: "res/img/defaultMap_dark.jpg",
|
||||
showMap: false,
|
||||
source: "IPify IPv6",
|
||||
},
|
||||
];
|
||||
@@ -1,157 +0,0 @@
|
||||
export default {
|
||||
nav: {
|
||||
id: "nav",
|
||||
Title: "IP 工具箱",
|
||||
IPinfo: "IP 信息",
|
||||
Connectivity: "网络连通性",
|
||||
WebRTC: "WebRTC 测试",
|
||||
DNSLeakTest: "DNS 泄漏测试",
|
||||
SpeedTest: "网速测试",
|
||||
PingTest: "全球测速",
|
||||
},
|
||||
ipInfos: {
|
||||
id: "ipinfos",
|
||||
Title: "IP 信息",
|
||||
Notes:
|
||||
"将会从 6 个来源检查 IP 数据,如果当前 IP 栈只有 1 个,则没有数据的来源会显示为空。",
|
||||
Simple: "简约",
|
||||
Map: "地图",
|
||||
MapUnavailable: "地图不可用",
|
||||
Source: "来源",
|
||||
IP: "IP",
|
||||
Country: "国家",
|
||||
Region: "地区",
|
||||
City: "城市",
|
||||
ISP: "ISP",
|
||||
ASN: "ASN",
|
||||
IPv4Error: "获取失败或不存在 IPv4 地址",
|
||||
IPv6Error: "获取失败或不存在 IPv6 地址",
|
||||
},
|
||||
connectivity: {
|
||||
id: "connectivity",
|
||||
Title: "网络连通性",
|
||||
Note: "通过加载对应网站上的小图片进行测试,延迟值仅供参考,实际会更小。",
|
||||
StatusWait: "待检测",
|
||||
StatusAvailable: "可用",
|
||||
StatusUnavailable: "不可用",
|
||||
StatusTimeout: "超时或不可用",
|
||||
checking: "检查中...",
|
||||
},
|
||||
webrtc: {
|
||||
id: "webrtc",
|
||||
Title: "WebRTC 测试",
|
||||
Note: "WebRTC 往往通过 UDP 直连进行建立,如果测试返回了真实 IP,则意味着你的代理设置没有覆盖这些连接。",
|
||||
StatusWait: "待检测或连接错误",
|
||||
StatusError: "测试出错",
|
||||
},
|
||||
dnsleaktest: {
|
||||
id: "dnsleaktest",
|
||||
Name: "测试",
|
||||
Title: "DNS 泄漏测试",
|
||||
Note: "DNS 泄露(DNS Leaks)的意思是,当你挂上 VPN/代理后,你解析域名时,依然通过当地的运营商进行解析,这时就有 DNS 泄露的风险。",
|
||||
Note2:
|
||||
"泄露测试的方法是通过访问新生成的域名,检测你是通过哪个地区的 DNS 出口进行解析,如果返回的出口区域和你当地的运营商区域相同,则有 DNS 泄露风险,你可能需要修改 VPN/代理设置。",
|
||||
Endpoint: "DNS 出口",
|
||||
EndpointCountry: "出口地区",
|
||||
StatusWait: "待检测",
|
||||
StatusError: "测试出错",
|
||||
},
|
||||
speedtest: {
|
||||
id: "speedtest",
|
||||
Title: "网速测试",
|
||||
Note: "将从 Cloudflare 使用边沿网络进行速度测试,时间可能较长。如果你的网络位于中国大陆,数据可能会有较大的偏差。测速结果仅供参考,实际速度会更快。",
|
||||
Download: "下载",
|
||||
Upload: "上传",
|
||||
Latency: "延迟",
|
||||
Jitter: "抖动",
|
||||
Unit: "Mbps",
|
||||
StatusWait: "--",
|
||||
StatusError: "测试出错",
|
||||
videoStreaming: "在线视频:",
|
||||
gaming: " 分,在线游戏:",
|
||||
rtc: " 分,视频会议:",
|
||||
score: "根据网速测试,得出以下质量分数:",
|
||||
resultNote: " 分。分数仅供参考。",
|
||||
},
|
||||
pingtest: {
|
||||
id: "pingtest",
|
||||
Title: "全球延迟测试",
|
||||
Note: "将从分布在全球不同大洲和地区的服务器,分别对你的 IP 进行 Ping 测试,并获取延迟值。从而了解你与全球网络的连接速度。",
|
||||
Note2: "目前仅支持 IPv4 地址,请选取你的 IP 地址进行测试",
|
||||
SelectIP: "选择一个 IP 地址",
|
||||
Region: "地区",
|
||||
MinDelay: "最小延迟(ms)",
|
||||
MaxDelay: "最大延迟(ms)",
|
||||
AvgDelay: "平均延迟(ms)",
|
||||
TotalPackets: "总包数",
|
||||
PacketLoss: "丢包率",
|
||||
ReceivedPackets: "收到",
|
||||
DroppedPackets: "丢失",
|
||||
Run: "运行测试",
|
||||
Error: "测试失败,看起来你的 IP 不允许进行 Ping 测试。",
|
||||
},
|
||||
ipcheck: {
|
||||
id: "ipcheck",
|
||||
Title: "IP 查询",
|
||||
Placeholder: "请输入 IP 地址",
|
||||
Button: "查询",
|
||||
Error: "请输入有效的 IPv4 或 IPv6 地址。",
|
||||
},
|
||||
alert: {
|
||||
id: "alert",
|
||||
refreshEverythingMessage: "正在刷新所有数据,请等待...",
|
||||
refreshEverythingTitle: "正在刷新",
|
||||
OhNo: "糟糕!",
|
||||
Congrats: "恭喜呀!",
|
||||
OhNo_Message: "连通性检测没有通过,部分网站无法访问。",
|
||||
Congrats_Message: "所有的连通性检测均通过,你可以访问所有网站。",
|
||||
maskedInfoTitle_1: "IP 隐藏成功",
|
||||
maskedInfoMessage_1: "IP 信息已隐藏,截图时请注意隐私。",
|
||||
maskedInfoTitle: "全部隐藏成功",
|
||||
maskedInfoMessage: "所有信息已隐藏,现在可以安心截图了。",
|
||||
unmaskedInfoTitle: "取消隐藏",
|
||||
unmaskedInfoMessage: "信息已显示,截图时请注意隐私。",
|
||||
},
|
||||
shortcutKeys: {
|
||||
id: "shortcutKeys",
|
||||
GoToTop: "回到顶部",
|
||||
GoToBottom: "回到底部",
|
||||
ToggleDarkMode: "切换深色模式",
|
||||
RefreshEverything: "刷新所有数据",
|
||||
RefreshIPCard: "刷新某张 IP 卡片",
|
||||
RefreshConnectivityTests: "刷新连通性测试",
|
||||
RefreshWebRTC: "刷新 WebRTC 测试",
|
||||
RefreshDNSLeakTest: "刷新 DNS 泄漏测试",
|
||||
StartSpeedTest: "开始网速测试",
|
||||
ToggleMaps: "切换地图显示",
|
||||
IPCheck: "IP 查询",
|
||||
AddBingMapKey: "添加 Bing Map API Key",
|
||||
ToggleInfoMask: "切换信息隐藏",
|
||||
Help: "显示快捷键",
|
||||
HelpNote: "使用快捷键的人,是喜欢效率的人,你真棒!",
|
||||
GoNext: "下一张卡片",
|
||||
GoPrevious: "上一张卡片",
|
||||
},
|
||||
page: {
|
||||
title:
|
||||
"查看我的 IP 地址 | 查询本机 IP 地址及归属地 | 查看 WebRTC 连接 IP | DNS 泄露检测 | 网速测试 | Jason Ng 阿禅开源作品",
|
||||
description:"一个更好的 IP 工具箱。轻松查看你的 IP 地址,IP 信息,检查 DNS 泄露,检查 WebRTC 连接,以及测试网站可用性",
|
||||
keywords:"我的IP,IP工具,我的IP,IP查询,DNS泄漏测试,WebRTC测试,网速测试",
|
||||
footerName: "Creators: Jason Ng, Seven Yu & ChatGPT",
|
||||
footerLink: "https://github.com/jason5ng32/MyIP",
|
||||
copyRightName: "Originally from ",
|
||||
copyRightLink: "https://ipcheck.ing",
|
||||
copyRightLinkName: "IPCheck.ing",
|
||||
},
|
||||
addBingMapKey: {
|
||||
Title: "添加 Bing Map API Key",
|
||||
Note: "添加后,可以在 IP 信息中显示地图。API Key 只会在当前浏览器中保存,方便下次使用,不会上传到服务器。",
|
||||
Placeholder: "请输入 Bing Map API Key",
|
||||
Button: "保存",
|
||||
removeButton: "删除",
|
||||
Error: "请输入有效的 Bing Map API Key。",
|
||||
},
|
||||
helpModal: {
|
||||
Title: "快捷键",
|
||||
},
|
||||
};
|
||||
@@ -1,161 +0,0 @@
|
||||
export default {
|
||||
nav: {
|
||||
id: "nav",
|
||||
Title: "IP Toolbox",
|
||||
IPinfo: "IP Infos",
|
||||
Connectivity: "Connectivity",
|
||||
WebRTC: "WebRTC Test",
|
||||
DNSLeakTest: "DNS Leak Test",
|
||||
SpeedTest: "Speed Test",
|
||||
PingTest: "Global Latency",
|
||||
},
|
||||
ipInfos: {
|
||||
id: "ipinfos",
|
||||
Title: "IP Infos",
|
||||
Notes:
|
||||
"IP data will be checked from 6 sources. If there is only 1 IP stack currently, sources without data will show as empty.",
|
||||
Simple: "Simple",
|
||||
Map: "Map",
|
||||
MapUnavailable: "Map Unavailable",
|
||||
Source: "Source",
|
||||
IP: "IP",
|
||||
Country: "Country",
|
||||
Region: "Region",
|
||||
City: "City",
|
||||
ISP: "ISP",
|
||||
ASN: "ASN",
|
||||
IPv4Error: "Fetch Failed or No IPv4 Address",
|
||||
IPv6Error: "Fetch Failed or No IPv6 Address",
|
||||
},
|
||||
connectivity: {
|
||||
id: "connectivity",
|
||||
Title: "Network Connectivity",
|
||||
Note: "Testing is done by loading small images from corresponding websites. Delay values are for reference only and will be smaller in reality.",
|
||||
StatusWait: "Awaiting Test",
|
||||
StatusAvailable: "OK",
|
||||
StatusUnavailable: "Unavailable",
|
||||
StatusTimeout: "Timeout or Unavailable",
|
||||
checking: "Checking...",
|
||||
},
|
||||
webrtc: {
|
||||
id: "webrtc",
|
||||
Title: "WebRTC Test",
|
||||
Note: "WebRTC often establishes connections directly via UDP. If the test returns your real IP, it means your proxy settings do not cover these connections.",
|
||||
StatusWait: "Awaiting Test or Connection Error",
|
||||
StatusError: "Test Error",
|
||||
},
|
||||
dnsleaktest: {
|
||||
id: "dnsleaktest",
|
||||
Name: "Test",
|
||||
Title: "DNS Leak Test",
|
||||
Note: "A DNS leak means that when you are connected to a VPN/proxy, your domain name resolutions are still done through your local ISP, thus posing a risk of DNS leaks.",
|
||||
Note2:
|
||||
"The method to test for DNS leaks involves accessing a newly generated domain name to detect which regional DNS exit you are resolving through. If the endpoint region returned matches your local service provider's region, there is a risk of DNS leakage. In this case, you may need to modify your VPN/proxy settings.",
|
||||
Endpoint: "DNS Endpoint",
|
||||
EndpointCountry: "Endpoint Region",
|
||||
StatusWait: "Awaiting Test",
|
||||
StatusError: "Test Error",
|
||||
},
|
||||
speedtest: {
|
||||
id: "speedtest",
|
||||
Title: "Speed Test",
|
||||
Note: "Speed test will be done via Cloudflare's edge network, which may take a long time. If your network is located in mainland China, the data may be biased. The speed test results are for reference only, the actual speed will be faster.",
|
||||
Download: "Download",
|
||||
Upload: "Upload",
|
||||
Latency: "Latency",
|
||||
Jitter: "Jitter",
|
||||
Unit: "Mbps",
|
||||
StatusWait: "--",
|
||||
StatusError: "Test Error",
|
||||
videoStreaming: "Video Streaming: ",
|
||||
gaming: ", Gaming: ",
|
||||
rtc: ", Video Chatting: ",
|
||||
score: "Based on the speed test, got the following quality scores:",
|
||||
resultNote: ". The score is for reference only.",
|
||||
},
|
||||
pingtest: {
|
||||
id: "pingtest",
|
||||
Title: "Global Latency Test",
|
||||
Note: "Latency tests will be performed on servers located in different continents and regions around the world to measure the latency of your IP. This will help you understand your connection speed to the global network.",
|
||||
Note2: "Currently only supports IPv4 addresses, please select your IP address for testing",
|
||||
SelectIP: "Select an IP Address",
|
||||
Region: "Region",
|
||||
MinDelay: "Min Delay (ms)",
|
||||
MaxDelay: "Max Delay (ms)",
|
||||
AvgDelay: "Avg Delay (ms)",
|
||||
TotalPackets: "Total Packets",
|
||||
PacketLoss: "Packet Loss",
|
||||
ReceivedPackets: "Received",
|
||||
DroppedPackets: "Dropped",
|
||||
Run: "Run Test",
|
||||
Error: "Test failed, it looks like your IP is not allowed to perform Ping test.",
|
||||
},
|
||||
ipcheck: {
|
||||
id: "ipcheck",
|
||||
Title: "IP Check",
|
||||
Placeholder: "Please enter an IP address",
|
||||
Button: "Check",
|
||||
Error: "Please enter a valid IPv4 or IPv6 address.",
|
||||
},
|
||||
alert: {
|
||||
id: "alert",
|
||||
refreshEverythingMessage: "Refreshing all data, please wait...",
|
||||
refreshEverythingTitle: "Refreshing",
|
||||
OhNo: "Oh No!",
|
||||
Congrats: "Congrats!",
|
||||
OhNo_Message: "Connectivity test failed, some websites are not accessible.",
|
||||
Congrats_Message:
|
||||
"All connectivity tests passed, you can access all websites.",
|
||||
maskedInfoTitle_1: "IP masked",
|
||||
maskedInfoMessage_1:
|
||||
"IP information has been masked, please be careful when taking screenshots.",
|
||||
maskedInfoTitle: "All infos masked",
|
||||
maskedInfoMessage:
|
||||
"All information has been masked, you can now safely take screenshots.",
|
||||
unmaskedInfoTitle: "Infos Unmasked",
|
||||
unmaskedInfoMessage:
|
||||
"Information has been unmasked, please be careful when taking screenshots.",
|
||||
},
|
||||
shortcutKeys: {
|
||||
id: "shortcutKeys",
|
||||
GoToTop: "Go to Top",
|
||||
GoToBottom: "Go to Bottom",
|
||||
ToggleDarkMode: "Toggle Dark Mode",
|
||||
RefreshEverything: "Refresh Everything",
|
||||
RefreshIPCard: "Refresh IP Card",
|
||||
RefreshConnectivityTests: "Refresh Connectivity Tests",
|
||||
RefreshWebRTC: "Refresh WebRTC Test",
|
||||
RefreshDNSLeakTest: "Refresh DNS Leak Test",
|
||||
StartSpeedTest: "Start Speed Test",
|
||||
ToggleMaps: "Toggle Maps",
|
||||
IPCheck: "IP Check",
|
||||
AddBingMapKey: "Add Bing Map API Key",
|
||||
ToggleInfoMask: "Toggle Info Masking",
|
||||
Help: "Show Shortcut Keys",
|
||||
HelpNote: "People who use shortcuts are really awesome!",
|
||||
GoNext: "Next Card",
|
||||
GoPrevious: "Previous Card",
|
||||
},
|
||||
page: {
|
||||
title:
|
||||
"Check My IP Address and Geolocation | Check WebRTC Connection IP | DNS Leak Test | Speed Test | Jason Ng Open Source",
|
||||
description:"A better IP Toolbox. Easy to check what's your IPs, IP informations, check for DNS leaks, examine WebRTC connections, and test website availability",
|
||||
keywords:"MyIP,IP Tool,My IP,IP check,IP lookup,DNS leak test,WebRTC test,Speed test",
|
||||
footerName: "Creators: Jason Ng, Seven Yu & ChatGPT",
|
||||
footerLink: "https://github.com/jason5ng32/MyIP",
|
||||
copyRightName: "Originally from ",
|
||||
copyRightLink: "https://ipcheck.ing",
|
||||
copyRightLinkName: "IPCheck.ing",
|
||||
},
|
||||
addBingMapKey: {
|
||||
Title: "Add Bing Map API Key",
|
||||
Note: "After adding, the map can be displayed in the IP information. The API Key will only be saved in the current browser for the next use, and will not be uploaded to the server.",
|
||||
Placeholder: "Please enter Bing Map API Key",
|
||||
Button: "Save",
|
||||
removeButton: "Remove",
|
||||
Error: "Please enter a valid Bing Map API Key.",
|
||||
},
|
||||
helpModal: {
|
||||
Title: "Shortcuts",
|
||||
},
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
export default [
|
||||
{
|
||||
id: "ipapi1",
|
||||
name: "",
|
||||
geo: "",
|
||||
ip: "",
|
||||
},
|
||||
{
|
||||
id: "ipapi2",
|
||||
name: "",
|
||||
geo: "",
|
||||
ip: "",
|
||||
},
|
||||
{
|
||||
id: "sfshark1",
|
||||
name: "",
|
||||
geo: "",
|
||||
ip: "",
|
||||
},
|
||||
{
|
||||
id: "sfshark2",
|
||||
name: "",
|
||||
geo: "",
|
||||
ip: "",
|
||||
},
|
||||
];
|
||||
@@ -1,11 +0,0 @@
|
||||
export default
|
||||
{
|
||||
id: "speedTest",
|
||||
downloadSpeed: "-",
|
||||
uploadSpeed: "-",
|
||||
latency: "-",
|
||||
jitter: "-",
|
||||
streamingScore: "-",
|
||||
gamingScore: "-",
|
||||
rtcScore: "-",
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
export default [
|
||||
{
|
||||
id: "google",
|
||||
name: "Google 1",
|
||||
url: "stun:stun.l.google.com:19302",
|
||||
ip: "",
|
||||
},
|
||||
{
|
||||
id: "google",
|
||||
name: "Google 2",
|
||||
url: "stun:stun2.l.google.com:19302",
|
||||
ip: "",
|
||||
},
|
||||
{
|
||||
id: "nextcloud",
|
||||
name: "NxtCld",
|
||||
url: "stun:stun.nextcloud.com:443",
|
||||
ip: "",
|
||||
},
|
||||
{
|
||||
id: "twilio",
|
||||
name: "Twilio",
|
||||
url: "stun:global.stun.twilio.com",
|
||||
ip: "",
|
||||
},
|
||||
{
|
||||
id: "cloudflare",
|
||||
name: "Cloudflare",
|
||||
url: "stun:stun.cloudflare.com",
|
||||
ip: "",
|
||||
},
|
||||
{
|
||||
id: "miwifi",
|
||||
name: "MiWiFi",
|
||||
url: "stun:stun.miwifi.com",
|
||||
ip: "",
|
||||
},
|
||||
{
|
||||
id: "qq",
|
||||
name: "QQ",
|
||||
url: "stun:stun.qq.com",
|
||||
ip: "",
|
||||
},
|
||||
{
|
||||
id: "stunprotocol",
|
||||
name: "StnPtc",
|
||||
url: "stun:stunserver.stunprotocol.org",
|
||||
ip: "",
|
||||
},
|
||||
];
|
||||
|
Before Width: | Height: | Size: 242 KiB After Width: | Height: | Size: 242 KiB |
|
Before Width: | Height: | Size: 248 KiB After Width: | Height: | Size: 248 KiB |
@@ -1,577 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="keywords" content="MyIP,IP Tool,My IP,IP check,IP lookup,DNS leak test,WebRTC test,Speed test">
|
||||
<meta name="description"
|
||||
content="A better IP Toolbox. Easy to check what's your IPs, IP informations, check for DNS leaks, examine WebRTC connections, and test website availability">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="theme-color" content="#f8f9fa">
|
||||
<meta name="background-color" content="#ffffff">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<link rel="apple-touch-icon" href="res/img/logo-512.png">
|
||||
<title>Check My IP Address and Geolocation | Check WebRTC Connection IP | DNS Leak Test | Speed Test | Jason Ng Open
|
||||
Source </title>
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/css/bootstrap.min.css" rel="stylesheet"
|
||||
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
|
||||
<link rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-icons/1.11.2/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/flag-icon-css/3.5.0/css/flag-icon.min.css">
|
||||
<link rel="icon" href="favicon.ico">
|
||||
<link rel="stylesheet" href="res/css/style.css">
|
||||
<link rel="manifest" href="manifest.json">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<!-- Loading -->
|
||||
<div id="loading" class="d-flex justify-content-center jn-loading">
|
||||
<div class="spinner-border text-success" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="app" v-cloak>
|
||||
<!-- Nav -->
|
||||
<nav id="navbar-top" class="navbar navbar-expand-lg bg-body-tertiary px-3 mb-3 jn-navbar-top"
|
||||
:class="{ 'dark-mode-nav navbar-dark bg-dark': isDarkMode }">
|
||||
<div class="jn-logo">
|
||||
<a class="navbar-brand" :class="{ 'text-white': isDarkMode }" href="#" @click="handleLogoClick"><i
|
||||
class="bi bi-box-seam-fill"></i>
|
||||
{{currentTexts.nav.Title}}</a>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="darkModeSwitch" @change="toggleDarkMode"
|
||||
:checked="isDarkMode">
|
||||
<label class="form-check-label" for="darkModeSwitch">
|
||||
<i v-if="isDarkMode" class="bi bi-moon-stars"></i>
|
||||
<i v-else class="bi bi-brightness-high"></i>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNavAltMarkup"
|
||||
aria-controls="navbarNavAltMarkup" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon bg-transparent "></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse justify-content-end" id="navbarNavAltMarkup">
|
||||
<div class="navbar-nav ">
|
||||
<a class="nav-link" :class="{ 'text-white jn-deactive': isDarkMode }"
|
||||
href="#IPInfo">{{currentTexts.nav.IPinfo}}</a>
|
||||
<a class="nav-link" :class="{ 'text-white jn-deactive': isDarkMode }"
|
||||
href="#Connectivity">{{currentTexts.nav.Connectivity}}</a>
|
||||
<a class="nav-link" :class="{ 'text-white jn-deactive': isDarkMode }"
|
||||
href="#WebRTC">{{currentTexts.nav.WebRTC}}</a>
|
||||
<a class="nav-link" :class="{ 'text-white jn-deactive': isDarkMode }"
|
||||
href="#DNSLeakTest">{{currentTexts.nav.DNSLeakTest}}</a>
|
||||
<a class="nav-link" :class="{ 'text-white jn-deactive': isDarkMode }"
|
||||
href="#SpeedTest">{{currentTexts.nav.SpeedTest}}</a>
|
||||
<a class="nav-link" :class="{ 'text-white jn-deactive': isDarkMode }"
|
||||
href="#PingTest">{{currentTexts.nav.PingTest}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- Proxy Alerts -->
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3 jn-toast">
|
||||
<div id="toast" class="toast" :class="{ 'dark-mode': isDarkMode }" role="alert" ref="toast" aria-live="assertive"
|
||||
aria-atomic="true">
|
||||
<div class="toast-header" :class="{ 'dark-mode-title': isDarkMode }">
|
||||
<strong class="me-auto" :class="alertStyle">{{ alertTitle }}</strong>
|
||||
<button type="button" class="btn-close" :class="{ 'dark-mode-close-button': isDarkMode }"
|
||||
data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
{{ alertMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="mainpart" class="container mt-5 jn-container">
|
||||
<div data-bs-spy="scroll" data-bs-target="#navbar-top" data-bs-root-margin="0px 0px -40%"
|
||||
data-bs-smooth-scroll="true" class="rounded-2" tabindex="0">
|
||||
<!-- IP Infos -->
|
||||
<div class="ip-data-section mb-4">
|
||||
<div class="row" :class="{'jn-title2': !isMobile,'jn-title':isMobile}">
|
||||
<h2 id="IPInfo" class="col-4" :class="{'mobile-h2' : isMobile }">🕵️
|
||||
{{currentTexts.ipInfos.Title}}</h2>
|
||||
<div class="form-check form-switch col-8 jn-radio">
|
||||
|
||||
<div :class="{'col-4': isMobile }">
|
||||
<input v-if="isMobile" class="form-check-input" type="checkbox" id="collapseSwitch"
|
||||
@change="toggleCollapse" :checked="!isCardsCollapsed">
|
||||
<label v-if="isMobile" class="form-check-label" for="collapseSwitch"> <i
|
||||
class="bi bi-list-columns-reverse"></i></label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="flexSwitchCheckDefault"
|
||||
@change="toggleMaps" :checked="isMapShown" :disabled="!isEnvBingMapKey">
|
||||
|
||||
<label class="form-check-label" for="flexSwitchCheckDefault">
|
||||
<i :class="['bi', isEnvBingMapKey ? 'bi bi-map-fill':'bi bi-map']"></i>
|
||||
</label>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-secondary">
|
||||
<p>{{currentTexts.ipInfos.Notes}}</p>
|
||||
</div>
|
||||
<div class="jn-card-deck">
|
||||
<div class="row">
|
||||
<div v-for="card in ipDataCards" :key="card.id" :ref="card.id"
|
||||
:class="{'jn-opacity': !card.asn, 'col-xl-4': true, 'col-lg-6': true, 'col-md-6': true, 'mb-4': true}">
|
||||
<div class="card jn-card" :class="{ 'dark-mode dark-mode-border': isDarkMode }">
|
||||
<div class="card-header jn-ip-title" :class="{ 'dark-mode-title': isDarkMode }"
|
||||
style="font-weight: bold;">
|
||||
<span>{{currentTexts.ipInfos.Source}}: {{ card.source }}</span>
|
||||
<button @click="refreshCard(card)"
|
||||
:class="['btn', isDarkMode ? 'btn-dark dark-mode-refresh' : 'btn-light']">
|
||||
<i class="bi bi-arrow-clockwise"></i></button>
|
||||
</div>
|
||||
<img v-if="isMapShown" :src="isDarkMode ? card.mapUrl_dark : card.mapUrl"
|
||||
class="card-img-top jn-map-image" alt="Map">
|
||||
<div class="card-body">
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item jn-list-group-item" :class="{ 'dark-mode': isDarkMode }"><span
|
||||
class="jn-text">🖥️ {{currentTexts.ipInfos.IP}}</span>: {{ card.ip
|
||||
}}
|
||||
<i v-if="isValidIP(card.ip)"
|
||||
:class="copiedStatus[card.id] ? 'bi bi-clipboard-check-fill' : 'bi bi-clipboard-plus'"
|
||||
@click="copyToClipboard(card.ip, card.id)"></i>
|
||||
</li>
|
||||
<li class="list-group-item jn-list-group-item"
|
||||
:class="{ 'dark-mode': isDarkMode,'mobile-list': isMobile && isCardsCollapsed }">
|
||||
<span class="jn-text">🌍 {{currentTexts.ipInfos.Country}}</span>: {{
|
||||
card.country_name }} <span v-if="card.country_code"
|
||||
:class="'flag-icon flag-icon-' + card.country_code.toLowerCase()"></span>
|
||||
</li>
|
||||
<li v-show="!isMobile || !isCardsCollapsed" class="list-group-item jn-list-group-item"
|
||||
:class="{ 'dark-mode': isDarkMode }"><span class="jn-text">🏚️
|
||||
{{currentTexts.ipInfos.Region}}</span>: {{ card.region
|
||||
}}</li>
|
||||
<li v-show="!isMobile || !isCardsCollapsed" class="list-group-item jn-list-group-item"
|
||||
:class="{ 'dark-mode': isDarkMode }"><span class="jn-text">🚏
|
||||
{{currentTexts.ipInfos.City}}</span>: {{ card.city }}
|
||||
</li>
|
||||
<li v-show="!isMobile || !isCardsCollapsed" class="list-group-item jn-list-group-item"
|
||||
:class="{ 'dark-mode': isDarkMode }"><span class="jn-text">🏢
|
||||
{{currentTexts.ipInfos.ISP}}</span>: {{ card.isp }}
|
||||
</li>
|
||||
<li v-show="!isMobile || !isCardsCollapsed" class="list-group-item jn-list-group-item"
|
||||
:class="{ 'dark-mode': isDarkMode }"><span class="jn-text">📶
|
||||
{{currentTexts.ipInfos.ASN}}</span>: <a v-if="card.asnlink" :href="card.asnlink"
|
||||
target="_blank">{{
|
||||
card.asn }}</a><a v-else>{{
|
||||
card.asn
|
||||
}}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connectivity -->
|
||||
<div class="availability-test-section mb-4">
|
||||
<div class="jn-title2">
|
||||
<h2 id="Connectivity" :class="{'mobile-h2' : isMobile }">🚦 {{currentTexts.connectivity.Title}}</h2>
|
||||
<button @click="checkAllConnectivity(false, true)"
|
||||
:class="['btn', isDarkMode ? 'btn-dark dark-mode-refresh' : 'btn-light']"><i
|
||||
class="bi bi-arrow-clockwise"></i></button>
|
||||
</div>
|
||||
<div class="text-secondary">
|
||||
<p>{{currentTexts.connectivity.Note}}</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div v-for="test in connectivityTests" :key="test.id" class="col-6 col-md-3 mb-4">
|
||||
<div class="card jn-card" :class="{ 'dark-mode dark-mode-border': isDarkMode }">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><i class="bi" :class="'bi-'+test.icon"></i> {{ test.name }}</h5>
|
||||
|
||||
<p class="card-text"
|
||||
:class="{'text-info': test.status === currentTexts.connectivity.StatusWait, 'text-success': test.status.includes(currentTexts.connectivity.StatusAvailable), 'text-danger': test.status === currentTexts.connectivity.StatusUnavailable || test.status === currentTexts.connectivity.StatusTimeout}"
|
||||
v-html="test.status">
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WebRTC Test -->
|
||||
<div class="webrtc-test-section mb-4">
|
||||
<div class="jn-title2">
|
||||
<h2 id="WebRTC" :class="{'mobile-h2' : isMobile }">🚥 {{currentTexts.webrtc.Title}}</h2>
|
||||
<button @click="checkAllWebRTC(true)"
|
||||
:class="['btn', isDarkMode ? 'btn-dark dark-mode-refresh' : 'btn-light']"><i
|
||||
class="bi bi-arrow-clockwise"></i></button>
|
||||
</div>
|
||||
<div class="text-secondary">
|
||||
<p>{{currentTexts.webrtc.Note}}</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div v-for="stun in stunServers" :key="stun.id" class="col-6 col-md-3 mb-4">
|
||||
<div class="card jn-card" :class="{ 'dark-mode dark-mode-border': isDarkMode }">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><i class="bi bi-sign-merge-left-fill"></i> {{ stun.name }}</h5>
|
||||
<p class="card-text text-secondary" style="font-size: 10pt;">{{ stun.url }}</p>
|
||||
<p class="card-text" :class="{
|
||||
'text-info': stun.ip === currentTexts.webrtc.StatusWait,
|
||||
'text-success': stun.ip.includes('.') || stun.ip.includes(':'),
|
||||
'text-danger': stun.ip === currentTexts.webrtc.StatusError
|
||||
}" v-html="stun.ip"></p>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DNS Leaks Test -->
|
||||
<div class="dnsleak-test-section mb-4">
|
||||
<div class="jn-title2">
|
||||
<h2 id="DNSLeakTest" :class="{'mobile-h2' : isMobile }">🛑 {{currentTexts.dnsleaktest.Title}}</h2>
|
||||
<button @click="checkAllDNSLeakTest(true)"
|
||||
:class="['btn', isDarkMode ? 'btn-dark dark-mode-refresh' : 'btn-light']"><i
|
||||
class="bi bi-arrow-clockwise"></i></button>
|
||||
</div>
|
||||
<div class="text-secondary">
|
||||
<p>{{currentTexts.dnsleaktest.Note}}</p>
|
||||
<p>{{currentTexts.dnsleaktest.Note2}}</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div v-for="leak in leakTest" :key="leak.id" class="col-6 col-md-3 mb-4">
|
||||
<div class="card jn-card" :class="{ 'dark-mode dark-mode-border': isDarkMode }">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><i class="bi bi-server"></i> {{ leak.name }}</h5>
|
||||
<p class="card-text text-secondary" style="font-size: 10pt;">{{currentTexts.dnsleaktest.Endpoint}}: {{
|
||||
leak.ip }}</p>
|
||||
<p class="card-text" :class="[
|
||||
'text-success',
|
||||
{'text-info': leak.geo === currentTexts.dnsleaktest.StatusWait, 'text-danger': leak.geo.includes('China') || leak.geo.includes('china')}
|
||||
]">{{currentTexts.dnsleaktest.EndpointCountry}}: {{ leak.geo }}</p>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Speed Test -->
|
||||
<div class="speed-test-section mb-4">
|
||||
<div class="jn-title2">
|
||||
<h2 id="SpeedTest" :class="{'mobile-h2' : isMobile }">🚀 {{currentTexts.speedtest.Title}}</h2>
|
||||
<button @click="startSpeedTest" :class="['btn', isDarkMode ? 'btn-dark dark-mode-refresh' : 'btn-light']"
|
||||
:disabled="speedTestStatus === 'running'">
|
||||
<i :class="speedTestStatus === 'running' ? 'bi bi-slash-circle' : 'bi bi-caret-right-fill'"></i>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
<div class="text-secondary">
|
||||
<p>{{currentTexts.speedtest.Note}}</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card jn-card" :class="{ 'dark-mode dark-mode-border': isDarkMode }">
|
||||
<div class="card-body">
|
||||
<div class="progress" style="height: 20px; margin: 4pt 0 20pt 0;"
|
||||
:class="{ 'jn-opacity-0': speedTestStatus == 'idle', 'jn-progress-dark': isDarkMode }">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated bg-success jn-progress"
|
||||
role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"
|
||||
id="speedtest-progress">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="margin-bottom: 10pt;">
|
||||
<div :class="['text-center', isMobile ? 'col-6' : 'col-3']">
|
||||
<h5 class="speedtest-h5">{{currentTexts.speedtest.Download}}</h5>
|
||||
<p id="download-speed" class="speedtest-h5" :class="updateSpeedTestColor(speedTestStatus)">
|
||||
<span class="jn-speedtest-number">{{speedTest.downloadSpeed}}</span>
|
||||
<span v-if="speedTestStatus !== 'idle'">Mb/s</span>
|
||||
</p>
|
||||
</div>
|
||||
<div :class="['text-center', isMobile ? 'col-6' : 'col-3']">
|
||||
<h5 class="speedtest-h5">{{currentTexts.speedtest.Upload}}</h5>
|
||||
<p id="upload-speed" class="speedtest-h5" :class="updateSpeedTestColor(speedTestStatus)">
|
||||
<span class="jn-speedtest-number">{{speedTest.uploadSpeed}}</span>
|
||||
<span v-if="speedTestStatus !== 'idle'">Mb/s</span>
|
||||
</p>
|
||||
</div>
|
||||
<div :class="['text-center', isMobile ? 'col-6' : 'col-3']">
|
||||
<h5 class="speedtest-h5">{{currentTexts.speedtest.Latency}}</h5>
|
||||
<p id="latency" class="speedtest-h5" :class="updateSpeedTestColor(speedTestStatus)">
|
||||
<span class="jn-speedtest-number">{{speedTest.latency}}</span>
|
||||
<span v-if="speedTestStatus !== 'idle'">ms</span>
|
||||
</p>
|
||||
</div>
|
||||
<div :class="['text-center', isMobile ? 'col-6' : 'col-3']">
|
||||
<h5 class="speedtest-h5">{{currentTexts.speedtest.Jitter}}</h5>
|
||||
<p id="jitter" class="speedtest-h5" :class="updateSpeedTestColor(speedTestStatus)">
|
||||
<span class="jn-speedtest-number">{{speedTest.jitter}}</span>
|
||||
<span v-if="speedTestStatus !== 'idle'">ms</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row jn-speedtest-note" v-if="speedTestStatus === 'finished'">
|
||||
<p id="score" class="speedtest-p">📋 {{currentTexts.speedtest.score}}
|
||||
{{currentTexts.speedtest.videoStreaming}}
|
||||
<span :class="speedTest.streamingScore >= 50 ? 'text-success' : 'text-warning'">
|
||||
{{speedTest.streamingScore}}
|
||||
</span>
|
||||
{{currentTexts.speedtest.gaming}}
|
||||
<span :class="speedTest.gamingScore >= 50 ? 'text-success' : 'text-warning'">
|
||||
{{speedTest.gamingScore}}
|
||||
</span>
|
||||
{{currentTexts.speedtest.rtc}}
|
||||
<span :class="speedTest.rtcScore >= 50 ? 'text-success' : 'text-warning'">
|
||||
{{speedTest.rtcScore}}
|
||||
</span>
|
||||
{{currentTexts.speedtest.resultNote}}
|
||||
</p>
|
||||
</div>
|
||||
<div id="result"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ping Test -->
|
||||
<div class="ping-test-section mb-4">
|
||||
<div class="jn-title2">
|
||||
<h2 id="PingTest" :class="{'mobile-h2' : isMobile }">🌐 {{currentTexts.pingtest.Title}}</h2>
|
||||
|
||||
</div>
|
||||
<div class="text-secondary">
|
||||
<p>{{currentTexts.pingtest.Note}}</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card jn-card" :class="{ 'dark-mode dark-mode-border': isDarkMode }">
|
||||
<div class="card-body">
|
||||
<!-- Dropdown for IP Selection -->
|
||||
<div class="row mt-3 mb-3 align-items-center justify-content-center">
|
||||
<div class="col-12 col-md-auto">
|
||||
<label for="pingIP" class="col-form-label">{{currentTexts.pingtest.Note2}}</label>
|
||||
</div>
|
||||
<div class="col-12 col-md-auto mt-2 mt-md-0">
|
||||
<div class="row justify-content-between">
|
||||
<div class="col-auto">
|
||||
<select id="pingIP" class="form-select jn-ping-form-select" v-model="selectedIP"
|
||||
:class="{'bg-dark text-light': isDarkMode}">
|
||||
<option v-for="ip in allIPs" :key="ip" :value="ip">{{ ip }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-primary" @click="startPingCheck"
|
||||
:disabled="pingCheckStatus === 'running' || selectedIP === allIPs[0]">
|
||||
<span
|
||||
v-if="pingCheckStatus === 'idle' || pingCheckStatus === 'finished' || pingCheckStatus === 'error' ">{{currentTexts.pingtest.Run}}</span>
|
||||
<span v-if="pingCheckStatus === 'running'" class="spinner-grow spinner-grow-sm"
|
||||
aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result Display -->
|
||||
<div id="pingresult" v-if="pingResults.length > 0">
|
||||
<div class="table-responsive text-nowrap">
|
||||
<table class="table" :class="{'table-dark': isDarkMode }">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{currentTexts.pingtest.Region}}</th>
|
||||
<th scope="col">{{currentTexts.pingtest.MinDelay}}</th>
|
||||
<th scope="col">{{currentTexts.pingtest.MaxDelay}}</th>
|
||||
<th scope="col">{{currentTexts.pingtest.AvgDelay}}</th>
|
||||
<th scope="col">{{currentTexts.pingtest.TotalPackets}}</th>
|
||||
<th scope="col">{{currentTexts.pingtest.PacketLoss}}</th>
|
||||
<th scope="col">{{currentTexts.pingtest.ReceivedPackets}}</th>
|
||||
<th scope="col">{{currentTexts.pingtest.DroppedPackets}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="result in pingResults" :key="result.country">
|
||||
<td>
|
||||
<span :class="'flag-icon flag-icon-' + result.country.toLowerCase()"></span>
|
||||
{{ result.country }}
|
||||
</td>
|
||||
<td :class="result.stats.min < 100 ? 'text-success' : ''">{{ result.stats.min.toFixed(1) }}
|
||||
</td>
|
||||
<td :class="result.stats.max < 100 ? 'text-success' : ''">{{ result.stats.max.toFixed(1) }}
|
||||
</td>
|
||||
<td :class="result.stats.avg < 100 ? 'text-success' : ''">{{ result.stats.avg.toFixed(1) }}
|
||||
</td>
|
||||
<td>{{ result.stats.total }}</td>
|
||||
<td>{{ Math.round(result.stats.loss) }}%</td>
|
||||
<td>{{ result.stats.rcv }}</td>
|
||||
<td>{{ result.stats.drop }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pingresult-error" v-if="pingCheckStatus === 'error'">
|
||||
<p class="text-center text-danger">{{currentTexts.pingtest.Error}}</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Mask BTN-->
|
||||
<button v-if="isInfosLoaded" class="btn position-fixed"
|
||||
:class="infoMaskLevel === 0 ? 'btn-success' : infoMaskLevel === 1 ? 'btn-warning' : 'btn-secondary'"
|
||||
style="bottom: 66px; right: 20px; z-index: 1050;" @click="toggleInfoMask">
|
||||
<i :class="infoMaskLevel === 0 ? 'bi bi-eye' : 'bi bi-eye-slash'"></i>
|
||||
</button>
|
||||
|
||||
<!-- Search BTN -->
|
||||
<button class="btn btn-primary position-fixed" style="bottom: 20px; right: 20px; z-index: 1050;"
|
||||
data-bs-toggle="modal" data-bs-target="#IPCheck"><i class="bi bi-search"></i></button>
|
||||
|
||||
<!-- Search Modal -->
|
||||
<div class="modal fade" id="IPCheck" tabindex="-1" aria-labelledby="IPCheck" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content" :class="{ 'dark-mode dark-mode-border': isDarkMode }">
|
||||
<div class="modal-header" :class="{ 'dark-mode-border': isDarkMode }">
|
||||
<h5 class="modal-title" id="IPCheck">{{currentTexts.ipcheck.Title}}</h5>
|
||||
<button type="button" class="btn-close" :class="{ 'dark-mode-close-button': isDarkMode }"
|
||||
data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
|
||||
</div>
|
||||
<div class="modal-body" :class="{ 'dark-mode': isDarkMode }">
|
||||
<input type="text" class="form-control mb-2" :class="{ 'dark-mode': isDarkMode }"
|
||||
:placeholder="currentTexts.ipcheck.Placeholder" v-model="inputIP" @keyup.enter="submitQuery"
|
||||
name="inputIP" id="inputIP">
|
||||
<div v-if="modalQueryError" class="text-danger">{{ modalQueryError }}</div>
|
||||
<div v-if="modalQueryResult" class="mt-2">
|
||||
<div class="card-body">
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item jn-list-group-item" :class="{ 'dark-mode': isDarkMode }">
|
||||
<span class="jn-text">🌍 {{currentTexts.ipInfos.Country}}</span>: {{
|
||||
modalQueryResult.country_name }} <span v-if="modalQueryResult.country_code"
|
||||
:class="'flag-icon flag-icon-' + modalQueryResult.country_code.toLowerCase()"></span>
|
||||
</li>
|
||||
<li class="list-group-item jn-list-group-item" :class="{ 'dark-mode': isDarkMode }"><span
|
||||
class="jn-text">🏚️ {{currentTexts.ipInfos.Region}}</span>: {{
|
||||
modalQueryResult.region
|
||||
}}</li>
|
||||
<li class="list-group-item jn-list-group-item" :class="{ 'dark-mode': isDarkMode }"><span
|
||||
class="jn-text">🚏 {{currentTexts.ipInfos.City}}</span>: {{
|
||||
modalQueryResult.city }}
|
||||
</li>
|
||||
<li class="list-group-item jn-list-group-item" :class="{ 'dark-mode': isDarkMode }"><span
|
||||
class="jn-text">🏢 {{currentTexts.ipInfos.ISP}}</span>: {{
|
||||
modalQueryResult.isp }}
|
||||
</li>
|
||||
<li class="list-group-item jn-list-group-item" :class="{ 'dark-mode': isDarkMode }"><span
|
||||
class="jn-text">📶 {{currentTexts.ipInfos.ASN}}</span>: <a v-if="modalQueryResult.asnlink"
|
||||
:href="modalQueryResult.asnlink" target="_blank">{{
|
||||
modalQueryResult.asn }}</a><a v-else>{{ modalQueryResult.asn
|
||||
}}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer" :class="{ 'dark-mode-border': isDarkMode }">
|
||||
<button type="button" class="btn btn-primary"
|
||||
:class="{ 'btn-secondary': !isValidIP(inputIP), 'btn-primary': isValidIP(inputIP) }" @click="submitQuery"
|
||||
:disabled="!isValidIP(inputIP)">{{currentTexts.ipcheck.Button}}</button>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- help modal -->
|
||||
<div class="modal fade" id="helpModal" tabindex="-1" aria-labelledby="helpModal" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
|
||||
<div class="modal-content" :class="{ 'dark-mode dark-mode-border': isDarkMode }">
|
||||
<div class="modal-header" :class="{ 'dark-mode-border': isDarkMode }">
|
||||
<h5 class="modal-title" id="helpModal">{{currentTexts.helpModal.Title}}</h5>
|
||||
<button type="button" class="btn-close" :class="{ 'dark-mode-close-button': isDarkMode }"
|
||||
data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
|
||||
</div>
|
||||
<div class="modal-body" :class="{'dark-mode' : isDarkMode }">
|
||||
<p class="jn-help-note "><i class="bi bi-hand-thumbs-up-fill"></i> {{currentTexts.shortcutKeys.HelpNote}}
|
||||
</p>
|
||||
<div v-for="(key, index) in keyMap" :key="key.keys" class="row p-2 jn-shortcut-row"
|
||||
:class="[ isDarkMode ? 'border-dark-subtle jn-dark-mode-help-border' : 'border-light-subtle', index === keyMap.length - 1 ? 'jn-border-bottom-none' : '' ]">
|
||||
<div class="col">
|
||||
<kbd :class="{ 'text-bg-light': isDarkMode }">
|
||||
{{ key.keys.replace(/[\[\]\\\(\)]/g, '') }}
|
||||
</kbd>
|
||||
</div>
|
||||
<div class="col-8">{{ key.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<footer>
|
||||
<p class="text-center">{{currentTexts.page.footerName}} <a :href="currentTexts.page.footerLink" class="link-dark"
|
||||
target="_blank"><i class="bi bi-github" :class="{ 'dark-mode': isDarkMode }"></i></a>
|
||||
</p>
|
||||
<p v-if="!siteValidate" class="text-center fs-6 fw-light" style="opacity: 0.5;">
|
||||
{{currentTexts.page.copyRightName}} <a :href="currentTexts.page.copyRightLink" class="link-underline-light"
|
||||
target="_blank"
|
||||
:class="[ isDarkMode? 'dark-mode link-light':'link-dark' ]">{{currentTexts.page.copyRightLinkName}}</a>
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
<!-- 引入 Vue.js -->
|
||||
<script src="res/vue.js"></script>
|
||||
<script type="module" src="res/app.js"></script>
|
||||
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('service-worker.js').then(function (registration) {
|
||||
}, function (err) {
|
||||
console.log('ServiceWorker Error: ', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-box-fill" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M15.528 2.973a.75.75 0 0 1 .472.696v8.662a.75.75 0 0 1-.472.696l-7.25 2.9a.75.75 0 0 1-.557 0l-7.25-2.9A.75.75 0 0 1 0 12.331V3.669a.75.75 0 0 1 .471-.696L7.443.184l.004-.001.274-.11a.75.75 0 0 1 .558 0l.274.11.004.001zm-1.374.527L8 5.962 1.846 3.5 1 3.839v.4l6.5 2.6v7.922l.5.2.5-.2V6.84l6.5-2.6v-.4l-.846-.339Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 480 B |
@@ -5,21 +5,26 @@
|
||||
"description": "IP Toolbox",
|
||||
"icons": [
|
||||
{
|
||||
"src": "res/img/logo-512.png",
|
||||
"src": "logo-512.png",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "res/img/logo-144.png",
|
||||
"src": "logo-144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "res/img/logo-192.png",
|
||||
"src": "logo-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "res/img/logo-512.png",
|
||||
"src": "logo-256.png",
|
||||
"sizes": "256x256",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "logo-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
|
||||
import SpeedTest from 'https://cdn.skypack.dev/@cloudflare/speedtest';
|
||||
|
||||
let engine = new SpeedTest({
|
||||
autoStart: false,
|
||||
});
|
||||
|
||||
// Event listeners
|
||||
engine.onRunningChange = running => {
|
||||
console.log('Speed Test is ' + (running ? 'running...' : 'finished!'));
|
||||
};
|
||||
|
||||
engine.onResultsChange = ({ type }) => {
|
||||
console.log('Results changed: ', type);
|
||||
};
|
||||
|
||||
|
||||
engine.onFinish = results => {
|
||||
console.log('Speed Test finished: ', results.getSummary());
|
||||
};
|
||||
|
||||
engine.onError = (e) => {
|
||||
console.log('Speed Test Error: ', e);
|
||||
};
|
||||
|
||||
const triggerSpeedTest = () => {
|
||||
engine.play();
|
||||
};
|
||||
|
||||
const resetSpeedTest = () => {
|
||||
engine = new SpeedTest({
|
||||
autoStart: false,
|
||||
measurements: [
|
||||
{ type: 'latency', numPackets: 20 },
|
||||
{ type: 'download', bytes: 5e7, count: 4 },
|
||||
{ type: 'upload', bytes: 1.5e7, count: 3 }
|
||||
]
|
||||
});
|
||||
return engine;
|
||||
};
|
||||
|
||||
export { triggerSpeedTest, resetSpeedTest, engine };
|
||||
@@ -1,265 +0,0 @@
|
||||
.jn-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
flex-direction: row;
|
||||
margin-top: 10pt;
|
||||
margin-bottom: 10pt;
|
||||
}
|
||||
|
||||
.jn-title2 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
flex-direction: row;
|
||||
margin-top: 10pt;
|
||||
margin-bottom: 10pt;
|
||||
}
|
||||
|
||||
.jn-ip-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
border: 0;
|
||||
background-color:unset;
|
||||
}
|
||||
|
||||
.jn-alert {
|
||||
z-index: 9999;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.jn-card {
|
||||
box-shadow: 0 0 10pt #0000001c;
|
||||
height: 100%;
|
||||
border: 1px solid #9c9c9c2e;
|
||||
}
|
||||
|
||||
.jn-card:hover {
|
||||
border-color: #4dc0e461;
|
||||
}
|
||||
|
||||
.jn-card.keyboard-hover {
|
||||
border-color: rgba(20, 184, 108, 0.42);
|
||||
}
|
||||
|
||||
.jn-card.keyboard-hover::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
border: 1px solid rgba(20, 184, 108, 0.42);
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
border-radius: 4pt;
|
||||
}
|
||||
|
||||
.jn-container {
|
||||
padding-top: 2pt;
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
.jn-text {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.jn-list-group-item {
|
||||
padding: 0.75rem 0.25rem;
|
||||
}
|
||||
|
||||
.jn-opacity {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.jn-map-image {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.jn-navbar-top {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 9999;
|
||||
border-bottom: 1px solid #0000001c;
|
||||
|
||||
}
|
||||
|
||||
.jn-toast {
|
||||
z-index: 9999;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin-bottom: 2pt;
|
||||
margin-right: 40pt;
|
||||
max-width: 80vw;
|
||||
}
|
||||
|
||||
footer {
|
||||
height: 100pt;
|
||||
padding-top: 20pt;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
footer {
|
||||
height: 140pt;
|
||||
}
|
||||
|
||||
.speedtest-h5 {
|
||||
/* font-size: 12px; */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.jn-radio {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: stretch;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.jn-logo {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.dark-mode {
|
||||
background-color: #212529 !important;
|
||||
color: #e3e3e3 !important;
|
||||
border-color: #757575;
|
||||
}
|
||||
|
||||
.dark-mode::placeholder {
|
||||
color: #e3e3e38e !important;
|
||||
}
|
||||
|
||||
.dark-mode-border {
|
||||
border-color: #a8a8a863;
|
||||
box-shadow: 0 0 10pt #00000038;
|
||||
}
|
||||
|
||||
.dark-mode-title {
|
||||
background-color: #171a1d;
|
||||
}
|
||||
|
||||
.dark-mode-nav {
|
||||
background-color: #171a1d !important;
|
||||
border-color: #a8a8a863 !important;
|
||||
border-color: #757575;
|
||||
}
|
||||
|
||||
.jn-deactive {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.active {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.dark-mode-nav-border {
|
||||
border-color: #a8a8a863 !important;
|
||||
}
|
||||
|
||||
.dark-mode-close-button {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.dark-mode-refresh {
|
||||
background-color: #141618;
|
||||
}
|
||||
|
||||
.dark-mode-refresh:hover {
|
||||
background-color: #00000054;
|
||||
}
|
||||
|
||||
.mobile-list {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.mobile-h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
[v-cloak] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.jn-loading {
|
||||
margin-top: 50vh;
|
||||
}
|
||||
|
||||
.jn-shortcut-row {
|
||||
margin: 2pt 0 2pt 0;
|
||||
border-bottom: 1px solid;
|
||||
}
|
||||
|
||||
.jn-dark-mode-help-border {
|
||||
border-color: #a8a8a863 !important;
|
||||
}
|
||||
|
||||
.jn-border-bottom-none {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.jn-help-note {
|
||||
background: linear-gradient(to right, #e04343, orange, #8d8d22, green, #50c0ae, indigo, violet);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.jn-progress {
|
||||
padding-bottom: 20pt;
|
||||
}
|
||||
|
||||
.jn-opacity-0 {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.jn-progress-dark {
|
||||
background-color: #4b4b4b !important;
|
||||
}
|
||||
|
||||
*:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.jn-speedtest-number {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.jn-speedtest-note {
|
||||
background-color: #b2d6c33b;
|
||||
border: 1px solid #1a8754;
|
||||
margin: 0;
|
||||
border-radius: 6pt;
|
||||
}
|
||||
/* .jn-speedtest-note::before {
|
||||
content: "";
|
||||
border-top: 1px solid #19945a5c;
|
||||
width: 50%;
|
||||
margin: 0 auto;
|
||||
} */
|
||||
|
||||
.speedtest-p {
|
||||
margin-top:10pt;
|
||||
}
|
||||
|
||||
.jn-ping-form-select {
|
||||
min-width: 140pt;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
const config = {
|
||||
GOOGLE_ANALYTICS_ID: 'G-TEYKKD81TL'
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,6 +0,0 @@
|
||||
/*!
|
||||
* Vue.js v2.6.11
|
||||
* (c) 2014-2019 Evan You
|
||||
* Released under the MIT License.
|
||||
*/
|
||||
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).Vue=t()}(this,function(){"use strict";var e=Object.freeze({});function t(e){return null==e}function n(e){return null!=e}function r(e){return!0===e}function i(e){return"string"==typeof e||"number"==typeof e||"symbol"==typeof e||"boolean"==typeof e}function o(e){return null!==e&&"object"==typeof e}var a=Object.prototype.toString;function s(e){return"[object Object]"===a.call(e)}function c(e){var t=parseFloat(String(e));return t>=0&&Math.floor(t)===t&&isFinite(e)}function u(e){return n(e)&&"function"==typeof e.then&&"function"==typeof e.catch}function l(e){return null==e?"":Array.isArray(e)||s(e)&&e.toString===a?JSON.stringify(e,null,2):String(e)}function f(e){var t=parseFloat(e);return isNaN(t)?e:t}function p(e,t){for(var n=Object.create(null),r=e.split(","),i=0;i<r.length;i++)n[r[i]]=!0;return t?function(e){return n[e.toLowerCase()]}:function(e){return n[e]}}var d=p("slot,component",!0),v=p("key,ref,slot,slot-scope,is");function h(e,t){if(e.length){var n=e.indexOf(t);if(n>-1)return e.splice(n,1)}}var m=Object.prototype.hasOwnProperty;function y(e,t){return m.call(e,t)}function g(e){var t=Object.create(null);return function(n){return t[n]||(t[n]=e(n))}}var _=/-(\w)/g,b=g(function(e){return e.replace(_,function(e,t){return t?t.toUpperCase():""})}),$=g(function(e){return e.charAt(0).toUpperCase()+e.slice(1)}),w=/\B([A-Z])/g,C=g(function(e){return e.replace(w,"-$1").toLowerCase()});var x=Function.prototype.bind?function(e,t){return e.bind(t)}:function(e,t){function n(n){var r=arguments.length;return r?r>1?e.apply(t,arguments):e.call(t,n):e.call(t)}return n._length=e.length,n};function k(e,t){t=t||0;for(var n=e.length-t,r=new Array(n);n--;)r[n]=e[n+t];return r}function A(e,t){for(var n in t)e[n]=t[n];return e}function O(e){for(var t={},n=0;n<e.length;n++)e[n]&&A(t,e[n]);return t}function S(e,t,n){}var T=function(e,t,n){return!1},E=function(e){return e};function N(e,t){if(e===t)return!0;var n=o(e),r=o(t);if(!n||!r)return!n&&!r&&String(e)===String(t);try{var i=Array.isArray(e),a=Array.isArray(t);if(i&&a)return e.length===t.length&&e.every(function(e,n){return N(e,t[n])});if(e instanceof Date&&t instanceof Date)return e.getTime()===t.getTime();if(i||a)return!1;var s=Object.keys(e),c=Object.keys(t);return s.length===c.length&&s.every(function(n){return N(e[n],t[n])})}catch(e){return!1}}function j(e,t){for(var n=0;n<e.length;n++)if(N(e[n],t))return n;return-1}function D(e){var t=!1;return function(){t||(t=!0,e.apply(this,arguments))}}var L="data-server-rendered",M=["component","directive","filter"],I=["beforeCreate","created","beforeMount","mounted","beforeUpdate","updated","beforeDestroy","destroyed","activated","deactivated","errorCaptured","serverPrefetch"],F={optionMergeStrategies:Object.create(null),silent:!1,productionTip:!1,devtools:!1,performance:!1,errorHandler:null,warnHandler:null,ignoredElements:[],keyCodes:Object.create(null),isReservedTag:T,isReservedAttr:T,isUnknownElement:T,getTagNamespace:S,parsePlatformTagName:E,mustUseProp:T,async:!0,_lifecycleHooks:I},P=/a-zA-Z\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD/;function R(e,t,n,r){Object.defineProperty(e,t,{value:n,enumerable:!!r,writable:!0,configurable:!0})}var H=new RegExp("[^"+P.source+".$_\\d]");var B,U="__proto__"in{},z="undefined"!=typeof window,V="undefined"!=typeof WXEnvironment&&!!WXEnvironment.platform,K=V&&WXEnvironment.platform.toLowerCase(),J=z&&window.navigator.userAgent.toLowerCase(),q=J&&/msie|trident/.test(J),W=J&&J.indexOf("msie 9.0")>0,Z=J&&J.indexOf("edge/")>0,G=(J&&J.indexOf("android"),J&&/iphone|ipad|ipod|ios/.test(J)||"ios"===K),X=(J&&/chrome\/\d+/.test(J),J&&/phantomjs/.test(J),J&&J.match(/firefox\/(\d+)/)),Y={}.watch,Q=!1;if(z)try{var ee={};Object.defineProperty(ee,"passive",{get:function(){Q=!0}}),window.addEventListener("test-passive",null,ee)}catch(e){}var te=function(){return void 0===B&&(B=!z&&!V&&"undefined"!=typeof global&&(global.process&&"server"===global.process.env.VUE_ENV)),B},ne=z&&window.__VUE_DEVTOOLS_GLOBAL_HOOK__;function re(e){return"function"==typeof e&&/native code/.test(e.toString())}var ie,oe="undefined"!=typeof Symbol&&re(Symbol)&&"undefined"!=typeof Reflect&&re(Reflect.ownKeys);ie="undefined"!=typeof Set&&re(Set)?Set:function(){function e(){this.set=Object.create(null)}return e.prototype.has=function(e){return!0===this.set[e]},e.prototype.add=function(e){this.set[e]=!0},e.prototype.clear=function(){this.set=Object.create(null)},e}();var ae=S,se=0,ce=function(){this.id=se++,this.subs=[]};ce.prototype.addSub=function(e){this.subs.push(e)},ce.prototype.removeSub=function(e){h(this.subs,e)},ce.prototype.depend=function(){ce.target&&ce.target.addDep(this)},ce.prototype.notify=function(){for(var e=this.subs.slice(),t=0,n=e.length;t<n;t++)e[t].update()},ce.target=Line truncated
|
||||
@@ -1,13 +1,12 @@
|
||||
// service-worker.js
|
||||
const CACHE_NAME = 'v3_cache';
|
||||
const CACHE_NAME = 'v2_cache';
|
||||
const urlsToCache = [
|
||||
'res/vue.js',
|
||||
'res/img/defaultMap.jpg',
|
||||
'https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/js/bootstrap.bundle.min.js',
|
||||
'https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/css/bootstrap.min.css',
|
||||
'https://cdnjs.cloudflare.com/ajax/libs/bootstrap-icons/1.11.2/font/bootstrap-icons.min.css',
|
||||
'https://cdnjs.cloudflare.com/ajax/libs/flag-icon-css/3.5.0/css/flag-icon.min.css',
|
||||
|
||||
'defaultMap.jpg',
|
||||
'defaultMap_dark.jpg',
|
||||
'logo-144.png',
|
||||
'logo-192.png',
|
||||
'logo-256.png',
|
||||
'logo-512.png',
|
||||
];
|
||||
|
||||
// 安装 Service Worker 时,缓存文件
|
||||
@@ -15,7 +14,6 @@ self.addEventListener('install', event => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then(cache => {
|
||||
console.log('Opened cache');
|
||||
return cache.addAll(urlsToCache);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const mapHandler = require('./api/map');
|
||||
const validateMapKeyHandler = require('./api/validate-map-key');
|
||||
const validateSite = require('./api/validate-site');
|
||||
const ipinfoHandler = require('./api/ipinfo');
|
||||
const ipapicomHandler = require('./api/ipapicom');
|
||||
import dotenv from 'dotenv';
|
||||
import express from 'express';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import mapHandler from './api/map.js';
|
||||
import validateMapKeyHandler from './api/validate-map-key.js';
|
||||
import validateSite from './api/validate-site.js';
|
||||
import ipinfoHandler from './api/ipinfo.js';
|
||||
import ipapicomHandler from './api/ipapicom.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 8966;
|
||||
const port = process.env.PORT || 11966;
|
||||
|
||||
// 使用查询参数处理所有地图请求
|
||||
app.get('/api/map', mapHandler);
|
||||
@@ -18,7 +21,9 @@ app.get('/api/ipinfo', ipinfoHandler);
|
||||
app.get('/api/ipapicom', ipapicomHandler);
|
||||
|
||||
// 设置静态文件服务
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
app.use(express.static(path.join(__dirname, './dist')));
|
||||
|
||||
// 一些判断
|
||||
app.all('/api/validate-map-key', validateMapKeyHandler);
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
<template>
|
||||
<NavBar ref="navBarRef" />
|
||||
<!-- Alerts -->
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3 jn-toast">
|
||||
<div id="toastInfoMask" class="toast" :class="{ 'dark-mode': isDarkMode }" role="alert" ref="toast"
|
||||
aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-header" :class="{ 'dark-mode-title': isDarkMode }">
|
||||
<strong class="me-auto" :class="alertStyle">{{ alertTitle }}</strong>
|
||||
<button type="button" class="btn-close" :class="{ 'dark-mode-close-button': isDarkMode }" data-bs-dismiss="toast"
|
||||
aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
{{ alertMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mainpart" class="container mt-5 jn-container">
|
||||
<div data-bs-spy="scroll" data-bs-target="#navbar-top" data-bs-root-margin="0px 0px -40%" data-bs-smooth-scroll="true"
|
||||
class="rounded-2" tabindex="0">
|
||||
<IPCheck ref="IPCheckRef" />
|
||||
<Connectivity ref="connectivityRef" />
|
||||
<WebRTC ref="webRTCRef" />
|
||||
<DNSLeaks ref="dnsLeaksRef" />
|
||||
<SpeedTest ref="speedTestRef" />
|
||||
<GlobalLatency ref="globalLatencyRef" />
|
||||
<QueryIP ref="queryIPRef" />
|
||||
<HelpModal ref="helpModalRef" />
|
||||
<!-- Info Mask BTN-->
|
||||
<button v-if="isInfosLoaded" class="btn position-fixed"
|
||||
:class="infoMaskLevel === 0 ? 'btn-success' : infoMaskLevel === 1 ? 'btn-warning' : 'btn-secondary'"
|
||||
style="bottom: 66px; right: 20px; z-index: 1050;" @click="toggleInfoMask">
|
||||
<i :class="infoMaskLevel === 0 ? 'bi bi-eye' : 'bi bi-eye-slash'"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import NavBar from './components/nav.vue'
|
||||
import IPCheck from './components/ipcheck.vue'
|
||||
import Connectivity from './components/connectivity.vue'
|
||||
import WebRTC from './components/webrtc.vue'
|
||||
import DNSLeaks from './components/dnsleaks.vue'
|
||||
import SpeedTest from './components/speedtest.vue'
|
||||
import GlobalLatency from './components/globallatency.vue'
|
||||
import Footer from './components/footer.vue'
|
||||
import QueryIP from './components/queryip.vue'
|
||||
import HelpModal from './components/help.vue'
|
||||
import { mappingKeys, navigateCards, keyMap } from "./shortcut.js";
|
||||
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { Modal, Toast } from 'bootstrap';
|
||||
|
||||
export default {
|
||||
|
||||
// 引入 Store
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const isDarkMode = computed(() => store.state.isDarkMode);
|
||||
const isMobile = computed(() => store.state.isMobile);
|
||||
const shouldRefreshEveryThing = computed(() => store.state.shouldRefreshEveryThing);
|
||||
const shouldRefresh = ref(false);
|
||||
|
||||
watch(shouldRefreshEveryThing, (newVal) => {
|
||||
shouldRefresh.value = newVal;
|
||||
});
|
||||
|
||||
return {
|
||||
isDarkMode,
|
||||
isMobile,
|
||||
shouldRefresh,
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
NavBar,
|
||||
IPCheck,
|
||||
Connectivity,
|
||||
WebRTC,
|
||||
DNSLeaks,
|
||||
SpeedTest,
|
||||
GlobalLatency,
|
||||
Footer,
|
||||
QueryIP,
|
||||
HelpModal,
|
||||
},
|
||||
name: 'App',
|
||||
data() {
|
||||
return {
|
||||
keyMap,
|
||||
infoMaskLevel: 0,
|
||||
isInfosLoaded: false,
|
||||
originipDataCards: [],
|
||||
originstunServers: [],
|
||||
originleakTest: [],
|
||||
alertToShow: false,
|
||||
alertStyle: "",
|
||||
alertMessage: "",
|
||||
alertTitle: "",
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
shouldRefresh(newVal) {
|
||||
if (newVal) {
|
||||
this.refreshEverything();
|
||||
}
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.hideLoading();
|
||||
},
|
||||
methods: {
|
||||
|
||||
// 加载完成后隐藏 loading
|
||||
hideLoading() {
|
||||
var loadingElement = document.getElementById("jn-loading");
|
||||
if (loadingElement) {
|
||||
loadingElement.style.display = "none";
|
||||
}
|
||||
},
|
||||
|
||||
// 设置 isMobile
|
||||
handleResize() {
|
||||
this.$store.commit('setIsMobile', window.innerWidth < 768);
|
||||
},
|
||||
// 显示 Toast
|
||||
showToast(duration = 2000) {
|
||||
this.$nextTick(() => {
|
||||
const toastEl = this.$refs.toast;
|
||||
if (toastEl) {
|
||||
const toastInfoMask = new Toast(toastEl, {
|
||||
delay: duration
|
||||
});
|
||||
toastInfoMask.show();
|
||||
} else {
|
||||
console.error("Toast element not found");
|
||||
}
|
||||
});
|
||||
},
|
||||
// 延迟设置 isInfosLoaded
|
||||
setInfosLoaded() {
|
||||
setTimeout(() => {
|
||||
this.isInfosLoaded = true;
|
||||
}, 6000);
|
||||
},
|
||||
|
||||
// 时间任务
|
||||
scheduleTimedTasks(tasks) {
|
||||
tasks.forEach(task => {
|
||||
setTimeout(() => {
|
||||
task.action();
|
||||
if (task.message) {
|
||||
this.displayAlert(task.message);
|
||||
}
|
||||
}, task.delay);
|
||||
});
|
||||
},
|
||||
|
||||
// 刷新所有
|
||||
refreshEverything() {
|
||||
const refreshTasks = [
|
||||
{ action: () => this.$refs.IPCheckRef.checkAllIPs(), delay: 0 },
|
||||
{ action: () => this.$refs.connectivityRef.checkAllConnectivity(false, true), delay: 2000 },
|
||||
{ action: () => this.$refs.webRTCRef.checkAllWebRTC(true), delay: 4000 },
|
||||
{ action: () => this.$refs.dnsLeaksRef.checkAllDNSLeakTest(true), delay: 2500 },
|
||||
{ action: () => this.refreshingAlert(), delay: 500 },
|
||||
];
|
||||
this.scheduleTimedTasks(refreshTasks);
|
||||
this.infoMaskLevel = 0;
|
||||
this.$store.commit('setRefreshEveryThing', false);
|
||||
},
|
||||
|
||||
// 刷新完成后显示 Toast
|
||||
refreshingAlert() {
|
||||
this.alertStyle = "text-success";
|
||||
this.alertMessage = this.$t('alert.refreshEverythingMessage');
|
||||
this.alertTitle = this.$t('alert.refreshEverythingTitle');
|
||||
this.alertToShow = true;
|
||||
this.showToast();
|
||||
},
|
||||
|
||||
// 信息遮罩
|
||||
toggleInfoMask() {
|
||||
if (this.infoMaskLevel === 0) {
|
||||
this.originipDataCards = JSON.parse(JSON.stringify(this.$refs.IPCheckRef.ipDataCards));
|
||||
this.originstunServers = JSON.parse(JSON.stringify(this.$refs.webRTCRef.stunServers));
|
||||
this.originleakTest = JSON.parse(JSON.stringify(this.$refs.dnsLeaksRef.leakTest));
|
||||
this.infoMask();
|
||||
this.alertStyle = "text-warning";
|
||||
this.alertMessage = this.$t('alert.maskedInfoMessage_1');
|
||||
this.alertTitle = this.$t('alert.maskedInfoTitle_1');
|
||||
this.alertToShow = true;
|
||||
this.showToast();
|
||||
} else if (this.infoMaskLevel === 1) {
|
||||
this.infoMask();
|
||||
this.alertStyle = "text-success";
|
||||
this.alertMessage = this.$t('alert.maskedInfoMessage');
|
||||
this.alertTitle = this.$t('alert.maskedInfoTitle');
|
||||
this.alertToShow = true;
|
||||
this.showToast();
|
||||
} else {
|
||||
this.infoUnmask();
|
||||
this.alertStyle = "text-danger";
|
||||
this.alertMessage = this.$t('alert.unmaskedInfoMessage');
|
||||
this.alertTitle = this.$t('alert.unmaskedInfoTitle');
|
||||
this.alertToShow = true;
|
||||
this.showToast();
|
||||
}
|
||||
},
|
||||
|
||||
// 信息遮罩内容
|
||||
infoMask() {
|
||||
if (this.infoMaskLevel === 0) {
|
||||
this.$refs.IPCheckRef.ipDataCards.forEach((card) => {
|
||||
card.ip = "8.8.8.8";
|
||||
});
|
||||
this.$refs.webRTCRef.stunServers.forEach((server) => {
|
||||
server.ip = "100.100.200.100";
|
||||
});
|
||||
this.$refs.dnsLeaksRef.leakTest.forEach((server) => {
|
||||
server.ip = "12.34.56.78";
|
||||
});
|
||||
this.infoMaskLevel = 1;
|
||||
} else if (this.infoMaskLevel === 1) {
|
||||
this.$refs.IPCheckRef.ipDataCards.forEach((card) => {
|
||||
card.country_name = "United States";
|
||||
card.country_code = "US";
|
||||
card.region = "California";
|
||||
card.city = "Mountain View";
|
||||
card.latitude = "37.40599";
|
||||
card.longitude = "-122.078514";
|
||||
card.isp = "Google LLC";
|
||||
card.asn = "AS15169";
|
||||
card.mapUrl = '/defaultMap.jpg';
|
||||
card.mapUrl_dark = '/defaultMap_dark.jpg';
|
||||
});
|
||||
this.$refs.dnsLeaksRef.leakTest.forEach((server) => {
|
||||
server.geo = "United States";
|
||||
});
|
||||
this.infoMaskLevel = 2;
|
||||
}
|
||||
},
|
||||
|
||||
// 信息遮罩内容还原
|
||||
infoUnmask() {
|
||||
this.$refs.IPCheckRef.ipDataCards = JSON.parse(JSON.stringify(this.originipDataCards));
|
||||
this.$refs.webRTCRef.stunServers = JSON.parse(JSON.stringify(this.originstunServers));
|
||||
this.$refs.dnsLeaksRef.leakTest = JSON.parse(JSON.stringify(this.originleakTest));
|
||||
this.infoMaskLevel = 0;
|
||||
},
|
||||
|
||||
// 打开与关闭 Modal
|
||||
openModal(id) {
|
||||
const modalElement = document.getElementById(id);
|
||||
const modalInstance = Modal.getOrCreateInstance(modalElement);
|
||||
if (modalInstance) {
|
||||
modalInstance.show();
|
||||
}
|
||||
},
|
||||
closeModal(id) {
|
||||
const modalElement = document.getElementById(id);
|
||||
const modalInstance = Modal.getInstance(modalElement);
|
||||
if (modalInstance) {
|
||||
modalInstance.hide();
|
||||
}
|
||||
},
|
||||
|
||||
// 设置 Modal 的聚焦
|
||||
setupModalFocus() {
|
||||
const modals = document.querySelectorAll(".modal");
|
||||
modals.forEach((modal) => {
|
||||
modal.addEventListener("shown.bs.modal", () => {
|
||||
this.$nextTick(() => {
|
||||
const inputElement = modal.querySelector(".form-control");
|
||||
if (inputElement) {
|
||||
inputElement.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// 滚动到指定元素
|
||||
scrollToElement(el, offset = 0) {
|
||||
const element = typeof el === "string" ? document.getElementById(el) : el;
|
||||
const y = element.getBoundingClientRect().top + window.scrollY - offset;
|
||||
window.scrollTo({ top: y, behavior: "smooth" });
|
||||
},
|
||||
|
||||
// 快捷键
|
||||
registerShortcutKeys() {
|
||||
const shortcutConfig = [
|
||||
{
|
||||
keys: "g",
|
||||
action() {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
},
|
||||
description: this.$t('shortcutKeys.GoToTop'),
|
||||
},
|
||||
{
|
||||
keys: 'j',
|
||||
action: () => navigateCards('down'),
|
||||
description: this.$t('shortcutKeys.GoNext'),
|
||||
},
|
||||
{
|
||||
keys: 'k',
|
||||
action: () => navigateCards('up'),
|
||||
description: this.$t('shortcutKeys.GoPrevious'),
|
||||
},
|
||||
{
|
||||
keys: "G",
|
||||
action() {
|
||||
window.scrollTo({
|
||||
top: document.body.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
},
|
||||
description: this.$t('shortcutKeys.GoToBottom'),
|
||||
},
|
||||
{
|
||||
keys: "D",
|
||||
action: this.$refs.navBarRef.toggleDarkMode,
|
||||
description: this.$t('shortcutKeys.ToggleDarkMode'),
|
||||
},
|
||||
{
|
||||
keys: "R",
|
||||
action: this.refreshEverything,
|
||||
description: this.$t('shortcutKeys.RefreshEverything'),
|
||||
},
|
||||
{
|
||||
keys: "([1-6])",
|
||||
type: "regex",
|
||||
action: (num) => {
|
||||
const card = this.$refs.IPCheckRef.ipDataCards[num - 1];
|
||||
this.scrollToElement("IPInfo", 80);
|
||||
this.$refs.IPCheckRef.refreshCard(card);
|
||||
},
|
||||
description: this.$t('shortcutKeys.RefreshIPCard'),
|
||||
},
|
||||
{
|
||||
keys: "c",
|
||||
action: () => {
|
||||
this.scrollToElement("Connectivity", 80);
|
||||
this.$refs.connectivityRef.checkAllConnectivity(false, true);
|
||||
},
|
||||
description: this.$t('shortcutKeys.RefreshConnectivityTests'),
|
||||
},
|
||||
{
|
||||
keys: "w",
|
||||
action: () => {
|
||||
this.scrollToElement("WebRTC", 80);
|
||||
this.$refs.webRTCRef.checkAllWebRTC(true);
|
||||
},
|
||||
description: this.$t('shortcutKeys.RefreshWebRTC'),
|
||||
},
|
||||
{
|
||||
keys: "d",
|
||||
action: () => {
|
||||
this.scrollToElement("DNSLeakTest", 80);
|
||||
this.$refs.dnsLeaksRef.checkAllDNSLeakTest(true);
|
||||
},
|
||||
description: this.$t('shortcutKeys.RefreshDNSLeakTest'),
|
||||
},
|
||||
{
|
||||
keys: "s",
|
||||
action: () => {
|
||||
this.scrollToElement("SpeedTest", 80);
|
||||
this.$refs.speedTestRef.refreshstartSpeedTest();
|
||||
},
|
||||
description: this.$t('shortcutKeys.StartSpeedTest'),
|
||||
},
|
||||
{
|
||||
keys: "m",
|
||||
action: () => {
|
||||
if (this.$refs.IPCheckRef.isEnvBingMapKey) {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
this.$refs.IPCheckRef.toggleMaps();
|
||||
}
|
||||
},
|
||||
description: this.$t('shortcutKeys.ToggleMaps'),
|
||||
},
|
||||
{
|
||||
keys: "q",
|
||||
action: () => {
|
||||
this.openModal("IPCheck");
|
||||
},
|
||||
description: this.$t('shortcutKeys.IPCheck'),
|
||||
},
|
||||
{
|
||||
keys: "h",
|
||||
action: () => {
|
||||
this.isInfosLoaded && this.toggleInfoMask();
|
||||
},
|
||||
description: this.$t('shortcutKeys.ToggleInfoMask'),
|
||||
},
|
||||
|
||||
// help
|
||||
{
|
||||
keys: "?",
|
||||
action: () => {
|
||||
this.openModal("helpModal");
|
||||
},
|
||||
description: this.$t('shortcutKeys.Help'),
|
||||
},
|
||||
];
|
||||
|
||||
shortcutConfig.forEach(config => mappingKeys(config));
|
||||
},
|
||||
|
||||
// 给 helpModal 发送快捷键内容
|
||||
sendKeyMap() {
|
||||
this.$refs.helpModalRef.keyMap = this.keyMap;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.registerShortcutKeys();
|
||||
this.setupModalFocus();
|
||||
this.keyMap = keyMap;
|
||||
this.sendKeyMap();
|
||||
this.setInfosLoaded();
|
||||
},
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,244 @@
|
||||
<template>
|
||||
<!-- Alerts -->
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3 jn-toast">
|
||||
<div id="toast" class="toast" :class="{ 'dark-mode': isDarkMode }" role="alert" ref="toast" aria-live="assertive"
|
||||
aria-atomic="true">
|
||||
<div class="toast-header" :class="{ 'dark-mode-title': isDarkMode }">
|
||||
<strong class="me-auto" :class="alertStyle">{{ alertTitle }}</strong>
|
||||
<button type="button" class="btn-close" :class="{ 'dark-mode-close-button': isDarkMode }" data-bs-dismiss="toast"
|
||||
aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
{{ alertMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<!-- Connectivity -->
|
||||
<div class="availability-test-section mb-4">
|
||||
<div class="jn-title2">
|
||||
<h2 id="Connectivity" :class="{ 'mobile-h2': isMobile }">🚦 {{ $t('connectivity.Title') }}</h2>
|
||||
<button @click="checkAllConnectivity(false, true)"
|
||||
:class="['btn', isDarkMode ? 'btn-dark dark-mode-refresh' : 'btn-light']"><i
|
||||
class="bi bi-arrow-clockwise"></i></button>
|
||||
</div>
|
||||
<div class="text-secondary">
|
||||
<p>{{ $t('connectivity.Note') }}</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div v-for="test in connectivityTests" :key="test.id" class="col-6 col-md-3 mb-4">
|
||||
<div class="card jn-card" :class="{ 'dark-mode dark-mode-border': isDarkMode }">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><i class="bi" :class="'bi-' + test.icon"></i> {{ test.name }}</h5>
|
||||
|
||||
<p class="card-text"
|
||||
:class="{ 'text-info': test.status === $t('connectivity.StatusWait'), 'text-success': test.status.includes($t('connectivity.StatusAvailable')), 'text-danger': test.status === $t('connectivity.StatusUnavailable') || test.status === $t('connectivity.StatusTimeout') }"
|
||||
v-html="test.status">
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { Toast, } from 'bootstrap';
|
||||
|
||||
|
||||
export default {
|
||||
name: 'Connectivity',
|
||||
|
||||
// 引入 Store
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const isDarkMode = computed(() => store.state.isDarkMode);
|
||||
const isMobile = computed(() => store.state.isMobile);
|
||||
|
||||
return {
|
||||
isDarkMode,
|
||||
isMobile,
|
||||
};
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
alertToShow: false,
|
||||
alertStyle: "",
|
||||
alertTitle: "",
|
||||
alertMessage: "",
|
||||
connectivityTests: [
|
||||
{
|
||||
id: "netease",
|
||||
name: "Netease",
|
||||
icon: "globe-americas",
|
||||
url: "https://www.163.com/favicon.ico?",
|
||||
status: this.$t('connectivity.StatusWait'),
|
||||
},
|
||||
{
|
||||
id: "baidu",
|
||||
name: "Baidu",
|
||||
icon: "globe-americas",
|
||||
url: "https://www.baidu.com/favicon.ico?",
|
||||
status: this.$t('connectivity.StatusWait'),
|
||||
},
|
||||
{
|
||||
id: "wechat",
|
||||
name: "WeChat",
|
||||
icon: "wechat",
|
||||
url: "https://res.wx.qq.com/a/wx_fed/assets/res/NTI4MWU5.ico?",
|
||||
status: this.$t('connectivity.StatusWait'),
|
||||
},
|
||||
{
|
||||
id: "google",
|
||||
name: "Google",
|
||||
icon: "google",
|
||||
url: "https://www.google.com/images/errors/robot.png?",
|
||||
status: this.$t('connectivity.StatusWait'),
|
||||
},
|
||||
{
|
||||
id: "cloudflare",
|
||||
name: "Cloudflare",
|
||||
icon: "cloud-fill",
|
||||
url: "https://www.cloudflare.com/favicon.ico?",
|
||||
status: this.$t('connectivity.StatusWait'),
|
||||
},
|
||||
{
|
||||
id: "youtube",
|
||||
name: "Youtube",
|
||||
icon: "youtube",
|
||||
url: "https://i.ytimg.com/vi/GYkq9Rgoj8E/hq720.jpg?",
|
||||
status: this.$t('connectivity.StatusWait'),
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
name: "Github",
|
||||
icon: "github",
|
||||
url: "https://raw.githubusercontent.com/jason5ng32/fulian4/master/background.jpg?",
|
||||
status: this.$t('connectivity.StatusWait'),
|
||||
},
|
||||
{
|
||||
id: "chatgpt",
|
||||
name: "ChatGPT",
|
||||
icon: "chat-quote-fill",
|
||||
url: "https://chat.openai.com/favicon.ico?",
|
||||
status: this.$t('connectivity.StatusWait'),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
// 检查网络连通性
|
||||
checkConnectivityHandler(test, isAlertToShow, onTestComplete) {
|
||||
const beginTime = +new Date();
|
||||
|
||||
var img = new Image();
|
||||
var timeout = setTimeout(() => {
|
||||
test.status = this.$t('connectivity.StatusUnavailable');
|
||||
onTestComplete(false);
|
||||
}, 3 * 1200);
|
||||
|
||||
img.onload = () => {
|
||||
clearTimeout(timeout);
|
||||
test.status =
|
||||
this.$t('connectivity.StatusAvailable') +
|
||||
` ( ${+new Date() - beginTime} ms )`;
|
||||
onTestComplete(true);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
test.status = this.$t('connectivity.StatusUnavailable');
|
||||
onTestComplete(false);
|
||||
};
|
||||
|
||||
img.src = `${test.url}${Date.now()}`;
|
||||
},
|
||||
|
||||
// 检查所有网络连通性
|
||||
checkAllConnectivity(isAlertToShow, isRefresh) {
|
||||
|
||||
if (isRefresh) {
|
||||
this.connectivityTests.forEach((test) => {
|
||||
test.status = this.$t('connectivity.StatusWait');
|
||||
});
|
||||
}
|
||||
let totalTests = this.connectivityTests.length;
|
||||
let successCount = 0;
|
||||
let completedCount = 0;
|
||||
|
||||
const onTestComplete = (isSuccess) => {
|
||||
if (isSuccess) {
|
||||
successCount++;
|
||||
}
|
||||
completedCount++;
|
||||
|
||||
// 只有当所有测试都完成时才做出最终判断
|
||||
if (completedCount === totalTests) {
|
||||
this.alertToShow = true;
|
||||
if (successCount === totalTests) {
|
||||
this.updateConnectivityAlert(true, "success");
|
||||
} else {
|
||||
this.updateConnectivityAlert(true, "error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.connectivityTests.forEach((test, index) => {
|
||||
setTimeout(() => {
|
||||
this.checkConnectivityHandler(test, isAlertToShow, onTestComplete);
|
||||
}, 50 * index);
|
||||
});
|
||||
|
||||
if (isAlertToShow) {
|
||||
setTimeout(() => {
|
||||
this.showToast();
|
||||
}, 4000);
|
||||
}
|
||||
},
|
||||
|
||||
// 更新通知气泡
|
||||
updateConnectivityAlert(show, type) {
|
||||
this.alertToShow = show;
|
||||
if (type === "success") {
|
||||
this.alertStyle = "text-success";
|
||||
this.alertMessage = this.$t('alert.Congrats_Message');
|
||||
this.alertTitle = this.$t('alert.Congrats');
|
||||
} else {
|
||||
this.alertStyle = "text-danger";
|
||||
this.alertMessage = this.$t('alert.OhNo_Message');
|
||||
this.alertTitle = this.$t('alert.OhNo');
|
||||
}
|
||||
},
|
||||
|
||||
// 通知气泡
|
||||
showToast(duration = 2000) {
|
||||
this.$nextTick(() => {
|
||||
const toastEl = this.$refs.toast;
|
||||
if (toastEl) {
|
||||
const toast = new Toast(toastEl, {
|
||||
delay: duration
|
||||
});
|
||||
toast.show();
|
||||
} else {
|
||||
console.error("Toast element not found");
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
setTimeout(() => {
|
||||
this.checkAllConnectivity(true, false);
|
||||
}, 2000);
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<!-- DNS Leaks Test -->
|
||||
<div class="dnsleak-test-section mb-4">
|
||||
<div class="jn-title2">
|
||||
<h2 id="DNSLeakTest" :class="{ 'mobile-h2': isMobile }">🛑 {{ $t('dnsleaktest.Title') }}</h2>
|
||||
<button @click="checkAllDNSLeakTest(true)"
|
||||
:class="['btn', isDarkMode ? 'btn-dark dark-mode-refresh' : 'btn-light']"><i
|
||||
class="bi bi-arrow-clockwise"></i></button>
|
||||
</div>
|
||||
<div class="text-secondary">
|
||||
<p>{{ $t('dnsleaktest.Note') }}</p>
|
||||
<p>{{ $t('dnsleaktest.Note2') }}</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div v-for="leak in leakTest" :key="leak.id" class="col-6 col-md-3 mb-4">
|
||||
<div class="card jn-card" :class="{ 'dark-mode dark-mode-border': isDarkMode }">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><i class="bi bi-server"></i> {{ leak.name }}</h5>
|
||||
<p class="card-text text-secondary" style="font-size: 10pt;">{{ $t('dnsleaktest.Endpoint') }}: {{
|
||||
leak.ip }}</p>
|
||||
<p class="card-text" :class="[
|
||||
'text-success',
|
||||
{ 'text-info': leak.geo === $t('dnsleaktest.StatusWait'), 'text-danger': leak.geo.includes('China') || leak.geo.includes('china') }
|
||||
]">{{ $t('dnsleaktest.EndpointCountry') }}: {{ leak.geo }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'DNSLeaks',
|
||||
|
||||
// 引入 Store
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const isDarkMode = computed(() => store.state.isDarkMode);
|
||||
const isMobile = computed(() => store.state.isMobile);
|
||||
|
||||
return {
|
||||
isDarkMode,
|
||||
isMobile,
|
||||
};
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
leakTest: [
|
||||
{
|
||||
id: "ipapi1",
|
||||
name: this.$t('dnsleaktest.Name') + " 1",
|
||||
geo: this.$t('dnsleaktest.StatusWait'),
|
||||
ip: this.$t('dnsleaktest.StatusWait'),
|
||||
},
|
||||
{
|
||||
id: "ipapi2",
|
||||
name: this.$t('dnsleaktest.Name') + " 2",
|
||||
geo: this.$t('dnsleaktest.StatusWait'),
|
||||
ip: this.$t('dnsleaktest.StatusWait'),
|
||||
},
|
||||
{
|
||||
id: "sfshark1",
|
||||
name: this.$t('dnsleaktest.Name') + " 3",
|
||||
geo: this.$t('dnsleaktest.StatusWait'),
|
||||
ip: "",
|
||||
},
|
||||
{
|
||||
id: "sfshark2",
|
||||
name: this.$t('dnsleaktest.Name') + " 4",
|
||||
geo: this.$t('dnsleaktest.StatusWait'),
|
||||
ip: this.$t('dnsleaktest.StatusWait'),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
// 生成 32 位随机字符串
|
||||
generate32DigitString() {
|
||||
const unixTime = Date.now().toString();
|
||||
const fixedString = "jason5ng32";
|
||||
const randomString = Math.random().toString(36).substring(2, 11);
|
||||
|
||||
return unixTime + fixedString + randomString;
|
||||
},
|
||||
|
||||
// 生成 14 位随机字符串
|
||||
generate14DigitString() {
|
||||
const fixedString = "jn32";
|
||||
const randomString = Math.random().toString(36).substring(2, 11);
|
||||
|
||||
return fixedString + randomString;
|
||||
},
|
||||
|
||||
// DNS 泄露测试 1
|
||||
fetchLeakTestIpApiCom(index) {
|
||||
const urlString = this.generate32DigitString();
|
||||
const url = `https://${urlString}.edns.ip-api.com/json`;
|
||||
|
||||
fetch(url)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.dns && "geo" in data.dns && "ip" in data.dns) {
|
||||
const geoSplit = data.dns.geo.split(" - ");
|
||||
this.leakTest[index].geo = geoSplit[0];
|
||||
this.leakTest[index].ip = data.dns.ip;
|
||||
} else {
|
||||
console.error("Unexpected data structure:", data);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching leak test data:", error);
|
||||
this.leakTest[index].geo = this.$t('dnsleaktest.StatusError');
|
||||
this.leakTest[index].ip = this.$t('dnsleaktest.StatusError');
|
||||
});
|
||||
},
|
||||
|
||||
// DNS 泄露测试 2
|
||||
fetchLeakTestSfSharkCom(index, key) {
|
||||
const urlString = this.generate14DigitString();
|
||||
const url = `https://${urlString}.ipv4.surfsharkdns.com`;
|
||||
|
||||
fetch(url)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
const getKey = Object.keys(data)[key];
|
||||
const keyEntry = data[getKey];
|
||||
|
||||
if (keyEntry && keyEntry.Country && keyEntry.IP) {
|
||||
this.leakTest[index].geo = keyEntry.Country;
|
||||
this.leakTest[index].ip = keyEntry.IP;
|
||||
} else {
|
||||
console.error("Unexpected data structure:", data);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching leak test data:", error);
|
||||
this.leakTest[index].geo = this.$t('dnsleaktest.StatusError');
|
||||
this.leakTest[index].ip = this.$t('dnsleaktest.StatusError');
|
||||
});
|
||||
},
|
||||
|
||||
// 检查所有 DNS 泄露测试
|
||||
checkAllDNSLeakTest(isRefresh) {
|
||||
if (isRefresh) {
|
||||
this.leakTest.forEach((server) => {
|
||||
server.geo = this.$t('dnsleaktest.StatusWait');
|
||||
server.ip = this.$t('dnsleaktest.StatusWait');
|
||||
});
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.fetchLeakTestIpApiCom(0);
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
this.fetchLeakTestIpApiCom(1);
|
||||
}, 1000);
|
||||
|
||||
setTimeout(() => {
|
||||
this.fetchLeakTestSfSharkCom(2, 0);
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
this.fetchLeakTestSfSharkCom(3, 0);
|
||||
}, 1000);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
setTimeout(() => {
|
||||
this.checkAllDNSLeakTest(true);
|
||||
}, 2500);
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Your style code goes here */
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<footer>
|
||||
<p class="text-center">{{ $t('page.footerName') }} <a :href="$t('page.footerLink')" class="link-dark"
|
||||
target="_blank"><i class="bi bi-github" :class="{ 'dark-mode': isDarkMode }"></i></a>
|
||||
</p>
|
||||
<p v-if="!siteValidate" class="text-center fs-6 fw-light" style="opacity: 0.5;">
|
||||
{{ $t('page.copyRightName') }} <a :href="$t('page.copyRightLink')" class="link-underline-light" target="_blank"
|
||||
:class="[isDarkMode ? 'dark-mode link-light' : 'link-dark']">{{ $t('page.copyRightLinkName') }}</a>
|
||||
</p>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'Footer',
|
||||
|
||||
// 引入 Store
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const isDarkMode = computed(() => store.state.isDarkMode);
|
||||
const isMobile = computed(() => store.state.isMobile);
|
||||
const siteVal = computed(() => store.state.siteValidate);
|
||||
const siteValidate = ref(true);
|
||||
|
||||
// 监控 siteVal 的变化
|
||||
watch(siteVal, (newVal) => {
|
||||
siteValidate.value = newVal;
|
||||
console.log('siteValidate: ', siteValidate.value);
|
||||
});
|
||||
|
||||
|
||||
return {
|
||||
isDarkMode,
|
||||
isMobile,
|
||||
siteValidate,
|
||||
};
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<!-- Ping Test -->
|
||||
<div class="ping-test-section mb-4">
|
||||
<div class="jn-title2">
|
||||
<h2 id="PingTest" :class="{ 'mobile-h2': isMobile }">🌐 {{ $t('pingtest.Title') }}</h2>
|
||||
|
||||
</div>
|
||||
<div class="text-secondary">
|
||||
<p>{{ $t('pingtest.Note') }}</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card jn-card" :class="{ 'dark-mode dark-mode-border': isDarkMode }">
|
||||
<div class="card-body">
|
||||
<!-- Dropdown for IP Selection -->
|
||||
<div class="row mt-3 mb-3 align-items-center justify-content-center">
|
||||
<div class="col-12 col-md-auto">
|
||||
<label for="pingIP" class="col-form-label">{{ $t('pingtest.Note2') }}</label>
|
||||
</div>
|
||||
<div class="col-12 col-md-auto mt-2 mt-md-0">
|
||||
<div class="row justify-content-between">
|
||||
<div class="col-auto">
|
||||
<select id="pingIP" class="form-select jn-ping-form-select" v-model="selectedIP"
|
||||
:class="{ 'bg-dark text-light': isDarkMode }">
|
||||
<option disabled value="">{{ $t('pingtest.SelectIP') }}</option>
|
||||
<option v-for="ip in allIPs" :key="ip" :value="ip">{{ ip }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-primary" @click="startPingCheck"
|
||||
:disabled="pingCheckStatus === 'running' || selectedIP === ''">
|
||||
<span
|
||||
v-if="pingCheckStatus === 'idle' || pingCheckStatus === 'finished' || pingCheckStatus === 'error'">{{
|
||||
$t('pingtest.Run') }}</span>
|
||||
<span v-if="pingCheckStatus === 'running'" class="spinner-grow spinner-grow-sm"
|
||||
aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result Display -->
|
||||
<div id="pingresult" v-if="pingResults.length > 0">
|
||||
<div class="table-responsive text-nowrap">
|
||||
<table class="table" :class="{ 'table-dark': isDarkMode }">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{ $t('pingtest.Region') }}</th>
|
||||
<th scope="col">{{ $t('pingtest.MinDelay') }}</th>
|
||||
<th scope="col">{{ $t('pingtest.MaxDelay') }}</th>
|
||||
<th scope="col">{{ $t('pingtest.AvgDelay') }}</th>
|
||||
<th scope="col">{{ $t('pingtest.TotalPackets') }}</th>
|
||||
<th scope="col">{{ $t('pingtest.PacketLoss') }}</th>
|
||||
<th scope="col">{{ $t('pingtest.ReceivedPackets') }}</th>
|
||||
<th scope="col">{{ $t('pingtest.DroppedPackets') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="result in pingResults" :key="result.country">
|
||||
<td>
|
||||
<span :class="'fi fi-' + result.country.toLowerCase()"></span>
|
||||
{{ result.country }}
|
||||
</td>
|
||||
<td :class="result.stats.min < 100 ? 'text-success' : ''">{{ result.stats.min.toFixed(1) }}
|
||||
</td>
|
||||
<td :class="result.stats.max < 100 ? 'text-success' : ''">{{ result.stats.max.toFixed(1) }}
|
||||
</td>
|
||||
<td :class="result.stats.avg < 100 ? 'text-success' : ''">{{ result.stats.avg.toFixed(1) }}
|
||||
</td>
|
||||
<td>{{ result.stats.total }}</td>
|
||||
<td>{{ Math.round(result.stats.loss) }}%</td>
|
||||
<td>{{ result.stats.rcv }}</td>
|
||||
<td>{{ result.stats.drop }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pingresult-error" v-if="pingCheckStatus === 'error'">
|
||||
<p class="text-center text-danger">{{ $t('pingtest.Error') }}</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'GlobalLatency',
|
||||
|
||||
// 引入 Store
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const isDarkMode = computed(() => store.state.isDarkMode);
|
||||
const isMobile = computed(() => store.state.isMobile);
|
||||
const ipDataCards = computed(() => store.state.Global_ipDataCards);
|
||||
const allIPs = ref([]); // 创建响应式引用
|
||||
|
||||
const getAllIPs = (cards) => {
|
||||
let Global_allIPs = []; // 初始化数组
|
||||
cards.forEach(card => {
|
||||
if (card.ip && !card.ip.includes(' ') && !card.ip.includes(':')) {
|
||||
Global_allIPs.push(card.ip);
|
||||
}
|
||||
});
|
||||
Global_allIPs = [...new Set(Global_allIPs)]; // 去重
|
||||
allIPs.value = Global_allIPs; // 更新 allIPs 响应式引用
|
||||
|
||||
};
|
||||
|
||||
// 监听 ipDataCards 的变化
|
||||
watch(ipDataCards, (newVal) => {
|
||||
getAllIPs(newVal);
|
||||
});
|
||||
|
||||
return {
|
||||
isDarkMode,
|
||||
isMobile,
|
||||
allIPs,
|
||||
};
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
selectedIP: '',
|
||||
pingResults: {},
|
||||
pingCheckStatus: "idle",
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
// 发起 ping 测试
|
||||
startPingCheck() {
|
||||
// 清空上一次结果
|
||||
this.pingResults = [];
|
||||
let tryCount = 0;
|
||||
// 子函数:发起 ping 请求
|
||||
const sendPingRequest = async () => {
|
||||
this.pingCheckStatus = "running";
|
||||
try {
|
||||
const response = await fetch("https://api.globalping.io/v1/measurements", {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
limit: 12,
|
||||
locations: [
|
||||
{ country: "HK" },
|
||||
{ country: "TW" },
|
||||
{ country: "US" },
|
||||
{ country: "CA" },
|
||||
{ country: "JP" },
|
||||
{ country: "SG" },
|
||||
{ country: "AU" },
|
||||
{ country: "GB" },
|
||||
{ country: "DE" },
|
||||
{ country: "FR" },
|
||||
{ country: "BR" },
|
||||
{ country: "IN" },
|
||||
],
|
||||
target: this.selectedIP, // 使用用户选中的 IP 地址
|
||||
type: "ping",
|
||||
measurementOptions: {
|
||||
packets: 8
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Error sending ping request:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 子函数:获取 ping 结果
|
||||
const fetchPingResults = async (id) => {
|
||||
try {
|
||||
const response = await fetch(`https://api.globalping.io/v1/measurements/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.processPingResults(data);
|
||||
|
||||
if (data.status === "in-progress" && tryCount < 4) {
|
||||
setTimeout(() => fetchPingResults(id), 1000);
|
||||
tryCount++;
|
||||
} else {
|
||||
// 如果 this.pingResults 是空数组,返回错误信息
|
||||
if (this.pingResults.length === 0) {
|
||||
this.pingCheckStatus = "error";
|
||||
} else {
|
||||
this.pingCheckStatus = "finished";
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching ping results:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 执行流程
|
||||
sendPingRequest().then(data => {
|
||||
if (data && data.id) {
|
||||
setTimeout(() => {
|
||||
fetchPingResults(data.id);
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
},
|
||||
processPingResults(data) {
|
||||
const cleanedData = data.results
|
||||
.filter(item => item.result.status === "finished")
|
||||
.filter(item => item.result.stats.min !== null)
|
||||
.map(item => ({
|
||||
country: item.probe.country,
|
||||
stats: item.result.stats
|
||||
}));
|
||||
|
||||
this.pingResults = cleanedData;
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<!-- Help Modal -->
|
||||
<div class="modal fade" id="helpModal" tabindex="-1" aria-labelledby="helpModal" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
|
||||
<div class="modal-content" :class="{ 'dark-mode dark-mode-border': isDarkMode }">
|
||||
<div class="modal-header" :class="{ 'dark-mode-border': isDarkMode }">
|
||||
<h5 class="modal-title" id="helpModal">{{ $t('helpModal.Title') }}</h5>
|
||||
<button type="button" class="btn-close" :class="{ 'dark-mode-close-button': isDarkMode }"
|
||||
data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
|
||||
</div>
|
||||
<div class="modal-body" :class="{ 'dark-mode': isDarkMode }">
|
||||
<p class="jn-help-note "><i class="bi bi-hand-thumbs-up-fill"></i>
|
||||
{{ $t('shortcutKeys.HelpNote') }}
|
||||
</p>
|
||||
<div v-for="(key, index) in keyMap" :key="key.keys" class="row p-2 jn-shortcut-row"
|
||||
:class="[isDarkMode ? 'border-dark-subtle jn-dark-mode-help-border' : 'border-light-subtle', index === keyMap.length - 1 ? 'jn-border-bottom-none' : '']">
|
||||
<div class="col">
|
||||
<kbd :class="{ 'text-bg-light': isDarkMode }">
|
||||
{{ key.keys.replace(/[\[\]\\\(\)]/g, '') }}
|
||||
</kbd>
|
||||
</div>
|
||||
<div class="col-8">{{ key.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { Modal } from 'bootstrap';
|
||||
|
||||
export default {
|
||||
name: 'HelpModal',
|
||||
|
||||
// 引入 Store
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const isDarkMode = computed(() => store.state.isDarkMode);
|
||||
const isMobile = computed(() => store.state.isMobile);
|
||||
|
||||
return {
|
||||
isDarkMode,
|
||||
isMobile
|
||||
};
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
keyMap: [],
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,580 @@
|
||||
<template>
|
||||
<!-- IP Infos -->
|
||||
<div class="ip-data-section mb-4">
|
||||
<div class="row" :class="{ 'jn-title2': !isMobile, 'jn-title': isMobile }">
|
||||
<h2 id="IPInfo" class="col-4" :class="{ 'mobile-h2': isMobile }">🕵️
|
||||
{{ $t('ipInfos.Title') }}</h2>
|
||||
<div class="form-check form-switch col-8 jn-radio">
|
||||
|
||||
<div :class="{ 'col-4': isMobile }">
|
||||
<input v-if="isMobile" class="form-check-input" type="checkbox" id="collapseSwitch" @change="toggleCollapse"
|
||||
:checked="!isCardsCollapsed">
|
||||
<label v-if="isMobile" class="form-check-label" for="collapseSwitch"> <i
|
||||
class="bi bi-list-columns-reverse"></i></label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="flexSwitchCheckDefault" @change="toggleMaps"
|
||||
:checked="isMapShown" :disabled="!isEnvBingMapKey">
|
||||
|
||||
<label class="form-check-label" for="flexSwitchCheckDefault">
|
||||
<i :class="['bi', isEnvBingMapKey ? 'bi bi-map-fill' : 'bi bi-map']"></i>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-secondary">
|
||||
<p>{{ $t('ipInfos.Notes') }}</p>
|
||||
</div>
|
||||
<div class="jn-card-deck">
|
||||
<div class="row">
|
||||
<div v-for="card in ipDataCards" :key="card.id" :ref="card.id"
|
||||
:class="{ 'jn-opacity': !card.asn, 'col-xl-4': true, 'col-lg-6': true, 'col-md-6': true, 'mb-4': true }">
|
||||
<div class="card jn-card" :class="{ 'dark-mode dark-mode-border': isDarkMode }">
|
||||
<div class="card-header jn-ip-title" :class="{ 'dark-mode-title': isDarkMode }" style="font-weight: bold;">
|
||||
<span>{{ $t('ipInfos.Source') }}: {{ card.source }}</span>
|
||||
<button @click="refreshCard(card)"
|
||||
:class="['btn', isDarkMode ? 'btn-dark dark-mode-refresh' : 'btn-light']">
|
||||
<i class="bi bi-arrow-clockwise"></i></button>
|
||||
</div>
|
||||
<img v-if="isMapShown" :src="isDarkMode ? card.mapUrl_dark : card.mapUrl" class="card-img-top jn-map-image"
|
||||
alt="Map">
|
||||
|
||||
<div class="card-body">
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item jn-list-group-item" :class="{ 'dark-mode': isDarkMode }"><span
|
||||
class="jn-text">🖥️ {{ $t('ipInfos.IP') }}</span>: {{ card.ip
|
||||
}}
|
||||
<i v-if="isValidIP(card.ip)"
|
||||
:class="copiedStatus[card.id] ? 'bi bi-clipboard-check-fill' : 'bi bi-clipboard-plus'"
|
||||
@click="copyToClipboard(card.ip, card.id)"></i>
|
||||
</li>
|
||||
<li class="list-group-item jn-list-group-item"
|
||||
:class="{ 'dark-mode': isDarkMode, 'mobile-list': isMobile && isCardsCollapsed }">
|
||||
<span class="jn-text">🌍 {{ $t('ipInfos.Country') }}</span>: {{
|
||||
card.country_name }} <span v-if="card.country_code"
|
||||
:class="'fi fi-' + card.country_code.toLowerCase()"></span>
|
||||
</li>
|
||||
<li v-show="!isMobile || !isCardsCollapsed" class="list-group-item jn-list-group-item"
|
||||
:class="{ 'dark-mode': isDarkMode }"><span class="jn-text">🏚️
|
||||
{{ $t('ipInfos.Region') }}</span>: {{ card.region
|
||||
}}</li>
|
||||
<li v-show="!isMobile || !isCardsCollapsed" class="list-group-item jn-list-group-item"
|
||||
:class="{ 'dark-mode': isDarkMode }"><span class="jn-text">🚏
|
||||
{{ $t('ipInfos.City') }}</span>: {{ card.city }}
|
||||
</li>
|
||||
<li v-show="!isMobile || !isCardsCollapsed" class="list-group-item jn-list-group-item"
|
||||
:class="{ 'dark-mode': isDarkMode }"><span class="jn-text">🏢
|
||||
{{ $t('ipInfos.ISP') }}</span>: {{ card.isp }}
|
||||
</li>
|
||||
<li v-show="!isMobile || !isCardsCollapsed" class="list-group-item jn-list-group-item"
|
||||
:class="{ 'dark-mode': isDarkMode }"><span class="jn-text">📶
|
||||
{{ $t('ipInfos.ASN') }}</span>: <a v-if="card.asnlink" :href="card.asnlink" target="_blank">{{
|
||||
card.asn }}</a><a v-else>{{ card.asn }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'IPCheck',
|
||||
|
||||
// 引入 Store
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const isDarkMode = computed(() => store.state.isDarkMode);
|
||||
const isMobile = computed(() => store.state.isMobile);
|
||||
|
||||
|
||||
return {
|
||||
isDarkMode,
|
||||
isMobile,
|
||||
};
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
isCardsCollapsed: JSON.parse(localStorage.getItem('isCardsCollapsed')) || false,
|
||||
ipDataCards: [
|
||||
{
|
||||
id: "taobao",
|
||||
ip: "",
|
||||
country_name: "",
|
||||
region: "",
|
||||
city: "",
|
||||
latitude: "",
|
||||
longitude: "",
|
||||
isp: "",
|
||||
asn: "",
|
||||
asnlink: "",
|
||||
mapUrl: '/defaultMap.jpg',
|
||||
mapUrl_dark: '/defaultMap_dark.jpg',
|
||||
showMap: false,
|
||||
source: "Taobao",
|
||||
},
|
||||
{
|
||||
id: "special",
|
||||
ip: "",
|
||||
country_name: "",
|
||||
region: "",
|
||||
city: "",
|
||||
latitude: "",
|
||||
longitude: "",
|
||||
isp: "",
|
||||
asn: "",
|
||||
asnlink: "",
|
||||
mapUrl: '/defaultMap.jpg',
|
||||
mapUrl_dark: '/defaultMap_dark.jpg',
|
||||
showMap: false,
|
||||
source: "Special",
|
||||
},
|
||||
{
|
||||
id: "cloudflare_v4",
|
||||
ip: "",
|
||||
country_name: "",
|
||||
region: "",
|
||||
city: "",
|
||||
latitude: "",
|
||||
longitude: "",
|
||||
isp: "",
|
||||
asn: "",
|
||||
asnlink: "",
|
||||
mapUrl: '/defaultMap.jpg',
|
||||
mapUrl_dark: '/defaultMap_dark.jpg',
|
||||
showMap: false,
|
||||
source: "Cloudflare IPv4",
|
||||
},
|
||||
{
|
||||
id: "cloudflare_v6",
|
||||
ip: "",
|
||||
country_name: "",
|
||||
region: "",
|
||||
city: "",
|
||||
latitude: "",
|
||||
longitude: "",
|
||||
isp: "",
|
||||
asn: "",
|
||||
asnlink: "",
|
||||
mapUrl: '/defaultMap.jpg',
|
||||
mapUrl_dark: '/defaultMap_dark.jpg',
|
||||
showMap: false,
|
||||
source: "Cloudflare IPv6",
|
||||
},
|
||||
{
|
||||
id: "ipify_v4",
|
||||
ip: "",
|
||||
country_name: "",
|
||||
region: "",
|
||||
city: "",
|
||||
latitude: "",
|
||||
longitude: "",
|
||||
isp: "",
|
||||
asn: "",
|
||||
asnlink: "",
|
||||
mapUrl: '/defaultMap.jpg',
|
||||
mapUrl_dark: '/defaultMap_dark.jpg',
|
||||
showMap: false,
|
||||
source: "IPify IPv4",
|
||||
},
|
||||
{
|
||||
id: "ipify_v6",
|
||||
ip: "",
|
||||
country_name: "",
|
||||
region: "",
|
||||
city: "",
|
||||
latitude: "",
|
||||
longitude: "",
|
||||
isp: "",
|
||||
asn: "",
|
||||
asnlink: "",
|
||||
mapUrl: '/defaultMap.jpg',
|
||||
mapUrl_dark: '/defaultMap_dark.jpg',
|
||||
showMap: false,
|
||||
source: "IPify IPv6",
|
||||
},
|
||||
],
|
||||
isEnvBingMapKey: false,
|
||||
isMapShown: false,
|
||||
ipDataCache: new Map(),
|
||||
copiedStatus: {},
|
||||
bingMapLanguage: this.$isLangCN ? 'zh' : 'en',
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
// 检查 IP 地址是否合法
|
||||
isValidIP(ip) {
|
||||
const ipv4Pattern =
|
||||
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
const ipv6Pattern =
|
||||
/^(([0-9a-fA-F]{1,4}:){7}([0-9a-fA-F]{1,4})|(([0-9a-fA-F]{1,4}:){0,6}([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:){0,6}([0-9a-fA-F]{1,4})?))$/;
|
||||
return ipv4Pattern.test(ip) || ipv6Pattern.test(ip);
|
||||
},
|
||||
|
||||
// 切换地图显示
|
||||
toggleMaps() {
|
||||
this.isMapShown = !this.isMapShown;
|
||||
this.ipDataCards.forEach((card) => {
|
||||
card.showMap = this.isMapShown;
|
||||
});
|
||||
},
|
||||
|
||||
// 切换卡片折叠
|
||||
toggleCollapse() {
|
||||
this.isCardsCollapsed = !this.isCardsCollapsed;
|
||||
},
|
||||
|
||||
// 验证 Bing Map Key
|
||||
validateBingMapKey() {
|
||||
fetch('/api/validate-map-key')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
this.isEnvBingMapKey = data.isValid;
|
||||
if (!this.isEnvBingMapKey) {
|
||||
this.isMapShown = false;
|
||||
} else if (localStorage.getItem("isMapShown")) {
|
||||
this.isMapShown = localStorage.getItem("isMapShown") === "true";
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error validating Bing Map Key:', error);
|
||||
this.isEnvBingMapKey = false;
|
||||
this.isMapShown = false;
|
||||
});
|
||||
},
|
||||
|
||||
// 从淘宝获取 IP 地址
|
||||
getIPFromTaobao() {
|
||||
window.ipCallback = (data) => {
|
||||
var ip = data.ip;
|
||||
this.ipDataCards[0].source = "TaoBao";
|
||||
this.fetchIPDetails(0, ip);
|
||||
delete window.ipCallback;
|
||||
};
|
||||
var script = document.createElement("script");
|
||||
script.src = "https://www.taobao.com/help/getip.php?callback=ipCallback";
|
||||
document.head.appendChild(script);
|
||||
document.head.removeChild(script);
|
||||
},
|
||||
|
||||
// 从特殊源获取 IP 地址
|
||||
getIPFromSpecial() {
|
||||
fetch('/api/validate-site')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// 将 data.isIpCheckEnabled 写入到 vuex 的 Global_siteValidate 中
|
||||
this.$store.commit('updateGlobalSiteValidate', data.isIpCheckEnabled);
|
||||
|
||||
if (data.isIpCheckEnabled) {
|
||||
this.getIPFromGCR();
|
||||
} else {
|
||||
this.getIPFromUpai();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 从 Upai 获取 IP 地址
|
||||
getIPFromUpai() {
|
||||
const unixTime = Date.now();
|
||||
const url = `https://pubstatic.b0.upaiyun.com/?_upnode&t=${unixTime}`;
|
||||
|
||||
fetch(url)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
const ip = data.remote_addr;
|
||||
this.ipDataCards[1].source = "Upai"
|
||||
this.fetchIPDetails(1, ip);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching IP from Upai:", error);
|
||||
this.ipDataCards[1].ip = this.$t('ipInfos.IPv4Error');
|
||||
});
|
||||
},
|
||||
|
||||
// 从 GCR 获取 IP 地址
|
||||
getIPFromGCR() {
|
||||
const url = `https://getipfromgoogle.ipcheck.ing/`;
|
||||
|
||||
fetch(url)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
const fullIp = data.ip;
|
||||
const ip = fullIp.includes(',') ? fullIp.split(',')[0] : fullIp;
|
||||
this.ipDataCards[1].source = "IPCheck.ing";
|
||||
this.fetchIPDetails(1, ip);
|
||||
})
|
||||
|
||||
.catch((error) => {
|
||||
console.error("Error fetching IP from IPCheck.ing:", error);
|
||||
this.getIPFromUpai();
|
||||
});
|
||||
},
|
||||
|
||||
// 从 Cloudflare 获取 IPv4 地址
|
||||
getIPFromCloudflare_V4() {
|
||||
fetch("https://1.0.0.1/cdn-cgi/trace")
|
||||
.then((response) => response.text())
|
||||
.then((data) => {
|
||||
const lines = data.split("\n");
|
||||
const ipLine = lines.find((line) => line.startsWith("ip="));
|
||||
if (ipLine) {
|
||||
const ip = ipLine.split("=")[1];
|
||||
this.fetchIPDetails(2, ip);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching IP from Cloudflare:", error);
|
||||
this.ipDataCards[2].ip = this.$t('ipInfos.IPv4Error');
|
||||
});
|
||||
},
|
||||
|
||||
// 从 Cloudflare 获取 IPv6 地址
|
||||
getIPFromCloudflare_V6() {
|
||||
fetch("https://[2606:4700:4700::1111]/cdn-cgi/trace")
|
||||
.then((response) => response.text())
|
||||
.then((data) => {
|
||||
const lines = data.split("\n");
|
||||
const ipLine = lines.find((line) => line.startsWith("ip="));
|
||||
if (ipLine) {
|
||||
const ip = ipLine.split("=")[1];
|
||||
this.fetchIPDetails(3, ip);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching IP from Cloudflare:", error);
|
||||
this.ipDataCards[3].ip = this.$t('ipInfos.IPv6Error');
|
||||
});
|
||||
},
|
||||
|
||||
// 从 IPify 获取 IPv4 地址
|
||||
getIPFromIpify_V4() {
|
||||
fetch("https://api4.ipify.org?format=json")
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
this.fetchIPDetails(4, data.ip);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching IPv4 address from ipify:", error);
|
||||
this.ipDataCards[4].ip = this.$t('ipInfos.IPv4Error');
|
||||
});
|
||||
},
|
||||
|
||||
// 从 IPify 获取 IPv6 地址
|
||||
getIPFromIpify_V6() {
|
||||
fetch("https://api6.ipify.org?format=json")
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
this.fetchIPDetails(5, data.ip);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching IPv6 address from ipify:", error);
|
||||
this.ipDataCards[5].ip = this.$t('ipInfos.IPv6Error');
|
||||
});
|
||||
},
|
||||
|
||||
// 从 IP 地址获取 IP 详细信息
|
||||
async fetchIPDetails(cardIndex, ip) {
|
||||
const card = this.ipDataCards[cardIndex];
|
||||
card.ip = ip;
|
||||
|
||||
// 检查缓存中是否已有该 IP 的数据
|
||||
if (this.ipDataCache.has(ip)) {
|
||||
// 使用缓存的数据填充卡片
|
||||
const cachedData = this.ipDataCache.get(ip);
|
||||
Object.assign(card, cachedData);
|
||||
return;
|
||||
}
|
||||
|
||||
// 尝试从多个不同的源获取数据
|
||||
const sources = [
|
||||
// { url: `/api/ipinfo?ip=${ip}`, transform: this.transformDataFromIPapi },
|
||||
// { url: `https://ipapi.co/${ip}/json/`, transform: this.transformDataFromIPapi },
|
||||
{ url: `/api/ipapicom?ip=${ip}`, transform: this.transformDataFromIPapi }
|
||||
];
|
||||
|
||||
for (const source of sources) {
|
||||
try {
|
||||
const response = await fetch(source.url);
|
||||
const data = await response.json();
|
||||
|
||||
// 根据数据源进行数据转换
|
||||
const cardData = source.transform(data);
|
||||
|
||||
if (cardData) {
|
||||
Object.assign(card, cardData);
|
||||
this.ipDataCache.set(ip, cardData);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching IP details:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 格式化 IP 数据
|
||||
transformDataFromIPapi(data) {
|
||||
if (data.error) {
|
||||
throw new Error(data.reason);
|
||||
}
|
||||
|
||||
return {
|
||||
country_name: data.country_name || "",
|
||||
country_code: data.country || "",
|
||||
region: data.region || "",
|
||||
city: data.city || "",
|
||||
latitude: data.latitude || "",
|
||||
longitude: data.longitude || "",
|
||||
isp: data.org || "",
|
||||
asn: data.asn || "",
|
||||
asnlink: data.asn ? `https://radar.cloudflare.com/traffic/${data.asn}` : false,
|
||||
mapUrl: data.latitude && data.longitude ? `/api/map?latitude=${data.latitude}&longitude=${data.longitude}&language=${this.bingMapLanguage}&CanvasMode=CanvasLight` : "",
|
||||
mapUrl_dark: data.latitude && data.longitude ? `/api/map?latitude=${data.latitude}&longitude=${data.longitude}&language=${this.bingMapLanguage}&CanvasMode=RoadDark` : ""
|
||||
};
|
||||
},
|
||||
|
||||
// 检查所有 IP 地址
|
||||
checkAllIPs() {
|
||||
setTimeout(() => {
|
||||
this.getIPFromSpecial();
|
||||
}, 500);
|
||||
setTimeout(() => {
|
||||
this.getIPFromTaobao();
|
||||
}, 500);
|
||||
setTimeout(() => {
|
||||
this.getIPFromCloudflare_V4();
|
||||
}, 500);
|
||||
setTimeout(() => {
|
||||
this.getIPFromCloudflare_V6();
|
||||
}, 100);
|
||||
setTimeout(() => {
|
||||
this.getIPFromIpify_V4();
|
||||
}, 1000);
|
||||
setTimeout(() => {
|
||||
this.getIPFromIpify_V6();
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
// 清空卡片数据
|
||||
refreshCard(card) {
|
||||
this.clearCardData(card);
|
||||
switch (card.source) {
|
||||
case "Cloudflare IPv4":
|
||||
this.getIPFromCloudflare_V4(card);
|
||||
break;
|
||||
case "Cloudflare IPv6":
|
||||
this.getIPFromCloudflare_V6(card);
|
||||
break;
|
||||
case "IPify IPv4":
|
||||
this.getIPFromIpify_V4(card);
|
||||
break;
|
||||
case "IPify IPv6":
|
||||
this.getIPFromIpify_V6(card);
|
||||
break;
|
||||
case "Upai":
|
||||
this.getIPFromUpai(card);
|
||||
break;
|
||||
case "IPCheck.ing":
|
||||
this.getIPFromGCR(card);
|
||||
break;
|
||||
case "TaoBao":
|
||||
this.getIPFromTaobao(card);
|
||||
break;
|
||||
default:
|
||||
console.error("Undefind Source:", card.source);
|
||||
}
|
||||
},
|
||||
|
||||
// 清空卡片数据
|
||||
clearCardData(card) {
|
||||
card.ip = "";
|
||||
card.country_name = "";
|
||||
card.country_code = "";
|
||||
card.region = "";
|
||||
card.city = "";
|
||||
card.latitude = "";
|
||||
card.longitude = "";
|
||||
card.asn = "";
|
||||
card.isp = "";
|
||||
card.mapUrl = '/defaultMap.jpg';
|
||||
card.mapUrl_dark = '/defaultMap_dark.jpg';
|
||||
},
|
||||
|
||||
// 复制 IP 地址
|
||||
copyToClipboard(ip, id) {
|
||||
navigator.clipboard.writeText(ip).then(() => {
|
||||
this.copiedStatus[id] = true;
|
||||
setTimeout(() => {
|
||||
this.copiedStatus[id] = false;
|
||||
}, 5000);
|
||||
}).catch(err => {
|
||||
console.error('Copy error', err);
|
||||
});
|
||||
},
|
||||
|
||||
// 将 ipDataCards 中的数据写入到 vuex 的 Global_ipDataCards 中
|
||||
updateGlobalIPDataCards() {
|
||||
setTimeout(() => {
|
||||
this.$store.commit('updateGlobalIpDataCards', this.ipDataCards);
|
||||
}, 5000);
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
created() {
|
||||
this.validateBingMapKey();
|
||||
},
|
||||
|
||||
|
||||
watch: {
|
||||
isMapShown(newVal) {
|
||||
localStorage.setItem("isMapShown", JSON.stringify(newVal));
|
||||
},
|
||||
isCardsCollapsed(newVal) {
|
||||
localStorage.setItem('isCardsCollapsed', JSON.stringify(newVal));
|
||||
},
|
||||
ipDataCards: {
|
||||
handler(newValue) {
|
||||
this.$store.commit('updateGlobalIpDataCards', newValue);
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.checkAllIPs();
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<!-- Nav -->
|
||||
<nav id="navbar-top" class="navbar navbar-expand-lg bg-body-tertiary px-3 mb-3 jn-navbar-top"
|
||||
:class="{ 'dark-mode-nav navbar-dark bg-dark': isDarkMode }">
|
||||
<div class="jn-logo">
|
||||
<a class="navbar-brand" :class="{ 'text-white': isDarkMode }" href="#" @click="handleLogoClick"><i
|
||||
class="bi bi-box-seam-fill"></i>
|
||||
{{ $t('nav.Title') }} </a>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="darkModeSwitch" @change="toggleDarkMode"
|
||||
:checked="isDarkMode">
|
||||
<label class="form-check-label" for="darkModeSwitch">
|
||||
<i v-if="isDarkMode" class="bi bi-moon-stars"></i>
|
||||
<i v-else class="bi bi-brightness-high"></i>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNavAltMarkup"
|
||||
aria-controls="navbarNavAltMarkup" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon bg-transparent "></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse justify-content-end" id="navbarNavAltMarkup">
|
||||
<div class="navbar-nav ">
|
||||
<a class="nav-link" :class="{ 'text-white jn-deactive': isDarkMode }" href="#IPInfo"> {{ $t('nav.IPinfo') }}</a>
|
||||
<a class="nav-link" :class="{ 'text-white jn-deactive': isDarkMode }" href="#Connectivity"> {{
|
||||
$t('nav.Connectivity') }}</a>
|
||||
<a class="nav-link" :class="{ 'text-white jn-deactive': isDarkMode }" href="#WebRTC"> {{ $t('nav.WebRTC') }}</a>
|
||||
<a class="nav-link" :class="{ 'text-white jn-deactive': isDarkMode }" href="#DNSLeakTest"> {{
|
||||
$t('nav.DNSLeakTest') }}</a>
|
||||
<a class="nav-link" :class="{ 'text-white jn-deactive': isDarkMode }" href="#SpeedTest"> {{ $t('nav.SpeedTest')
|
||||
}}</a>
|
||||
<a class="nav-link" :class="{ 'text-white jn-deactive': isDarkMode }" href="#PingTest"> {{ $t('nav.PingTest')
|
||||
}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'NavBar',
|
||||
|
||||
// 引入 Store
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const isDarkMode = computed(() => store.state.isDarkMode);
|
||||
|
||||
return {
|
||||
isDarkMode,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
// 切换暗黑模式
|
||||
toggleDarkMode() {
|
||||
this.$store.commit('toggleDarkMode');
|
||||
this.updateBodyClass();
|
||||
this.PWAColor();
|
||||
},
|
||||
|
||||
// 更新 body class
|
||||
updateBodyClass() {
|
||||
if (this.isDarkMode) {
|
||||
document.body.classList.add("dark-mode");
|
||||
} else {
|
||||
document.body.classList.remove("dark-mode");
|
||||
}
|
||||
},
|
||||
|
||||
// 更新 PWA 颜色
|
||||
PWAColor() {
|
||||
if (this.isDarkMode) {
|
||||
document
|
||||
.querySelector('meta[name="theme-color"]')
|
||||
.setAttribute("content", "#171a1d");
|
||||
document
|
||||
.querySelector('meta[name="background-color"]')
|
||||
.setAttribute("content", "#212529");
|
||||
} else {
|
||||
document
|
||||
.querySelector('meta[name="theme-color"]')
|
||||
.setAttribute("content", "#f8f9fa");
|
||||
document
|
||||
.querySelector('meta[name="background-color"]')
|
||||
.setAttribute("content", "#ffffff");
|
||||
}
|
||||
},
|
||||
|
||||
// 点击 Logo 事件处理
|
||||
handleLogoClick() {
|
||||
if (window.scrollY === 0) {
|
||||
this.$store.commit('setRefreshEveryThing', true);
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('checkDarkMode');
|
||||
this.updateBodyClass();
|
||||
this.PWAColor();
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<!-- Search BTN -->
|
||||
<button class="btn btn-primary position-fixed" style="bottom: 20px; right: 20px; z-index: 1050;" data-bs-toggle="modal"
|
||||
data-bs-target="#IPCheck"><i class="bi bi-search"></i></button>
|
||||
|
||||
<!-- Search Modal -->
|
||||
<div class="modal fade" id="IPCheck" tabindex="-1" aria-labelledby="IPCheck" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content" :class="{ 'dark-mode dark-mode-border': isDarkMode }">
|
||||
<div class="modal-header" :class="{ 'dark-mode-border': isDarkMode }">
|
||||
<h5 class="modal-title" id="IPCheck">{{ $t('ipcheck.Title') }}</h5>
|
||||
<button type="button" class="btn-close" :class="{ 'dark-mode-close-button': isDarkMode }"
|
||||
data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
|
||||
</div>
|
||||
<div class="modal-body" :class="{ 'dark-mode': isDarkMode }">
|
||||
<input type="text" class="form-control mb-2" :class="{ 'dark-mode': isDarkMode }"
|
||||
:placeholder="$t('ipcheck.Placeholder')" v-model="inputIP" @keyup.enter="submitQuery" name="inputIP"
|
||||
id="inputIP">
|
||||
<div v-if="modalQueryError" class="text-danger">{{ modalQueryError }}</div>
|
||||
<div v-if="modalQueryResult" class="mt-2">
|
||||
<div class="card-body">
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item jn-list-group-item" :class="{ 'dark-mode': isDarkMode }">
|
||||
<span class="jn-text">🌍 {{ $t('ipInfos.Country') }}</span>: {{
|
||||
modalQueryResult.country_name }} <span v-if="modalQueryResult.country_code"
|
||||
:class="'fi fi-' + modalQueryResult.country_code.toLowerCase()"></span>
|
||||
</li>
|
||||
<li class="list-group-item jn-list-group-item" :class="{ 'dark-mode': isDarkMode }"><span
|
||||
class="jn-text">🏚️ {{ $t('ipInfos.Region') }}</span>: {{
|
||||
modalQueryResult.region
|
||||
}}</li>
|
||||
<li class="list-group-item jn-list-group-item" :class="{ 'dark-mode': isDarkMode }"><span
|
||||
class="jn-text">🚏 {{ $t('ipInfos.City') }}</span>: {{
|
||||
modalQueryResult.city }}
|
||||
</li>
|
||||
<li class="list-group-item jn-list-group-item" :class="{ 'dark-mode': isDarkMode }"><span
|
||||
class="jn-text">🏢 {{ $t('ipInfos.ISP') }}</span>: {{
|
||||
modalQueryResult.isp }}
|
||||
</li>
|
||||
<li class="list-group-item jn-list-group-item" :class="{ 'dark-mode': isDarkMode }"><span
|
||||
class="jn-text">📶 {{ $t('ipInfos.ASN') }}</span>: <a
|
||||
v-if="modalQueryResult.asnlink" :href="modalQueryResult.asnlink" target="_blank">{{
|
||||
modalQueryResult.asn }}</a><a v-else>{{ modalQueryResult.asn }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer" :class="{ 'dark-mode-border': isDarkMode }">
|
||||
<button type="button" class="btn btn-primary"
|
||||
:class="{ 'btn-secondary': !isValidIP(inputIP), 'btn-primary': isValidIP(inputIP) }"
|
||||
@click="submitQuery" :disabled="!isValidIP(inputIP)">{{ $t('ipcheck.Button') }}</button>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { Modal } from 'bootstrap';
|
||||
|
||||
export default {
|
||||
name: 'QueryIP',
|
||||
|
||||
// 引入 Store
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const isDarkMode = computed(() => store.state.isDarkMode);
|
||||
const isMobile = computed(() => store.state.isMobile);
|
||||
|
||||
return {
|
||||
isDarkMode,
|
||||
isMobile
|
||||
};
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
inputIP: '',
|
||||
modalQueryResult: null,
|
||||
modalQueryError: "",
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
// 查询 IP 信息
|
||||
async submitQuery() {
|
||||
if (this.isValidIP(this.inputIP)) {
|
||||
this.modalQueryError = "";
|
||||
this.modalQueryResult = null;
|
||||
await this.fetchIPForModal(this.inputIP);
|
||||
} else {
|
||||
this.modalQueryError = this.$t('ipcheck.Error');
|
||||
this.modalQueryResult = null;
|
||||
}
|
||||
},
|
||||
|
||||
// 重置 modalQueryResult
|
||||
setupModalEventListener() {
|
||||
const modalElement = document.getElementById("IPCheck");
|
||||
modalElement.addEventListener("hidden.bs.modal", this.resetModalData);
|
||||
},
|
||||
|
||||
// 验证 IP 地址合法性
|
||||
isValidIP(ip) {
|
||||
const ipv4Pattern =
|
||||
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
const ipv6Pattern =
|
||||
/^(([0-9a-fA-F]{1,4}:){7}([0-9a-fA-F]{1,4})|(([0-9a-fA-F]{1,4}:){0,6}([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:){0,6}([0-9a-fA-F]{1,4})?))$/;
|
||||
return ipv4Pattern.test(ip) || ipv6Pattern.test(ip);
|
||||
},
|
||||
|
||||
// 转换 IP 信息
|
||||
transformDataFromIPapi(data) {
|
||||
if (data.error) {
|
||||
throw new Error(data.reason);
|
||||
}
|
||||
|
||||
return {
|
||||
country_name: data.country_name || "",
|
||||
country_code: data.country || "",
|
||||
region: data.region || "",
|
||||
city: data.city || "",
|
||||
latitude: data.latitude || "",
|
||||
longitude: data.longitude || "",
|
||||
isp: data.org || "",
|
||||
asn: data.asn || "",
|
||||
asnlink: data.asn ? `https://radar.cloudflare.com/traffic/${data.asn}` : false,
|
||||
mapUrl: data.latitude && data.longitude ? `/api/map?latitude=${data.latitude}&longitude=${data.longitude}&language=${this.bingMapLanguage}&CanvasMode=CanvasLight` : "",
|
||||
mapUrl_dark: data.latitude && data.longitude ? `/api/map?latitude=${data.latitude}&longitude=${data.longitude}&language=${this.bingMapLanguage}&CanvasMode=RoadDark` : ""
|
||||
};
|
||||
},
|
||||
|
||||
// 获取 IP 信息
|
||||
async fetchIPForModal(ip) {
|
||||
const sources = [
|
||||
{ url: `/api/ipinfo?ip=${ip}`, transform: this.transformDataFromIPapi },
|
||||
{ url: `https://ipapi.co/${ip}/json/`, transform: this.transformDataFromIPapi },
|
||||
{ url: `/api/ipapicom?ip=${ip}`, transform: this.transformDataFromIPapi }
|
||||
];
|
||||
|
||||
for (const source of sources) {
|
||||
try {
|
||||
const response = await fetch(source.url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.reason || "IP lookup failed");
|
||||
}
|
||||
|
||||
// 使用对应的转换函数更新 modalQueryResult
|
||||
this.modalQueryResult = source.transform(data);
|
||||
break;
|
||||
} catch (error) {
|
||||
console.error("Error fetching IP details:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,274 @@
|
||||
<template>
|
||||
<!-- Speed Test -->
|
||||
<div class="speed-test-section mb-4">
|
||||
<div class="jn-title2">
|
||||
<h2 id="SpeedTest" :class="{ 'mobile-h2': isMobile }">🚀 {{ $t('speedtest.Title') }}</h2>
|
||||
<button @click="startSpeedTest" :class="['btn', isDarkMode ? 'btn-dark dark-mode-refresh' : 'btn-light']"
|
||||
:disabled="speedTestStatus === 'running'">
|
||||
<i :class="speedTestStatus === 'running' ? 'bi bi-slash-circle' : 'bi bi-caret-right-fill'"></i>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
<div class="text-secondary">
|
||||
<p>{{ $t('speedtest.Note') }}</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card jn-card" :class="{ 'dark-mode dark-mode-border': isDarkMode }">
|
||||
<div class="card-body">
|
||||
<div class="progress" style="height: 20px; margin: 4pt 0 20pt 0;"
|
||||
:class="{ 'jn-opacity-0': speedTestStatus == 'idle', 'jn-progress-dark': isDarkMode }">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated bg-success jn-progress"
|
||||
role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"
|
||||
id="speedtest-progress">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="margin-bottom: 10pt;">
|
||||
<div :class="['text-center', isMobile ? 'col-6' : 'col-3']">
|
||||
<h5 class="speedtest-h5">{{ $t('speedtest.Download') }}</h5>
|
||||
<p id="download-speed" class="speedtest-h5" :class="updateSpeedTestColor(speedTestStatus)">
|
||||
<span class="jn-speedtest-number">{{ speedTest.downloadSpeed }}</span>
|
||||
<span v-if="speedTestStatus !== 'idle'">Mb/s</span>
|
||||
</p>
|
||||
</div>
|
||||
<div :class="['text-center', isMobile ? 'col-6' : 'col-3']">
|
||||
<h5 class="speedtest-h5">{{ $t('speedtest.Upload') }}</h5>
|
||||
<p id="upload-speed" class="speedtest-h5" :class="updateSpeedTestColor(speedTestStatus)">
|
||||
<span class="jn-speedtest-number">{{ speedTest.uploadSpeed }}</span>
|
||||
<span v-if="speedTestStatus !== 'idle'">Mb/s</span>
|
||||
</p>
|
||||
</div>
|
||||
<div :class="['text-center', isMobile ? 'col-6' : 'col-3']">
|
||||
<h5 class="speedtest-h5">{{ $t('speedtest.Latency') }}</h5>
|
||||
<p id="latency" class="speedtest-h5" :class="updateSpeedTestColor(speedTestStatus)">
|
||||
<span class="jn-speedtest-number">{{ speedTest.latency }}</span>
|
||||
<span v-if="speedTestStatus !== 'idle'">ms</span>
|
||||
</p>
|
||||
</div>
|
||||
<div :class="['text-center', isMobile ? 'col-6' : 'col-3']">
|
||||
<h5 class="speedtest-h5">{{ $t('speedtest.Jitter') }}</h5>
|
||||
<p id="jitter" class="speedtest-h5" :class="updateSpeedTestColor(speedTestStatus)">
|
||||
<span class="jn-speedtest-number">{{ speedTest.jitter }}</span>
|
||||
<span v-if="speedTestStatus !== 'idle'">ms</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row jn-speedtest-note" v-if="speedTestStatus === 'finished'">
|
||||
<p id="score" class="speedtest-p">📋 {{ $t('speedtest.score') }}
|
||||
{{ $t('speedtest.videoStreaming') }}
|
||||
<span :class="speedTest.streamingScore >= 50 ? 'text-success' : 'text-warning'">
|
||||
{{ speedTest.streamingScore }}
|
||||
</span>
|
||||
{{ $t('speedtest.gaming') }}
|
||||
<span :class="speedTest.gamingScore >= 50 ? 'text-success' : 'text-warning'">
|
||||
{{ speedTest.gamingScore }}
|
||||
</span>
|
||||
{{ $t('speedtest.rtc') }}
|
||||
<span :class="speedTest.rtcScore >= 50 ? 'text-success' : 'text-warning'">
|
||||
{{ speedTest.rtcScore }}
|
||||
</span>
|
||||
{{ $t('speedtest.resultNote') }}
|
||||
</p>
|
||||
</div>
|
||||
<div id="result"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
// 引入 SpeedTest
|
||||
import SpeedTestEngine from '@cloudflare/speedtest';
|
||||
|
||||
export default {
|
||||
name: 'SpeedTest',
|
||||
|
||||
// 引入 Store
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const isDarkMode = computed(() => store.state.isDarkMode);
|
||||
const isMobile = computed(() => store.state.isMobile);
|
||||
|
||||
return {
|
||||
isDarkMode,
|
||||
isMobile,
|
||||
};
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
speedTest: {
|
||||
id: "speedTest",
|
||||
downloadSpeed: "-",
|
||||
uploadSpeed: "-",
|
||||
latency: "-",
|
||||
jitter: "-",
|
||||
streamingScore: "-",
|
||||
gamingScore: "-",
|
||||
rtcScore: "-",
|
||||
},
|
||||
speedTestStatus: 'idle',
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
// 更新 Speed Test 结果
|
||||
updateSpeedTestResults(results) {
|
||||
const summary = results.getSummary();
|
||||
|
||||
this.speedTest.downloadSpeed = parseFloat((summary.download / 1000000).toFixed(2));
|
||||
this.speedTest.uploadSpeed = parseFloat((summary.upload / 1000000).toFixed(2));
|
||||
this.speedTest.latency = parseFloat(summary.latency.toFixed(2));
|
||||
this.speedTest.jitter = parseFloat(summary.jitter.toFixed(2));
|
||||
},
|
||||
|
||||
// 更新 Speed Test 进度条颜色
|
||||
updateSpeedTestColor(status) {
|
||||
switch (status) {
|
||||
case 'idle':
|
||||
return 'text-secondary';
|
||||
case 'running':
|
||||
return 'text-info';
|
||||
case 'finished':
|
||||
return 'text-success';
|
||||
case 'error':
|
||||
return 'text-danger';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
},
|
||||
|
||||
// 重置 Speed Test
|
||||
resetSpeedTest() {
|
||||
const engine = new SpeedTestEngine({
|
||||
autoStart: false,
|
||||
measurements: [
|
||||
{ type: 'latency', numPackets: 20 },
|
||||
{ type: 'download', bytes: 5e7, count: 4 },
|
||||
{ type: 'upload', bytes: 1.5e7, count: 3 }
|
||||
]
|
||||
});
|
||||
return engine;
|
||||
},
|
||||
|
||||
// 开始 Speed Test
|
||||
startSpeedTest() {
|
||||
const newEngine = this.resetSpeedTest();
|
||||
newEngine.onRunningChange = running => {
|
||||
this.speedTestStatus = "running";
|
||||
this.speedTest.downloadSpeed = 0;
|
||||
this.speedTest.uploadSpeed = 0;
|
||||
this.speedTest.latency = 0;
|
||||
this.speedTest.jitter = 0;
|
||||
};
|
||||
|
||||
newEngine.onResultsChange = ({ type }) => {
|
||||
const rawData = newEngine.results.raw;
|
||||
|
||||
// 进度条
|
||||
let progress = 0;
|
||||
const progressPerStage = 100 / 3; // 将总进度平均分配到每个阶段
|
||||
|
||||
if (rawData.download && rawData.download.started) {
|
||||
progress += rawData.download.finished ? progressPerStage : progressPerStage / 2;
|
||||
}
|
||||
if (rawData.upload && rawData.upload.started) {
|
||||
progress += rawData.upload.finished ? progressPerStage : progressPerStage / 2;
|
||||
}
|
||||
if (rawData.latency && rawData.latency.started) {
|
||||
progress += rawData.latency.finished ? progressPerStage : progressPerStage / 2;
|
||||
}
|
||||
|
||||
// 确保进度不超过100%
|
||||
progress = Math.min(progress, 100);
|
||||
|
||||
|
||||
// 更新进度条
|
||||
const progressBar = document.getElementById('speedtest-progress');
|
||||
progressBar.style.width = `${progress}%`;
|
||||
progressBar.setAttribute('aria-valuenow', progress);
|
||||
|
||||
// 更新下载速度
|
||||
if (rawData.download && rawData.download.results) {
|
||||
const downloadKeys = Object.keys(rawData.download.results);
|
||||
if (downloadKeys.length > 0) {
|
||||
const lastDownloadKey = downloadKeys[downloadKeys.length - 1];
|
||||
const downloadTimings = rawData.download.results[lastDownloadKey].timings;
|
||||
if (downloadTimings.length > 0) {
|
||||
const latestDownload = downloadTimings[downloadTimings.length - 1];
|
||||
const newDownloadSpeed = parseFloat((latestDownload.bps / 1000000).toFixed(2));
|
||||
if (newDownloadSpeed > this.speedTest.downloadSpeed) {
|
||||
this.speedTest.downloadSpeed = newDownloadSpeed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 更新上传速度
|
||||
if (rawData.upload && rawData.upload.results) {
|
||||
const uploadKeys = Object.keys(rawData.upload.results);
|
||||
if (uploadKeys.length > 0) {
|
||||
const lastUploadKey = uploadKeys[uploadKeys.length - 1];
|
||||
const uploadTimings = rawData.upload.results[lastUploadKey].timings;
|
||||
if (uploadTimings.length > 0) {
|
||||
const latestUpload = uploadTimings[uploadTimings.length - 1];
|
||||
const newUploadSpeed = parseFloat((latestUpload.bps / 1000000).toFixed(2));
|
||||
if (newUploadSpeed > this.speedTest.uploadSpeed) {
|
||||
this.speedTest.uploadSpeed = newUploadSpeed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 更新延迟
|
||||
if (rawData.latency && rawData.latency.results && rawData.latency.results.timings && rawData.latency.results.timings.length > 0) {
|
||||
const latencyTimings = rawData.latency.results.timings;
|
||||
const latestLatency = latencyTimings[latencyTimings.length - 1].ping;
|
||||
const newLatency = parseFloat(latestLatency.toFixed(2));
|
||||
if (newLatency < this.speedTest.latency || this.speedTest.latency === 0) {
|
||||
this.speedTest.latency = newLatency;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
newEngine.onFinish = results => {
|
||||
this.speedTestStatus = "finished";
|
||||
this.updateSpeedTestResults(results);
|
||||
const scores = results.getScores();
|
||||
|
||||
// 更新 Vue 实例的数据属性
|
||||
this.speedTest.streamingScore = scores.streaming.points;
|
||||
this.speedTest.gamingScore = scores.gaming.points;
|
||||
this.speedTest.rtcScore = scores.rtc.points;
|
||||
};
|
||||
|
||||
newEngine.onError = (e) => {
|
||||
if (typeof e === 'string' && !e.includes("ICE")) {
|
||||
this.speedTestStatus = "error";
|
||||
}
|
||||
console.error('Speed Test Error: ', e);
|
||||
};
|
||||
|
||||
newEngine.play();
|
||||
},
|
||||
|
||||
// 刷新 Speed Test
|
||||
refreshstartSpeedTest() {
|
||||
if (this.speedTestStatus !== "running") {
|
||||
this.startSpeedTest();
|
||||
}
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
mounted() {
|
||||
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<!-- WebRTC Test -->
|
||||
<div class="webrtc-test-section mb-4">
|
||||
<div class="jn-title2">
|
||||
<h2 id="WebRTC" :class="{ 'mobile-h2': isMobile }">🚥 {{ $t('webrtc.Title') }}</h2>
|
||||
<button @click="checkAllWebRTC(true)" :class="['btn', isDarkMode ? 'btn-dark dark-mode-refresh' : 'btn-light']"><i
|
||||
class="bi bi-arrow-clockwise"></i></button>
|
||||
</div>
|
||||
<div class="text-secondary">
|
||||
<p>{{ $t('webrtc.Note') }}</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div v-for="stun in stunServers" :key="stun.id" class="col-6 col-md-3 mb-4">
|
||||
<div class="card jn-card" :class="{ 'dark-mode dark-mode-border': isDarkMode }">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><i class="bi bi-sign-merge-left-fill"></i> {{ stun.name }}</h5>
|
||||
<p class="card-text text-secondary" style="font-size: 10pt;">{{ stun.url }}</p>
|
||||
<p class="card-text" :class="{
|
||||
'text-info': stun.ip === $t('webrtc.StatusWait'),
|
||||
'text-success': stun.ip.includes('.') || stun.ip.includes(':'),
|
||||
'text-danger': stun.ip === $t('webrtc.StatusError')
|
||||
}" v-html="stun.ip"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'WebRTC',
|
||||
|
||||
// 引入 Store
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const isDarkMode = computed(() => store.state.isDarkMode);
|
||||
const isMobile = computed(() => store.state.isMobile);
|
||||
|
||||
return {
|
||||
isDarkMode,
|
||||
isMobile,
|
||||
};
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
stunServers: [
|
||||
{
|
||||
id: "google",
|
||||
name: "Google 1",
|
||||
url: "stun:stun.l.google.com:19302",
|
||||
ip: this.$t('webrtc.StatusWait'),
|
||||
},
|
||||
{
|
||||
id: "google",
|
||||
name: "Google 2",
|
||||
url: "stun:stun2.l.google.com:19302",
|
||||
ip: this.$t('webrtc.StatusWait'),
|
||||
},
|
||||
{
|
||||
id: "nextcloud",
|
||||
name: "NxtCld",
|
||||
url: "stun:stun.nextcloud.com:443",
|
||||
ip: this.$t('webrtc.StatusWait'),
|
||||
},
|
||||
{
|
||||
id: "twilio",
|
||||
name: "Twilio",
|
||||
url: "stun:global.stun.twilio.com",
|
||||
ip: this.$t('webrtc.StatusWait'),
|
||||
},
|
||||
{
|
||||
id: "cloudflare",
|
||||
name: "Cloudflare",
|
||||
url: "stun:stun.cloudflare.com",
|
||||
ip: this.$t('webrtc.StatusWait'),
|
||||
},
|
||||
{
|
||||
id: "miwifi",
|
||||
name: "MiWiFi",
|
||||
url: "stun:stun.miwifi.com",
|
||||
ip: this.$t('webrtc.StatusWait'),
|
||||
},
|
||||
{
|
||||
id: "qq",
|
||||
name: "QQ",
|
||||
url: "stun:stun.qq.com",
|
||||
ip: this.$t('webrtc.StatusWait'),
|
||||
},
|
||||
{
|
||||
id: "stunprotocol",
|
||||
name: "StnPtc",
|
||||
url: "stun:stunserver.stunprotocol.org",
|
||||
ip: this.$t('webrtc.StatusWait'),
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
// 测试 STUN 服务器
|
||||
async checkSTUNServer(stun) {
|
||||
try {
|
||||
const servers = { iceServers: [{ urls: stun.url }] };
|
||||
const pc = new RTCPeerConnection(servers);
|
||||
let candidateReceived = false;
|
||||
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
candidateReceived = true;
|
||||
const candidate = event.candidate.candidate;
|
||||
const ipMatch = /([0-9a-f]{1,4}(:[0-9a-f]{1,4}){7}|[0-9a-f]{0,4}(:[0-9a-f]{1,4}){0,6}::[0-9a-f]{0,4}|::[0-9a-f]{1,4}(:[0-9a-f]{1,4}){0,6}|[0-9]{1,3}(\.[0-9]{1,3}){3})/i.exec(candidate);
|
||||
if (ipMatch) {
|
||||
stun.ip = ipMatch[0];
|
||||
pc.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pc.createDataChannel("");
|
||||
await pc.createOffer().then((offer) => pc.setLocalDescription(offer));
|
||||
|
||||
// 设置一个超时计时器
|
||||
await new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
if (!candidateReceived) {
|
||||
reject(new Error("Stun Server Test Timeout"));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("STUN Server Test Error:", error);
|
||||
stun.ip = this.$t('webrtc.StatusError');
|
||||
}
|
||||
},
|
||||
|
||||
// 测试所有 STUN 服务器
|
||||
checkAllWebRTC(isRefresh) {
|
||||
this.stunServers.forEach((server) => {
|
||||
if (isRefresh) {
|
||||
server.ip = this.$t('webrtc.StatusWait');
|
||||
}
|
||||
this.checkSTUNServer(server);
|
||||
});
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
setTimeout(() => {
|
||||
this.checkAllWebRTC(true);
|
||||
}, 4000);
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createI18n } from 'vue-i18n';
|
||||
|
||||
|
||||
import en from './locales/en.json';
|
||||
import zh from './locales/zh.json';
|
||||
|
||||
|
||||
const messages = { en, zh };
|
||||
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: true,
|
||||
locale: 'zh',
|
||||
fallbackLocale: 'en',
|
||||
messages,
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"nav": {
|
||||
"id": "nav",
|
||||
"Title": "IP Toolbox",
|
||||
"IPinfo": "IP Infos",
|
||||
"Connectivity": "Connectivity",
|
||||
"WebRTC": "WebRTC Test",
|
||||
"DNSLeakTest": "DNS Leak Test",
|
||||
"SpeedTest": "Speed Test",
|
||||
"PingTest": "Global Latency"
|
||||
},
|
||||
"ipInfos": {
|
||||
"id": "ipinfos",
|
||||
"Title": "IP Infos",
|
||||
"Notes": "IP data will be checked from 6 sources. If there is only 1 IP stack currently, sources without data will show as empty.",
|
||||
"Simple": "Simple",
|
||||
"Map": "Map",
|
||||
"MapUnavailable": "Map Unavailable",
|
||||
"Source": "Source",
|
||||
"IP": "IP",
|
||||
"Country": "Country",
|
||||
"Region": "Region",
|
||||
"City": "City",
|
||||
"ISP": "ISP",
|
||||
"ASN": "ASN",
|
||||
"IPv4Error": "Fetch Failed or No IPv4 Address",
|
||||
"IPv6Error": "Fetch Failed or No IPv6 Address"
|
||||
},
|
||||
"connectivity": {
|
||||
"id": "connectivity",
|
||||
"Title": "Network Connectivity",
|
||||
"Note": "Testing is done by loading small images from corresponding websites. Delay values are for reference only and will be smaller in reality.",
|
||||
"StatusWait": "Awaiting Test",
|
||||
"StatusAvailable": "OK",
|
||||
"StatusUnavailable": "Unavailable",
|
||||
"StatusTimeout": "Timeout or Unavailable",
|
||||
"checking": "Checking..."
|
||||
},
|
||||
"webrtc": {
|
||||
"id": "webrtc",
|
||||
"Title": "WebRTC Test",
|
||||
"Note": "WebRTC often establishes connections directly via UDP. If the test returns your real IP, it means your proxy settings do not cover these connections.",
|
||||
"StatusWait": "Awaiting Test or Connection Error",
|
||||
"StatusError": "Test Error"
|
||||
},
|
||||
"dnsleaktest": {
|
||||
"id": "dnsleaktest",
|
||||
"Name": "Test",
|
||||
"Title": "DNS Leak Test",
|
||||
"Note": "A DNS leak means that when you are connected to a VPN/proxy, your domain name resolutions are still done through your local ISP, thus posing a risk of DNS leaks.",
|
||||
"Note2": "The method to test for DNS leaks involves accessing a newly generated domain name to detect which regional DNS exit you are resolving through. If the endpoint region returned matches your local service provider's region, there is a risk of DNS leakage. In this case, you may need to modify your VPN/proxy settings.",
|
||||
"Endpoint": "DNS Endpoint",
|
||||
"EndpointCountry": "Endpoint Region",
|
||||
"StatusWait": "Awaiting Test",
|
||||
"StatusError": "Test Error"
|
||||
},
|
||||
"speedtest": {
|
||||
"id": "speedtest",
|
||||
"Title": "Speed Test",
|
||||
"Note": "Speed test will be done via Cloudflare's edge network, which may take a long time. If your network is located in mainland China, the data may be biased. The speed test results are for reference only, the actual speed will be faster.",
|
||||
"Download": "Download",
|
||||
"Upload": "Upload",
|
||||
"Latency": "Latency",
|
||||
"Jitter": "Jitter",
|
||||
"Unit": "Mbps",
|
||||
"StatusWait": "--",
|
||||
"StatusError": "Test Error",
|
||||
"videoStreaming": "Video Streaming: ",
|
||||
"gaming": ", Gaming: ",
|
||||
"rtc": ", Video Chatting: ",
|
||||
"score": "Based on the speed test, got the following quality scores:",
|
||||
"resultNote": ". The score is for reference only."
|
||||
},
|
||||
"pingtest": {
|
||||
"id": "pingtest",
|
||||
"Title": "Global Latency Test",
|
||||
"Note": "Latency tests will be performed on servers located in different continents and regions around the world to measure the latency of your IP. This will help you understand your connection speed to the global network.",
|
||||
"Note2": "Currently only supports IPv4 addresses, please select your IP address for testing",
|
||||
"SelectIP": "Select an IP Address",
|
||||
"Region": "Region",
|
||||
"MinDelay": "Min Delay (ms)",
|
||||
"MaxDelay": "Max Delay (ms)",
|
||||
"AvgDelay": "Avg Delay (ms)",
|
||||
"TotalPackets": "Total Packets",
|
||||
"PacketLoss": "Packet Loss",
|
||||
"ReceivedPackets": "Received",
|
||||
"DroppedPackets": "Dropped",
|
||||
"Run": "Run Test",
|
||||
"Error": "Test failed, it looks like your IP is not allowed to perform Ping test."
|
||||
},
|
||||
"ipcheck": {
|
||||
"id": "ipcheck",
|
||||
"Title": "IP Check",
|
||||
"Placeholder": "Please enter an IP address",
|
||||
"Button": "Check",
|
||||
"Error": "Please enter a valid IPv4 or IPv6 address."
|
||||
},
|
||||
"alert": {
|
||||
"id": "alert",
|
||||
"refreshEverythingMessage": "Refreshing all data, please wait...",
|
||||
"refreshEverythingTitle": "Refreshing",
|
||||
"OhNo": "Oh No!",
|
||||
"Congrats": "Congrats!",
|
||||
"OhNo_Message": "Connectivity test failed, some websites are not accessible.",
|
||||
"Congrats_Message": "All connectivity tests passed, you can access all websites.",
|
||||
"maskedInfoTitle_1": "IP masked",
|
||||
"maskedInfoMessage_1": "IP information has been masked, please be careful when taking screenshots.",
|
||||
"maskedInfoTitle": "All infos masked",
|
||||
"maskedInfoMessage": "All information has been masked, you can now safely take screenshots.",
|
||||
"unmaskedInfoTitle": "Infos Unmasked",
|
||||
"unmaskedInfoMessage": "Information has been unmasked, please be careful when taking screenshots."
|
||||
},
|
||||
"shortcutKeys": {
|
||||
"id": "shortcutKeys",
|
||||
"GoToTop": "Go to Top",
|
||||
"GoToBottom": "Go to Bottom",
|
||||
"ToggleDarkMode": "Toggle Dark Mode",
|
||||
"RefreshEverything": "Refresh Everything",
|
||||
"RefreshIPCard": "Refresh IP Card",
|
||||
"RefreshConnectivityTests": "Refresh Connectivity Tests",
|
||||
"RefreshWebRTC": "Refresh WebRTC Test",
|
||||
"RefreshDNSLeakTest": "Refresh DNS Leak Test",
|
||||
"StartSpeedTest": "Start Speed Test",
|
||||
"ToggleMaps": "Toggle Maps",
|
||||
"IPCheck": "IP Check",
|
||||
"AddBingMapKey": "Add Bing Map API Key",
|
||||
"ToggleInfoMask": "Toggle Info Masking",
|
||||
"Help": "Show Shortcut Keys",
|
||||
"HelpNote": "People who use shortcuts are really awesome!",
|
||||
"GoNext": "Next Card",
|
||||
"GoPrevious": "Previous Card"
|
||||
},
|
||||
"page": {
|
||||
"title": "Check My IP Address and Geolocation | Check WebRTC Connection IP | DNS Leak Test | Speed Test | Jason Ng Open Source",
|
||||
"description": "A better IP Toolbox. Easy to check what's your IPs, IP informations, check for DNS leaks, examine WebRTC connections, and test website availability",
|
||||
"keywords": "MyIP,IP Tool,My IP,IP check,IP lookup,DNS leak test,WebRTC test,Speed test",
|
||||
"footerName": "Creators: Jason Ng, Seven Yu & ChatGPT",
|
||||
"footerLink": "https://github.com/jason5ng32/MyIP",
|
||||
"copyRightName": "Originally from ",
|
||||
"copyRightLink": "https://ipcheck.ing",
|
||||
"copyRightLinkName": "IPCheck.ing"
|
||||
},
|
||||
"addBingMapKey": {
|
||||
"Title": "Add Bing Map API Key",
|
||||
"Note": "After adding, the map can be displayed in the IP information. The API Key will only be saved in the current browser for the next use, and will not be uploaded to the server.",
|
||||
"Placeholder": "Please enter Bing Map API Key",
|
||||
"Button": "Save",
|
||||
"removeButton": "Remove",
|
||||
"Error": "Please enter a valid Bing Map API Key."
|
||||
},
|
||||
"helpModal": {
|
||||
"Title": "Shortcuts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"nav": {
|
||||
"id": "nav",
|
||||
"Title": "IP 工具箱",
|
||||
"IPinfo": "IP 信息",
|
||||
"Connectivity": "网络连通性",
|
||||
"WebRTC": "WebRTC 测试",
|
||||
"DNSLeakTest": "DNS 泄漏测试",
|
||||
"SpeedTest": "网速测试",
|
||||
"PingTest": "全球测速"
|
||||
},
|
||||
"ipInfos": {
|
||||
"id": "ipinfos",
|
||||
"Title": "IP 信息",
|
||||
"Notes": "将会从 6 个来源检查 IP 数据,如果当前 IP 栈只有 1 个,则没有数据的来源会显示为空。",
|
||||
"Simple": "简约",
|
||||
"Map": "地图",
|
||||
"MapUnavailable": "地图不可用",
|
||||
"Source": "来源",
|
||||
"IP": "IP",
|
||||
"Country": "国家",
|
||||
"Region": "地区",
|
||||
"City": "城市",
|
||||
"ISP": "ISP",
|
||||
"ASN": "ASN",
|
||||
"IPv4Error": "获取失败或不存在 IPv4 地址",
|
||||
"IPv6Error": "获取失败或不存在 IPv6 地址"
|
||||
},
|
||||
"connectivity": {
|
||||
"id": "connectivity",
|
||||
"Title": "网络连通性",
|
||||
"Note": "通过加载对应网站上的小图片进行测试,延迟值仅供参考,实际会更小。",
|
||||
"StatusWait": "待检测",
|
||||
"StatusAvailable": "可用",
|
||||
"StatusUnavailable": "不可用",
|
||||
"StatusTimeout": "超时或不可用",
|
||||
"checking": "检查中..."
|
||||
},
|
||||
"webrtc": {
|
||||
"id": "webrtc",
|
||||
"Title": "WebRTC 测试",
|
||||
"Note": "WebRTC 往往通过 UDP 直连进行建立,如果测试返回了真实 IP,则意味着你的代理设置没有覆盖这些连接。",
|
||||
"StatusWait": "待检测或连接错误",
|
||||
"StatusError": "测试出错"
|
||||
},
|
||||
"dnsleaktest": {
|
||||
"id": "dnsleaktest",
|
||||
"Name": "检测",
|
||||
"Title": "DNS 泄漏测试",
|
||||
"Note": "DNS 泄露(DNS Leaks)的意思是,当你挂上 VPN/代理后,你解析域名时,依然通过当地的运营商进行解析,这时就有 DNS 泄露的风险。",
|
||||
"Note2": "泄露测试的方法是通过访问新生成的域名,检测你是通过哪个地区的 DNS 出口进行解析,如果返回的出口区域和你当地的运营商区域相同,则有 DNS 泄露风险,你可能需要修改 VPN/代理设置。",
|
||||
"Endpoint": "DNS 出口",
|
||||
"EndpointCountry": "出口地区",
|
||||
"StatusWait": "待检测",
|
||||
"StatusError": "测试出错"
|
||||
},
|
||||
"speedtest": {
|
||||
"id": "speedtest",
|
||||
"Title": "网速测试",
|
||||
"Note": "将从 Cloudflare 使用边沿网络进行速度测试,时间可能较长。如果你的网络位于中国大陆,数据可能会有较大的偏差。测速结果仅供参考,实际速度会更快。",
|
||||
"Download": "下载",
|
||||
"Upload": "上传",
|
||||
"Latency": "延迟",
|
||||
"Jitter": "抖动",
|
||||
"Unit": "Mbps",
|
||||
"StatusWait": "--",
|
||||
"StatusError": "测试出错",
|
||||
"videoStreaming": "在线视频:",
|
||||
"gaming": " 分,在线游戏:",
|
||||
"rtc": " 分,视频会议:",
|
||||
"score": "根据网速测试,得出以下质量分数:",
|
||||
"resultNote": " 分。分数仅供参考。"
|
||||
},
|
||||
"pingtest": {
|
||||
"id": "pingtest",
|
||||
"Title": "全球延迟测试",
|
||||
"Note": "将从分布在全球不同大洲和地区的服务器,分别对你的 IP 进行 Ping 测试,并获取延迟值。从而了解你与全球网络的连接速度。",
|
||||
"Note2": "目前仅支持 IPv4 地址,请选取你的 IP 地址进行测试",
|
||||
"SelectIP": "选择一个 IP 地址",
|
||||
"Region": "地区",
|
||||
"MinDelay": "最小延迟(ms)",
|
||||
"MaxDelay": "最大延迟(ms)",
|
||||
"AvgDelay": "平均延迟(ms)",
|
||||
"TotalPackets": "总包数",
|
||||
"PacketLoss": "丢包率",
|
||||
"ReceivedPackets": "收到",
|
||||
"DroppedPackets": "丢失",
|
||||
"Run": "运行测试",
|
||||
"Error": "测试失败,看起来你的 IP 不允许进行 Ping 测试。"
|
||||
},
|
||||
"ipcheck": {
|
||||
"id": "ipcheck",
|
||||
"Title": "IP 查询",
|
||||
"Placeholder": "请输入 IP 地址",
|
||||
"Button": "查询",
|
||||
"Error": "请输入有效的 IPv4 或 IPv6 地址。"
|
||||
},
|
||||
"alert": {
|
||||
"id": "alert",
|
||||
"refreshEverythingMessage": "正在刷新所有数据,请等待...",
|
||||
"refreshEverythingTitle": "正在刷新",
|
||||
"OhNo": "糟糕!",
|
||||
"Congrats": "恭喜呀!",
|
||||
"OhNo_Message": "连通性检测没有通过,部分网站无法访问。",
|
||||
"Congrats_Message": "所有的连通性检测均通过,你可以访问所有网站。",
|
||||
"maskedInfoTitle_1": "IP 隐藏成功",
|
||||
"maskedInfoMessage_1": "IP 信息已隐藏,截图时请注意隐私。",
|
||||
"maskedInfoTitle": "全部隐藏成功",
|
||||
"maskedInfoMessage": "所有信息已隐藏,现在可以安心截图了。",
|
||||
"unmaskedInfoTitle": "取消隐藏",
|
||||
"unmaskedInfoMessage": "信息已显示,截图时请注意隐私。"
|
||||
},
|
||||
"shortcutKeys": {
|
||||
"id": "shortcutKeys",
|
||||
"GoToTop": "回到顶部",
|
||||
"GoToBottom": "回到底部",
|
||||
"ToggleDarkMode": "切换深色模式",
|
||||
"RefreshEverything": "刷新所有数据",
|
||||
"RefreshIPCard": "刷新某张 IP 卡片",
|
||||
"RefreshConnectivityTests": "刷新连通性测试",
|
||||
"RefreshWebRTC": "刷新 WebRTC 测试",
|
||||
"RefreshDNSLeakTest": "刷新 DNS 泄漏测试",
|
||||
"StartSpeedTest": "开始网速测试",
|
||||
"ToggleMaps": "切换地图显示",
|
||||
"IPCheck": "IP 查询",
|
||||
"AddBingMapKey": "添加 Bing Map API Key",
|
||||
"ToggleInfoMask": "切换信息隐藏",
|
||||
"Help": "显示快捷键",
|
||||
"HelpNote": "使用快捷键的人,是喜欢效率的人,你真棒!",
|
||||
"GoNext": "下一张卡片",
|
||||
"GoPrevious": "上一张卡片"
|
||||
},
|
||||
"page": {
|
||||
"title": "查看我的 IP 地址 | 查询本机 IP 地址及归属地 | 查看 WebRTC 连接 IP | DNS 泄露检测 | 网速测试 | Jason Ng 阿禅开源作品",
|
||||
"description": "一个更好的 IP 工具箱。轻松查看你的 IP 地址,IP 信息,检查 DNS 泄露,检查 WebRTC 连接,以及测试网站可用性",
|
||||
"keywords": "我的IP,IP工具,我的IP,IP查询,DNS泄漏测试,WebRTC测试,网速测试",
|
||||
"footerName": "Creators: Jason Ng, Seven Yu & ChatGPT",
|
||||
"footerLink": "https://github.com/jason5ng32/MyIP",
|
||||
"copyRightName": "Originally from ",
|
||||
"copyRightLink": "https://ipcheck.ing",
|
||||
"copyRightLinkName": "IPCheck.ing"
|
||||
},
|
||||
"addBingMapKey": {
|
||||
"Title": "添加 Bing Map API Key",
|
||||
"Note": "添加后,可以在 IP 信息中显示地图。API Key 只会在当前浏览器中保存,方便下次使用,不会上传到服务器。",
|
||||
"Placeholder": "请输入 Bing Map API Key",
|
||||
"Button": "保存",
|
||||
"removeButton": "删除",
|
||||
"Error": "请输入有效的 Bing Map API Key。"
|
||||
},
|
||||
"helpModal": {
|
||||
"Title": "快捷键"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createApp } from 'vue'
|
||||
import i18n from './i18n';
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import store from './store';
|
||||
import Analytics from 'analytics';
|
||||
import googleAnalytics from '@analytics/google-analytics';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
const analytics = Analytics({
|
||||
app: 'MyIP',
|
||||
plugins: [
|
||||
googleAnalytics({
|
||||
measurementIds: ['G-TEYKKD81TL'],
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
// 窗口大小变化处理函数
|
||||
function handleResize() {
|
||||
store.commit('setIsMobile', window.innerWidth < 768);
|
||||
}
|
||||
|
||||
// 更新网页标题和元数据的函数
|
||||
function updateMeta() {
|
||||
// 使用 i18n.global.t 来获取翻译后的值
|
||||
document.title = i18n.global.t('page.title');
|
||||
|
||||
const metaKeywords = document.querySelector('meta[name="keywords"]');
|
||||
const metaDescription = document.querySelector('meta[name="description"]');
|
||||
if (metaKeywords) {
|
||||
metaKeywords.setAttribute('content', i18n.global.t('page.keywords'));
|
||||
}
|
||||
if (metaDescription) {
|
||||
metaDescription.setAttribute('content', i18n.global.t('page.description'));
|
||||
}
|
||||
}
|
||||
|
||||
// 设置语言的函数
|
||||
function setLanguageFromURL() {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const browserLanguage = navigator.language || navigator.userLanguage;
|
||||
const langCN = browserLanguage.startsWith('zh');
|
||||
const hl = searchParams.get('hl');
|
||||
if (hl && ['en', 'zh'].includes(hl)) {
|
||||
i18n.global.locale = hl;
|
||||
updateMeta(); // 更新网页标题和元数据
|
||||
} else if (langCN) {
|
||||
i18n.global.locale = 'zh';
|
||||
updateMeta(); // 更新网页标题和元数据
|
||||
} else {
|
||||
i18n.global.locale = 'en';
|
||||
updateMeta(); // 更新网页标题和元数据
|
||||
}
|
||||
}
|
||||
|
||||
// 在应用启动时设置语言
|
||||
setLanguageFromURL();
|
||||
|
||||
// 监听窗口大小变化
|
||||
handleResize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// 监听 URL 变化
|
||||
window.addEventListener('popstate', setLanguageFromURL);
|
||||
|
||||
// 启动 Google Analytics
|
||||
analytics.page();
|
||||
|
||||
app.use(store);
|
||||
app.use(i18n);
|
||||
app.config.globalProperties.$isLangCN = i18n.global.locale === 'zh';
|
||||
app.config.globalProperties.$analytics = analytics;
|
||||
app.mount('#app');
|
||||
@@ -0,0 +1,55 @@
|
||||
// store.js
|
||||
import { createStore } from 'vuex';
|
||||
|
||||
const store = createStore({
|
||||
state() {
|
||||
return {
|
||||
// 定义全局共享的状态
|
||||
inputBingMapAPIKEY: "",
|
||||
bingMapAPIKEYError: false,
|
||||
bingMapLanguage: "en",
|
||||
isDarkMode: false,
|
||||
isMobile: false,
|
||||
siteValidate: true,
|
||||
|
||||
// 全局变量
|
||||
shouldRefreshEveryThing: false,
|
||||
Global_ipDataCards: [],
|
||||
};
|
||||
},
|
||||
mutations: {
|
||||
toggleDarkMode(state) {
|
||||
state.isDarkMode = !state.isDarkMode;
|
||||
},
|
||||
updateGlobalIpDataCards(state, payload) {
|
||||
state.Global_ipDataCards = [...payload];
|
||||
},
|
||||
updateGlobalSiteValidate(state, payload) {
|
||||
state.siteValidate = payload;
|
||||
},
|
||||
setIsMobile(state, payload) {
|
||||
state.isMobile = payload;
|
||||
},
|
||||
setRefreshEveryThing(state, payload) {
|
||||
state.shouldRefreshEveryThing = payload;
|
||||
},
|
||||
SET_DARK_MODE(state, value) {
|
||||
state.isDarkMode = value;
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
checkDarkMode({ commit }) {
|
||||
const prefersDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
commit('SET_DARK_MODE', prefersDarkMode);
|
||||
},
|
||||
},
|
||||
|
||||
getters: {
|
||||
isMobile(state) {
|
||||
return state.isMobile;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default store;
|
||||
@@ -0,0 +1,269 @@
|
||||
@import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
@import 'bootstrap-icons/font/bootstrap-icons.css';
|
||||
@import 'flag-icons/css/flag-icons.min.css';
|
||||
|
||||
.jn-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
flex-direction: row;
|
||||
margin-top: 10pt;
|
||||
margin-bottom: 10pt;
|
||||
}
|
||||
|
||||
.jn-title2 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
flex-direction: row;
|
||||
margin-top: 10pt;
|
||||
margin-bottom: 10pt;
|
||||
}
|
||||
|
||||
.jn-ip-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
border: 0;
|
||||
background-color:unset;
|
||||
}
|
||||
|
||||
.jn-alert {
|
||||
z-index: 9999;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.jn-card {
|
||||
box-shadow: 0 0 10pt #0000001c;
|
||||
height: 100%;
|
||||
border: 1px solid #9c9c9c2e;
|
||||
}
|
||||
|
||||
.jn-card:hover {
|
||||
border-color: #4dc0e461;
|
||||
}
|
||||
|
||||
.jn-card.keyboard-hover {
|
||||
border-color: rgba(20, 184, 108, 0.42);
|
||||
}
|
||||
|
||||
.jn-card.keyboard-hover::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
border: 1px solid rgba(20, 184, 108, 0.42);
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
border-radius: 4pt;
|
||||
}
|
||||
|
||||
.jn-container {
|
||||
padding-top: 2pt;
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
.jn-text {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.jn-list-group-item {
|
||||
padding: 0.75rem 0.25rem;
|
||||
}
|
||||
|
||||
.jn-opacity {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.jn-map-image {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.jn-navbar-top {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 9999;
|
||||
border-bottom: 1px solid #0000001c;
|
||||
|
||||
}
|
||||
|
||||
.jn-toast {
|
||||
z-index: 9999;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin-bottom: 2pt;
|
||||
margin-right: 40pt;
|
||||
max-width: 80vw;
|
||||
}
|
||||
|
||||
footer {
|
||||
height: 100pt;
|
||||
padding-top: 20pt;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
footer {
|
||||
height: 140pt;
|
||||
}
|
||||
|
||||
.speedtest-h5 {
|
||||
/* font-size: 12px; */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.jn-radio {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: stretch;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.jn-logo {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.dark-mode {
|
||||
background-color: #212529 !important;
|
||||
color: #e3e3e3 !important;
|
||||
border-color: #757575;
|
||||
}
|
||||
|
||||
.dark-mode::placeholder {
|
||||
color: #e3e3e38e !important;
|
||||
}
|
||||
|
||||
.dark-mode-border {
|
||||
border-color: #a8a8a863;
|
||||
box-shadow: 0 0 10pt #00000038;
|
||||
}
|
||||
|
||||
.dark-mode-title {
|
||||
background-color: #171a1d;
|
||||
}
|
||||
|
||||
.dark-mode-nav {
|
||||
background-color: #171a1d !important;
|
||||
border-color: #a8a8a863 !important;
|
||||
border-color: #757575;
|
||||
}
|
||||
|
||||
.jn-deactive {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.active {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.dark-mode-nav-border {
|
||||
border-color: #a8a8a863 !important;
|
||||
}
|
||||
|
||||
.dark-mode-close-button {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.dark-mode-refresh {
|
||||
background-color: #141618;
|
||||
}
|
||||
|
||||
.dark-mode-refresh:hover {
|
||||
background-color: #00000054;
|
||||
}
|
||||
|
||||
.mobile-list {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.mobile-h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
[v-cloak] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.jn-loading {
|
||||
margin-top: 50vh;
|
||||
}
|
||||
|
||||
.jn-shortcut-row {
|
||||
margin: 2pt 0 2pt 0;
|
||||
border-bottom: 1px solid;
|
||||
}
|
||||
|
||||
.jn-dark-mode-help-border {
|
||||
border-color: #a8a8a863 !important;
|
||||
}
|
||||
|
||||
.jn-border-bottom-none {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.jn-help-note {
|
||||
background: linear-gradient(to right, #e04343, orange, #8d8d22, green, #50c0ae, indigo, violet);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.jn-progress {
|
||||
padding-bottom: 20pt;
|
||||
}
|
||||
|
||||
.jn-opacity-0 {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.jn-progress-dark {
|
||||
background-color: #4b4b4b !important;
|
||||
}
|
||||
|
||||
*:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.jn-speedtest-number {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.jn-speedtest-note {
|
||||
background-color: #b2d6c33b;
|
||||
border: 1px solid #1a8754;
|
||||
margin: 0;
|
||||
border-radius: 6pt;
|
||||
}
|
||||
/* .jn-speedtest-note::before {
|
||||
content: "";
|
||||
border-top: 1px solid #19945a5c;
|
||||
width: 50%;
|
||||
margin: 0 auto;
|
||||
} */
|
||||
|
||||
.speedtest-p {
|
||||
margin-top:10pt;
|
||||
}
|
||||
|
||||
.jn-ping-form-select {
|
||||
min-width: 140pt;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// static-server.js
|
||||
import dotenv from 'dotenv';
|
||||
import express from 'express';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const staticApp = express();
|
||||
const staticPort = 18966;
|
||||
const apiPort = process.env.PORT || 11966;
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// API 请求代理到后端服务
|
||||
staticApp.use('/api', createProxyMiddleware({
|
||||
target: `http://localhost:${apiPort}`,
|
||||
changeOrigin: true
|
||||
}));
|
||||
|
||||
// 设置静态文件目录
|
||||
staticApp.use(express.static(path.join(__dirname, './dist')));
|
||||
|
||||
// 启动静态文件服务
|
||||
staticApp.listen(staticPort, () => {
|
||||
console.log(`Static file server running on port http://localhost:${staticPort}`);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import dotenv from 'dotenv';
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const apiPort = process.env.PORT || 11966;
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 18966,
|
||||
proxy: {
|
||||
'/api': `http://localhost:${apiPort}`
|
||||
}
|
||||
}
|
||||
})
|
||||