feat(web): связка страны и города в форме VPS

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-06-27 02:18:18 +07:00
co-authored by Cursor
parent a954591493
commit 836c8a2936
2 changed files with 119 additions and 24 deletions
+25 -23
View File
@@ -43,7 +43,7 @@ import { VpsFiltersToolbar } from '@/components/vps-filters-toolbar'
import type { Vps } from '@/types/entities'
import { vpsStatusLabel, tariffTypeLabel } from '@/lib/format'
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
import { COUNTRIES, COUNTRY_BY_NAME_RU, listCities } from '@cfdm/shared/geo'
import { COUNTRIES, COUNTRY_BY_NAME_RU, buildCityOptions, cityMatchesCountry, resolveCountryForCityFromRows } from '@cfdm/shared/geo'
export const Route = createFileRoute('/_auth/vps')({
loader: ({ context: { queryClient } }) =>
@@ -168,23 +168,10 @@ function VpsPage() {
})
}, [snapshot?.vps])
const cityOptions = useMemo(() => {
const names = new Set<string>()
// Из существующих VPS
for (const v of snapshot?.vps ?? []) {
const c = (v.city || '').trim()
if (c) names.add(c)
}
// Из стандартизированного справочника (фильтр по первой выбранной стране, если есть)
const countryCode = filters.country[0]
? COUNTRY_BY_NAME_RU[filters.country[0].toLowerCase()]?.code
: undefined
for (const city of listCities(countryCode)) names.add(city.name)
return [...names].sort((a, b) => a.localeCompare(b, 'ru')).map((name) => ({
value: name,
label: name,
}))
}, [snapshot?.vps, filters.country])
const cityOptions = useMemo(
() => buildCityOptions(snapshot?.vps, filters.country[0]),
[snapshot?.vps, filters.country],
)
const tableSections = useMemo(() => {
if (!filters.groupByProject) {
@@ -390,6 +377,12 @@ function VpsPage() {
{(form) => {
const { register, formState: { errors }, watch, setValue } = form
const providerId = watch('providerId')
const formCountry = watch('country') ?? ''
const formCity = watch('city') ?? ''
const formCityOptions = buildCityOptions(
snapshot?.vps,
formCountry.trim() || undefined,
)
return (
<>
<FormField label="IP" htmlFor="vps-ip" error={errors.ip?.message} invalid={!!errors.ip}>
@@ -425,8 +418,13 @@ function VpsPage() {
<AutoCompleteInput
id="vps-country"
placeholder="Любая"
value={watch('country') ?? ''}
onChange={(v) => setValue('country', v)}
value={formCountry}
onChange={(v) => {
setValue('country', v)
if (v.trim() && formCity.trim() && !cityMatchesCountry(formCity, v, snapshot?.vps)) {
setValue('city', '')
}
}}
options={countryOptions}
searchPlaceholder="Поиск страны…"
emptyText="Нет вариантов"
@@ -436,9 +434,13 @@ function VpsPage() {
<AutoCompleteInput
id="vps-city"
placeholder="Любой"
value={watch('city') ?? ''}
onChange={(v) => setValue('city', v)}
options={cityOptions}
value={formCity}
onChange={(v) => {
setValue('city', v)
const country = resolveCountryForCityFromRows(v, snapshot?.vps)
if (country) setValue('country', country)
}}
options={formCityOptions}
searchPlaceholder="Поиск города…"
emptyText="Нет вариантов"
showLeadingInInput={false}
+94 -1
View File
@@ -4,7 +4,7 @@
* Не зависит от данных в БД vps-tracker — общедоступный статичный справочник.
*/
import { COUNTRY_BY_CODE, COUNTRIES, type CountryRef } from './countries.js'
import { COUNTRY_BY_CODE, COUNTRY_BY_NAME_RU, COUNTRIES, type CountryRef } from './countries.js'
export interface CityRef {
/** ISO 3166-1 alpha-2 код страны */
@@ -178,6 +178,99 @@ export const CITIES: readonly CityRef[] = [
{ countryCode: 'IL', name: 'Тель-Авив', nameEn: 'Tel Aviv' },
]
export const CITY_BY_NAME_RU: Record<string, CityRef> = Object.fromEntries(
CITIES.map((c) => [c.name.toLowerCase(), c]),
)
/** Найти город в справочнике по русскому названию. */
export function resolveCityRef(cityName: string): CityRef | undefined {
return CITY_BY_NAME_RU[cityName.trim().toLowerCase()]
}
/** Страна из справочника для города (если есть привязка). */
export function resolveCountryForCity(cityName: string): CountryRef | undefined {
const city = resolveCityRef(cityName)
return city ? COUNTRY_BY_CODE[city.countryCode] : undefined
}
export interface CityLocationRow {
city?: string | null
country?: string | null
}
/** Проверить, относится ли город к выбранной стране (справочник или данные VPS). */
export function cityMatchesCountry(
cityName: string,
countryName: string,
rows?: readonly CityLocationRow[],
): boolean {
const countryCode = COUNTRY_BY_NAME_RU[countryName.trim().toLowerCase()]?.code
if (!countryCode) return true
const catalog = resolveCityRef(cityName)
if (catalog) return catalog.countryCode === countryCode
return (rows ?? []).some(
(row) =>
row.city?.trim().toLowerCase() === cityName.trim().toLowerCase() &&
COUNTRY_BY_NAME_RU[(row.country || '').trim().toLowerCase()]?.code === countryCode,
)
}
/** Опции городов: из VPS и справочника, опционально по стране. */
export function buildCityOptions(
rows: readonly CityLocationRow[] | undefined,
countryName?: string,
): { value: string; label: string }[] {
const names = new Set<string>()
const countryCode = countryName?.trim()
? COUNTRY_BY_NAME_RU[countryName.trim().toLowerCase()]?.code
: undefined
const belongsToCountry = (city: string, rowCountry?: string): boolean => {
if (!countryCode) return true
const catalog = resolveCityRef(city)
if (catalog) return catalog.countryCode === countryCode
if (rowCountry?.trim()) {
return COUNTRY_BY_NAME_RU[rowCountry.trim().toLowerCase()]?.code === countryCode
}
return false
}
for (const row of rows ?? []) {
const city = (row.city || '').trim()
if (!city) continue
if (belongsToCountry(city, row.country ?? undefined)) {
names.add(city)
}
}
for (const { name } of listCities(countryCode)) {
names.add(name)
}
return [...names]
.sort((a, b) => a.localeCompare(b, 'ru'))
.map((name) => ({ value: name, label: name }))
}
/** Страна для города: справочник, иначе первая запись VPS с таким городом. */
export function resolveCountryForCityFromRows(
cityName: string,
rows: readonly CityLocationRow[] | undefined,
): string | undefined {
const trimmed = cityName.trim()
if (!trimmed) return undefined
const fromCatalog = resolveCountryForCity(trimmed)
if (fromCatalog) return fromCatalog.name
const row = (rows ?? []).find(
(r) => r.city?.trim().toLowerCase() === trimmed.toLowerCase() && r.country?.trim(),
)
return row?.country?.trim()
}
export interface CityOption extends CityRef {
country: CountryRef
}