343 lines
9.5 KiB
JavaScript
343 lines
9.5 KiB
JavaScript
/**
|
|
* Утилиты vps-tracker: ID, URL, форматирование, валюта, лейблы, CSV
|
|
*/
|
|
|
|
/**
|
|
* Генерирует уникальный ID (UUID или fallback)
|
|
* @returns {string}
|
|
*/
|
|
export function uid() {
|
|
if (crypto && crypto.randomUUID) {
|
|
return crypto.randomUUID()
|
|
}
|
|
return `id-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
|
}
|
|
|
|
/**
|
|
* Добавляет https:// к URL при отсутствии протокола
|
|
* @param {string} website
|
|
* @returns {string}
|
|
*/
|
|
export function normalizeWebsiteUrl(website) {
|
|
if (!website) {
|
|
return ''
|
|
}
|
|
if (website.startsWith('http://') || website.startsWith('https://')) {
|
|
return website
|
|
}
|
|
return `https://${website}`
|
|
}
|
|
|
|
/**
|
|
* URL иконки сайта через Google Favicon API
|
|
* @param {string} website
|
|
* @returns {string}
|
|
*/
|
|
export function faviconUrlFromWebsite(website) {
|
|
const normalized = normalizeWebsiteUrl(website)
|
|
if (!normalized) {
|
|
return ''
|
|
}
|
|
try {
|
|
const { hostname } = new URL(normalized)
|
|
return `https://www.google.com/s2/favicons?domain=${hostname}&sz=32`
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
const countryCodeByName = {
|
|
germany: 'DE',
|
|
netherlands: 'NL',
|
|
russia: 'RU',
|
|
usa: 'US',
|
|
'united states': 'US',
|
|
ukraine: 'UA',
|
|
poland: 'PL',
|
|
france: 'FR',
|
|
spain: 'ES',
|
|
italy: 'IT',
|
|
estonia: 'EE',
|
|
finland: 'FI',
|
|
sweden: 'SE',
|
|
norway: 'NO',
|
|
latvia: 'LV',
|
|
lithuania: 'LT',
|
|
czechia: 'CZ',
|
|
czech: 'CZ',
|
|
singapore: 'SG',
|
|
japan: 'JP',
|
|
canada: 'CA',
|
|
brazil: 'BR',
|
|
turkey: 'TR',
|
|
georgia: 'GE',
|
|
kazakhstan: 'KZ',
|
|
}
|
|
|
|
/**
|
|
* Эмодзи флага страны по названию (ru-RU)
|
|
* @param {string} country - название страны
|
|
* @returns {string} эмодзи флага или 🌐
|
|
*/
|
|
export function getCountryFlagEmoji(country) {
|
|
if (!country) {
|
|
return '🌐'
|
|
}
|
|
const code = countryCodeByName[country.trim().toLowerCase()]
|
|
if (!code) {
|
|
return '🌐'
|
|
}
|
|
return code
|
|
.toUpperCase()
|
|
.split('')
|
|
.map((char) => String.fromCodePoint(127397 + char.charCodeAt(0)))
|
|
.join('')
|
|
}
|
|
|
|
const paymentTypeLabels = {
|
|
direct_vps_payment: 'Прямой платеж за VPS',
|
|
provider_balance_topup: 'Пополнение баланса хостера',
|
|
daily_debit: 'Ежедневное списание',
|
|
monthly_debit: 'Ежемесячное списание',
|
|
}
|
|
|
|
/**
|
|
* Человекочитаемая метка типа платежа
|
|
* @param {string} type - direct_vps_payment | provider_balance_topup | daily_debit | monthly_debit
|
|
* @returns {string}
|
|
*/
|
|
export function paymentTypeLabel(type) {
|
|
return paymentTypeLabels[type] || type
|
|
}
|
|
|
|
const vpsStatusLabels = {
|
|
active: 'Активен',
|
|
paused: 'Приостановлен',
|
|
archived: 'Архив',
|
|
}
|
|
|
|
/**
|
|
* Человекочитаемая метка статуса VPS
|
|
* @param {string} status - active | paused | archived
|
|
* @returns {string}
|
|
*/
|
|
export function vpsStatusLabel(status) {
|
|
return vpsStatusLabels[status] || status
|
|
}
|
|
|
|
const billingModeLabels = {
|
|
daily: 'Ежедневно',
|
|
monthly: 'Ежемесячно',
|
|
}
|
|
|
|
/**
|
|
* Человекочитаемая метка режима биллинга
|
|
* @param {string} mode - daily | monthly
|
|
* @returns {string}
|
|
*/
|
|
export function billingModeLabel(mode) {
|
|
return billingModeLabels[mode] || mode
|
|
}
|
|
|
|
const tariffTypeLabels = {
|
|
daily: 'Суточный',
|
|
monthly: 'Месячный',
|
|
}
|
|
|
|
/**
|
|
* Человекочитаемая метка типа тарифа
|
|
* @param {string} type - daily | monthly
|
|
* @returns {string}
|
|
*/
|
|
export function tariffTypeLabel(type) {
|
|
return tariffTypeLabels[type] || type
|
|
}
|
|
|
|
const CURRENCY_SYMBOL_MAP = {
|
|
'€': 'EUR',
|
|
'$': 'USD',
|
|
'₽': 'RUB',
|
|
'£': 'GBP',
|
|
'¥': 'JPY',
|
|
'₴': 'UAH',
|
|
'₸': 'KZT',
|
|
}
|
|
|
|
function toIsoCurrency(currency) {
|
|
if (!currency || typeof currency !== 'string') return 'USD'
|
|
const trimmed = currency.trim()
|
|
if (CURRENCY_SYMBOL_MAP[trimmed]) return CURRENCY_SYMBOL_MAP[trimmed]
|
|
if (trimmed.length === 3 && /^[A-Z]{3}$/i.test(trimmed)) return trimmed.toUpperCase()
|
|
return 'USD'
|
|
}
|
|
|
|
/**
|
|
* Форматирует сумму в валюте (ru-RU)
|
|
* @param {number} amount
|
|
* @param {string} [currency='USD']
|
|
* @returns {string}
|
|
*/
|
|
export function formatCurrency(amount, currency = 'USD') {
|
|
const safeAmount = Number.isFinite(Number(amount)) ? Number(amount) : 0
|
|
const isoCurrency = toIsoCurrency(currency)
|
|
return new Intl.NumberFormat('ru-RU', {
|
|
style: 'currency',
|
|
currency: isoCurrency,
|
|
minimumFractionDigits: 2,
|
|
}).format(safeAmount)
|
|
}
|
|
|
|
/**
|
|
* Конвертирует сумму из одной валюты в другую по ratesData (CBR и т.п.)
|
|
* @param {number} amount
|
|
* @param {string} fromCurrency
|
|
* @param {string} toCurrency
|
|
* @param {object} ratesData - { base, rates: { USD: 1.2, EUR: 1.1, ... } }
|
|
* @returns {number}
|
|
*/
|
|
export function convertCurrency(amount, fromCurrency, toCurrency, ratesData) {
|
|
const safeAmount = Number(amount)
|
|
if (!Number.isFinite(safeAmount)) {
|
|
return 0
|
|
}
|
|
const from = toIsoCurrency(fromCurrency)
|
|
const to = toIsoCurrency(toCurrency)
|
|
if (!from || !to || from === to) {
|
|
return safeAmount
|
|
}
|
|
if (!ratesData || !ratesData.rates || !ratesData.base) {
|
|
return safeAmount
|
|
}
|
|
const apiBase = ratesData.base.toUpperCase()
|
|
const rates = { ...ratesData.rates, [apiBase]: 1 }
|
|
|
|
if (!rates[from] || !rates[to]) {
|
|
return safeAmount
|
|
}
|
|
|
|
const amountInApiBase = safeAmount / rates[from]
|
|
return amountInApiBase * rates[to]
|
|
}
|
|
|
|
/**
|
|
* Форматирует сумму в базовой валюте приложения (settings.baseCurrency)
|
|
* @param {number} amount
|
|
* @param {string} currency
|
|
* @param {object[]} appSettings - settings из API
|
|
* @param {object} ratesData
|
|
* @returns {string}
|
|
*/
|
|
export function formatInBaseCurrency(amount, currency, appSettings, ratesData) {
|
|
const settings = appSettings?.[0] || {}
|
|
const baseCurrency = settings.baseCurrency || 'RUB'
|
|
const autoConvert = settings.autoConvert !== false
|
|
|
|
if (!autoConvert) {
|
|
return formatCurrency(amount, currency)
|
|
}
|
|
|
|
const converted = convertCurrency(amount, currency, baseCurrency, ratesData)
|
|
return formatCurrency(converted, baseCurrency)
|
|
}
|
|
|
|
/**
|
|
* Конвертирует сумму в валюту отображения (из настроек).
|
|
* provider.baseCurrency — валюта, в которой хостер принимает платежи.
|
|
* settings.baseCurrency — валюта отображения на дашбордах.
|
|
* Курсы хостера (usdRate, eurRate) — курс 1 USD/EUR в валюту отображения.
|
|
*/
|
|
export function convertWithProviderRate(amount, currency, provider, appSettings, ratesData) {
|
|
const safeAmount = Number(amount)
|
|
const appBase = (appSettings?.[0]?.baseCurrency || 'RUB').toUpperCase()
|
|
if (!Number.isFinite(safeAmount)) {
|
|
return { value: 0, currency: appBase, source: 'global' }
|
|
}
|
|
|
|
const fromCurrency = toIsoCurrency(currency || appBase)
|
|
|
|
if (fromCurrency === appBase) {
|
|
return { value: safeAmount, currency: appBase, source: 'native' }
|
|
}
|
|
|
|
const usdRate = Number(provider?.usdRate)
|
|
const eurRate = Number(provider?.eurRate)
|
|
if (fromCurrency === 'USD' && Number.isFinite(usdRate) && usdRate > 0) {
|
|
return { value: safeAmount * usdRate, currency: appBase, source: 'provider' }
|
|
}
|
|
if (fromCurrency === 'EUR' && Number.isFinite(eurRate) && eurRate > 0) {
|
|
return { value: safeAmount * eurRate, currency: appBase, source: 'provider' }
|
|
}
|
|
|
|
return {
|
|
value: convertCurrency(safeAmount, fromCurrency, appBase, ratesData),
|
|
currency: appBase,
|
|
source: 'global',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Форматирует сумму с учётом курсов провайдера (usdRate, eurRate)
|
|
* @param {number} amount
|
|
* @param {string} currency
|
|
* @param {object} provider
|
|
* @param {object[]} appSettings
|
|
* @param {object} ratesData
|
|
* @returns {string}
|
|
*/
|
|
export function formatInProviderCurrency(amount, currency, provider, appSettings, ratesData) {
|
|
const converted = convertWithProviderRate(amount, currency, provider, appSettings, ratesData)
|
|
return formatCurrency(converted.value, converted.currency)
|
|
}
|
|
|
|
/**
|
|
* Ключ месяца для группировки: "2025-03"
|
|
* @param {string} dateString
|
|
* @returns {string}
|
|
*/
|
|
export function monthKey(dateString) {
|
|
const date = new Date(dateString)
|
|
if (Number.isNaN(date.getTime())) {
|
|
return ''
|
|
}
|
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
|
}
|
|
|
|
/**
|
|
* Преобразует массив объектов в CSV-строку
|
|
* @param {object[]} rows
|
|
* @returns {string}
|
|
*/
|
|
export function toCsv(rows) {
|
|
if (!rows.length) {
|
|
return ''
|
|
}
|
|
const headers = Object.keys(rows[0])
|
|
const escapeValue = (value) => {
|
|
const str = `${value ?? ''}`
|
|
if (str.includes('"') || str.includes(',') || str.includes('\n')) {
|
|
return `"${str.replaceAll('"', '""')}"`
|
|
}
|
|
return str
|
|
}
|
|
const lines = [headers.join(',')]
|
|
for (const row of rows) {
|
|
lines.push(headers.map((h) => escapeValue(row[h])).join(','))
|
|
}
|
|
return lines.join('\n')
|
|
}
|
|
|
|
/**
|
|
* Инициирует скачивание текстового файла (CSV)
|
|
* @param {string} fileName
|
|
* @param {string} content
|
|
*/
|
|
export function downloadTextFile(fileName, content) {
|
|
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' })
|
|
const url = URL.createObjectURL(blob)
|
|
const anchor = document.createElement('a')
|
|
anchor.href = url
|
|
anchor.download = fileName
|
|
anchor.click()
|
|
URL.revokeObjectURL(url)
|
|
}
|