UI: один адрес на шаге с координатами + рабочее геокодирование + авто«прошло времени»
- Step3Circumstances: удалено дублирующее «Последнее известное место» (last_known_place); остаётся только tnp_address на шаге координат. - Step4Environment: кнопка «Найти координаты» теперь РАБОЧАЯ — вызывает новый GET /api/v1/geocode (прокси Nominatim, countrycodes=by, короткий запрос «пункт, адрес» по граблям geocoding), заполняет lat/lon, показывает найденный display_name или ошибку. Без debounce-автоплейса (Pitfall 2) — только явный клик. - Step3Circumstances: «Прошло времени» считается автоматически из lost_time (пересчёт каждые 30 с, округление до 0.1 ч); поле остаётся редактируемым для корректировки. - БАГФИКС: форма шлёт lost_time, модель хранит loss_time — алиас в _normalize_case_data. Раньше время пропажи молча терялось (Pydantic игнорирует неизвестные поля) — elapsed_hours приходилось вводить руками. - last_known_place убран из валидации шага 4. - backend/routers/geocode.py: Nominatim-прокси с auth (operator/field/admin). 192 passed, JSX валиден.
This commit is contained in:
@@ -67,6 +67,10 @@ def _normalize_case_data(data: dict[str, Any]) -> dict[str, Any]:
|
||||
'last_known_place': 'tnp_address',
|
||||
'reason': 'loss_reason',
|
||||
'clothes_shoes': 'shoes',
|
||||
# Форма шлёт lost_time (datetime-local), модель хранит loss_time.
|
||||
# До 2026-09-09 время пропажи молча терялось (Pydantic игнорирует
|
||||
# неизвестные поля) — elapsed_hours приходилось вводить руками.
|
||||
'lost_time': 'loss_time',
|
||||
}
|
||||
for source, target in aliases.items():
|
||||
if source in normalized and target not in normalized:
|
||||
|
||||
@@ -12,6 +12,7 @@ from backend.routers.analyze import router as analyze_router
|
||||
from backend.routers.auth import router as auth_router
|
||||
from backend.routers.cases import router as cases_router
|
||||
from backend.routers.closed_cases import router as closed_cases_router
|
||||
from backend.routers.geocode import router as geocode_router
|
||||
from backend.routers.health import router as health_router
|
||||
from backend.routers.stats import router as stats_router
|
||||
from backend.routers.water import router as water_router
|
||||
@@ -53,6 +54,7 @@ app.include_router(closed_cases_router)
|
||||
app.include_router(stats_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(water_router)
|
||||
app.include_router(geocode_router)
|
||||
|
||||
|
||||
@app.get('/')
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Геокодирование: прокси к Nominatim (OpenStreetMap).
|
||||
|
||||
GET /api/v1/geocode?q=<запрос>&limit=5
|
||||
Возвращает {results: [{lat, lng, display_name, type}]}.
|
||||
|
||||
Nominatim Usage Policy: User-Agent обязателен, ≤1 rps — проксируем через
|
||||
бэкенд с корректным UA и ограничением countrycodes=by.
|
||||
|
||||
Правила запросов (nominatim-грабли, см. homelab-ops skill):
|
||||
- Короткий запрос: пункт + адрес, БЕЗ района/области.
|
||||
- Пункт ПЕРВЫМ словом.
|
||||
"""
|
||||
import os
|
||||
from typing import List, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.routers.auth import require_roles
|
||||
from fastapi import Depends
|
||||
|
||||
router = APIRouter(prefix='/api/v1/geocode', tags=['geocode'])
|
||||
|
||||
NOMINATIM_URL = 'https://nominatim.openstreetmap.org/search'
|
||||
USER_AGENT = os.getenv('NOMINATIM_USER_AGENT', 'Vector-MChS/1.0 (geocoding proxy)')
|
||||
COUNTRY_CODE = 'by'
|
||||
|
||||
|
||||
class GeocodeResult(BaseModel):
|
||||
lat: float
|
||||
lng: float
|
||||
display_name: str
|
||||
type: Optional[str] = None
|
||||
|
||||
|
||||
class GeocodeResponse(BaseModel):
|
||||
results: List[GeocodeResult]
|
||||
|
||||
|
||||
@router.get('', response_model=GeocodeResponse,
|
||||
dependencies=[Depends(require_roles(['operator', 'field', 'admin']))])
|
||||
async def geocode(
|
||||
q: str = Query(..., min_length=2, description='Адрес: «пункт, улица»'),
|
||||
limit: int = Query(5, ge=1, le=10),
|
||||
):
|
||||
"""Геокодирование через Nominatim, ограничено Беларусью."""
|
||||
headers = {'User-Agent': USER_AGENT}
|
||||
params = {
|
||||
'q': q.strip(),
|
||||
'format': 'jsonv2',
|
||||
'limit': limit,
|
||||
'countrycodes': COUNTRY_CODE,
|
||||
'addressdetails': 1,
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(NOMINATIM_URL, headers=headers, params=params)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.TimeoutException:
|
||||
raise HTTPException(status_code=504, detail='Сервис геокодирования не ответил')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f'Ошибка геокодирования: {e}')
|
||||
|
||||
return {'results': [
|
||||
GeocodeResult(
|
||||
lat=float(item['lat']),
|
||||
lng=float(item['lon']),
|
||||
display_name=item.get('display_name', ''),
|
||||
type=item.get('type'),
|
||||
)
|
||||
for item in data
|
||||
]}
|
||||
@@ -51,9 +51,6 @@ const CaseForm = ({ isMobile = false }) => {
|
||||
if (!formData.lost_time) {
|
||||
newErrors.push("Укажите дату и время потери");
|
||||
}
|
||||
if (!formData.last_known_place) {
|
||||
newErrors.push("Укажите последнее известное место");
|
||||
}
|
||||
}
|
||||
|
||||
// Для десктопа: Шаг 5 = Среда и местность
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
const Step3Circumstances = ({ data, updateData }) => {
|
||||
// «Прошло времени» — автоподсчёт от времени потери (пересчёт каждые 30 с,
|
||||
// чтобы долго открытая форма не показывала устаревшее значение).
|
||||
useEffect(() => {
|
||||
if (!data.lost_time) return;
|
||||
const recalc = () => {
|
||||
const lost = new Date(data.lost_time);
|
||||
if (isNaN(lost.getTime())) return;
|
||||
const hours = (Date.now() - lost.getTime()) / 3600000;
|
||||
if (hours >= 0) {
|
||||
updateData({ elapsed_hours: Math.round(hours * 10) / 10 });
|
||||
}
|
||||
};
|
||||
recalc();
|
||||
const timer = setInterval(recalc, 30000);
|
||||
return () => clearInterval(timer);
|
||||
}, [data.lost_time]);
|
||||
|
||||
return (
|
||||
<div className="form-step">
|
||||
<h2>Шаг 3: Обстоятельства потери</h2>
|
||||
|
||||
|
||||
<div className="form-group">
|
||||
<label>Причина потери *</label>
|
||||
<div className="button-group">
|
||||
@@ -59,22 +76,17 @@ const Step3Circumstances = ({ data, updateData }) => {
|
||||
<label>Прошло времени с момента потери (часов)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={data.elapsed_hours || ""}
|
||||
onChange={(e) => updateData({ elapsed_hours: parseFloat(e.target.value) })}
|
||||
value={data.elapsed_hours ?? ""}
|
||||
onChange={(e) => updateData({ elapsed_hours: e.target.value === "" ? null : parseFloat(e.target.value) })}
|
||||
min="0"
|
||||
step="0.5"
|
||||
placeholder="Например: 2.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Последнее известное место *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.last_known_place || ""}
|
||||
onChange={(e) => updateData({ last_known_place: e.target.value })}
|
||||
placeholder="Деревня Заречье, у магазина"
|
||||
placeholder="Рассчитается из времени потери"
|
||||
/>
|
||||
<p style={{ fontSize: "12px", color: "#8b949e", marginTop: "4px" }}>
|
||||
{data.lost_time
|
||||
? "Заполняется автоматически от времени потери; можно скорректировать"
|
||||
: "Заполните дату и время потери — посчитается автоматически"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
@@ -159,4 +171,4 @@ const Step3Circumstances = ({ data, updateData }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Step3Circumstances;
|
||||
export default Step3Circumstances;
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { apiFetch } from "../../api/client";
|
||||
|
||||
const Step4Environment = ({ data, updateData }) => {
|
||||
const toggleTerrain = (terrain) => {
|
||||
@@ -25,6 +26,40 @@ const Step4Environment = ({ data, updateData }) => {
|
||||
}
|
||||
}, [data.lost_time]);
|
||||
|
||||
const handleGeocode = async () => {
|
||||
const query = (data.tnp_address || "").trim();
|
||||
if (query.length < 3) {
|
||||
updateData({ geocode_error: "Введите адрес (минимум 3 символа)" });
|
||||
return;
|
||||
}
|
||||
updateData({ geocode_loading: true, geocode_error: null, geocode_found: null });
|
||||
try {
|
||||
const resp = await apiFetch(`/api/v1/geocode?q=${encodeURIComponent(query)}&limit=1`);
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `Ошибка ${resp.status}`);
|
||||
}
|
||||
const result = await resp.json();
|
||||
const hit = result.results && result.results[0];
|
||||
if (!hit) {
|
||||
updateData({
|
||||
geocode_loading: false,
|
||||
geocode_error: "Адрес не найден. Уточните: «пункт, адрес» без района/области.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateData({
|
||||
tnp_lat: hit.lat.toFixed(6),
|
||||
tnp_lon: hit.lng.toFixed(6),
|
||||
geocode_loading: false,
|
||||
geocode_error: null,
|
||||
geocode_found: hit.display_name,
|
||||
});
|
||||
} catch (e) {
|
||||
updateData({ geocode_loading: false, geocode_error: "Не удалось найти адрес: " + e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleGetLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
updateData({ gps_error: 'Геолокация не поддерживается вашим браузером' });
|
||||
@@ -256,16 +291,40 @@ const Step4Environment = ({ data, updateData }) => {
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Или введите адрес</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.tnp_address || ""}
|
||||
onChange={(e) => updateData({ tnp_address: e.target.value })}
|
||||
placeholder="Минск, ул. Ленина 10"
|
||||
/>
|
||||
<p style={{ fontSize: "12px", color: "#8b949e", marginTop: "4px" }}>
|
||||
Адрес будет преобразован в координаты автоматически
|
||||
</p>
|
||||
<label>Или найдите координаты по адресу</label>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<input
|
||||
type="text"
|
||||
style={{ flex: 1 }}
|
||||
value={data.tnp_address || ""}
|
||||
onChange={(e) => updateData({ tnp_address: e.target.value })}
|
||||
placeholder="Минск, ул. Ленина 10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="gps-button"
|
||||
onClick={handleGeocode}
|
||||
disabled={data.geocode_loading || !(data.tnp_address || "").trim()}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{data.geocode_loading ? "Поиск..." : "🔎 Найти координаты"}
|
||||
</button>
|
||||
</div>
|
||||
{data.geocode_error && (
|
||||
<p style={{ fontSize: "12px", color: "#f85149", marginTop: "4px" }}>
|
||||
{data.geocode_error}
|
||||
</p>
|
||||
)}
|
||||
{data.geocode_found && (
|
||||
<p style={{ fontSize: "12px", color: "#3fb950", marginTop: "4px" }}>
|
||||
Найдено: {data.geocode_found}
|
||||
</p>
|
||||
)}
|
||||
{!data.geocode_error && !data.geocode_found && (
|
||||
<p style={{ fontSize: "12px", color: "#8b949e", marginTop: "4px" }}>
|
||||
Формат «пункт, адрес» (район и область не нужны). Координаты заполнятся в поля выше.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user