Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43749647ca | ||
|
|
3dbacb4359 | ||
|
|
d8e20754c7 | ||
|
|
4e6e0d4d48 | ||
|
|
a22662f11b | ||
|
|
75531a9ec3 | ||
|
|
e6ab2667c9 | ||
|
|
3a38b3f7fa | ||
|
|
b69cc4bc4b | ||
|
|
4b7b2bc5c6 | ||
|
|
f6af141f56 | ||
|
|
c5c0a6614a | ||
|
|
acb11b43c7 | ||
|
|
b12c1a07c9 | ||
|
|
03fbbed39c | ||
|
|
0c825abad2 | ||
|
|
89ffe3c50e | ||
|
|
07229efac0 | ||
|
|
39023a66c2 | ||
|
|
20cd884718 | ||
|
|
1e4882379a | ||
|
|
bb5b8cf012 | ||
|
|
3c0891fe94 | ||
|
|
bf9b095b27 | ||
|
|
c8486ca001 | ||
|
|
7b7d58f61f | ||
|
|
fa88b2ff84 | ||
|
|
82b4bf7b37 | ||
|
|
e56f0f54d3 | ||
|
|
10b05ffd45 | ||
|
|
5dd97a03a4 | ||
|
|
8f8eeb958c | ||
|
|
b3fd3219d7 | ||
|
|
fadaed985e | ||
|
|
4a812f60b3 | ||
|
|
75f95ee2f8 | ||
|
|
67b2fe7688 | ||
|
|
76ba1948e7 | ||
|
|
96ac249acf | ||
|
|
f0165aaa2e | ||
|
|
05096522e0 | ||
|
|
a52fa0f28e |
@@ -0,0 +1,84 @@
|
|||||||
|
name: Build and Deploy Frontend via SSH
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Build project
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Create Gitea Release via API
|
||||||
|
run: |
|
||||||
|
# Получаем описание из тега (если есть)
|
||||||
|
TAG_MESSAGE=$(git tag -l --format='%(contents)' "${{ gitea.ref_name }}" 2>/dev/null || echo "Автоматический релиз через CI/CD")
|
||||||
|
|
||||||
|
# Экранируем кавычки и переносы для JSON
|
||||||
|
TAG_MESSAGE_ESCAPED=$(echo "$TAG_MESSAGE" | sed 's/"/\\"/g' | awk '{printf "%s\\n", $0}')
|
||||||
|
|
||||||
|
# Формируем JSON
|
||||||
|
JSON_PAYLOAD=$(cat <<EOF
|
||||||
|
{
|
||||||
|
"tag_name": "${{ gitea.ref_name }}",
|
||||||
|
"name": "Release ${{ gitea.ref_name }}",
|
||||||
|
"body": "## Релиз ${{ gitea.ref_name }}\n\n📝 ${TAG_MESSAGE_ESCAPED}\n\n---\n\n**🏷️ Тег:** \`${{ gitea.ref_name }}\`\n**👤 Автор:** @${{ gitea.actor }}\n\n✅ Автоматически собрано и развёрнуто",
|
||||||
|
"draft": false,
|
||||||
|
"prerelease": false
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
# Отправляем запрос
|
||||||
|
RESPONSE=$(curl -s -w "\n%{http_code}" \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: token ${{ secrets.ACCESS_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$JSON_PAYLOAD" \
|
||||||
|
"https://git.uvpn.shop/api/v1/repos/${{ gitea.repository }}/releases")
|
||||||
|
|
||||||
|
# Проверяем ответ
|
||||||
|
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||||
|
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" -eq 201 ]; then
|
||||||
|
echo "✅ Релиз успешно создан!"
|
||||||
|
echo "$BODY" | jq '.'
|
||||||
|
elif [ "$HTTP_CODE" -eq 409 ]; then
|
||||||
|
echo "⚠️ Релиз с таким тегом уже существует, пропускаем"
|
||||||
|
else
|
||||||
|
echo "❌ Ошибка создания релиза (HTTP $HTTP_CODE)"
|
||||||
|
echo "$BODY"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install SSH key
|
||||||
|
uses: webfactory/ssh-agent@v0.9.0
|
||||||
|
with:
|
||||||
|
ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
|
|
||||||
|
- name: Deploy via SCP
|
||||||
|
run: |
|
||||||
|
tar -czf frontend.tar.gz -C dist .
|
||||||
|
scp -o StrictHostKeyChecking=no frontend.tar.gz ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/tmp/
|
||||||
|
ssh -o StrictHostKeyChecking=no ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} << 'EOF'
|
||||||
|
mkdir -p /opt/uvpn.shop/frontend
|
||||||
|
rm -rf /opt/uvpn.shop/frontend/*
|
||||||
|
tar -xzf /tmp/frontend.tar.gz -C /opt/uvpn.shop/frontend
|
||||||
|
rm /tmp/frontend.tar.gz
|
||||||
|
EOF
|
||||||
Vendored
-3
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"recommendations": ["Vue.volar"]
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,15 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="icon" href="/images/logo.png">
|
<link rel="icon" href="/images/logo.png">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="Безопасный, быстрый и анонимный VPN-сервис для вашего ПК и смартфона. Защитите свои данные от посторонних глаз уже сегодня.">
|
||||||
|
<meta name="keywords" content="vpn, сервис, анонимность, безопасность, шифрование, купить vpn">
|
||||||
|
<meta property="og:title" content="uVPN">
|
||||||
|
<meta property="og:description" content="Безопасный, быстрый и анонимный VPN-сервис для вашего ПК и смартфона. Защитите свои данные от посторонних глаз уже сегодня.">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:url" content="https://uvpn.shop/">
|
||||||
|
<meta property="og:site_name" content="uVPN">
|
||||||
|
<meta property="telegram:channel" content="@uvpn_shop">
|
||||||
|
<meta property="tg:site_verification" content="g7j8/rPFXfhyrq5q0QQV7EsYWv4=">
|
||||||
<title>uVPN</title>
|
<title>uVPN</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Generated
+728
-490
File diff suppressed because it is too large
Load Diff
+5
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "uvpn.shop",
|
"name": "frontend",
|
||||||
"version": "0.4.1",
|
"version": "0.9.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -13,6 +13,8 @@
|
|||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@tresjs/cientos": "^5.7.1",
|
"@tresjs/cientos": "^5.7.1",
|
||||||
"@tresjs/core": "^5.8.1",
|
"@tresjs/core": "^5.8.1",
|
||||||
|
"@tw-labs/countries": "^0.0.2",
|
||||||
|
"@vueuse/head": "^0.9.8",
|
||||||
"marked": "^18.0.5",
|
"marked": "^18.0.5",
|
||||||
"swiper": "^12.1.4",
|
"swiper": "^12.1.4",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
@@ -25,6 +27,7 @@
|
|||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
"vite": "^8.0.8",
|
"vite": "^8.0.8",
|
||||||
|
"vite-plugin-sitemap": "^0.8.2",
|
||||||
"vite-plugin-vue-devtools": "^8.1.1"
|
"vite-plugin-vue-devtools": "^8.1.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -105,3 +105,5 @@
|
|||||||
10.1. По всем вопросам Пользователь может обратиться в службу поддержки через форму в самом боте.
|
10.1. По всем вопросам Пользователь может обратиться в службу поддержки через форму в самом боте.
|
||||||
|
|
||||||
**Используя Сервис (в том числе запуская бота и/или вводя команду /start), Пользователь подтверждает, что ознакомлен с настоящим Соглашением и принимает его условия в полном объёме.**
|
**Используя Сервис (в том числе запуская бота и/или вводя команду /start), Пользователь подтверждает, что ознакомлен с настоящим Соглашением и принимает его условия в полном объёме.**
|
||||||
|
|
||||||
|
ИНН: 973300277093
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
* идентификаторы аккаунта (логин, ID, никнейм и т.п.);
|
* идентификаторы аккаунта (логин, ID, никнейм и т.п.);
|
||||||
* техническую информацию (IP-адрес, данные о браузере, устройстве и операционной системе);
|
* техническую информацию (IP-адрес, данные о браузере, устройстве и операционной системе);
|
||||||
* историю взаимодействий с Сервисом.
|
* историю взаимодействий с Сервисом.
|
||||||
* 2.2. Сервис не требует от Пользователя предоставления паспортных данных, документов, фотографий или другой личной информации, кроме минимально необходимой для работы.
|
|
||||||
|
2.2. Сервис не требует от Пользователя предоставления паспортных данных, документов, фотографий или другой личной информации, кроме минимально необходимой для работы.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -25,5 +25,4 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
// Скрипты больше не нужны, всю магию сделает роутер!
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
|
|
||||||
|
<IpSecurity />
|
||||||
|
|
||||||
<section id="hero" class="relative overflow-hidden px-6 py-16 md:py-24 md:px-12">
|
<section id="hero" class="relative overflow-hidden px-6 py-16 md:py-24 md:px-12">
|
||||||
<!-- Контейнер для параллакс-фона -->
|
<!-- Контейнер для параллакс-фона -->
|
||||||
<div class="parallax-bg absolute inset-0 z-0"></div>
|
<div class="parallax-bg absolute inset-0 z-0"></div>
|
||||||
@@ -38,6 +41,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { Zap } from '@lucide/vue'
|
import { Zap } from '@lucide/vue'
|
||||||
import Hero3D from './Hero3D.vue'
|
import Hero3D from './Hero3D.vue'
|
||||||
|
import IpSecurity from './IpSecurity.vue';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="ipData" class="bg-gray-50/80 dark:bg-gray-800/30 border-b border-gray-200 dark:border-gray-700 text-sm backdrop-blur-sm">
|
||||||
|
<div class="container mx-auto px-6 py-2 md:px-12">
|
||||||
|
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
|
||||||
|
<!-- Блок с IP и защитой -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400 text-xs">🌐 Ваш IP:</span>
|
||||||
|
<code class="font-mono text-xs md:text-sm font-medium bg-white dark:bg-gray-900 px-2 py-0.5 rounded border border-gray-200 dark:border-gray-700">
|
||||||
|
{{ ipData.ip }}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<span class="inline-flex items-center gap-2 text-xs px-2 py-0.5 rounded-full font-medium" :class="isProtected ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'">
|
||||||
|
<span class="relative flex h-1.5 w-1.5">
|
||||||
|
<span class="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75" :class="isProtected ? 'bg-green-500' : 'bg-amber-500'"></span>
|
||||||
|
<span class="relative inline-flex rounded-full h-1.5 w-1.5" :class="isProtected ? 'bg-green-600' : 'bg-amber-600'"></span>
|
||||||
|
</span>
|
||||||
|
{{ isProtected ? 'Защищено' : 'Не защищено' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Локация с флагом -->
|
||||||
|
<div class="flex items-center justify-center gap-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<!-- Локальный SVG-флаг -->
|
||||||
|
<img
|
||||||
|
v-if="countryCode"
|
||||||
|
:src="`https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/flags/4x3/${countryCode}.svg`"
|
||||||
|
:alt="countryCode"
|
||||||
|
class="h-3 w-auto"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-if="ipData.location?.city">
|
||||||
|
<span class="hidden sm:inline">{{ ipData.location.city }}</span>
|
||||||
|
<span class="text-gray-400 hidden sm:inline">•</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<span v-if="ipData.location?.country" class="text-gray-600 dark:text-gray-300 font-medium hidden sm:inline">
|
||||||
|
{{ ipData.location.country }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<template v-if="ipData.company?.name">
|
||||||
|
<span class="text-gray-400 hidden md:inline">•</span>
|
||||||
|
<span class="hidden md:inline text-gray-400">
|
||||||
|
{{ truncateText(ipData.company.name, 30) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Лоадер -->
|
||||||
|
<div v-else-if="loading" class="bg-gray-50/80 dark:bg-gray-800/30 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="container mx-auto px-6 py-2 md:px-12">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="h-5 w-28 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||||
|
<div class="h-5 w-16 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ошибка -->
|
||||||
|
<div v-else-if="error" class="bg-amber-50 dark:bg-amber-900/10 border-b border-amber-200 dark:border-amber-800/50">
|
||||||
|
<div class="container mx-auto px-6 py-1.5 md:px-12">
|
||||||
|
<div class="flex items-center gap-2 text-xs text-amber-700 dark:text-amber-400">
|
||||||
|
<span>⚠️</span>
|
||||||
|
<span>Не удалось определить IP</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { getAllCountries } from '@tw-labs/countries'
|
||||||
|
|
||||||
|
const ipData = ref(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref(false)
|
||||||
|
|
||||||
|
// Список стран для маппинга названия -> код
|
||||||
|
const countriesList = getAllCountries()
|
||||||
|
|
||||||
|
function findCountryByName(countryName) {
|
||||||
|
if (!countryName) return null
|
||||||
|
const found = countriesList.find(c => c.label.toLowerCase() === countryName.toLowerCase())
|
||||||
|
if (found) return found
|
||||||
|
// частичное совпадение (например "Russian Federation" vs "Russia")
|
||||||
|
const partial = countriesList.find(c =>
|
||||||
|
countryName.toLowerCase().includes(c.label.toLowerCase()) ||
|
||||||
|
c.label.toLowerCase().includes(countryName.toLowerCase())
|
||||||
|
)
|
||||||
|
return partial || null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchIp() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/ip')
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
const countryName = data.location?.country
|
||||||
|
const foundCountry = findCountryByName(countryName)
|
||||||
|
const countryCode = foundCountry?.code || null
|
||||||
|
|
||||||
|
ipData.value = {
|
||||||
|
...data,
|
||||||
|
location: {
|
||||||
|
...data.location,
|
||||||
|
computed_country_code: countryCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('IP fetch error:', err)
|
||||||
|
error.value = true
|
||||||
|
ipData.value = null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isProtected = computed(() => {
|
||||||
|
if (!ipData.value) return false
|
||||||
|
return ipData.value.security?.vpn ||
|
||||||
|
ipData.value.security?.proxy ||
|
||||||
|
ipData.value.security?.tor ||
|
||||||
|
ipData.value.security?.hosting
|
||||||
|
})
|
||||||
|
|
||||||
|
const countryCode = computed(() => {
|
||||||
|
const code = ipData.value?.location?.computed_country_code
|
||||||
|
return code ? code.toLowerCase() : null
|
||||||
|
})
|
||||||
|
|
||||||
|
function truncateText(text, maxLength) {
|
||||||
|
if (!text) return ''
|
||||||
|
return text.length > maxLength ? text.slice(0, maxLength) + '...' : text
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchIp()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
<template>
|
||||||
|
<section class="px-6 py-20 md:px-12 max-w-7xl mx-auto">
|
||||||
|
<div class="flex items-center justify-between mb-10 flex-wrap gap-4">
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold">
|
||||||
|
Статус серверов
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
@click="refreshData"
|
||||||
|
:disabled="loading"
|
||||||
|
class="px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl text-sm font-medium transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw :size="16" :class="{ 'animate-spin': loading }" />
|
||||||
|
{{ loading ? 'Обновление...' : 'Обновить пинг' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading && !hosts.length" class="flex justify-center py-12">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else-if="error" class="text-center text-red-500 py-12">{{ error }}</p>
|
||||||
|
|
||||||
|
<div v-else class="space-y-2 max-w-3xl mx-auto">
|
||||||
|
<div
|
||||||
|
v-for="host in sortedHosts"
|
||||||
|
:key="host.address"
|
||||||
|
class="flex items-center justify-between bg-white dark:bg-gray-900 rounded-xl px-4 py-2.5 border transition hover:shadow-md"
|
||||||
|
:class="host.isOnline ? 'border-green-500/30' : 'border-red-500/30 opacity-60'"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<img
|
||||||
|
v-if="host.countryCode"
|
||||||
|
:src="`https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/flags/4x3/${host.countryCode}.svg`"
|
||||||
|
class="h-5 w-auto rounded-sm"
|
||||||
|
/>
|
||||||
|
<span class="font-semibold text-gray-800 dark:text-white">{{ host.name }}</span>
|
||||||
|
|
||||||
|
<!-- Индикатор состояния (вместо online/offline) -->
|
||||||
|
<span class="relative flex h-3 w-3">
|
||||||
|
<span
|
||||||
|
class="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75"
|
||||||
|
:class="host.isOnline ? 'bg-green-500' : 'bg-red-500'"
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
class="relative inline-flex rounded-full h-3 w-3"
|
||||||
|
:class="host.isOnline ? 'bg-green-600' : 'bg-red-600'"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
v-if="host.ping !== undefined && host.ping !== null"
|
||||||
|
class="font-mono font-semibold flex items-center gap-1.5"
|
||||||
|
:class="{
|
||||||
|
'text-green-600': host.ping < 200,
|
||||||
|
'text-yellow-600': host.ping >= 200 && host.ping < 500,
|
||||||
|
'text-red-600': host.ping >= 500,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Wifi :size="14" />
|
||||||
|
{{ host.ping }}ms
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-gray-300 font-mono">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { Wifi, RefreshCw } from '@lucide/vue'
|
||||||
|
import { getAllCountries } from '@tw-labs/countries'
|
||||||
|
|
||||||
|
const hosts = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref(null)
|
||||||
|
|
||||||
|
const countriesList = getAllCountries()
|
||||||
|
|
||||||
|
// Ручной маппинг для проблемных стран
|
||||||
|
const countryOverrides = {
|
||||||
|
'United States': 'us',
|
||||||
|
'USA': 'us',
|
||||||
|
'U.S.A.': 'us',
|
||||||
|
'America': 'us',
|
||||||
|
'United Arab Emirates': 'ae',
|
||||||
|
'UAE': 'ae',
|
||||||
|
}
|
||||||
|
|
||||||
|
const findCountryByFirstWord = (name) => {
|
||||||
|
if (!name) return null
|
||||||
|
|
||||||
|
// 1. Проверяем ручные override
|
||||||
|
const override = countryOverrides[name]
|
||||||
|
if (override) {
|
||||||
|
return { code: override.toUpperCase() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Ищем по первому слову
|
||||||
|
const firstWord = name.split(' ')[0]
|
||||||
|
if (!firstWord) return null
|
||||||
|
|
||||||
|
// Точное совпадение
|
||||||
|
const exact = countriesList.find(c =>
|
||||||
|
c.label.toLowerCase() === firstWord.toLowerCase()
|
||||||
|
)
|
||||||
|
if (exact) return exact
|
||||||
|
|
||||||
|
// Частичное совпадение
|
||||||
|
const partial = countriesList.find(c =>
|
||||||
|
firstWord.toLowerCase().includes(c.label.toLowerCase()) ||
|
||||||
|
c.label.toLowerCase().includes(firstWord.toLowerCase())
|
||||||
|
)
|
||||||
|
if (partial) return partial
|
||||||
|
|
||||||
|
// 3. Пробуем найти по полному имени
|
||||||
|
const fullMatch = countriesList.find(c =>
|
||||||
|
name.toLowerCase().includes(c.label.toLowerCase()) ||
|
||||||
|
c.label.toLowerCase().includes(name.toLowerCase())
|
||||||
|
)
|
||||||
|
return fullMatch || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedHosts = computed(() => {
|
||||||
|
return [...hosts.value].sort((a, b) => {
|
||||||
|
if (a.ping === null && b.ping === null) return 0
|
||||||
|
if (a.ping === null) return 1
|
||||||
|
if (b.ping === null) return -1
|
||||||
|
return a.ping - b.ping
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const pingHost = async (host) => {
|
||||||
|
const url = `https://${host.address}:${host.port}`
|
||||||
|
const start = performance.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(url, {
|
||||||
|
method: 'HEAD',
|
||||||
|
mode: 'no-cors',
|
||||||
|
cache: 'no-cache',
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
})
|
||||||
|
return Math.round(performance.now() - start)
|
||||||
|
} catch {
|
||||||
|
return Math.round(performance.now() - start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchHosts = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/hosts')
|
||||||
|
if (!res.ok) throw new Error()
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
hosts.value = data.map((h) => {
|
||||||
|
const country = findCountryByFirstWord(h.name)
|
||||||
|
return {
|
||||||
|
...h,
|
||||||
|
countryCode: country?.code?.toLowerCase() || null,
|
||||||
|
isOnline: true,
|
||||||
|
ping: null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
error.value = 'Ошибка загрузки'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/hosts')
|
||||||
|
if (!res.ok) throw new Error()
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
hosts.value = data.map((h) => {
|
||||||
|
const country = findCountryByFirstWord(h.name)
|
||||||
|
return {
|
||||||
|
...h,
|
||||||
|
countryCode: country?.code?.toLowerCase() || null,
|
||||||
|
isOnline: true,
|
||||||
|
ping: null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const pings = await Promise.all(hosts.value.map(h => pingHost(h)))
|
||||||
|
hosts.value.forEach((h, i) => {
|
||||||
|
if (h.isOnline) h.ping = pings[i]
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
error.value = 'Ошибка обновления'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// При первом открытии — загружаем и пингуем
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchHosts()
|
||||||
|
// Автоматический пинг только при первом открытии
|
||||||
|
await refreshData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -4,8 +4,20 @@
|
|||||||
Прозрачные тарифы
|
Прозрачные тарифы
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
<!-- Состояние загрузки -->
|
||||||
|
<div v-if="loading" class="flex justify-center py-12">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Состояние ошибки -->
|
||||||
|
<div v-else-if="error" class="text-center text-red-500 py-12">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Основной контент -->
|
||||||
|
<template v-else>
|
||||||
<!-- Переключатель периода -->
|
<!-- Переключатель периода -->
|
||||||
<div class="flex justify-center gap-2 mb-10">
|
<div class="flex justify-center gap-2 mb-10 flex-wrap">
|
||||||
<button
|
<button
|
||||||
v-for="period in availablePeriods"
|
v-for="period in availablePeriods"
|
||||||
:key="period.value"
|
:key="period.value"
|
||||||
@@ -20,17 +32,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Сетка тарифов -->
|
<!-- Сетка тарифов -->
|
||||||
<div class="grid md:grid-cols-2 gap-12">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8"
|
||||||
|
style="grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
v-for="tariff in tariffCards"
|
v-for="tariff in tariffCards"
|
||||||
:key="tariff.key"
|
:key="tariff.key"
|
||||||
class="bg-white dark:bg-gray-900 rounded-2xl p-8 flex flex-col border transition"
|
class="bg-white dark:bg-gray-900 rounded-2xl p-8 flex flex-col border transition relative"
|
||||||
:class="[
|
:class="[
|
||||||
tariff.isPopular
|
tariff.isPopular
|
||||||
? 'border-2 border-blue-600 relative transform scale-105 shadow-2xl shadow-blue-600/20'
|
? 'border-2 border-blue-600 shadow-xl shadow-blue-600/20'
|
||||||
: 'border border-gray-200 dark:border-gray-800 hover:border-blue-600'
|
: 'border border-gray-200 dark:border-gray-800 hover:border-blue-600'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
|
<!-- Бейдж "ПОПУЛЯРНЫЙ" -->
|
||||||
<span
|
<span
|
||||||
v-if="tariff.isPopular"
|
v-if="tariff.isPopular"
|
||||||
class="absolute top-0 right-0 bg-blue-600 dark:bg-blue-500 text-gray-900 dark:text-white text-xs font-bold px-4 py-1 rounded-bl-xl rounded-tr-xl"
|
class="absolute top-0 right-0 bg-blue-600 dark:bg-blue-500 text-gray-900 dark:text-white text-xs font-bold px-4 py-1 rounded-bl-xl rounded-tr-xl"
|
||||||
@@ -49,98 +64,126 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<button
|
<a
|
||||||
class="w-full transition py-3 rounded-xl font-medium text-white"
|
:href="'https://app.uvpn.shop'"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="w-full transition py-3 rounded-xl font-medium text-white block text-center"
|
||||||
:class="tariff.isPopular
|
:class="tariff.isPopular
|
||||||
? 'bg-blue-600 dark:bg-blue-500 hover:bg-blue-700 font-bold'
|
? 'bg-blue-600 dark:bg-blue-500 hover:bg-blue-700 font-bold'
|
||||||
: 'bg-gray-800 hover:bg-gray-700'"
|
: 'bg-gray-800 hover:bg-gray-700'"
|
||||||
>
|
>
|
||||||
{{ tariff.isPopular ? 'Выбрать тариф' : 'Выбрать' }}
|
{{ tariff.isPopular ? 'Выбрать тариф' : 'Выбрать' }}
|
||||||
</button>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { Check } from '@lucide/vue'
|
import { Check } from '@lucide/vue'
|
||||||
|
|
||||||
// Исходный конфиг
|
// Состояния
|
||||||
const pricingConfig = {
|
const loading = ref(true)
|
||||||
default_tariff: "premium",
|
const error = ref(null)
|
||||||
tariffs: [
|
const tariffsData = ref([]) // исходные тарифы (из API)
|
||||||
{
|
const defaultTariffKey = ref('') // ключ популярного тарифа
|
||||||
key: "standard",
|
const selectedPeriod = ref(1) // выбранный период (месяцев)
|
||||||
names: { ru: "Standard", en: "Standard" },
|
|
||||||
descriptions: { ru: "До 3 устройств", en: "up to 3 devices" },
|
// Доступные периоды (вычисляются динамически)
|
||||||
prices_rub: { "1": 179.0, "3": 387.0, "6": 594.0, "12": 996.0 },
|
const availablePeriods = computed(() => {
|
||||||
hwid_device_limit: 2,
|
const periodsSet = new Set()
|
||||||
monthly_gb: 0.0
|
for (const tariff of tariffsData.value) {
|
||||||
},
|
if (tariff.enabled && tariff.enabled_periods) {
|
||||||
{
|
tariff.enabled_periods.forEach(p => periodsSet.add(p))
|
||||||
key: "premium",
|
|
||||||
names: { ru: "Premium", en: "Premium"},
|
|
||||||
descriptions: { ru: "До 10 устройств, сервера РФ и США", en: "Up to 10 devices, RU, USA servers" },
|
|
||||||
prices_rub: { "1": 349.0, "3": 987.0, "6": 1734.0, "12": 1992.0 },
|
|
||||||
hwid_device_limit: 10,
|
|
||||||
monthly_gb: 0.0
|
|
||||||
}
|
}
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
const sorted = Array.from(periodsSet).sort((a, b) => a - b)
|
||||||
|
return sorted.map(value => ({
|
||||||
|
value,
|
||||||
|
label: value === 1 ? '1 мес' : `${value} мес`
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
// Доступные периоды
|
// Активные (включённые) тарифы, отфильтрованные по выбранному периоду
|
||||||
const availablePeriods = [
|
const activeTariffs = computed(() => {
|
||||||
{ value: 1, label: '1 мес' },
|
return tariffsData.value.filter(tariff =>
|
||||||
{ value: 3, label: '3 мес' },
|
tariff.enabled && tariff.enabled_periods?.includes(selectedPeriod.value)
|
||||||
{ value: 6, label: '6 мес' },
|
)
|
||||||
{ value: 12, label: '12 мес' }
|
})
|
||||||
]
|
|
||||||
|
|
||||||
// Выбранный период (по умолчанию 1 месяц)
|
// Карточки для отображения (с ценами, фичами и флагом популярности)
|
||||||
const selectedPeriod = ref(1)
|
|
||||||
|
|
||||||
// Функция для получения цены за месяц
|
|
||||||
const getMonthlyPrice = (tariff, period) => {
|
|
||||||
const total = tariff.prices_rub[String(period)]
|
|
||||||
if (!total) return '—'
|
|
||||||
const monthly = total / period
|
|
||||||
return Number.isInteger(monthly) ? monthly : monthly.toFixed(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Подготовка данных для карточек
|
|
||||||
const tariffCards = computed(() => {
|
const tariffCards = computed(() => {
|
||||||
const tariffs = pricingConfig.tariffs
|
return activeTariffs.value.map(tariff => {
|
||||||
const defaultKey = pricingConfig.default_tariff
|
const totalPrice = tariff.prices_rub?.[String(selectedPeriod.value)]
|
||||||
|
let monthlyPrice = '—'
|
||||||
|
if (totalPrice && totalPrice > 0) {
|
||||||
|
const monthly = totalPrice / selectedPeriod.value
|
||||||
|
monthlyPrice = Number.isInteger(monthly) ? monthly : monthly.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
// Разделяем тарифы: популярный (премиум) и остальные
|
// Формируем список особенностей (features)
|
||||||
const premiumTariff = tariffs.find(t => t.key === defaultKey)
|
const features = []
|
||||||
const otherTariffs = tariffs.filter(t => t.key !== defaultKey)
|
|
||||||
|
|
||||||
// Порядок: первый обычный, премиум, второй обычный
|
// 1. Описание из JSON (если есть)
|
||||||
const ordered = [
|
const description = tariff.descriptions?.ru?.trim()
|
||||||
{ ...otherTariffs[0], isPopular: false },
|
if (description) {
|
||||||
{ ...premiumTariff, isPopular: true },
|
features.push(description)
|
||||||
]
|
}
|
||||||
|
|
||||||
return ordered.map(tariff => {
|
// 2. Лимит устройств
|
||||||
const price = getMonthlyPrice(tariff, selectedPeriod.value)
|
if (tariff.hwid_device_limit) {
|
||||||
|
features.push(`До ${tariff.hwid_device_limit} устройств`)
|
||||||
|
}
|
||||||
|
|
||||||
// Фичи: разбиваем описание по запятой и добавляем общие пункты
|
// 3. Безлимитный трафик (всегда)
|
||||||
const descriptionFeatures = tariff.descriptions.ru
|
features.push('Безлимитный трафик')
|
||||||
.split(',')
|
|
||||||
.map(s => s.trim())
|
// 4. Поддержка 24/7
|
||||||
.filter(Boolean)
|
features.push('Поддержка 24/7')
|
||||||
const staticFeatures = ['Безлимитный трафик', 'Поддержка 24/7']
|
|
||||||
const features = [...new Set([...descriptionFeatures, ...staticFeatures])]
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: tariff.key,
|
key: tariff.key,
|
||||||
name: tariff.names.ru,
|
name: tariff.names?.ru || tariff.key,
|
||||||
price: `${price} ₽`,
|
price: `${monthlyPrice} ₽`,
|
||||||
features,
|
features,
|
||||||
isPopular: tariff.isPopular
|
isPopular: tariff.key === defaultTariffKey.value
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Загрузка данных с API
|
||||||
|
const fetchTariffs = async () => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/tariffs')
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Ошибка загрузки: ${response.status}`)
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
tariffsData.value = data.tariffs || []
|
||||||
|
defaultTariffKey.value = data.default_tariff || ''
|
||||||
|
|
||||||
|
// Установка начального периода (первый доступный)
|
||||||
|
if (availablePeriods.value.length > 0) {
|
||||||
|
const exists = availablePeriods.value.some(p => p.value === selectedPeriod.value)
|
||||||
|
if (!exists) {
|
||||||
|
selectedPeriod.value = availablePeriods.value[0].value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load tariffs:', err)
|
||||||
|
error.value = 'Не удалось загрузить тарифы. Попробуйте позже.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchTariffs()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
import { createHead } from '@vueuse/head'
|
||||||
import './assets/styles.css'
|
import './assets/styles.css'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
const head = createHead()
|
||||||
|
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
app.use(head)
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
+21
-2
@@ -26,8 +26,27 @@ const router = createRouter({
|
|||||||
},
|
},
|
||||||
|
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/', name: 'home', component: HomeView },
|
{
|
||||||
{ path: '/docs/:docName', name: 'documents', component: DocViewer, props: true }
|
path: '/',
|
||||||
|
name: 'home',
|
||||||
|
component: HomeView,
|
||||||
|
meta: {
|
||||||
|
title: 'uVPN',
|
||||||
|
description: 'Безопасный, быстрый и анонимный VPN-сервис для вашего ПК и смартфона. Защитите свои данные от посторонних глаз уже сегодня.',
|
||||||
|
keywords: 'vpn, сервис, анонимность, безопасность, шифрование, купить vpn'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/docs/:docName',
|
||||||
|
name: 'documents',
|
||||||
|
component: DocViewer,
|
||||||
|
props: true,
|
||||||
|
meta: {
|
||||||
|
title: 'Юридические сведения',
|
||||||
|
description: 'Пользовательское соглашение и политика конфиденциальности сервиса uVPN.',
|
||||||
|
keywords: 'vpn, пользовательское соглашение, политика конфиденциальности, документы'
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
|
||||||
<HeroSection />
|
<HeroSection />
|
||||||
<FeaturesSection />
|
<FeaturesSection />
|
||||||
<CompatibilitySection />
|
<CompatibilitySection />
|
||||||
<PricingSection />
|
<PricingSection />
|
||||||
|
<NodesStatus />
|
||||||
<StepsSection />
|
<StepsSection />
|
||||||
<TestimonialsSection />
|
<TestimonialsSection />
|
||||||
<FaqSection />
|
<FaqSection />
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -15,7 +14,13 @@ import HeroSection from '../components/HeroSection.vue'
|
|||||||
import FeaturesSection from '../components/FeaturesSection.vue'
|
import FeaturesSection from '../components/FeaturesSection.vue'
|
||||||
import CompatibilitySection from '../components/CompatibilitySection.vue'
|
import CompatibilitySection from '../components/CompatibilitySection.vue'
|
||||||
import PricingSection from '../components/PricingSection.vue'
|
import PricingSection from '../components/PricingSection.vue'
|
||||||
|
import NodesStatus from '../components/NodesStatus.vue'
|
||||||
import StepsSection from '../components/StepsSection.vue'
|
import StepsSection from '../components/StepsSection.vue'
|
||||||
import TestimonialsSection from '../components/TestimonialsSection.vue'
|
import TestimonialsSection from '../components/TestimonialsSection.vue'
|
||||||
import FaqSection from '../components/FaqSection.vue'
|
import FaqSection from '../components/FaqSection.vue'
|
||||||
|
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+11
-1
@@ -3,17 +3,27 @@ import { fileURLToPath, URL } from 'node:url'
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import { default as Sitemap } from 'vite-plugin-sitemap';
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
vue(),
|
vue(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
|
Sitemap({
|
||||||
|
hostname: 'https://uvpn.shop',
|
||||||
|
dynamicRoutes: ['/docs/policy', '/docs/agreement'],
|
||||||
|
outDir: 'dist',
|
||||||
|
changefreq: 'weekly',
|
||||||
|
priority: 0.8
|
||||||
|
})
|
||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
build: {
|
||||||
|
chunkSizeWarningLimit: 1000
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user