fix(ui): полный справочник стран и городов как в VPS Tracker
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 4s
quality / docker-check (push) Skipped
quality / web (push) Successful in 45s
quality / api (push) Successful in 36s
CD / quality (push) Successful in 1m30s
CD / publish (push) Successful in 1m51s
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 4s
quality / docker-check (push) Skipped
quality / web (push) Successful in 45s
quality / api (push) Successful in 36s
CD / quality (push) Successful in 1m30s
CD / publish (push) Successful in 1m51s
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -3,6 +3,7 @@ import fp from "fastify-plugin";
|
||||
import {
|
||||
createDb,
|
||||
createMemoryDb,
|
||||
ensureCatalogLocations,
|
||||
healthCheck,
|
||||
runMigrations,
|
||||
type Db,
|
||||
@@ -31,6 +32,7 @@ async function dbPlugin(
|
||||
: createDb(opts.config!.databaseUrl);
|
||||
|
||||
runMigrations(sqlite);
|
||||
ensureCatalogLocations(db);
|
||||
app.decorate("db", db);
|
||||
app.decorate("sqlite", sqlite);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@cdnmanager/ui/lib/utils'
|
||||
import { COUNTRY_BY_NAME_RU } from '@cdnmanager/shared'
|
||||
import { countryCodeFromName, getCountryFlagUrl } from '@/lib/country-labels'
|
||||
|
||||
interface CountryFlagProps {
|
||||
@@ -8,7 +9,12 @@ interface CountryFlagProps {
|
||||
}
|
||||
|
||||
export function CountryFlag({ code, country, className }: CountryFlagProps) {
|
||||
const resolvedCode = code ?? (country ? countryCodeFromName(country) : undefined)
|
||||
const resolvedCode =
|
||||
code ??
|
||||
(country
|
||||
? COUNTRY_BY_NAME_RU[country.trim().toLowerCase()]?.code ??
|
||||
countryCodeFromName(country)
|
||||
: undefined)
|
||||
const url = getCountryFlagUrl(resolvedCode)
|
||||
if (!url) return null
|
||||
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
/** ISO → русское название (seed fleet locations). */
|
||||
const COUNTRY_NAME_BY_CODE: Record<string, string> = {
|
||||
RU: 'Россия',
|
||||
DE: 'Германия',
|
||||
NL: 'Нидерланды',
|
||||
FI: 'Финляндия',
|
||||
FR: 'Франция',
|
||||
}
|
||||
|
||||
const COUNTRY_CODE_BY_NAME: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(COUNTRY_NAME_BY_CODE).map(([code, name]) => [name.toLowerCase(), code]),
|
||||
)
|
||||
import {
|
||||
COUNTRY_BY_CODE,
|
||||
COUNTRY_BY_NAME_RU,
|
||||
} from '@cdnmanager/shared'
|
||||
|
||||
export function countryNameFromCode(code: string | null | undefined): string {
|
||||
if (!code) return ''
|
||||
return COUNTRY_NAME_BY_CODE[code.toUpperCase()] ?? code
|
||||
return COUNTRY_BY_CODE[code.toUpperCase()]?.name ?? code
|
||||
}
|
||||
|
||||
export function countryCodeFromName(name: string | null | undefined): string {
|
||||
if (!name?.trim()) return ''
|
||||
const trimmed = name.trim()
|
||||
if (trimmed.length === 2) return trimmed.toUpperCase()
|
||||
return COUNTRY_CODE_BY_NAME[trimmed.toLowerCase()] ?? ''
|
||||
return COUNTRY_BY_NAME_RU[trimmed.toLowerCase()]?.code ?? ''
|
||||
}
|
||||
|
||||
export function getCountryFlagUrl(code?: string): string | undefined {
|
||||
|
||||
@@ -15,6 +15,13 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { Node, NodeRole } from '@cdnmanager/shared'
|
||||
import {
|
||||
COUNTRIES,
|
||||
COUNTRY_BY_NAME_RU,
|
||||
buildCityOptions,
|
||||
cityMatchesCountry,
|
||||
resolveCountryForCityFromRows,
|
||||
} from '@cdnmanager/shared'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { ResourcePage } from '@/components/reui-kit'
|
||||
@@ -124,36 +131,57 @@ function NodesPage() {
|
||||
|
||||
const countryCode = countryCodeFromName(countryName)
|
||||
|
||||
const locationRowsForGeo = useMemo(
|
||||
() =>
|
||||
locations.map((l) => ({
|
||||
city: l.name,
|
||||
country: countryNameFromCode(l.country),
|
||||
})),
|
||||
[locations],
|
||||
)
|
||||
|
||||
const countryOptions = useMemo(() => {
|
||||
const codes = new Set<string>()
|
||||
const names = new Set(COUNTRIES.map((c) => c.name))
|
||||
for (const loc of locations) {
|
||||
if (loc.country) codes.add(loc.country)
|
||||
const n = countryNameFromCode(loc.country)
|
||||
if (n) names.add(n)
|
||||
}
|
||||
return [...codes]
|
||||
.map((code) => {
|
||||
const name = countryNameFromCode(code)
|
||||
return [...names]
|
||||
.sort((a, b) => a.localeCompare(b, 'ru'))
|
||||
.map((name) => {
|
||||
const ref = COUNTRY_BY_NAME_RU[name.toLowerCase()]
|
||||
return {
|
||||
value: name,
|
||||
label: name,
|
||||
leading: <CountryFlag code={code} country={name} />,
|
||||
leading: <CountryFlag code={ref?.code} country={name} />,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label, 'ru'))
|
||||
}, [locations])
|
||||
|
||||
const locationOptions = useMemo(() => {
|
||||
if (!countryCode) return []
|
||||
return locations
|
||||
.filter((l) => l.country === countryCode)
|
||||
.map((l) => ({
|
||||
value: `${l.code} — ${l.name}`,
|
||||
label: `${l.code} — ${l.name}`,
|
||||
}))
|
||||
}, [locations, countryCode])
|
||||
const cityOptions = useMemo(
|
||||
() =>
|
||||
buildCityOptions(locationRowsForGeo, countryName.trim() || undefined, {
|
||||
includeCatalog: true,
|
||||
}),
|
||||
[locationRowsForGeo, countryName],
|
||||
)
|
||||
|
||||
function locationLabel(locationId: string): string {
|
||||
const loc = locations.find((l) => l.id === locationId)
|
||||
return loc ? `${loc.code} — ${loc.name}` : ''
|
||||
function locationIdForCity(cityName: string, country?: string): string {
|
||||
const q = cityName.trim().toLowerCase()
|
||||
if (!q) return ''
|
||||
const code = country ? countryCodeFromName(country) : countryCode
|
||||
const match = locations.find((l) => {
|
||||
if (l.name.trim().toLowerCase() !== q) return false
|
||||
if (code && l.country && l.country.toUpperCase() !== code.toUpperCase()) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return match?.id ?? ''
|
||||
}
|
||||
|
||||
function cityNameForLocationId(locationId: string): string {
|
||||
return locations.find((l) => l.id === locationId)?.name ?? ''
|
||||
}
|
||||
|
||||
function countryNameForLocationId(locationId: string): string {
|
||||
@@ -161,17 +189,6 @@ function NodesPage() {
|
||||
return countryNameFromCode(code)
|
||||
}
|
||||
|
||||
function locationIdFromDisplay(display: string): string {
|
||||
const q = display.trim().toLowerCase()
|
||||
if (!q) return ''
|
||||
const match = locations.find((l) => {
|
||||
if (countryCode && l.country !== countryCode) return false
|
||||
const label = `${l.code} — ${l.name}`.toLowerCase()
|
||||
return label === q || l.code.toLowerCase() === q || l.name.toLowerCase() === q
|
||||
})
|
||||
return match?.id ?? ''
|
||||
}
|
||||
|
||||
async function refreshPreview(
|
||||
overrides?: Partial<{
|
||||
zoneId: string
|
||||
@@ -377,7 +394,7 @@ function NodesPage() {
|
||||
const n = row.original
|
||||
setEditing(n)
|
||||
setCountryName(countryNameForLocationId(n.locationId))
|
||||
setLocationQuery(locationLabel(n.locationId))
|
||||
setLocationQuery(cityNameForLocationId(n.locationId))
|
||||
form.reset({
|
||||
zoneId: n.zoneId,
|
||||
locationId: n.locationId,
|
||||
@@ -418,12 +435,11 @@ function NodesPage() {
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditing(null)
|
||||
const firstLoc = locations[0]
|
||||
setCountryName(countryNameFromCode(firstLoc?.country))
|
||||
setLocationQuery(firstLoc ? `${firstLoc.code} — ${firstLoc.name}` : '')
|
||||
setCountryName('')
|
||||
setLocationQuery('')
|
||||
form.reset({
|
||||
zoneId: zones[0]?.id ?? '',
|
||||
locationId: firstLoc?.id ?? '',
|
||||
locationId: '',
|
||||
role: 'gw',
|
||||
indexNum: 1,
|
||||
ipv4: '',
|
||||
@@ -517,11 +533,13 @@ function NodesPage() {
|
||||
value={countryName}
|
||||
onChange={(v) => {
|
||||
setCountryName(v)
|
||||
const nextCode = countryCodeFromName(v)
|
||||
const currentLoc = locations.find((l) => l.id === form.getValues('locationId'))
|
||||
if (!nextCode || !currentLoc || currentLoc.country !== nextCode) {
|
||||
form.setValue('locationId', '')
|
||||
if (
|
||||
v.trim() &&
|
||||
locationQuery.trim() &&
|
||||
!cityMatchesCountry(locationQuery, v, locationRowsForGeo)
|
||||
) {
|
||||
setLocationQuery('')
|
||||
form.setValue('locationId', '')
|
||||
setPreviewHost('')
|
||||
setPreviewRecords([])
|
||||
}
|
||||
@@ -531,28 +549,30 @@ function NodesPage() {
|
||||
emptyText="Нет вариантов"
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple label="Локация" htmlFor="node-location">
|
||||
<FormFieldSimple label="Город" htmlFor="node-city">
|
||||
<AutoCompleteInput
|
||||
id="node-location"
|
||||
placeholder="Любая"
|
||||
id="node-city"
|
||||
placeholder="Любой"
|
||||
value={locationQuery}
|
||||
onChange={(v) => {
|
||||
setLocationQuery(v)
|
||||
const id = locationIdFromDisplay(v)
|
||||
const resolvedCountry =
|
||||
resolveCountryForCityFromRows(v, locationRowsForGeo) ?? countryName
|
||||
if (resolvedCountry && resolvedCountry !== countryName) {
|
||||
setCountryName(resolvedCountry)
|
||||
}
|
||||
const id = locationIdForCity(v, resolvedCountry || countryName)
|
||||
form.setValue('locationId', id)
|
||||
const loc = locations.find((l) => l.id === id)
|
||||
if (loc?.country) setCountryName(countryNameFromCode(loc.country))
|
||||
if (id) void refreshPreview({ locationId: id })
|
||||
else {
|
||||
setPreviewHost('')
|
||||
setPreviewRecords([])
|
||||
}
|
||||
}}
|
||||
options={locationOptions}
|
||||
searchPlaceholder="Поиск локации…"
|
||||
emptyText={countryCode ? 'Нет вариантов' : 'Сначала выберите страну'}
|
||||
options={cityOptions}
|
||||
searchPlaceholder="Поиск города…"
|
||||
emptyText="Нет вариантов"
|
||||
showLeadingInInput={false}
|
||||
disabled={!countryCode}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user