PHP项目Symfony form与地理位置

wen PHP项目 4

本文目录导读:

PHP项目Symfony form与地理位置

  1. 基础设置
  2. 地理位置表单字段类型
  3. 实体类设计
  4. 控制器实现
  5. 搜索表单与距离查询
  6. JavaScript前端集成
  7. 模板文件
  8. API服务
  9. 自定义表单主题
  10. 验证约束
  11. 最佳实践建议

我来详细介绍Symfony表单与地理位置的结合使用。

基础设置

安装依赖包

composer require lexik/form-filter-bundle
composer require geoip2/geoip2
composer require stof/doctrine-extensions-bundle

配置地理信息服务

# config/packages/lexik_form_filter.yaml
lexik_form_filter:
    listeners:
        force_parameter_type: false

地理位置表单字段类型

创建地理位置表单类型

// src/Form/Type/GeolocationType.php
namespace App\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\CallbackTransformer;
class GeolocationType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('latitude', HiddenType::class, [
                'required' => false,
                'attr' => ['class' => 'latitude-input']
            ])
            ->add('longitude', HiddenType::class, [
                'required' => false,
                'attr' => ['class' => 'longitude-input']
            ])
            ->add('address', null, [
                'required' => false,
                'label' => '地址',
                'attr' => [
                    'placeholder' => '输入地址搜索',
                    'class' => 'geolocation-address'
                ]
            ]);
        // 添加数据转换
        $builder->addModelTransformer(new CallbackTransformer(
            function ($location) {
                // 实体到表单
                if ($location instanceof Location) {
                    return [
                        'latitude' => $location->getLatitude(),
                        'longitude' => $location->getLongitude(),
                        'address' => $location->getAddress()
                    ];
                }
                return ['latitude' => null, 'longitude' => null, 'address' => null];
            },
            function ($data) {
                // 表单到实体
                if (is_array($data) && isset($data['latitude'])) {
                    $location = new Location();
                    $location->setLatitude($data['latitude']);
                    $location->setLongitude($data['longitude']);
                    $location->setAddress($data['address'] ?? '');
                    return $location;
                }
                return null;
            }
        ));
    }
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => null,
            'compound' => true,
        ]);
    }
}

实体类设计

地理位置实体

// src/Entity/Location.php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Embeddable]
class Location
{
    #[ORM\Column(type: 'decimal', precision: 10, scale: 7, nullable: true)]
    #[Assert\Range(min: -90, max: 90)]
    private ?float $latitude = null;
    #[ORM\Column(type: 'decimal', precision: 10, scale: 7, nullable: true)]
    #[Assert\Range(min: -180, max: 180)]
    private ?float $longitude = null;
    #[ORM\Column(type: 'string', length: 255, nullable: true)]
    private ?string $address = null;
    // Getters and Setters
    public function getLatitude(): ?float
    {
        return $this->latitude;
    }
    public function setLatitude(?float $latitude): self
    {
        $this->latitude = $latitude;
        return $this;
    }
    public function getLongitude(): ?float
    {
        return $this->longitude;
    }
    public function setLongitude(?float $longitude): self
    {
        $this->longitude = $longitude;
        return $this;
    }
    public function getAddress(): ?string
    {
        return $this->address;
    }
    public function setAddress(?string $address): self
    {
        $this->address = $address;
        return $this;
    }
    public function getCoordinates(): ?array
    {
        if ($this->latitude && $this->longitude) {
            return ['lat' => $this->latitude, 'lng' => $this->longitude];
        }
        return null;
    }
}

主实体示例

// src/Entity/Place.php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Place
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;
    #[ORM\Column(length: 255)]
    private ?string $name = null;
    #[ORM\Embedded(class: Location::class)]
    private ?Location $location = null;
    public function __construct()
    {
        $this->location = new Location();
    }
    // Getters and Setters
    public function getId(): ?int
    {
        return $this->id;
    }
    public function getName(): ?string
    {
        return $this->name;
    }
    public function setName(string $name): self
    {
        $this->name = $name;
        return $this;
    }
    public function getLocation(): ?Location
    {
        return $this->location;
    }
    public function setLocation(Location $location): self
    {
        $this->location = $location;
        return $this;
    }
}

控制器实现

PlaceController

// src/Controller/PlaceController.php
namespace App\Controller;
use App\Entity\Place;
use App\Form\Type\PlaceType;
use App\Repository\PlaceRepository;
use App\Service\GeolocationService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
#[Route('/places')]
class PlaceController extends AbstractController
{
    #[Route('/new', name: 'place_new', methods: ['GET', 'POST'])]
    public function new(Request $request, GeolocationService $geolocationService, EntityManagerInterface $em): Response
    {
        $place = new Place();
        $form = $this->createForm(PlaceType::class, $place);
        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            // 验证地理位置
            $location = $place->getLocation();
            if ($location->getLatitude() && $location->getLongitude()) {
                // 反向地理编码获取地址
                $address = $geolocationService->reverseGeocode(
                    $location->getLatitude(),
                    $location->getLongitude()
                );
                $location->setAddress($address);
            }
            $em->persist($place);
            $em->flush();
            $this->addFlash('success', '地点创建成功');
            return $this->redirectToRoute('place_index');
        }
        return $this->render('place/new.html.twig', [
            'form' => $form->createView(),
        ]);
    }
    #[Route('/search', name: 'place_search', methods: ['GET'])]
    public function search(Request $request, PlaceRepository $placeRepository): Response
    {
        $form = $this->createForm(PlaceSearchType::class);
        $form->handleRequest($request);
        $places = [];
        if ($form->isSubmitted() && $form->isValid()) {
            $data = $form->getData();
            $places = $placeRepository->findNearby(
                $data['latitude'],
                $data['longitude'],
                $data['radius'] ?? 10
            );
        }
        return $this->render('place/search.html.twig', [
            'form' => $form->createView(),
            'places' => $places,
        ]);
    }
}

