1
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
package consts
|
||||
|
||||
const (
|
||||
TableNamePoiMerchant = "poi_merchant"
|
||||
)
|
||||
@@ -1,63 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"pointly-tel/amap/consts"
|
||||
"pointly-tel/amap/model/entity"
|
||||
"pointly-tel/common"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var PoiMerchant = &poiMerchantDao{}
|
||||
|
||||
type poiMerchantDao struct{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePoiMerchant+` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
poi_id VARCHAR(64) NOT NULL UNIQUE,
|
||||
name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
address VARCHAR(500) NOT NULL DEFAULT '',
|
||||
phone VARCHAR(100) NOT NULL DEFAULT '',
|
||||
category VARCHAR(255) NOT NULL DEFAULT '',
|
||||
category_code VARCHAR(64) NOT NULL DEFAULT '',
|
||||
city VARCHAR(64) NOT NULL DEFAULT '',
|
||||
city_code VARCHAR(64) NOT NULL DEFAULT '',
|
||||
longitude REAL DEFAULT 0,
|
||||
latitude REAL DEFAULT 0,
|
||||
business_area VARCHAR(255) NOT NULL DEFAULT '',
|
||||
website VARCHAR(500) NOT NULL DEFAULT '',
|
||||
raw_data TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now','localtime')),
|
||||
updated_at DATETIME DEFAULT (datetime('now','localtime')),
|
||||
deleted_at DATETIME
|
||||
)`)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create poi_merchant table failed: %v", err)
|
||||
}
|
||||
_, err = g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_poi_merchant_poi_id ON "+consts.TableNamePoiMerchant+"(poi_id)")
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create poi_merchant index failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *poiMerchantDao) Insert(ctx context.Context, data *entity.PoiMerchant) (int64, error) {
|
||||
return common.InsertAndReturnId(ctx, consts.TableNamePoiMerchant, data)
|
||||
}
|
||||
|
||||
func (d *poiMerchantDao) GetByPoiId(ctx context.Context, poiId string) (*entity.PoiMerchant, error) {
|
||||
var data *entity.PoiMerchant
|
||||
err := g.DB().Model(consts.TableNamePoiMerchant).Ctx(ctx).Where("poi_id", poiId).Scan(&data)
|
||||
return data, err
|
||||
}
|
||||
|
||||
func (d *poiMerchantDao) GetListByKeywords(ctx context.Context, keywords string) ([]*entity.PoiMerchant, error) {
|
||||
var list []*entity.PoiMerchant
|
||||
err := g.DB().Model(consts.TableNamePoiMerchant).Ctx(ctx).
|
||||
Where("name LIKE ?", "%"+keywords+"%").
|
||||
Limit(100).
|
||||
Scan(&list)
|
||||
return list, err
|
||||
}
|
||||
@@ -3,11 +3,14 @@ package dto
|
||||
import "github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
type SearchPoiReq struct {
|
||||
g.Meta `path:"/search" method:"get" tags:"POI检索" summary:"POI关键词搜索"`
|
||||
Keywords string `v:"required" json:"keywords" dc:"搜索关键词"`
|
||||
City string `json:"city" dc:"城市名称或代码"`
|
||||
Offset int `d:"20" json:"offset" dc:"每页记录数"`
|
||||
Page int `d:"1" json:"page" dc:"页码"`
|
||||
g.Meta `path:"/search" method:"get" tags:"POI检索" summary:"POI搜索"`
|
||||
Keywords string `json:"keywords" dc:"搜索关键词(周边搜索时选填)"`
|
||||
City string `json:"city" dc:"城市名称(文本搜索时使用)"`
|
||||
Longitude float64 `json:"longitude" dc:"中心点经度(周边搜索时使用)"`
|
||||
Latitude float64 `json:"latitude" dc:"中心点纬度(周边搜索时使用)"`
|
||||
Radius int `d:"3000" json:"radius" dc:"搜索半径(米,默认3000,最大50000)"`
|
||||
Offset int `d:"20" json:"offset" dc:"每页记录数"`
|
||||
Page int `d:"1" json:"page" dc:"页码"`
|
||||
}
|
||||
|
||||
type PoiItem struct {
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package entity
|
||||
|
||||
import "github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
type PoiMerchant struct {
|
||||
Id int64 `orm:"id" json:"id" dc:"主键ID"`
|
||||
PoiId string `orm:"poi_id" json:"poiId" dc:"高德POI ID"`
|
||||
Name string `orm:"name" json:"name" dc:"商户名称"`
|
||||
Address string `orm:"address" json:"address" dc:"地址"`
|
||||
Phone string `orm:"phone" json:"phone" dc:"联系电话"`
|
||||
Category string `orm:"category" json:"category" dc:"分类"`
|
||||
CategoryCode string `orm:"category_code" json:"categoryCode" dc:"分类代码"`
|
||||
City string `orm:"city" json:"city" dc:"城市"`
|
||||
CityCode string `orm:"city_code" json:"cityCode" dc:"城市代码"`
|
||||
Longitude float64 `orm:"longitude" json:"longitude" dc:"经度"`
|
||||
Latitude float64 `orm:"latitude" json:"latitude" dc:"纬度"`
|
||||
BusinessArea string `orm:"business_area" json:"businessArea" dc:"商圈"`
|
||||
Website string `orm:"website" json:"website" dc:"网址"`
|
||||
RawData string `orm:"raw_data" json:"rawData" dc:"原始数据"`
|
||||
CreatedAt *gtime.Time `orm:"created_at" json:"createdAt" dc:"创建时间"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt" dc:"更新时间"`
|
||||
DeletedAt *gtime.Time `orm:"deleted_at" json:"deletedAt" dc:"删除时间"`
|
||||
}
|
||||
+173
-88
@@ -3,12 +3,11 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"pointly-tel/amap/dao"
|
||||
"pointly-tel/amap/model/dto"
|
||||
"pointly-tel/amap/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
@@ -19,6 +18,11 @@ type poiService struct{}
|
||||
|
||||
var PoiService = new(poiService)
|
||||
|
||||
const (
|
||||
maxOffset = 25 // 高德API单页最大条数
|
||||
maxPage = 100 // 高德API最大页数
|
||||
)
|
||||
|
||||
// amapString 处理高德API中字段有时返回string有时返回[]的情况
|
||||
type amapString string
|
||||
|
||||
@@ -32,7 +36,6 @@ func (s *amapString) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Amap API 响应结构
|
||||
type amapResponse struct {
|
||||
Status string `json:"status"`
|
||||
Info string `json:"info"`
|
||||
@@ -63,18 +66,8 @@ func (s *poiService) Search(ctx context.Context, req *dto.SearchPoiReq) (res *dt
|
||||
if apiKeyVar.IsEmpty() {
|
||||
return nil, gerror.New("请先配置高德地图API Key(config.yml 中的 amap.api_key)")
|
||||
}
|
||||
apiKey := apiKeyVar.String()
|
||||
|
||||
apiUrlVar, _ := g.Cfg().Get(ctx, "amap.api_url")
|
||||
baseUrl := apiUrlVar.String()
|
||||
if baseUrl == "" {
|
||||
baseUrl = "https://restapi.amap.com/v3/place/text"
|
||||
}
|
||||
|
||||
// 高德POI分页限制:offset 最大 25,page 最大 100
|
||||
const (
|
||||
maxOffset = 25
|
||||
maxPage = 100
|
||||
)
|
||||
offset := req.Offset
|
||||
if offset < 1 {
|
||||
offset = 1
|
||||
@@ -88,62 +81,109 @@ func (s *poiService) Search(ctx context.Context, req *dto.SearchPoiReq) (res *dt
|
||||
page = maxPage
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("key", apiKeyVar.String())
|
||||
params.Set("keywords", req.Keywords)
|
||||
params.Set("offset", gconv.String(offset))
|
||||
params.Set("page", gconv.String(page))
|
||||
params.Set("extensions", "all")
|
||||
if req.City != "" {
|
||||
params.Set("city", req.City)
|
||||
if req.Longitude != 0 && req.Latitude != 0 {
|
||||
return s.searchAround(ctx, apiKey, req, page, offset)
|
||||
}
|
||||
return s.searchText(ctx, apiKey, req, page, offset)
|
||||
}
|
||||
|
||||
requestUrl := baseUrl + "?" + params.Encode()
|
||||
g.Log().Info(ctx, "调用高德POI搜索", "url", requestUrl)
|
||||
// searchText 文本搜索,实时扫描高德页面,在内存中过滤有电话的POI
|
||||
func (s *poiService) searchText(ctx context.Context, apiKey string, req *dto.SearchPoiReq, page, offset int) (*dto.SearchPoiRes, error) {
|
||||
const baseUrl = "https://restapi.amap.com/v3/place/text"
|
||||
needTotal := page * offset
|
||||
all := make([]*dto.PoiItem, 0, needTotal)
|
||||
|
||||
response, err := g.Client().Get(ctx, requestUrl)
|
||||
if err != nil {
|
||||
return nil, gerror.Newf("高德API请求失败: %v", err)
|
||||
}
|
||||
defer response.Close()
|
||||
|
||||
var amapResp amapResponse
|
||||
if err := json.Unmarshal(response.ReadAll(), &amapResp); err != nil {
|
||||
return nil, gerror.Newf("响应解析失败: %v", err)
|
||||
}
|
||||
|
||||
if amapResp.Status != "1" {
|
||||
return nil, gerror.Newf("高德API返回错误: %s", amapResp.Info)
|
||||
}
|
||||
|
||||
total := gconv.Int(amapResp.Count)
|
||||
items := make([]*dto.PoiItem, 0, len(amapResp.Pois))
|
||||
|
||||
for _, p := range amapResp.Pois {
|
||||
lng, lat := parseLocation(p.Location)
|
||||
item := &dto.PoiItem{
|
||||
PoiId: p.Id,
|
||||
Name: p.Name,
|
||||
Address: p.Address,
|
||||
Phone: string(p.Tel),
|
||||
Category: p.Type,
|
||||
City: string(p.City),
|
||||
Longitude: gconv.String(lng),
|
||||
Latitude: gconv.String(lat),
|
||||
BusinessArea: string(p.BusinessArea),
|
||||
Website: string(p.Website),
|
||||
for ap := 1; ap <= maxPage; ap++ {
|
||||
pois, _, err := s.fetchTextPage(ctx, apiKey, baseUrl, req.Keywords, req.City, ap, offset)
|
||||
if err != nil || len(pois) == 0 {
|
||||
break
|
||||
}
|
||||
items = append(items, item)
|
||||
for _, p := range pois {
|
||||
if string(p.Tel) == "" {
|
||||
continue
|
||||
}
|
||||
all = append(all, s.toPoiItem(p))
|
||||
}
|
||||
if len(all) >= needTotal || len(pois) < offset {
|
||||
break
|
||||
}
|
||||
}
|
||||
return s.buildResult(all, page, offset), nil
|
||||
}
|
||||
|
||||
go s.cachePoi(ctx, p)
|
||||
// searchAround 周边搜索,实时扫描高德页面,在内存中过滤有电话的POI
|
||||
func (s *poiService) searchAround(ctx context.Context, apiKey string, req *dto.SearchPoiReq, page, offset int) (*dto.SearchPoiRes, error) {
|
||||
const baseUrl = "https://restapi.amap.com/v3/place/around"
|
||||
|
||||
radius := req.Radius
|
||||
if radius < 1 {
|
||||
radius = 1
|
||||
} else if radius > 50000 {
|
||||
radius = 50000
|
||||
}
|
||||
|
||||
// 计算最大可查页数(高德限制最多查 100 页)
|
||||
maxPageCalculated := maxPage
|
||||
if total > 0 {
|
||||
if maxPageCalculated*offset > total {
|
||||
maxPageCalculated = (total + offset - 1) / offset
|
||||
locStr := fmt.Sprintf("%.6f,%.6f", req.Longitude, req.Latitude)
|
||||
needTotal := page * offset
|
||||
all := make([]*dto.PoiItem, 0, needTotal)
|
||||
|
||||
for ap := 1; ap <= maxPage; ap++ {
|
||||
pois, _, err := s.fetchAroundPage(ctx, apiKey, baseUrl, req.Keywords, locStr, radius, ap, offset)
|
||||
if err != nil || len(pois) == 0 {
|
||||
break
|
||||
}
|
||||
for _, p := range pois {
|
||||
if string(p.Tel) == "" {
|
||||
continue
|
||||
}
|
||||
all = append(all, s.toPoiItem(p))
|
||||
}
|
||||
if len(all) >= needTotal || len(pois) < offset {
|
||||
break
|
||||
}
|
||||
}
|
||||
return s.buildResult(all, page, offset), nil
|
||||
}
|
||||
|
||||
// toPoiItem 将高德POI原始数据转换为输出DTO
|
||||
func (s *poiService) toPoiItem(p *amapPoi) *dto.PoiItem {
|
||||
lng, lat := parseLocation(p.Location)
|
||||
return &dto.PoiItem{
|
||||
PoiId: p.Id,
|
||||
Name: p.Name,
|
||||
Address: p.Address,
|
||||
Phone: string(p.Tel),
|
||||
Category: p.Type,
|
||||
City: string(p.City),
|
||||
Longitude: gconv.String(lng),
|
||||
Latitude: gconv.String(lat),
|
||||
BusinessArea: string(p.BusinessArea),
|
||||
Website: string(p.Website),
|
||||
}
|
||||
}
|
||||
|
||||
// buildResult 从内存列表中截取当前页数据
|
||||
func (s *poiService) buildResult(all []*dto.PoiItem, page, offset int) *dto.SearchPoiRes {
|
||||
total := len(all)
|
||||
maxPageCalc := total / offset
|
||||
if total%offset != 0 {
|
||||
maxPageCalc++
|
||||
}
|
||||
if maxPageCalc < 1 {
|
||||
maxPageCalc = 1
|
||||
}
|
||||
|
||||
start := (page - 1) * offset
|
||||
end := start + offset
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
|
||||
items := all[start:end]
|
||||
if items == nil {
|
||||
items = []*dto.PoiItem{}
|
||||
}
|
||||
|
||||
return &dto.SearchPoiRes{
|
||||
@@ -152,38 +192,83 @@ func (s *poiService) Search(ctx context.Context, req *dto.SearchPoiReq) (res *dt
|
||||
Count: len(items),
|
||||
Page: page,
|
||||
Offset: offset,
|
||||
MaxPage: maxPageCalculated,
|
||||
}, nil
|
||||
MaxPage: maxPageCalc,
|
||||
}
|
||||
}
|
||||
|
||||
// cachePoi 将POI结果异步缓存到数据库
|
||||
func (s *poiService) cachePoi(ctx context.Context, p *amapPoi) {
|
||||
existing, _ := dao.PoiMerchant.GetByPoiId(ctx, p.Id)
|
||||
if existing != nil {
|
||||
return
|
||||
}
|
||||
// fetchTextPage 文本搜索单页
|
||||
func (s *poiService) fetchTextPage(ctx context.Context, apiKey, baseUrl, keywords, city string, page, offset int) ([]*amapPoi, int, error) {
|
||||
pois, total := s.fetchAmapPage(ctx, apiKey, baseUrl, keywords, city, page, offset)
|
||||
return pois, total, nil
|
||||
}
|
||||
|
||||
lng, lat := parseLocation(p.Location)
|
||||
rawBytes, _ := json.Marshal(p)
|
||||
// fetchAroundPage 周边搜索单页
|
||||
func (s *poiService) fetchAroundPage(ctx context.Context, apiKey, baseUrl, keywords, location string, radius, page, offset int) ([]*amapPoi, int, error) {
|
||||
params := url.Values{}
|
||||
params.Set("key", apiKey)
|
||||
params.Set("keywords", keywords)
|
||||
params.Set("location", location)
|
||||
params.Set("radius", gconv.String(radius))
|
||||
params.Set("offset", gconv.String(offset))
|
||||
params.Set("page", gconv.String(page))
|
||||
params.Set("extensions", "all")
|
||||
|
||||
_, err := dao.PoiMerchant.Insert(ctx, &entity.PoiMerchant{
|
||||
PoiId: p.Id,
|
||||
Name: p.Name,
|
||||
Address: p.Address,
|
||||
Phone: string(p.Tel),
|
||||
Category: p.Type,
|
||||
CategoryCode: p.Typecode,
|
||||
City: string(p.City),
|
||||
CityCode: string(p.CityCode),
|
||||
Longitude: lng,
|
||||
Latitude: lat,
|
||||
BusinessArea: string(p.BusinessArea),
|
||||
Website: string(p.Website),
|
||||
RawData: string(rawBytes),
|
||||
})
|
||||
requestUrl := baseUrl + "?" + params.Encode()
|
||||
|
||||
response, err := g.Client().Get(ctx, requestUrl)
|
||||
if err != nil {
|
||||
g.Log().Warning(ctx, "缓存POI数据失败", "poiId", p.Id, "error", err)
|
||||
g.Log().Warning(ctx, "高德API请求失败", "error", err)
|
||||
return nil, 0, err
|
||||
}
|
||||
defer response.Close()
|
||||
|
||||
var amapResp amapResponse
|
||||
if err := json.Unmarshal(response.ReadAll(), &amapResp); err != nil {
|
||||
g.Log().Warning(ctx, "高德API响应解析失败", "error", err)
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if amapResp.Status != "1" {
|
||||
g.Log().Warning(ctx, "高德API返回错误", "info", amapResp.Info)
|
||||
return nil, 0, gerror.New(amapResp.Info)
|
||||
}
|
||||
|
||||
return amapResp.Pois, gconv.Int(amapResp.Count), nil
|
||||
}
|
||||
|
||||
// fetchAmapPage 调用高德文本搜索单页接口
|
||||
func (s *poiService) fetchAmapPage(ctx context.Context, apiKey, baseUrl, keywords, city string, page, offset int) ([]*amapPoi, int) {
|
||||
params := url.Values{}
|
||||
params.Set("key", apiKey)
|
||||
params.Set("keywords", keywords)
|
||||
params.Set("offset", gconv.String(offset))
|
||||
params.Set("page", gconv.String(page))
|
||||
params.Set("extensions", "all")
|
||||
if city != "" {
|
||||
params.Set("city", city)
|
||||
}
|
||||
|
||||
requestUrl := baseUrl + "?" + params.Encode()
|
||||
|
||||
response, err := g.Client().Get(ctx, requestUrl)
|
||||
if err != nil {
|
||||
g.Log().Warning(ctx, "高德API请求失败", "error", err)
|
||||
return nil, 0
|
||||
}
|
||||
defer response.Close()
|
||||
|
||||
var amapResp amapResponse
|
||||
if err := json.Unmarshal(response.ReadAll(), &amapResp); err != nil {
|
||||
g.Log().Warning(ctx, "高德API响应解析失败", "error", err)
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
if amapResp.Status != "1" {
|
||||
g.Log().Warning(ctx, "高德API返回错误", "info", amapResp.Info)
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
return amapResp.Pois, gconv.Int(amapResp.Count)
|
||||
}
|
||||
|
||||
func parseLocation(location string) (float64, float64) {
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
func prepareInsertData(data any) map[string]any {
|
||||
m := gconv.Map(data, gconv.MapOption{Tags: []string{"orm"}})
|
||||
delete(m, "id")
|
||||
m["created_at"] = gtime.Now().Format("Y-m-d H:i:s")
|
||||
m["updated_at"] = gtime.Now().Format("Y-m-d H:i:s")
|
||||
delete(m, "deleted_at")
|
||||
return m
|
||||
}
|
||||
|
||||
func InsertAndReturnId(ctx context.Context, table string, data any) (id int64, err error) {
|
||||
m := prepareInsertData(data)
|
||||
r, err := g.DB().Model(table).Ctx(ctx).Data(m).Insert()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if r == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
|
||||
func GetOneByPk[T any](ctx context.Context, table string, pk int64) (res *T, err error) {
|
||||
r, err := g.DB().Model(table).Ctx(ctx).
|
||||
Where("id", pk).One()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r == nil {
|
||||
return nil, nil
|
||||
}
|
||||
err = r.Struct(&res)
|
||||
return
|
||||
}
|
||||
|
||||
func UpdateByPk(ctx context.Context, table string, pk int64, data any) error {
|
||||
_, err := g.DB().Model(table).Ctx(ctx).Data(data).Where("id", pk).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteByPk(ctx context.Context, table string, pk int64) error {
|
||||
_, err := g.DB().Model(table).Ctx(ctx).Unscoped().Where("id", pk).Delete()
|
||||
return err
|
||||
}
|
||||
@@ -1,11 +1,5 @@
|
||||
database:
|
||||
default:
|
||||
name: pointly_tel.db
|
||||
type: sqlite
|
||||
debug: false
|
||||
server:
|
||||
address: :3000
|
||||
name: pointly-tel
|
||||
amap:
|
||||
api_key: "884d33aef9842e60b840e2b2b6aedce9"
|
||||
api_url: "https://restapi.amap.com/v3/place/text"
|
||||
|
||||
@@ -2,19 +2,14 @@ module pointly-tel
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2
|
||||
github.com/gogf/gf/v2 v2.10.2
|
||||
)
|
||||
require github.com/gogf/gf/v2 v2.10.2
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||
github.com/clbanning/mxj/v2 v2.7.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emirpasic/gods/v2 v2.0.0-alpha // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
@@ -27,7 +22,6 @@ require (
|
||||
github.com/olekukonko/errors v1.1.0 // indirect
|
||||
github.com/olekukonko/ll v0.0.9 // indirect
|
||||
github.com/olekukonko/tablewriter v1.1.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||
@@ -38,8 +32,4 @@ require (
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
|
||||
@@ -4,29 +4,21 @@ github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyM
|
||||
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU=
|
||||
github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2 h1:KLS68SWS2W749x7e+eCCOO3UD2Sbw+bIbLEPR8o1FXw=
|
||||
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2/go.mod h1:uLcsu73PfpyhRc0Jq0gGAWQjN1tyGU9iBRrYgt/lu7g=
|
||||
github.com/gogf/gf/v2 v2.10.2 h1:46IO0Uc8e85/FqdftJFskfDejJLBL0JBnGS5qOftUu8=
|
||||
github.com/gogf/gf/v2 v2.10.2/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
@@ -54,9 +46,6 @@ github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjK
|
||||
github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
@@ -90,11 +79,3 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
|
||||
@@ -3,8 +3,6 @@ package main
|
||||
import (
|
||||
"pointly-tel/amap/controller"
|
||||
commonHttp "pointly-tel/common"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
Generated
+7
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.7.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"vue": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1403,6 +1404,12 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
||||
+3
-2
@@ -9,8 +9,9 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.0",
|
||||
"axios": "^1.7.0"
|
||||
"axios": "^1.7.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"vue": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
|
||||
+517
-228
@@ -1,82 +1,101 @@
|
||||
<template>
|
||||
<div class="app">
|
||||
<header class="header">
|
||||
<h1>Pointly-Tel</h1>
|
||||
<p class="subtitle">高德地图 POI 商户信息查询</p>
|
||||
</header>
|
||||
<!-- 地图(全屏底层) -->
|
||||
<PoiMap ref="mapRef" @marker-click="onMarkerClick" @map-click="onMapClick" />
|
||||
|
||||
<main class="main">
|
||||
<div class="search-box">
|
||||
<div class="form-row">
|
||||
<input
|
||||
v-model="keywords"
|
||||
class="input"
|
||||
placeholder="输入关键词(如:火锅店 咖啡厅)"
|
||||
@keyup.enter="doSearch"
|
||||
/>
|
||||
<input
|
||||
v-model="city"
|
||||
class="input city-input"
|
||||
placeholder="城市(可选)"
|
||||
@keyup.enter="doSearch"
|
||||
/>
|
||||
<button class="btn" :disabled="loading" @click="doSearch">
|
||||
{{ loading ? '搜索中...' : '搜索' }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- 顶部搜索栏 -->
|
||||
<div class="top-bar">
|
||||
<div class="search-form">
|
||||
<input
|
||||
v-model="keywords"
|
||||
class="input"
|
||||
placeholder="关键词(选填,如:火锅店)"
|
||||
@keyup.enter="doSearch"
|
||||
/>
|
||||
<select v-model="province" class="select province-select" @change="provinceChanged">
|
||||
<option value="">所有省份</option>
|
||||
<option v-for="p in provinces" :key="p" :value="p">{{ p }}</option>
|
||||
</select>
|
||||
<select v-model="city" class="select city-select" :disabled="!province">
|
||||
<option value="">{{ province ? '所有城市' : '请先选省份' }}</option>
|
||||
<option v-for="c in cities" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
<select v-model="radius" class="select radius-select">
|
||||
<option v-for="r in radiusOptions" :key="r.value" :value="r.value">{{ r.label }}</option>
|
||||
</select>
|
||||
<button class="btn" :disabled="loading" @click="doSearch">
|
||||
{{ loading ? '搜索中...' : '搜索' }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- 已选中心点 -->
|
||||
<div v-if="centerLat || centerLng" class="center-badge">
|
||||
<span>中心点:{{ centerLng.toFixed(4) }}, {{ centerLat.toFixed(4) }}</span>
|
||||
<button class="clear-center" @click="clearCenter">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
|
||||
<div v-if="loading" class="loading">
|
||||
<!-- 右侧结果面板 -->
|
||||
<div v-if="searched" class="side-panel" :class="{ 'has-results': results.length > 0 }">
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading" class="panel-loading">
|
||||
<div class="spinner"></div>
|
||||
<span>正在查询...</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && searched && results.length === 0" class="empty">
|
||||
未找到相关商户,请尝试其他关键词
|
||||
<!-- 错误 -->
|
||||
<div v-else-if="error" class="panel-error">{{ error }}</div>
|
||||
|
||||
<!-- 空结果 -->
|
||||
<div v-else-if="results.length === 0" class="panel-empty">
|
||||
未找到相关商户<br /><small>请尝试其他关键词</small>
|
||||
</div>
|
||||
|
||||
<div v-if="results.length > 0" class="results">
|
||||
<div class="result-meta">共 {{ total }} 条结果</div>
|
||||
<div v-for="item in results" :key="item.poiId" class="card">
|
||||
<div class="card-header">
|
||||
<h3>{{ item.name }}</h3>
|
||||
<span v-if="item.category" class="tag">{{ item.category.split('|').pop() }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="info-row">
|
||||
<span class="label">电话</span>
|
||||
<a v-if="item.phone" :href="'tel:' + item.phone" class="phone">{{ item.phone }}</a>
|
||||
<span v-else class="na">暂无</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">地址</span>
|
||||
<span>{{ item.address || '暂无' }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="item.businessArea">
|
||||
<span class="label">商圈</span>
|
||||
<span>{{ item.businessArea }}</span>
|
||||
</div>
|
||||
<!-- 结果列表 -->
|
||||
<template v-else>
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">共 {{ total }} 条结果</span>
|
||||
<span class="panel-page">第 {{ page }}/{{ maxPage }} 页</span>
|
||||
</div>
|
||||
|
||||
<div class="panel-list">
|
||||
<div
|
||||
v-for="item in results"
|
||||
:key="item.poiId"
|
||||
class="result-card"
|
||||
:class="{ active: activePoi && activePoi.poiId === item.poiId }"
|
||||
@click="focusItem(item)"
|
||||
>
|
||||
<div class="card-name">{{ item.name }}</div>
|
||||
<div class="card-phone">{{ item.phone || '暂无电话' }}</div>
|
||||
<div class="card-addr">{{ item.address }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="total > results.length" class="pagination">
|
||||
<button class="btn-outline" :disabled="page <= 1" @click="page--; doSearch()">上一页</button>
|
||||
<span class="page-info">第 {{ page }} / {{ maxPage }} 页(共 {{ total }} 条)</span>
|
||||
<button class="btn-outline" :disabled="page >= maxPage" @click="page++; doSearch()">下一页</button>
|
||||
<div class="panel-footer">
|
||||
<button class="page-btn" :disabled="page <= 1" @click="page--; doSearch()">上一页</button>
|
||||
<button class="page-btn" :disabled="page >= maxPage" @click="page++; doSearch()">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 操作提示 -->
|
||||
<div class="hint-bar">
|
||||
<span>点击地图设置搜索中心点</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { searchPoi } from './api/poi.js'
|
||||
import PoiMap from './components/PoiMap.vue'
|
||||
|
||||
const mapRef = ref(null)
|
||||
const keywords = ref('')
|
||||
const city = ref('')
|
||||
const radius = ref(3000)
|
||||
const centerLat = ref(0)
|
||||
const centerLng = ref(0)
|
||||
const results = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
@@ -85,22 +104,210 @@ const error = ref('')
|
||||
const page = ref(1)
|
||||
const offset = ref(20)
|
||||
const maxPage = ref(1)
|
||||
const activePoi = ref(null)
|
||||
|
||||
const province = ref('')
|
||||
const provinces = [
|
||||
'北京', '天津', '上海', '重庆',
|
||||
'广东', '浙江', '江苏', '福建', '山东', '四川', '湖北', '湖南',
|
||||
'河南', '河北', '安徽', '江西', '陕西', '山西', '辽宁', '吉林',
|
||||
'黑龙江', '广西', '内蒙古', '西藏', '新疆', '宁夏', '青海',
|
||||
'甘肃', '贵州', '云南', '海南',
|
||||
]
|
||||
|
||||
const regions = {
|
||||
'北京': ['北京'],
|
||||
'天津': ['天津'],
|
||||
'上海': ['上海'],
|
||||
'重庆': ['重庆'],
|
||||
'广东': ['广州', '深圳', '东莞', '佛山', '珠海', '中山', '惠州', '汕头', '江门', '湛江', '肇庆', '茂名', '揭阳', '梅州', '清远', '韶关', '阳江', '河源', '汕尾', '潮州', '云浮'],
|
||||
'浙江': ['杭州', '宁波', '温州', '绍兴', '嘉兴', '金华', '台州', '湖州', '衢州', '舟山', '丽水'],
|
||||
'江苏': ['南京', '苏州', '无锡', '常州', '南通', '徐州', '扬州', '盐城', '淮安', '连云港', '镇江', '泰州', '宿迁'],
|
||||
'福建': ['福州', '厦门', '泉州', '漳州', '莆田', '宁德', '龙岩', '三明', '南平'],
|
||||
'山东': ['济南', '青岛', '烟台', '潍坊', '临沂', '淄博', '济宁', '威海', '泰安', '德州', '聊城', '东营', '枣庄', '日照', '滨州', '菏泽'],
|
||||
'四川': ['成都', '绵阳', '德阳', '宜宾', '南充', '泸州', '自贡', '乐山', '眉山', '达州', '内江', '遂宁', '广安', '攀枝花', '资阳', '广元', '雅安', '巴中'],
|
||||
'湖北': ['武汉', '襄阳', '宜昌', '荆州', '黄冈', '十堰', '孝感', '荆门', '鄂州', '黄石', '咸宁', '随州'],
|
||||
'湖南': ['长沙', '株洲', '湘潭', '衡阳', '岳阳', '常德', '益阳', '邵阳', '郴州', '怀化', '永州', '娄底', '湘西'],
|
||||
'河南': ['郑州', '洛阳', '南阳', '许昌', '周口', '新乡', '安阳', '开封', '商丘', '信阳', '驻马店', '平顶山', '焦作', '濮阳', '三门峡', '漯河', '鹤壁'],
|
||||
'河北': ['石家庄', '唐山', '保定', '邯郸', '沧州', '廊坊', '秦皇岛', '张家口', '衡水', '邢台', '承德'],
|
||||
'安徽': ['合肥', '芜湖', '安庆', '阜阳', '滁州', '马鞍山', '蚌埠', '宿州', '六安', '宣城', '铜陵', '池州', '淮南', '淮北', '黄山'],
|
||||
'江西': ['南昌', '赣州', '九江', '宜春', '吉安', '上饶', '抚州', '萍乡', '景德镇', '新余', '鹰潭'],
|
||||
'陕西': ['西安', '咸阳', '宝鸡', '渭南', '汉中', '延安', '榆林', '安康', '商洛', '铜川'],
|
||||
'山西': ['太原', '大同', '长治', '临汾', '晋中', '运城', '晋城', '吕梁', '忻州', '阳泉', '朔州'],
|
||||
'辽宁': ['沈阳', '大连', '鞍山', '抚顺', '锦州', '营口', '盘锦', '丹东', '本溪', '辽阳', '朝阳', '阜新', '葫芦岛', '铁岭'],
|
||||
'吉林': ['长春', '吉林', '延边', '四平', '通化', '松原', '白城', '辽源', '白山'],
|
||||
'黑龙江': ['哈尔滨', '大庆', '齐齐哈尔', '牡丹江', '佳木斯', '绥化', '鸡西', '鹤岗', '双鸭山', '伊春', '七台河', '黑河', '大兴安岭'],
|
||||
'广西': ['南宁', '桂林', '柳州', '北海', '玉林', '百色', '梧州', '贵港', '钦州', '河池', '防城港', '崇左', '来宾', '贺州'],
|
||||
'内蒙古': ['呼和浩特', '包头', '赤峰', '鄂尔多斯', '通辽', '呼伦贝尔', '乌兰察布', '巴彦淖尔', '兴安', '锡林郭勒', '阿拉善'],
|
||||
'西藏': ['拉萨', '日喀则', '昌都', '林芝', '山南', '那曲', '阿里'],
|
||||
'新疆': ['乌鲁木齐', '克拉玛依', '吐鲁番', '哈密', '昌吉', '伊犁', '喀什', '阿克苏', '和田', '塔城', '阿勒泰', '博尔塔拉', '巴音郭楞', '克孜勒苏'],
|
||||
'宁夏': ['银川', '石嘴山', '吴忠', '固原', '中卫'],
|
||||
'青海': ['西宁', '海东', '海西', '海南', '海北', '黄南', '玉树', '果洛'],
|
||||
'甘肃': ['兰州', '天水', '白银', '金昌', '嘉峪关', '武威', '张掖', '平凉', '酒泉', '庆阳', '定西', '陇南', '临夏', '甘南'],
|
||||
'贵州': ['贵阳', '遵义', '六盘水', '安顺', '毕节', '铜仁', '黔东南', '黔南', '黔西南'],
|
||||
'云南': ['昆明', '大理', '丽江', '曲靖', '玉溪', '红河', '楚雄', '普洱', '昭通', '文山', '西双版纳', '保山', '德宏', '临沧', '怒江', '迪庆'],
|
||||
'海南': ['海口', '三亚', '儋州', '三沙', '琼海', '文昌', '万宁', '陵水', '五指山'],
|
||||
}
|
||||
|
||||
const cities = computed(() => regions[province.value] || [])
|
||||
|
||||
// 城市中心坐标(用于选中城市时地图跳转)
|
||||
const cityCoords = {
|
||||
'北京': [39.9042, 116.4074], '天津': [39.1252, 117.1908], '上海': [31.2304, 121.4737], '重庆': [29.4316, 106.9123],
|
||||
'广州': [23.1291, 113.2644], '深圳': [22.5431, 114.0579], '东莞': [23.0208, 113.7518], '佛山': [23.0219, 113.1219],
|
||||
'珠海': [22.2710, 113.5767], '中山': [22.5150, 113.3926], '惠州': [23.1110, 114.4162], '汕头': [23.3545, 116.6822],
|
||||
'江门': [22.5786, 113.0945], '湛江': [21.2713, 110.3594], '肇庆': [23.0469, 112.4653], '茂名': [21.6630, 110.9252],
|
||||
'揭阳': [23.5499, 116.3727], '梅州': [24.2884, 116.1176], '清远': [23.6810, 113.0560], '韶关': [24.8104, 113.5975],
|
||||
'阳江': [21.8583, 111.9822], '河源': [23.7437, 114.7009], '汕尾': [22.7870, 115.3753], '潮州': [23.6564, 116.6300],
|
||||
'云浮': [22.9150, 112.0445],
|
||||
'杭州': [30.2741, 120.1551], '宁波': [29.8683, 121.5440], '温州': [28.0000, 120.6994], '绍兴': [30.0300, 120.5800],
|
||||
'嘉兴': [30.7711, 120.7551], '金华': [29.0792, 119.6474], '台州': [28.6562, 121.4208], '湖州': [30.8931, 120.0875],
|
||||
'衢州': [28.9359, 118.8742], '舟山': [29.9855, 122.2076], '丽水': [28.4680, 119.9228],
|
||||
'南京': [32.0603, 118.7969], '苏州': [31.2990, 120.5853], '无锡': [31.4910, 120.3119], '常州': [31.8107, 119.9737],
|
||||
'南通': [31.9798, 120.8938], '徐州': [34.2044, 117.2858], '扬州': [32.3937, 119.4126], '盐城': [33.3876, 120.1633],
|
||||
'淮安': [33.5516, 119.1131], '连云港': [34.5969, 119.2215], '镇江': [32.1898, 119.4251], '泰州': [32.4555, 119.9227],
|
||||
'宿迁': [33.9630, 118.2755],
|
||||
'福州': [26.0745, 119.2965], '厦门': [24.4798, 118.0895], '泉州': [24.8741, 118.6756], '漳州': [24.5134, 117.6470],
|
||||
'莆田': [25.4540, 119.0077], '宁德': [26.6657, 119.5479], '龙岩': [25.0751, 117.0170], '三明': [26.2634, 117.6392],
|
||||
'南平': [26.6417, 118.1779],
|
||||
'济南': [36.6512, 116.9972], '青岛': [36.0671, 120.3826], '烟台': [37.4645, 121.4479], '潍坊': [36.7068, 119.1618],
|
||||
'临沂': [35.1046, 118.3564], '淄博': [36.8135, 118.0550], '济宁': [35.4148, 116.5872], '威海': [37.5133, 122.1210],
|
||||
'泰安': [36.1994, 117.0875], '德州': [37.4363, 116.3594], '聊城': [36.4568, 115.9854], '东营': [37.4341, 118.6746],
|
||||
'枣庄': [34.8105, 117.3211], '日照': [35.4204, 119.5272], '滨州': [37.3819, 117.9724], '菏泽': [35.2337, 115.4812],
|
||||
'成都': [30.5728, 104.0668], '绵阳': [31.4675, 104.6791], '德阳': [31.1268, 104.3980], '宜宾': [28.7519, 104.6417],
|
||||
'南充': [30.7991, 106.0827], '泸州': [28.8717, 105.4430], '自贡': [29.3390, 104.7784], '乐山': [29.5527, 103.7654],
|
||||
'眉山': [30.0595, 103.8265], '达州': [31.2091, 107.4677], '内江': [29.5802, 105.0584], '遂宁': [30.5329, 105.5929],
|
||||
'广安': [30.4560, 106.6332], '攀枝花': [26.5823, 101.7186], '资阳': [30.1286, 104.6276], '广元': [32.4355, 105.8450],
|
||||
'雅安': [29.9765, 103.0094], '巴中': [31.8674, 106.7477],
|
||||
'武汉': [30.5928, 114.3055], '襄阳': [32.0090, 112.1226], '宜昌': [30.6920, 111.2864], '荆州': [30.3345, 112.2407],
|
||||
'黄冈': [30.4544, 114.8724], '十堰': [32.6319, 110.7939], '孝感': [30.9178, 113.9168], '荆门': [31.0354, 112.1992],
|
||||
'鄂州': [30.3908, 114.8950], '黄石': [30.2010, 115.0769], '咸宁': [29.8327, 114.3222], '随州': [31.6902, 113.3826],
|
||||
'长沙': [28.2282, 112.9388], '株洲': [27.8278, 113.1339], '湘潭': [27.8298, 112.9442], '衡阳': [26.8932, 112.5720],
|
||||
'岳阳': [29.3572, 113.1288], '常德': [29.0316, 111.6985], '益阳': [28.5889, 112.3551], '邵阳': [27.2389, 111.4673],
|
||||
'郴州': [25.7705, 113.0145], '怀化': [27.5697, 109.9985], '永州': [26.4207, 111.6132], '娄底': [27.7000, 112.0004],
|
||||
'湘西': [28.3118, 109.7394],
|
||||
'郑州': [34.7470, 113.6247], '洛阳': [34.6202, 112.4545], '南阳': [32.9908, 112.5286], '许昌': [34.0355, 113.8523],
|
||||
'周口': [33.6260, 114.6473], '新乡': [35.3036, 113.9269], '安阳': [36.0969, 114.3931], '开封': [34.7973, 114.3073],
|
||||
'商丘': [34.4140, 115.6554], '信阳': [32.1471, 114.0932], '驻马店': [32.9815, 114.0220], '平顶山': [33.7662, 113.1924],
|
||||
'焦作': [35.2159, 113.2418], '濮阳': [35.7624, 115.0297], '三门峡': [34.7731, 111.1999], '漯河': [33.5814, 114.0165],
|
||||
'鹤壁': [35.8995, 114.2975],
|
||||
'石家庄': [38.0423, 114.5145], '唐山': [39.6303, 118.1824], '保定': [38.8739, 115.4646], '邯郸': [36.6256, 114.5391],
|
||||
'沧州': [38.3044, 116.8387], '廊坊': [39.5379, 116.6838], '秦皇岛': [39.8891, 119.5997], '张家口': [40.7680, 114.8862],
|
||||
'衡水': [37.7389, 115.6707], '邢台': [37.0736, 114.5047], '承德': [40.9495, 117.9593],
|
||||
'合肥': [31.8206, 117.2272], '芜湖': [31.3525, 118.4329], '安庆': [30.5429, 117.0635], '阜阳': [32.8901, 115.8141],
|
||||
'滁州': [32.3019, 118.3071], '马鞍山': [31.6714, 118.5071], '蚌埠': [32.9188, 117.3894], '宿州': [33.6476, 116.9639],
|
||||
'六安': [31.7331, 116.5199], '宣城': [30.9404, 118.7587], '铜陵': [30.8789, 117.8107], '池州': [30.6648, 117.4916],
|
||||
'淮南': [32.6261, 116.9997], '淮北': [33.9653, 116.7985], '黄山': [29.7153, 118.3370],
|
||||
'南昌': [28.6829, 115.8582], '赣州': [25.8310, 114.9356], '九江': [29.6619, 115.9973], '宜春': [27.8143, 114.4161],
|
||||
'吉安': [27.1130, 114.9934], '上饶': [28.4546, 117.9435], '抚州': [27.9479, 116.3581], '萍乡': [27.6229, 113.8548],
|
||||
'景德镇': [29.2686, 117.1784], '新余': [27.8178, 114.9171], '鹰潭': [28.2588, 117.0692],
|
||||
'西安': [34.3416, 108.9398], '咸阳': [34.3293, 108.7092], '宝鸡': [34.3621, 107.2373], '渭南': [34.5205, 109.5103],
|
||||
'汉中': [33.0677, 107.0236], '延安': [36.5854, 109.4888], '榆林': [38.2855, 109.7345], '安康': [32.6849, 109.0291],
|
||||
'商洛': [33.8683, 109.9202], '铜川': [34.8970, 108.9451],
|
||||
'太原': [37.8706, 112.5499], '大同': [40.0769, 113.3001], '长治': [36.1954, 113.1164], '临汾': [36.0880, 111.5193],
|
||||
'晋中': [37.6875, 112.7528], '运城': [35.0265, 111.0070], '晋城': [35.4905, 112.8513], '吕梁': [37.5193, 111.1446],
|
||||
'忻州': [38.4170, 112.7340], '阳泉': [37.8568, 113.5803], '朔州': [39.3315, 112.4327],
|
||||
'沈阳': [41.8057, 123.4315], '大连': [38.9140, 121.6148], '鞍山': [41.1078, 122.9945], '抚顺': [41.8796, 123.9572],
|
||||
'锦州': [41.0952, 121.1270], '营口': [40.6241, 122.2351], '盘锦': [41.1198, 122.0708], '丹东': [40.0024, 124.3567],
|
||||
'本溪': [41.4870, 123.7716], '辽阳': [41.2691, 123.2378], '朝阳': [41.5737, 120.4508], '阜新': [42.0210, 121.6703],
|
||||
'葫芦岛': [40.7109, 120.8372], '铁岭': [42.2860, 123.8426],
|
||||
'长春': [43.8960, 125.3265], '吉林': [43.8379, 126.5493], '延边': [42.8907, 129.5094], '四平': [43.1639, 124.3504],
|
||||
'通化': [41.7278, 125.9395], '松原': [45.1410, 124.8255], '白城': [45.6193, 122.8389], '辽源': [42.8877, 125.1437],
|
||||
'白山': [41.9406, 126.4244],
|
||||
'哈尔滨': [45.8038, 126.5350], '大庆': [46.5967, 125.1041], '齐齐哈尔': [47.3543, 123.9180], '牡丹江': [44.5528, 129.6186],
|
||||
'佳木斯': [46.7998, 130.3194], '绥化': [46.6368, 126.9692], '鸡西': [45.2953, 130.9661], '鹤岗': [47.3499, 130.2978],
|
||||
'双鸭山': [46.6467, 131.1585], '伊春': [47.7277, 128.8404], '七台河': [45.7708, 131.0153], '黑河': [50.2454, 127.5284],
|
||||
'大兴安岭': [50.5506, 126.2050],
|
||||
'南宁': [22.8170, 108.3665], '桂林': [25.2344, 110.1798], '柳州': [24.3249, 109.4280], '北海': [21.4679, 109.1197],
|
||||
'玉林': [22.6529, 110.1411], '百色': [23.9020, 106.6184], '梧州': [23.4769, 111.2786], '贵港': [23.1131, 109.5980],
|
||||
'钦州': [21.9799, 108.6541], '河池': [24.6929, 108.0854], '防城港': [21.6869, 108.3783], '崇左': [22.3795, 107.3652],
|
||||
'来宾': [23.7332, 109.2213], '贺州': [24.4035, 111.5667],
|
||||
'呼和浩特': [40.8422, 111.7498], '包头': [40.6571, 109.8402], '赤峰': [42.2576, 118.8888], '鄂尔多斯': [39.6083, 109.7809],
|
||||
'通辽': [43.6530, 122.2445], '呼伦贝尔': [49.2000, 119.7600], '乌兰察布': [40.9935, 113.1310], '巴彦淖尔': [40.7274, 107.3862],
|
||||
'兴安': [46.0820, 122.0380], '锡林郭勒': [43.9335, 116.0480], '阿拉善': [38.8483, 105.6878],
|
||||
'拉萨': [29.6500, 91.1000], '日喀则': [29.2670, 88.8810], '昌都': [31.1400, 97.1700], '林芝': [29.6500, 94.3600],
|
||||
'山南': [29.2300, 91.7700], '那曲': [31.4700, 92.0600], '阿里': [30.4000, 81.1400],
|
||||
'乌鲁木齐': [43.8256, 87.6168], '克拉玛依': [45.5750, 84.8820], '吐鲁番': [42.9400, 89.1900], '哈密': [42.8200, 93.5100],
|
||||
'昌吉': [44.0130, 87.3050], '伊犁': [43.9200, 81.3200], '喀什': [39.4700, 75.9900], '阿克苏': [41.1700, 80.2600],
|
||||
'和田': [37.1100, 79.9200], '塔城': [46.7500, 82.9800], '阿勒泰': [47.8700, 88.1400],
|
||||
'博尔塔拉': [44.9000, 82.0700], '巴音郭楞': [41.7700, 86.1500], '克孜勒苏': [39.7100, 76.1700],
|
||||
'银川': [38.4710, 106.2590], '石嘴山': [38.9860, 106.3850], '吴忠': [37.9990, 106.2010], '固原': [36.0170, 106.2860],
|
||||
'中卫': [37.5170, 105.1960],
|
||||
'西宁': [36.6230, 101.7800], '海东': [36.5020, 102.1040], '海西': [37.3730, 97.3710],
|
||||
'海南': [36.2800, 100.6200], '海北': [36.9600, 100.9000], '黄南': [35.5200, 102.0200], '玉树': [33.0000, 97.0100],
|
||||
'果洛': [34.4800, 100.2400],
|
||||
'兰州': [36.0611, 103.8343], '天水': [34.5811, 105.7250], '白银': [36.5447, 104.1382], '金昌': [38.5210, 102.1880],
|
||||
'嘉峪关': [39.7730, 98.2900], '武威': [37.9277, 102.6379], '张掖': [38.9349, 100.4599], '平凉': [35.5434, 106.6682],
|
||||
'酒泉': [39.7368, 98.4940], '庆阳': [35.7100, 107.6440], '定西': [35.5800, 104.6250], '陇南': [33.3700, 104.9200],
|
||||
'临夏': [35.6000, 103.2100], '甘南': [34.9900, 102.9100],
|
||||
'贵阳': [26.6470, 106.6302], '遵义': [27.7254, 106.9270], '六盘水': [26.5927, 104.8300], '安顺': [26.2537, 105.9462],
|
||||
'毕节': [27.3020, 105.2900], '铜仁': [27.7300, 109.1800],
|
||||
'黔东南': [26.5800, 107.9700], '黔南': [26.2600, 107.5200], '黔西南': [25.0900, 104.9000],
|
||||
'昆明': [25.0389, 102.7183], '大理': [25.6065, 100.2280], '丽江': [26.8720, 100.2300], '曲靖': [25.4900, 103.8000],
|
||||
'玉溪': [24.3520, 102.5470], '红河': [23.3700, 103.3800], '楚雄': [25.0400, 101.5400], '普洱': [22.8300, 100.9700],
|
||||
'昭通': [27.3400, 103.7200], '文山': [23.3800, 104.2400], '西双版纳': [22.0100, 100.8000],
|
||||
'保山': [25.1200, 99.1700], '德宏': [24.4400, 98.5900], '临沧': [23.8900, 100.0900], '怒江': [25.8500, 98.8600],
|
||||
'迪庆': [27.8200, 99.7100],
|
||||
'海口': [20.0440, 110.3500], '三亚': [18.2540, 109.5020], '儋州': [19.5210, 109.5810], '三沙': [16.8300, 112.3400],
|
||||
'琼海': [19.2590, 110.4660], '文昌': [19.5440, 110.7980], '万宁': [18.7970, 110.3890], '陵水': [18.5060, 110.0370],
|
||||
'五指山': [18.7750, 109.5170],
|
||||
}
|
||||
|
||||
const radiusOptions = [
|
||||
{ value: 1000, label: '1km' },
|
||||
{ value: 3000, label: '3km' },
|
||||
{ value: 5000, label: '5km' },
|
||||
{ value: 10000, label: '10km' },
|
||||
{ value: 20000, label: '20km' },
|
||||
{ value: 50000, label: '50km' },
|
||||
]
|
||||
|
||||
// 选中城市时地图跳转
|
||||
watch(city, (val, oldVal) => {
|
||||
if (!val || val === oldVal || !mapRef.value) return
|
||||
const coord = cityCoords[val]
|
||||
if (!coord) return
|
||||
centerLat.value = 0
|
||||
centerLng.value = 0
|
||||
mapRef.value.clearRadiusCircle()
|
||||
mapRef.value.flyTo(coord[0], coord[1], 12)
|
||||
})
|
||||
|
||||
async function doSearch() {
|
||||
if (!keywords.value.trim()) {
|
||||
error.value = '请输入搜索关键词'
|
||||
error.value = ''
|
||||
|
||||
// 关键词和坐标必须至少有一个
|
||||
const kw = keywords.value.trim()
|
||||
const hasCoord = centerLng.value && centerLat.value
|
||||
if (!kw && !hasCoord) {
|
||||
error.value = '请输入关键词或点击地图选择搜索范围'
|
||||
searched.value = true
|
||||
return
|
||||
}
|
||||
error.value = ''
|
||||
|
||||
loading.value = true
|
||||
searched.value = true
|
||||
activePoi.value = null
|
||||
try {
|
||||
const data = await searchPoi(keywords.value.trim(), city.value.trim(), page.value, offset.value)
|
||||
const data = await searchPoi(
|
||||
kw,
|
||||
city.value,
|
||||
page.value,
|
||||
offset.value,
|
||||
centerLng.value,
|
||||
centerLat.value,
|
||||
radius.value,
|
||||
)
|
||||
results.value = data.list || []
|
||||
total.value = data.total || 0
|
||||
maxPage.value = data.maxPage || 1
|
||||
page.value = data.page || 1
|
||||
offset.value = data.offset || 20
|
||||
|
||||
// 在地图上标注
|
||||
if (mapRef.value && results.value.length > 0) {
|
||||
mapRef.value.addMarkers(results.value)
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || e.message || '请求失败'
|
||||
results.value = []
|
||||
@@ -110,63 +317,92 @@ async function doSearch() {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function focusItem(item) {
|
||||
activePoi.value = item
|
||||
if (mapRef.value) {
|
||||
mapRef.value.focusMarker(item)
|
||||
}
|
||||
}
|
||||
|
||||
function onMarkerClick(poi) {
|
||||
activePoi.value = poi
|
||||
}
|
||||
|
||||
function onMapClick({ lat, lng }) {
|
||||
centerLat.value = lat
|
||||
centerLng.value = lng
|
||||
// 切换为周边搜索时清除城市条件
|
||||
province.value = ''
|
||||
city.value = ''
|
||||
// 地图上画圆圈
|
||||
if (mapRef.value) {
|
||||
mapRef.value.drawRadiusCircle(lat, lng, radius.value)
|
||||
}
|
||||
}
|
||||
|
||||
function clearCenter() {
|
||||
centerLat.value = 0
|
||||
centerLng.value = 0
|
||||
if (mapRef.value) {
|
||||
mapRef.value.clearRadiusCircle()
|
||||
}
|
||||
}
|
||||
|
||||
function provinceChanged() {
|
||||
city.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* === Global reset === */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
html, body, #app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: #f5f7fa;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* === App layout === */
|
||||
.app {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
/* === Top search bar === */
|
||||
.top-bar {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
right: 380px;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 28px;
|
||||
color: #1a73e8;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
.search-form {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
background: #fff;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
||||
pointer-events: auto;
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
padding: 10px 16px;
|
||||
padding: 9px 14px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
@@ -175,17 +411,40 @@ body {
|
||||
border-color: #1a73e8;
|
||||
}
|
||||
|
||||
.city-input {
|
||||
max-width: 140px;
|
||||
.select {
|
||||
padding: 9px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.select:focus {
|
||||
border-color: #1a73e8;
|
||||
}
|
||||
|
||||
.province-select {
|
||||
max-width: 110px;
|
||||
}
|
||||
|
||||
.city-select {
|
||||
max-width: 110px;
|
||||
}
|
||||
|
||||
.radius-select {
|
||||
max-width: 90px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 28px;
|
||||
padding: 9px 24px;
|
||||
background: #1a73e8;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.2s;
|
||||
@@ -200,157 +459,187 @@ body {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #fef2f2;
|
||||
.center-badge {
|
||||
margin-top: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
color: #1a73e8;
|
||||
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.12);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.clear-center {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.clear-center:hover {
|
||||
color: #dc2626;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
/* === Side panel === */
|
||||
.side-panel {
|
||||
position: absolute;
|
||||
top: 80px;
|
||||
right: 16px;
|
||||
width: 350px;
|
||||
max-height: calc(100% - 100px);
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 16px 10px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.panel-page {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.panel-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.result-card:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.result-card.active {
|
||||
background: #e8f0fe;
|
||||
border-left: 3px solid #1a73e8;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.card-phone {
|
||||
font-size: 15px;
|
||||
color: #1a73e8;
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.card-addr {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.panel-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
padding: 10px 16px 14px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
flex: 1;
|
||||
padding: 7px 0;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.page-btn:hover:not(:disabled) {
|
||||
border-color: #1a73e8;
|
||||
color: #1a73e8;
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.panel-loading,
|
||||
.panel-error,
|
||||
.panel-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 16px;
|
||||
text-align: center;
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 48px 0;
|
||||
color: #666;
|
||||
.panel-error {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.panel-empty small {
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top-color: #1a73e8;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 48px 0;
|
||||
color: #999;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.results {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 16px 20px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
margin-bottom: 12px;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
font-size: 16px;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.tag {
|
||||
/* === Hint bar === */
|
||||
.hint-bar {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
padding: 6px 18px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
background: #e8f0fe;
|
||||
color: #1a73e8;
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
font-size: 14px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
min-width: 40px;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.phone {
|
||||
color: #1a73e8;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.phone:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.na {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
padding: 8px 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-outline:hover:not(:disabled) {
|
||||
border-color: #1a73e8;
|
||||
color: #1a73e8;
|
||||
}
|
||||
|
||||
.btn-outline:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
|
||||
+5
-4
@@ -5,11 +5,12 @@ const api = axios.create({
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
export async function searchPoi(keywords, city = '', page = 1, offset = 20) {
|
||||
const params = { keywords }
|
||||
export async function searchPoi(keywords, city = '', page = 1, offset = 20, longitude = 0, latitude = 0, radius = 3000) {
|
||||
const params = { keywords, page, offset }
|
||||
if (city) params.city = city
|
||||
params.page = page
|
||||
params.offset = offset
|
||||
if (longitude) params.longitude = longitude
|
||||
if (latitude) params.latitude = latitude
|
||||
if (radius) params.radius = radius
|
||||
|
||||
const res = await api.get('/poi/search', { params })
|
||||
return res.data.data
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div ref="mapContainer" class="map-container"></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
|
||||
const mapContainer = ref(null)
|
||||
let map = null
|
||||
let markerLayer = null
|
||||
let currentMarkers = []
|
||||
let radiusCircle = null
|
||||
|
||||
const emit = defineEmits(['marker-click', 'map-click'])
|
||||
|
||||
// 高德地图瓦片
|
||||
const amapTileUrl = 'https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}'
|
||||
const amapSubdomains = ['1', '2', '3', '4']
|
||||
|
||||
onMounted(() => {
|
||||
map = L.map(mapContainer.value, {
|
||||
center: [39.9042, 116.4074], // 北京
|
||||
zoom: 12,
|
||||
zoomControl: true,
|
||||
})
|
||||
|
||||
// 高德地图瓦片层
|
||||
L.tileLayer(amapTileUrl, {
|
||||
subdomains: amapSubdomains,
|
||||
attribution: '© 高德地图',
|
||||
maxZoom: 18,
|
||||
}).addTo(map)
|
||||
|
||||
markerLayer = L.layerGroup().addTo(map)
|
||||
|
||||
// 地图点击 → 设置中心点
|
||||
map.on('click', (e) => {
|
||||
emit('map-click', { lat: e.latlng.lat, lng: e.latlng.lng })
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (map) {
|
||||
map.remove()
|
||||
map = null
|
||||
}
|
||||
})
|
||||
|
||||
function clearMarkers() {
|
||||
markerLayer.clearLayers()
|
||||
currentMarkers = []
|
||||
}
|
||||
|
||||
function addMarkers(pois) {
|
||||
clearMarkers()
|
||||
if (!map || !pois.length) return
|
||||
|
||||
const bounds = L.latLngBounds()
|
||||
const pinIcon = L.divIcon({
|
||||
className: '',
|
||||
html: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 42" width="32" height="42">
|
||||
<path d="M16 2C8.268 2 2 8.268 2 16c0 9.678 14 24 14 24s14-14.322 14-24C30 8.268 23.732 2 16 2z" fill="#1a73e8" stroke="#fff" stroke-width="2"/>
|
||||
<circle cx="16" cy="15" r="6" fill="#fff"/>
|
||||
</svg>`,
|
||||
iconSize: [32, 42],
|
||||
iconAnchor: [16, 42],
|
||||
popupAnchor: [0, -42],
|
||||
})
|
||||
|
||||
pois.forEach((poi) => {
|
||||
const lat = parseFloat(poi.latitude)
|
||||
const lng = parseFloat(poi.longitude)
|
||||
if (isNaN(lat) || isNaN(lng)) return
|
||||
|
||||
const marker = L.marker([lat, lng], { icon: pinIcon }).addTo(markerLayer)
|
||||
marker.bindPopup(`
|
||||
<div class="map-popup">
|
||||
<h4>${poi.name}</h4>
|
||||
<p class="popup-phone">${poi.phone ? `<a href="tel:${poi.phone}">${poi.phone}</a>` : '暂无电话'}</p>
|
||||
<p class="popup-addr">${poi.address || ''}</p>
|
||||
${poi.businessArea ? `<p class="popup-area">商圈:${poi.businessArea}</p>` : ''}
|
||||
</div>
|
||||
`)
|
||||
|
||||
marker.on('click', () => {
|
||||
emit('marker-click', poi)
|
||||
})
|
||||
|
||||
currentMarkers.push(marker)
|
||||
bounds.extend([lat, lng])
|
||||
})
|
||||
|
||||
// 自动调整视野
|
||||
if (pois.length === 1) {
|
||||
map.setView(bounds.getCenter(), 15)
|
||||
} else {
|
||||
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 16 })
|
||||
}
|
||||
}
|
||||
|
||||
function focusMarker(poi) {
|
||||
const lat = parseFloat(poi.latitude)
|
||||
const lng = parseFloat(poi.longitude)
|
||||
if (isNaN(lat) || isNaN(lng)) return
|
||||
|
||||
map.setView([lat, lng], 16)
|
||||
for (const marker of currentMarkers) {
|
||||
const pos = marker.getLatLng()
|
||||
if (Math.abs(pos.lat - lat) < 0.001 && Math.abs(pos.lng - lng) < 0.001) {
|
||||
marker.openPopup()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 地图跳转到指定坐标
|
||||
function flyTo(lat, lng, zoom) {
|
||||
if (!map) return
|
||||
map.setView([lat, lng], zoom || 12)
|
||||
}
|
||||
|
||||
// 绘制半径圆圈
|
||||
function drawRadiusCircle(lat, lng, radiusMeters) {
|
||||
clearRadiusCircle()
|
||||
if (!map) return
|
||||
radiusCircle = L.circle([lat, lng], {
|
||||
radius: radiusMeters,
|
||||
color: '#1a73e8',
|
||||
fillColor: '#1a73e8',
|
||||
fillOpacity: 0.08,
|
||||
weight: 2,
|
||||
dashArray: '6, 6',
|
||||
}).addTo(map)
|
||||
map.fitBounds(radiusCircle.getBounds(), { padding: [40, 40] })
|
||||
}
|
||||
|
||||
function clearRadiusCircle() {
|
||||
if (radiusCircle) {
|
||||
map.removeLayer(radiusCircle)
|
||||
radiusCircle = null
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ addMarkers, clearMarkers, focusMarker, drawRadiusCircle, clearRadiusCircle, flyTo })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.map-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
:deep(.map-popup h4) {
|
||||
margin: 0 0 4px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
:deep(.popup-phone) {
|
||||
margin: 2px 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1a73e8;
|
||||
}
|
||||
|
||||
:deep(.popup-phone a) {
|
||||
color: #1a73e8;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
:deep(.popup-addr) {
|
||||
margin: 2px 0;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
:deep(.popup-area) {
|
||||
margin: 2px 0;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user