This commit is contained in:
2026-07-30 10:53:58 +08:00
commit 42da6fc74e
20 changed files with 2771 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
# Go
*.db
*.db-journal
*.db-wal
*.db-shm
# IDE
.idea/
# Node
web/node_modules/
web/dist/
# OS
.DS_Store
Thumbs.db
+5
View File
@@ -0,0 +1,5 @@
package consts
const (
TableNamePoiMerchant = "poi_merchant"
)
+16
View File
@@ -0,0 +1,16 @@
package controller
import (
"context"
"pointly-tel/amap/model/dto"
"pointly-tel/amap/service"
)
type poi struct{}
var Poi = new(poi)
func (c *poi) Search(ctx context.Context, req *dto.SearchPoiReq) (res *dto.SearchPoiRes, err error) {
return service.PoiService.Search(ctx, req)
}
+63
View File
@@ -0,0 +1,63 @@
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
}
+33
View File
@@ -0,0 +1,33 @@
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:"页码"`
}
type PoiItem struct {
PoiId string `json:"poiId" dc:"POI ID"`
Name string `json:"name" dc:"商户名称"`
Address string `json:"address" dc:"地址"`
Phone string `json:"phone" dc:"联系电话"`
Category string `json:"category" dc:"分类"`
City string `json:"city" dc:"城市"`
Longitude string `json:"longitude" dc:"经度"`
Latitude string `json:"latitude" dc:"纬度"`
BusinessArea string `json:"businessArea" dc:"商圈"`
Website string `json:"website" dc:"网址"`
}
type SearchPoiRes struct {
List []*PoiItem `json:"list" dc:"POI列表"`
Total int `json:"total" dc:"匹配总数"`
Count int `json:"count" dc:"当前返回数量"`
Page int `json:"page" dc:"当前页码"`
Offset int `json:"offset" dc:"每页数量"`
MaxPage int `json:"maxPage" dc:"最大可查询页码(高德限制100页)"`
}
+23
View File
@@ -0,0 +1,23 @@
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:"删除时间"`
}
+198
View File
@@ -0,0 +1,198 @@
package service
import (
"context"
"encoding/json"
"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"
"github.com/gogf/gf/v2/util/gconv"
)
type poiService struct{}
var PoiService = new(poiService)
// amapString 处理高德API中字段有时返回string有时返回[]的情况
type amapString string
func (s *amapString) UnmarshalJSON(data []byte) error {
var str string
if err := json.Unmarshal(data, &str); err == nil {
*s = amapString(str)
return nil
}
*s = ""
return nil
}
// Amap API 响应结构
type amapResponse struct {
Status string `json:"status"`
Info string `json:"info"`
Infocode string `json:"infocode"`
Count string `json:"count"`
Pois []*amapPoi `json:"pois"`
}
type amapPoi struct {
Id string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Typecode string `json:"typecode"`
Address string `json:"address"`
Tel amapString `json:"tel"`
Location string `json:"location"`
BusinessArea amapString `json:"business_area"`
City amapString `json:"city"`
CityCode amapString `json:"citycode"`
Website amapString `json:"website"`
}
func (s *poiService) Search(ctx context.Context, req *dto.SearchPoiReq) (res *dto.SearchPoiRes, err error) {
apiKeyVar, err := g.Cfg().Get(ctx, "amap.api_key")
if err != nil {
return nil, gerror.New("读取配置失败")
}
if apiKeyVar.IsEmpty() {
return nil, gerror.New("请先配置高德地图API Keyconfig.yml 中的 amap.api_key")
}
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
} else if offset > maxOffset {
offset = maxOffset
}
page := req.Page
if page < 1 {
page = 1
} else if page > maxPage {
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)
}
requestUrl := baseUrl + "?" + params.Encode()
g.Log().Info(ctx, "调用高德POI搜索", "url", requestUrl)
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),
}
items = append(items, item)
go s.cachePoi(ctx, p)
}
// 计算最大可查页数(高德限制最多查 100 页)
maxPageCalculated := maxPage
if total > 0 {
if maxPageCalculated*offset > total {
maxPageCalculated = (total + offset - 1) / offset
}
}
return &dto.SearchPoiRes{
List: items,
Total: total,
Count: len(items),
Page: page,
Offset: offset,
MaxPage: maxPageCalculated,
}, nil
}
// cachePoi 将POI结果异步缓存到数据库
func (s *poiService) cachePoi(ctx context.Context, p *amapPoi) {
existing, _ := dao.PoiMerchant.GetByPoiId(ctx, p.Id)
if existing != nil {
return
}
lng, lat := parseLocation(p.Location)
rawBytes, _ := json.Marshal(p)
_, 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),
})
if err != nil {
g.Log().Warning(ctx, "缓存POI数据失败", "poiId", p.Id, "error", err)
}
}
func parseLocation(location string) (float64, float64) {
if location == "" {
return 0, 0
}
parts := strings.Split(location, ",")
if len(parts) != 2 {
return 0, 0
}
return gconv.Float64(parts[0]), gconv.Float64(parts[1])
}
+53
View File
@@ -0,0 +1,53 @@
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
}
+72
View File
@@ -0,0 +1,72 @@
package common
import (
"fmt"
"os"
"path/filepath"
"reflect"
"regexp"
"strings"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gtime"
)
var Httpserver = g.Server()
func init() {
err := gtime.SetTimeZone("Asia/Shanghai")
if err != nil {
panic("设置时区失败")
}
Httpserver.SetOpenApiPath("/api.json")
Httpserver.BindMiddlewareDefault(ghttp.MiddlewareHandlerResponse)
Httpserver.BindMiddlewareDefault(func(r *ghttp.Request) {
r.Response.CORS(r.Response.DefaultCORSOptions())
r.Middleware.Next()
})
}
// RouteRegister 根据控制器结构体名称自动注册路由
func RouteRegister(controllers []interface{}) {
re := regexp.MustCompile("[A-Z]")
for _, t := range controllers {
sName := reflect.ValueOf(t).Elem().Type().Name()
convertedStr := re.ReplaceAllStringFunc(sName, func(s string) string {
return fmt.Sprintf("-%s", strings.ToLower(s))
})
if len(convertedStr) > 0 && convertedStr[0] == '-' {
convertedStr = convertedStr[1:]
}
Httpserver.Group("/"+convertedStr, func(group *ghttp.RouterGroup) {
group.Bind(t)
})
}
}
// ServeFrontend 配置前端静态文件服务
func ServeFrontend(distDir string) {
absDir, err := filepath.Abs(distDir)
if err != nil {
g.Log().Fatalf(nil, "前端目录路径无效: %v", err)
}
// 静态资源(JS/CSS/图片)
Httpserver.AddStaticPath("/assets", absDir+"/assets")
// SPA 兜底:非API路由返回 index.html
Httpserver.BindHandler("/*", func(r *ghttp.Request) {
indexFile := filepath.Join(absDir, "index.html")
if fileExists(indexFile) {
r.Response.ServeFile(indexFile)
} else {
r.Response.WriteStatus(404)
}
})
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
+11
View File
@@ -0,0 +1,11 @@
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"
+45
View File
@@ -0,0 +1,45 @@
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/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
github.com/gorilla/websocket v1.5.3 // indirect
github.com/grokify/html-strip-tags-go v0.1.0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
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
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
golang.org/x/net v0.40.0 // indirect
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
)
+100
View File
@@ -0,0 +1,100 @@
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
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=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grokify/html-strip-tags-go v0.1.0 h1:03UrQLjAny8xci+R+qjCce/MYnpNXCtgzltlQbOBae4=
github.com/grokify/html-strip-tags-go v0.1.0/go.mod h1:ZdzgfHEzAfz9X6Xe5eBLVblWIxXfYSQ40S/VKrAOGpc=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY=
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=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
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=
+21
View File
@@ -0,0 +1,21 @@
package main
import (
"pointly-tel/amap/controller"
commonHttp "pointly-tel/common"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
)
func main() {
// 注册 API 路由
commonHttp.RouteRegister([]interface{}{
controller.Poi,
})
// 前端静态文件服务(需先执行 cd web && npm run build
commonHttp.ServeFrontend("web/dist")
// 启动 HTTP 服务(监听 config.yml 中 server.address,默认 :3000
commonHttp.Httpserver.Run()
}
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pointly-Tel - 高德POI商户查询</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1693
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "pointly-tel-web",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.0",
"axios": "^1.7.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.0",
"vite": "^6.3.0"
}
}
+356
View File
@@ -0,0 +1,356 @@
<template>
<div class="app">
<header class="header">
<h1>Pointly-Tel</h1>
<p class="subtitle">高德地图 POI 商户信息查询</p>
</header>
<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>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="loading" class="loading">
<div class="spinner"></div>
<span>正在查询...</span>
</div>
<div v-if="!loading && searched && results.length === 0" class="empty">
未找到相关商户请尝试其他关键词
</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>
</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>
</div>
</main>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { searchPoi } from './api/poi.js'
const keywords = ref('')
const city = ref('')
const results = ref([])
const total = ref(0)
const loading = ref(false)
const searched = ref(false)
const error = ref('')
const page = ref(1)
const offset = ref(20)
const maxPage = ref(1)
async function doSearch() {
if (!keywords.value.trim()) {
error.value = '请输入搜索关键词'
return
}
error.value = ''
loading.value = true
searched.value = true
try {
const data = await searchPoi(keywords.value.trim(), city.value.trim(), page.value, offset.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
} catch (e) {
error.value = e.response?.data?.message || e.message || '请求失败'
results.value = []
total.value = 0
maxPage.value = 1
} finally {
loading.value = false
}
}
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: #f5f7fa;
color: #333;
line-height: 1.6;
}
.app {
max-width: 800px;
margin: 0 auto;
padding: 24px 16px;
}
.header {
text-align: center;
margin-bottom: 32px;
}
.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 {
display: flex;
gap: 12px;
}
.input {
flex: 1;
padding: 10px 16px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 15px;
outline: none;
transition: border-color 0.2s;
}
.input:focus {
border-color: #1a73e8;
}
.city-input {
max-width: 140px;
}
.btn {
padding: 10px 28px;
background: #1a73e8;
color: #fff;
border: none;
border-radius: 8px;
font-size: 15px;
cursor: pointer;
white-space: nowrap;
transition: background 0.2s;
}
.btn:hover:not(:disabled) {
background: #1557b0;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error {
background: #fef2f2;
color: #dc2626;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 16px;
font-size: 14px;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 0;
color: #666;
}
.spinner {
width: 24px;
height: 24px;
border: 3px solid #e5e7eb;
border-top-color: #1a73e8;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@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 {
font-size: 12px;
background: #e8f0fe;
color: #1a73e8;
padding: 2px 10px;
border-radius: 12px;
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>
+16
View File
@@ -0,0 +1,16 @@
import axios from 'axios'
const api = axios.create({
baseURL: import.meta.env.DEV ? '' : '',
timeout: 30000,
})
export async function searchPoi(keywords, city = '', page = 1, offset = 20) {
const params = { keywords }
if (city) params.city = city
params.page = page
params.offset = offset
const res = await api.get('/poi/search', { params })
return res.data.data
}
+4
View File
@@ -0,0 +1,4 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/poi': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
})