init commit

This commit is contained in:
Denozordec
2026-03-17 23:19:28 +07:00
commit 221edcda16
58 changed files with 11938 additions and 0 deletions
+144
View File
@@ -0,0 +1,144 @@
import { uid } from './utils'
const COLLECTIONS = {
vps: 'vps',
providers: 'providers',
providerAccounts: 'providerAccounts',
payments: 'payments',
balanceLedger: 'balanceLedger',
settings: 'settings',
}
const API_PATHS = {
[COLLECTIONS.vps]: '/api/vps',
[COLLECTIONS.providers]: '/api/providers',
[COLLECTIONS.providerAccounts]: '/api/provider-accounts',
[COLLECTIONS.payments]: '/api/payments',
[COLLECTIONS.balanceLedger]: '/api/balance-ledger',
[COLLECTIONS.settings]: '/api/settings',
}
const STORAGE_KEY_PREFIX = 'vps-tracker:'
const API_BASE = import.meta.env.VITE_API_URL || ''
function getLocalStorageData() {
const data = {}
for (const name of Object.values(COLLECTIONS)) {
const key = `${STORAGE_KEY_PREFIX}${name}`
const raw = localStorage.getItem(key)
if (raw) {
try {
data[name] = JSON.parse(raw)
} catch {
data[name] = []
}
}
}
return data
}
function clearLocalStorage() {
for (const name of Object.values(COLLECTIONS)) {
localStorage.removeItem(`${STORAGE_KEY_PREFIX}${name}`)
}
}
async function fetchApi(path, options = {}) {
const url = `${API_BASE}${path.startsWith('/') ? path : `/${path}`}`
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json', ...options.headers },
...options,
})
if (!res.ok) {
const err = new Error(res.statusText || 'API error')
err.status = res.status
err.response = res
throw err
}
if (res.status === 204) return null
return res.json()
}
export async function initDataStore() {
const localData = getLocalStorageData()
const hasLocalData = Object.keys(localData).some((k) => {
const arr = localData[k]
return Array.isArray(arr) ? arr.length > 0 : arr && typeof arr === 'object'
})
if (hasLocalData) {
try {
await fetchApi('/api/migrate', {
method: 'POST',
body: JSON.stringify(localData),
})
clearLocalStorage()
} catch (err) {
console.warn('Migration from localStorage failed:', err)
}
}
}
export async function loadDataSet() {
const data = await fetchApi('/api/data')
return {
[COLLECTIONS.vps]: data.vps ?? [],
[COLLECTIONS.providers]: data.providers ?? [],
[COLLECTIONS.providerAccounts]: data.providerAccounts ?? [],
[COLLECTIONS.payments]: data.payments ?? [],
[COLLECTIONS.balanceLedger]: data.balanceLedger ?? [],
[COLLECTIONS.settings]: data.settings ?? [],
activeTariffs: data.activeTariffs ?? [],
tariffSyncOptions: data.tariffSyncOptions ?? [],
}
}
async function fetchCollection(collectionName) {
const path = API_PATHS[collectionName]
if (!path) throw new Error(`Unknown collection: ${collectionName}`)
return fetchApi(path)
}
export async function createRecord(collectionName, record) {
const path = API_PATHS[collectionName]
if (!path) throw new Error(`Unknown collection: ${collectionName}`)
const payload = { ...record, id: record.id || uid() }
await fetchApi(path, {
method: 'POST',
body: JSON.stringify(payload),
})
return fetchCollection(collectionName)
}
export async function updateRecord(collectionName, id, patch) {
const path = API_PATHS[collectionName]
if (!path) throw new Error(`Unknown collection: ${collectionName}`)
await fetchApi(`${path}/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(patch),
})
return fetchCollection(collectionName)
}
export async function deleteRecord(collectionName, id) {
const path = API_PATHS[collectionName]
if (!path) throw new Error(`Unknown collection: ${collectionName}`)
await fetchApi(`${path}/${encodeURIComponent(id)}`, { method: 'DELETE' })
return fetchCollection(collectionName)
}
export async function syncAccount(accountId) {
return fetchApi(`/api/sync/${encodeURIComponent(accountId)}`, { method: 'POST' })
}
export async function fetchAccountBalance(accountId) {
return fetchApi(`/api/sync/${encodeURIComponent(accountId)}/balance`, { method: 'GET' })
}
export async function testApiConnection(apiBaseUrl, apiCredentials) {
return fetchApi('/api/sync/test-connection', {
method: 'POST',
body: JSON.stringify({ apiBaseUrl, apiCredentials }),
})
}
+342
View File
@@ -0,0 +1,342 @@
/**
* Утилиты 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)
}