搜索表单与距离查询

搜索表单类型

// src/Form/Type/PlaceSearchType.php
namespace App\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\RangeType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class PlaceSearchType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('latitude', NumberType::class, [
                'required' => true,
                'label' => '纬度'
            ])
            ->add('longitude', NumberType::class, [
                'required' => true,
                'label' => '经度'
            ])
            ->add('radius', RangeType::class, [
                'required' => true,
                'label' => '搜索半径 (公里)',
                'attr' => [
                    'min' => 1,
                    'max' => 50,
                    'step' => 1
                ]
            ]);
    }
}

带距离查询的Repository

// src/Repository/PlaceRepository.php
namespace App\Repository;
use App\Entity\Place;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class PlaceRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Place::class);
    }
    public function findNearby(float $latitude, float $longitude, float $radius = 10): array
    {
        $earthRadius = 6371; // 地球半径(公里)
        $sql = "
            SELECT p, 
                   ( :earthRadius * ACOS(
                       COS(RADIANS(:latitude)) * 
                       COS(RADIANS(p.location.latitude)) * 
                       COS(RADIANS(p.location.longitude) - RADIANS(:longitude)) + 
                       SIN(RADIANS(:latitude)) * 
                       SIN(RADIANS(p.location.latitude))
                   ) ) AS distance
            FROM App\Entity\Place p
            WHERE p.location.latitude IS NOT NULL 
              AND p.location.longitude IS NOT NULL
            HAVING distance <= :radius
            ORDER BY distance ASC
        ";
        $query = $this->getEntityManager()
            ->createQuery($sql)
            ->setParameter('earthRadius', $earthRadius)
            ->setParameter('latitude', $latitude)
            ->setParameter('longitude', $longitude)
            ->setParameter('radius', $radius);
        return $query->getResult();
    }
}

JavaScript前端集成

前端JavaScript

// assets/js/geolocation.js
class GeolocationHelper {
    constructor(formId) {
        this.form = document.getElementById(formId);
        this.addressInput = this.form.querySelector('.geolocation-address');
        this.latitudeInput = this.form.querySelector('.latitude-input');
        this.longitudeInput = this.form.querySelector('.longitude-input');
        this.initAutocomplete();
        this.initGeoLocation();
    }
    initAutocomplete() {
        // 使用Google Maps Autocomplete
        if (typeof google !== 'undefined') {
            const autocomplete = new google.maps.places.Autocomplete(this.addressInput);
            autocomplete.addListener('place_changed', () => {
                const place = autocomplete.getPlace();
                if (place.geometry) {
                    this.latitudeInput.value = place.geometry.location.lat();
                    this.longitudeInput.value = place.geometry.location.lng();
                    this.addressInput.value = place.formatted_address;
                }
            });
        }
    }
    initGeoLocation() {
        if (navigator.geolocation) {
            const getLocationBtn = document.getElementById('get-location-btn');
            if (getLocationBtn) {
                getLocationBtn.addEventListener('click', () => {
                    navigator.geolocation.getCurrentPosition(
                        (position) => {
                            this.latitudeInput.value = position.coords.latitude;
                            this.longitudeInput.value = position.coords.longitude;
                            this.reverseGeocode(
                                position.coords.latitude, 
                                position.coords.longitude
                            );
                        },
                        (error) => {
                            console.error('GeoLocation Error:', error);
                        }
                    );
                });
            }
        }
    }
    async reverseGeocode(lat, lng) {
        try {
            const response = await fetch(`/api/reverse-geocode?lat=${lat}&lng=${lng}`);
            const data = await response.json();
            if (data.address) {
                this.addressInput.value = data.address;
            }
        } catch (error) {
            console.error('Reverse Geocode Error:', error);
        }
    }
}
// 初始化
document.addEventListener('DOMContentLoaded', () => {
    new GeolocationHelper('place_form');
});

模板文件

Twig模板

