This commit is contained in:
2026-07-30 11:24:40 +08:00
parent 42da6fc74e
commit 0eb2835215
15 changed files with 899 additions and 509 deletions
-5
View File
@@ -1,5 +0,0 @@
package consts
const (
TableNamePoiMerchant = "poi_merchant"
)
-63
View File
@@ -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
}
+8 -5
View File
@@ -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 {
-23
View File
@@ -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
View File
@@ -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 Keyconfig.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 最大 25page 最大 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) {
-53
View File
@@ -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
}
-6
View File
@@ -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"
+1 -11
View File
@@ -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
)
-19
View File
@@ -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=
-2
View File
@@ -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() {
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+185
View File
@@ -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: '&copy; 高德地图',
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>