feat(web): связка страны и города в форме VPS
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -43,7 +43,7 @@ import { VpsFiltersToolbar } from '@/components/vps-filters-toolbar'
|
|||||||
import type { Vps } from '@/types/entities'
|
import type { Vps } from '@/types/entities'
|
||||||
import { vpsStatusLabel, tariffTypeLabel } from '@/lib/format'
|
import { vpsStatusLabel, tariffTypeLabel } from '@/lib/format'
|
||||||
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
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')({
|
export const Route = createFileRoute('/_auth/vps')({
|
||||||
loader: ({ context: { queryClient } }) =>
|
loader: ({ context: { queryClient } }) =>
|
||||||
@@ -168,23 +168,10 @@ function VpsPage() {
|
|||||||
})
|
})
|
||||||
}, [snapshot?.vps])
|
}, [snapshot?.vps])
|
||||||
|
|
||||||
const cityOptions = useMemo(() => {
|
const cityOptions = useMemo(
|
||||||
const names = new Set<string>()
|
() => buildCityOptions(snapshot?.vps, filters.country[0]),
|
||||||
// Из существующих VPS
|
[snapshot?.vps, filters.country],
|
||||||
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 tableSections = useMemo(() => {
|
const tableSections = useMemo(() => {
|
||||||
if (!filters.groupByProject) {
|
if (!filters.groupByProject) {
|
||||||
@@ -390,6 +377,12 @@ function VpsPage() {
|
|||||||
{(form) => {
|
{(form) => {
|
||||||
const { register, formState: { errors }, watch, setValue } = form
|
const { register, formState: { errors }, watch, setValue } = form
|
||||||
const providerId = watch('providerId')
|
const providerId = watch('providerId')
|
||||||
|
const formCountry = watch('country') ?? ''
|
||||||
|
const formCity = watch('city') ?? ''
|
||||||
|
const formCityOptions = buildCityOptions(
|
||||||
|
snapshot?.vps,
|
||||||
|
formCountry.trim() || undefined,
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FormField label="IP" htmlFor="vps-ip" error={errors.ip?.message} invalid={!!errors.ip}>
|
<FormField label="IP" htmlFor="vps-ip" error={errors.ip?.message} invalid={!!errors.ip}>
|
||||||
@@ -425,8 +418,13 @@ function VpsPage() {
|
|||||||
<AutoCompleteInput
|
<AutoCompleteInput
|
||||||
id="vps-country"
|
id="vps-country"
|
||||||
placeholder="Любая"
|
placeholder="Любая"
|
||||||
value={watch('country') ?? ''}
|
value={formCountry}
|
||||||
onChange={(v) => setValue('country', v)}
|
onChange={(v) => {
|
||||||
|
setValue('country', v)
|
||||||
|
if (v.trim() && formCity.trim() && !cityMatchesCountry(formCity, v, snapshot?.vps)) {
|
||||||
|
setValue('city', '')
|
||||||
|
}
|
||||||
|
}}
|
||||||
options={countryOptions}
|
options={countryOptions}
|
||||||
searchPlaceholder="Поиск страны…"
|
searchPlaceholder="Поиск страны…"
|
||||||
emptyText="Нет вариантов"
|
emptyText="Нет вариантов"
|
||||||
@@ -436,9 +434,13 @@ function VpsPage() {
|
|||||||
<AutoCompleteInput
|
<AutoCompleteInput
|
||||||
id="vps-city"
|
id="vps-city"
|
||||||
placeholder="Любой"
|
placeholder="Любой"
|
||||||
value={watch('city') ?? ''}
|
value={formCity}
|
||||||
onChange={(v) => setValue('city', v)}
|
onChange={(v) => {
|
||||||
options={cityOptions}
|
setValue('city', v)
|
||||||
|
const country = resolveCountryForCityFromRows(v, snapshot?.vps)
|
||||||
|
if (country) setValue('country', country)
|
||||||
|
}}
|
||||||
|
options={formCityOptions}
|
||||||
searchPlaceholder="Поиск города…"
|
searchPlaceholder="Поиск города…"
|
||||||
emptyText="Нет вариантов"
|
emptyText="Нет вариантов"
|
||||||
showLeadingInInput={false}
|
showLeadingInInput={false}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Не зависит от данных в БД vps-tracker — общедоступный статичный справочник.
|
* Не зависит от данных в БД 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 {
|
export interface CityRef {
|
||||||
/** ISO 3166-1 alpha-2 код страны */
|
/** ISO 3166-1 alpha-2 код страны */
|
||||||
@@ -178,6 +178,99 @@ export const CITIES: readonly CityRef[] = [
|
|||||||
{ countryCode: 'IL', name: 'Тель-Авив', nameEn: 'Tel Aviv' },
|
{ 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 {
|
export interface CityOption extends CityRef {
|
||||||
country: CountryRef
|
country: CountryRef
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user