{# templates/place/new.html.twig #}
{% extends 'base.html.twig' %}
{% block stylesheets %}
    {{ parent() }}
    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css" />
{% endblock %}
{% block body %}
    <div class="container mt-4">
        <h1>创建新地点</h1>
        {{ form_start(form) }}
            <div class="card">
                <div class="card-body">
                    <div class="mb-3">
                        {{ form_label(form.name) }}
                        {{ form_widget(form.name) }}
                    </div>
                    <div class="mb-3">
                        {{ form_label(form.location) }}
                        <div id="map" style="height: 400px;" class="mb-3"></div>
                        {{ form_widget(form.location) }}
                        <button type="button" id="get-location-btn" class="btn btn-secondary mt-2">
                            获取当前位置
                        </button>
                    </div>
                </div>
            </div>
            <button type="submit" class="btn btn-primary mt-3">保存</button>
        {{ form_end(form) }}
    </div>
{% endblock %}
{% block javascripts %}
    {{ parent() }}
    <script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js"></script>
    <script src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY&libraries=places"></script>
    <script src="{{ asset('js/geolocation.js') }}"></script>
    <script>
        // 初始化Leaflet地图
        var map = L.map('map').setView([39.9042, 116.4074], 10);
        L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
            attribution: '&copy; OpenStreetMap contributors'
        }).addTo(map);
        var marker;
        map.on('click', function(e) {
            if (marker) {
                map.removeLayer(marker);
            }
            marker = L.marker(e.latlng).addTo(map);
            document.querySelector('.latitude-input').value = e.latlng.lat;
            document.querySelector('.longitude-input').value = e.latlng.lng;
        });
    </script>
{% endblock %}

API服务

反向地理编码服务

// src/Service/GeolocationService.php
namespace App\Service;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class GeolocationService
{
    private $client;
    public function __construct(HttpClientInterface $client)
    {
        $this->client = $client;
    }
    public function reverseGeocode(float $latitude, float $longitude): ?string
    {
        try {
            $response = $this->client->request('GET', 'https://nominatim.openstreetmap.org/reverse', [
                'query' => [
                    'lat' => $latitude,
                    'lon' => $longitude,
                    'format' => 'json',
                    'addressdetails' => 1,
                ]
            ]);
            $data = $response->toArray();
            return $data['display_name'] ?? null;
        } catch (\Exception $e) {
            return null;
        }
    }
    public function geocode(string $address): ?array
    {
        try {
            $response = $this->client->request('GET', 'https://nominatim.openstreetmap.org/search', [
                'query' => [
                    'q' => $address,
                    'format' => 'json',
                    'limit' => 1,
                ]
            ]);
            $data = $response->toArray();
            if (!empty($data)) {
                return [
                    'latitude' => $data[0]['lat'],
                    'longitude' => $data[0]['lon'],
                    'address' => $data[0]['display_name']
                ];
            }
            return null;
        } catch (\Exception $e) {
            return null;
        }
    }
}

自定义表单主题

# config/packages/twig.yaml
twig:
    form_themes:
        - 'form/geolocation_fields.html.twig'
{# templates/form/geolocation_fields.html.twig #}
{% block geolocation_widget %}
    <div class="geolocation-widget">
        <div class="mb-3">
            {{ form_widget(form.address, {'attr': {'class': 'form-control geolocation-address', 'placeholder': '输入地址...'}}) }}
        </div>
        <div class="row">
            <div class="col-md-6">
                {{ form_label(form.latitude, '纬度') }}
                {{ form_widget(form.latitude, {'attr': {'class': 'form-control latitude-input'}}) }}
            </div>
            <div class="col-md-6">
                {{ form_label(form.longitude, '经度') }}
                {{ form_widget(form.longitude, {'attr': {'class': 'form-control longitude-input'}}) }}
            </div>
        </div>
        <div id="map-container" style="height: 300px;" class="mt-3"></div>
    </div>
{% endblock %}

验证约束

// src/Validator/Constraints/ValidLocation.php
namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
#[Attribute]
class ValidLocation extends Constraint
{
    public $message = '请提供有效的地理位置坐标。';
    public $latitudeRangeMessage = '纬度值必须在 -90 到 90 之间。';
    public $longitudeRangeMessage = '经度值必须在 -180 到 180 之间。';
}
// src/Validator/Constraints/ValidLocationValidator.php
namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
class ValidLocationValidator extends ConstraintValidator
{
    public function validate($value, Constraint $constraint)
    {
        if (!$constraint instanceof ValidLocation) {
            return;
        }
        if ($value === null || $value === '') {
            return;
        }
        $latitude = $value->getLatitude();
        $longitude = $value->getLongitude();
        if ($latitude !== null && ($latitude < -90 || $latitude > 90)) {
            $this->context->buildViolation($constraint->latitudeRangeMessage)
                ->atPath('latitude')
                ->addViolation();
        }
        if ($longitude !== null && ($longitude < -180 || $longitude > 180)) {
            $this->context->buildViolation($constraint->longitudeRangeMessage)
                ->atPath('longitude')
                ->addViolation();
        }
    }
}

最佳实践建议

  1. 使用覆盖嵌入(Embedded):将地理位置作为值对象处理
  2. 前端验证:确保前端也进行坐标验证
  3. 数据库索引:为经纬度字段创建索引
  4. API限流:使用外部地理编码服务时注意API调用限制
  5. 缓存:缓存地理编码结果
  6. 渐进增强:确保无JavaScript时也能使用基本功能

这样你就可以在Symfony项目中实现完整的地理位置表单功能了!

抱歉,评论功能暂时关闭!