This commit is contained in:
Ethanfly 2026-01-09 18:52:32 +08:00
commit 1bf109ca6d
9 changed files with 2907 additions and 0 deletions

182
README.md Normal file
View File

@ -0,0 +1,182 @@
# 会面点 Meeting Point
一个帮助多人寻找最佳聚会地点的地图应用。输入多个参与者的位置自动计算几何中心并在中心点附近搜索咖啡馆、餐厅、KTV 等聚会场所。
![Preview](preview.png)
## ✨ 功能特点
- **📍 多点位置设置**
- 通过地址搜索添加位置
- 直接在地图上点击添加位置
- 支持输入提示和自动补全
- **🎯 智能中心计算**
- 使用球面几何算法计算多点中心
- 准确计算地球曲面上的几何中心
- **🔍 周边搜索**
- 支持自定义搜索关键词
- 可调节搜索半径500米-10公里
- 预设常用场所类型咖啡馆、餐厅、KTV等
- **🗺️ 地图可视化**
- 深色主题地图
- 清晰的标记和信息展示
- 搜索范围可视化
## 🚀 快速开始
### 前置要求
- Go 1.21+
- 高德地图开发者账号和 Web 服务 API Key
### 获取高德地图 API Key
1. 访问 [高德开放平台](https://lbs.amap.com/)
2. 注册/登录开发者账号
3. 进入「控制台」→「应用管理」→「创建新应用」
4. 添加 Key选择「Web 服务」类型
5. 复制生成的 Key
### 安装步骤
1. 克隆项目
```bash
git clone https://github.com/yourusername/meeting-point.git
cd meeting-point
```
2. 安装依赖
```bash
go mod download
```
3. 配置 API Key
复制配置文件模板并填入你的 Key
```bash
cp config.example.json config.json
```
编辑 `config.json`
```json
{
"amap_key": "你的高德地图API_Key",
"port": "8080"
}
```
或者使用环境变量:
```bash
export AMAP_KEY="你的高德地图API_Key"
export PORT="8080"
```
4. 启动服务
```bash
go run main.go
```
5. 访问应用
打开浏览器访问 http://localhost:8080
## 📖 使用说明
### 添加位置
有两种方式添加参与者位置:
1. **搜索添加**:在左侧搜索框中输入地址或地点名称,从下拉列表中选择
2. **点击添加**:直接在地图上点击想要添加的位置
### 搜索聚会地点
1. 添加至少 2 个位置点
2. 在「搜索类型」中输入或选择想要查找的场所类型
3. 调整搜索半径
4. 点击「搜索最佳会面点」按钮
### 查看结果
- 地图上会显示计算出的中心点(金色星星标记)
- 搜索到的场所会以绿色标记显示
- 左侧列表会显示详细信息,点击可在地图上定位
## 🏗️ 技术架构
### 后端
- **Go** - 高性能后端语言
- **Gin** - Web 框架
- **高德地图 Web API** - 地理编码、POI 搜索
### 前端
- **原生 JavaScript** - 无框架依赖
- **高德地图 JS API** - 地图展示和交互
- **CSS3** - 现代化 UI 设计
### API 端点
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/` | 主页面 |
| GET | `/api/config` | 获取配置 |
| POST | `/api/geocode` | 地址转坐标 |
| POST | `/api/center` | 计算中心点 |
| POST | `/api/search` | 搜索周边 POI |
| GET | `/api/tips` | 输入提示 |
## 📁 项目结构
```
meeting-point/
├── main.go # 后端主程序
├── go.mod # Go 模块文件
├── go.sum # 依赖锁定文件
├── config.json # 配置文件(需创建)
├── config.example.json # 配置文件模板
├── README.md # 项目说明
├── templates/
│ └── index.html # 前端页面模板
└── static/
├── css/
│ └── style.css # 样式文件
└── js/
└── app.js # 前端逻辑
```
## 🔧 配置说明
### config.json
```json
{
"amap_key": "高德地图API Key",
"port": "服务端口默认8080"
}
```
### 环境变量
- `AMAP_KEY` - 高德地图 API Key优先级高于配置文件
- `PORT` - 服务端口
## 📝 开发计划
- [ ] 添加路线规划功能
- [ ] 支持更多地图服务商
- [ ] 添加位置分享功能
- [ ] 移动端适配优化
- [ ] 添加历史记录功能
## 📄 许可证
MIT License
## 🤝 贡献
欢迎提交 Issue 和 Pull Request

6
config.example.json Normal file
View File

@ -0,0 +1,6 @@
{
"amap_key": "YOUR_WEB_SERVICE_API_KEY",
"amap_js_key": "YOUR_JS_API_KEY",
"amap_js_secret": "YOUR_JS_API_SECURITY_CODE",
"port": "8080"
}

6
config.json Normal file
View File

@ -0,0 +1,6 @@
{
"amap_key": "5b8a9b4a256b1975f986a300bc239db9",
"amap_js_key": "6fd88503bbdb3a57245ab9541d7f22ac",
"amap_js_secret": "3be4b8e3bde18519f59528e5555313a9",
"port": "9876"
}

32
go.mod Normal file
View File

@ -0,0 +1,32 @@
module meeting-point
go 1.21
require github.com/gin-gonic/gin v1.9.1
require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.9.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/text v0.9.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

86
go.sum Normal file
View File

@ -0,0 +1,86 @@
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

467
main.go Normal file
View File

@ -0,0 +1,467 @@
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"net/url"
"os"
"strings"
"github.com/gin-gonic/gin"
)
// Config 配置结构
type Config struct {
AmapKey string `json:"amap_key"` // Web服务API Key
AmapJsKey string `json:"amap_js_key"` // JS API Key
AmapJsSecret string `json:"amap_js_secret"` // JS API 安全密钥
Port string `json:"port"`
}
// Coordinate 坐标结构
type Coordinate struct {
Lng float64 `json:"lng"`
Lat float64 `json:"lat"`
}
// Location 位置信息
type Location struct {
Name string `json:"name"`
Address string `json:"address"`
Coordinate Coordinate `json:"coordinate"`
}
// CenterRequest 计算中心点请求
type CenterRequest struct {
Locations []Location `json:"locations"`
}
// SearchRequest 搜索请求
type SearchRequest struct {
Center Coordinate `json:"center"`
Keywords string `json:"keywords"`
Radius int `json:"radius"` // 搜索半径,单位米
}
// POI 兴趣点
type POI struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Address string `json:"address"`
Location Coordinate `json:"location"`
Distance string `json:"distance"`
Tel string `json:"tel"`
}
// AmapGeoResponse 高德地理编码响应
type AmapGeoResponse struct {
Status string `json:"status"`
Info string `json:"info"`
Count string `json:"count"`
Geocodes []struct {
FormattedAddress string `json:"formatted_address"`
Location string `json:"location"`
Level string `json:"level"`
} `json:"geocodes"`
}
// AmapPOI 高德POI项
type AmapPOI struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Address interface{} `json:"address"` // 可能是字符串或空数组
Location string `json:"location"`
Distance string `json:"distance"`
Tel interface{} `json:"tel"` // 可能是字符串或空数组
}
// AmapPOIResponse 高德POI搜索响应
type AmapPOIResponse struct {
Status string `json:"status"`
Info string `json:"info"`
Count string `json:"count"`
Suggestion struct {
Keywords []interface{} `json:"keywords"`
} `json:"suggestion"`
Pois []AmapPOI `json:"pois"`
}
// AmapTip 高德输入提示项
type AmapTip struct {
ID interface{} `json:"id"` // 可能是字符串或空数组
Name string `json:"name"`
District string `json:"district"`
Address interface{} `json:"address"` // 可能是字符串或空数组
Location interface{} `json:"location"` // 可能是字符串或空数组
}
// AmapTipsResponse 高德输入提示响应
type AmapTipsResponse struct {
Status string `json:"status"`
Info string `json:"info"`
Count string `json:"count"`
Tips []AmapTip `json:"tips"`
}
var config Config
func main() {
// 加载配置
loadConfig()
// 设置Gin模式
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
// 静态文件服务
r.Static("/static", "./static")
r.LoadHTMLGlob("templates/*")
// 路由
r.GET("/", indexHandler)
r.GET("/api/config", getConfigHandler)
r.POST("/api/geocode", geocodeHandler)
r.POST("/api/center", calculateCenterHandler)
r.POST("/api/search", searchPOIHandler)
r.GET("/api/tips", getTipsHandler)
port := config.Port
if port == "" {
port = "8080"
}
log.Printf("服务启动在 http://localhost:%s", port)
r.Run(":" + port)
}
func loadConfig() {
// 尝试从配置文件加载
file, err := os.Open("config.json")
if err == nil {
defer file.Close()
decoder := json.NewDecoder(file)
err = decoder.Decode(&config)
if err != nil {
log.Printf("解析配置文件失败: %v", err)
}
}
// 环境变量覆盖
if key := os.Getenv("AMAP_KEY"); key != "" {
config.AmapKey = key
}
if port := os.Getenv("PORT"); port != "" {
config.Port = port
}
if config.Port == "" {
config.Port = "8080"
}
}
func indexHandler(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", nil)
}
func getConfigHandler(c *gin.Context) {
// 返回前端需要的配置
jsKey := config.AmapJsKey
if jsKey == "" {
jsKey = config.AmapKey // 兼容如果没有单独配置JS Key使用Web服务Key
}
c.JSON(http.StatusOK, gin.H{
"amap_key": config.AmapKey,
"amap_js_key": jsKey,
"amap_js_secret": config.AmapJsSecret,
})
}
// geocodeHandler 地理编码 - 地址转坐标
func geocodeHandler(c *gin.Context) {
var req struct {
Address string `json:"address"`
City string `json:"city"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数"})
return
}
if req.Address == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "地址不能为空"})
return
}
// 调用高德地理编码API
apiURL := fmt.Sprintf(
"https://restapi.amap.com/v3/geocode/geo?key=%s&address=%s&city=%s",
config.AmapKey,
url.QueryEscape(req.Address),
url.QueryEscape(req.City),
)
resp, err := http.Get(apiURL)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "请求高德API失败"})
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var amapResp AmapGeoResponse
if err := json.Unmarshal(body, &amapResp); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "解析响应失败"})
return
}
if amapResp.Status != "1" || len(amapResp.Geocodes) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "未找到该地址"})
return
}
// 解析坐标
location := amapResp.Geocodes[0].Location
coords := strings.Split(location, ",")
if len(coords) != 2 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "坐标格式错误"})
return
}
var lng, lat float64
fmt.Sscanf(coords[0], "%f", &lng)
fmt.Sscanf(coords[1], "%f", &lat)
c.JSON(http.StatusOK, gin.H{
"location": Location{
Name: req.Address,
Address: amapResp.Geocodes[0].FormattedAddress,
Coordinate: Coordinate{
Lng: lng,
Lat: lat,
},
},
})
}
// calculateCenterHandler 计算多点中心
func calculateCenterHandler(c *gin.Context) {
var req CenterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数"})
return
}
if len(req.Locations) < 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": "至少需要2个位置点"})
return
}
// 计算几何中心(加权平均)
center := calculateGeometricCenter(req.Locations)
c.JSON(http.StatusOK, gin.H{
"center": center,
})
}
// calculateGeometricCenter 计算几何中心点
func calculateGeometricCenter(locations []Location) Coordinate {
// 使用球面坐标转换计算更准确的中心点
var x, y, z float64
for _, loc := range locations {
// 转换为弧度
latRad := loc.Coordinate.Lat * math.Pi / 180
lngRad := loc.Coordinate.Lng * math.Pi / 180
// 转换为笛卡尔坐标
x += math.Cos(latRad) * math.Cos(lngRad)
y += math.Cos(latRad) * math.Sin(lngRad)
z += math.Sin(latRad)
}
// 平均值
n := float64(len(locations))
x /= n
y /= n
z /= n
// 转换回经纬度
lng := math.Atan2(y, x) * 180 / math.Pi
hyp := math.Sqrt(x*x + y*y)
lat := math.Atan2(z, hyp) * 180 / math.Pi
return Coordinate{
Lng: lng,
Lat: lat,
}
}
// searchPOIHandler 搜索周边POI
func searchPOIHandler(c *gin.Context) {
var req SearchRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数"})
return
}
if req.Keywords == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "搜索关键词不能为空"})
return
}
if req.Radius <= 0 {
req.Radius = 3000 // 默认3公里
}
// 调用高德POI搜索API
location := fmt.Sprintf("%f,%f", req.Center.Lng, req.Center.Lat)
apiURL := fmt.Sprintf(
"https://restapi.amap.com/v3/place/around?key=%s&location=%s&keywords=%s&radius=%d&offset=20&page=1&extensions=all",
config.AmapKey,
location,
url.QueryEscape(req.Keywords),
req.Radius,
)
resp, err := http.Get(apiURL)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "请求高德API失败"})
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
log.Printf("高德POI搜索响应: %s", string(body))
var amapResp AmapPOIResponse
if err := json.Unmarshal(body, &amapResp); err != nil {
log.Printf("解析POI响应失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "解析响应失败"})
return
}
if amapResp.Status != "1" {
log.Printf("高德POI搜索API返回错误: status=%s, info=%s", amapResp.Status, amapResp.Info)
c.JSON(http.StatusInternalServerError, gin.H{"error": "高德API返回错误: " + amapResp.Info})
return
}
// 转换POI数据
pois := make([]POI, 0, len(amapResp.Pois))
for _, p := range amapResp.Pois {
coords := strings.Split(p.Location, ",")
if len(coords) != 2 {
continue
}
var lng, lat float64
fmt.Sscanf(coords[0], "%f", &lng)
fmt.Sscanf(coords[1], "%f", &lat)
// 处理可能是数组或字符串的字段
address, _ := p.Address.(string)
tel, _ := p.Tel.(string)
pois = append(pois, POI{
ID: p.ID,
Name: p.Name,
Type: p.Type,
Address: address,
Location: Coordinate{
Lng: lng,
Lat: lat,
},
Distance: p.Distance,
Tel: tel,
})
}
c.JSON(http.StatusOK, gin.H{
"pois": pois,
"count": len(pois),
})
}
// getTipsHandler 输入提示
func getTipsHandler(c *gin.Context) {
keywords := c.Query("keywords")
city := c.Query("city")
if keywords == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "关键词不能为空"})
return
}
apiURL := fmt.Sprintf(
"https://restapi.amap.com/v3/assistant/inputtips?key=%s&keywords=%s&city=%s&datatype=all",
config.AmapKey,
url.QueryEscape(keywords),
url.QueryEscape(city),
)
resp, err := http.Get(apiURL)
if err != nil {
log.Printf("请求高德API失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "请求高德API失败"})
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var amapResp AmapTipsResponse
if err := json.Unmarshal(body, &amapResp); err != nil {
log.Printf("解析响应失败: %v, body: %s", err, string(body))
c.JSON(http.StatusInternalServerError, gin.H{"error": "解析响应失败"})
return
}
if amapResp.Status != "1" {
log.Printf("高德API返回错误: status=%s, info=%s", amapResp.Status, amapResp.Info)
c.JSON(http.StatusInternalServerError, gin.H{"error": "高德API返回错误: " + amapResp.Info})
return
}
// 过滤有效的提示
tips := make([]map[string]interface{}, 0)
for _, tip := range amapResp.Tips {
// 解析 location 字段(可能是字符串或空数组)
locationStr, ok := tip.Location.(string)
if !ok || locationStr == "" {
continue // 跳过非字符串类型的 location
}
coords := strings.Split(locationStr, ",")
if len(coords) != 2 {
continue
}
var lng, lat float64
fmt.Sscanf(coords[0], "%f", &lng)
fmt.Sscanf(coords[1], "%f", &lat)
// 解析 address 字段(可能是字符串或数组)
addressStr, _ := tip.Address.(string)
// 解析 id 字段(可能是字符串或数组)
idStr, _ := tip.ID.(string)
tips = append(tips, map[string]interface{}{
"id": idStr,
"name": tip.Name,
"district": tip.District,
"address": addressStr,
"location": Coordinate{Lng: lng, Lat: lat},
})
}
c.JSON(http.StatusOK, gin.H{
"tips": tips,
})
}

1299
static/css/style.css Normal file

File diff suppressed because it is too large Load Diff

692
static/js/app.js Normal file
View File

@ -0,0 +1,692 @@
/**
* 会面点 - Meeting Point
* 前端应用主逻辑
*/
// ========================================
// 全局状态
// ========================================
const state = {
map: null,
amapKey: '', // Web服务API Key
amapJsKey: '', // JS API Key
amapJsSecret: '', // JS API 安全密钥
locations: [], // 参与者位置列表
markers: [], // 位置标记
centerMarker: null, // 中心点标记
poiMarkers: [], // POI标记
center: null, // 计算出的中心点
searchCircle: null, // 搜索范围圆
infoWindow: null, // 信息窗口
currentPOIs: [], // 当前搜索结果
colors: [
'#3b82f6', '#ef4444', '#10b981', '#8b5cf6',
'#f59e0b', '#ec4899', '#06b6d4', '#84cc16'
]
};
// ========================================
// 初始化
// ========================================
document.addEventListener('DOMContentLoaded', async () => {
await loadConfig();
initMap();
bindEvents();
});
async function loadConfig() {
try {
const response = await fetch('/api/config');
const data = await response.json();
state.amapKey = data.amap_key;
state.amapJsKey = data.amap_js_key || data.amap_key;
state.amapJsSecret = data.amap_js_secret;
// 配置安全密钥必须在加载JS API之前设置
if (state.amapJsSecret) {
window._AMapSecurityConfig = {
securityJsCode: state.amapJsSecret
};
}
// 动态加载高德地图JS API
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = `https://webapi.amap.com/maps?v=2.0&key=${state.amapJsKey}&plugin=AMap.PlaceSearch,AMap.Geocoder,AMap.AutoComplete`;
script.onload = resolve;
script.onerror = () => reject(new Error('加载高德地图JS API失败'));
document.head.appendChild(script);
});
} catch (error) {
console.error('加载配置失败:', error);
alert('加载配置失败,请检查后端服务是否启动');
}
}
function initMap() {
// 初始化地图
state.map = new AMap.Map('mapContainer', {
zoom: 12,
center: [116.397428, 39.90923], // 默认北京
mapStyle: 'amap://styles/dark', // 深色主题
viewMode: '2D'
});
// 初始化信息窗口
state.infoWindow = new AMap.InfoWindow({
isCustom: true,
autoMove: true,
offset: new AMap.Pixel(0, -40)
});
// 地图点击事件
state.map.on('click', handleMapClick);
// 尝试定位到用户位置
AMap.plugin('AMap.Geolocation', () => {
const geolocation = new AMap.Geolocation({
enableHighAccuracy: true,
timeout: 10000
});
geolocation.getCurrentPosition((status, result) => {
if (status === 'complete') {
state.map.setCenter([result.position.lng, result.position.lat]);
}
});
});
}
// ========================================
// 事件绑定
// ========================================
function bindEvents() {
// 搜索输入
const searchInput = document.getElementById('searchInput');
const clearSearch = document.getElementById('clearSearch');
const searchTips = document.getElementById('searchTips');
let searchTimeout;
searchInput.addEventListener('input', (e) => {
const value = e.target.value.trim();
clearSearch.style.display = value ? 'block' : 'none';
clearTimeout(searchTimeout);
if (value.length >= 2) {
searchTimeout = setTimeout(() => fetchSearchTips(value), 300);
} else {
searchTips.classList.remove('active');
}
});
clearSearch.addEventListener('click', () => {
searchInput.value = '';
clearSearch.style.display = 'none';
searchTips.classList.remove('active');
});
// 点击外部关闭搜索提示
document.addEventListener('click', (e) => {
if (!e.target.closest('.search-box')) {
searchTips.classList.remove('active');
}
});
// 快捷标签
document.querySelectorAll('.tag').forEach(tag => {
tag.addEventListener('click', () => {
document.querySelectorAll('.tag').forEach(t => t.classList.remove('active'));
tag.classList.add('active');
document.getElementById('poiKeywords').value = tag.dataset.keyword;
});
});
// 搜索半径滑块
const radiusSlider = document.getElementById('searchRadius');
const radiusValue = document.getElementById('radiusValue');
radiusSlider.addEventListener('input', () => {
const value = parseInt(radiusSlider.value);
radiusValue.textContent = value >= 1000 ? `${value / 1000} 公里` : `${value}`;
});
// 搜索按钮
document.getElementById('searchBtn').addEventListener('click', handleSearch);
// POI关键词输入
document.getElementById('poiKeywords').addEventListener('input', () => {
document.querySelectorAll('.tag').forEach(t => t.classList.remove('active'));
});
}
// ========================================
// 搜索提示
// ========================================
async function fetchSearchTips(keywords) {
const searchTips = document.getElementById('searchTips');
try {
const response = await fetch(`/api/tips?keywords=${encodeURIComponent(keywords)}&city=`);
const data = await response.json();
if (data.tips && data.tips.length > 0) {
renderSearchTips(data.tips);
} else {
searchTips.classList.remove('active');
}
} catch (error) {
console.error('获取搜索提示失败:', error);
}
}
function renderSearchTips(tips) {
const searchTips = document.getElementById('searchTips');
searchTips.innerHTML = tips.map(tip => `
<div class="search-tip-item" data-lng="${tip.location.lng}" data-lat="${tip.location.lat}" data-name="${tip.name}" data-address="${tip.district}${tip.address}">
<div class="name">${tip.name}</div>
<div class="address">${tip.district}${tip.address}</div>
</div>
`).join('');
// 绑定点击事件
searchTips.querySelectorAll('.search-tip-item').forEach(item => {
item.addEventListener('click', () => {
const location = {
name: item.dataset.name,
address: item.dataset.address,
coordinate: {
lng: parseFloat(item.dataset.lng),
lat: parseFloat(item.dataset.lat)
}
};
addLocation(location);
// 清理搜索框
document.getElementById('searchInput').value = '';
document.getElementById('clearSearch').style.display = 'none';
searchTips.classList.remove('active');
});
});
searchTips.classList.add('active');
}
// ========================================
// 地图点击添加位置
// ========================================
function handleMapClick(e) {
const lnglat = e.lnglat;
// 逆地理编码获取地址
AMap.plugin('AMap.Geocoder', () => {
const geocoder = new AMap.Geocoder();
geocoder.getAddress([lnglat.lng, lnglat.lat], (status, result) => {
if (status === 'complete' && result.info === 'OK') {
const address = result.regeocode.formattedAddress;
const location = {
name: `位置 ${state.locations.length + 1}`,
address: address,
coordinate: {
lng: lnglat.lng,
lat: lnglat.lat
}
};
addLocation(location);
}
});
});
}
// ========================================
// 位置管理
// ========================================
function addLocation(location) {
state.locations.push(location);
// 添加地图标记
const index = state.locations.length - 1;
const color = state.colors[index % state.colors.length];
const marker = new AMap.Marker({
position: [location.coordinate.lng, location.coordinate.lat],
content: `<div class="custom-marker" style="background: ${color}">${index + 1}</div>`,
offset: new AMap.Pixel(-18, -18)
});
marker.on('click', () => {
showInfoWindow(marker, location);
});
state.markers.push(marker);
state.map.add(marker);
// 调整视野
if (state.locations.length > 1) {
state.map.setFitView(state.markers);
} else {
state.map.setCenter([location.coordinate.lng, location.coordinate.lat]);
state.map.setZoom(14);
}
// 更新UI
updateLocationList();
updateSearchButton();
// 清除之前的搜索结果
clearSearchResults();
}
function removeLocation(index) {
// 移除标记
state.map.remove(state.markers[index]);
state.markers.splice(index, 1);
state.locations.splice(index, 1);
// 更新剩余标记的编号和颜色
state.markers.forEach((marker, i) => {
const color = state.colors[i % state.colors.length];
marker.setContent(`<div class="custom-marker" style="background: ${color}">${i + 1}</div>`);
});
// 更新UI
updateLocationList();
updateSearchButton();
// 调整视野
if (state.markers.length > 0) {
state.map.setFitView(state.markers);
}
// 清除之前的搜索结果
clearSearchResults();
}
function updateLocationList() {
const listEl = document.getElementById('locationList');
const countEl = document.getElementById('locationCount');
countEl.textContent = state.locations.length;
if (state.locations.length === 0) {
listEl.innerHTML = `
<li class="empty-state">
<span class="empty-icon">🗺</span>
<span>还没有添加位置</span>
<span class="empty-hint">搜索地址或点击地图开始</span>
</li>
`;
return;
}
listEl.innerHTML = state.locations.map((loc, index) => {
const color = state.colors[index % state.colors.length];
return `
<li class="location-item">
<div class="location-marker" style="background: ${color}">${index + 1}</div>
<div class="location-info">
<div class="location-name">${loc.name}</div>
<div class="location-address">${loc.address || '暂无地址'}</div>
</div>
<button class="location-remove" onclick="removeLocation(${index})">×</button>
</li>
`;
}).join('');
}
function updateSearchButton() {
const btn = document.getElementById('searchBtn');
btn.disabled = state.locations.length < 2;
}
// ========================================
// 搜索功能
// ========================================
async function handleSearch() {
const keywords = document.getElementById('poiKeywords').value.trim();
const radius = parseInt(document.getElementById('searchRadius').value);
if (!keywords) {
alert('请输入搜索关键词');
return;
}
if (state.locations.length < 2) {
alert('请至少添加2个位置');
return;
}
showLoading(true);
try {
// 1. 计算中心点
const centerResponse = await fetch('/api/center', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locations: state.locations })
});
const centerData = await centerResponse.json();
state.center = centerData.center;
// 显示中心点标记
showCenterMarker();
// 2. 搜索周边POI
const searchResponse = await fetch('/api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
center: state.center,
keywords: keywords,
radius: radius
})
});
const searchData = await searchResponse.json();
// 显示搜索结果
showSearchResults(searchData.pois, radius);
} catch (error) {
console.error('搜索失败:', error);
alert('搜索失败,请稍后重试');
} finally {
showLoading(false);
}
}
function showCenterMarker() {
// 移除旧的中心点标记
if (state.centerMarker) {
state.map.remove(state.centerMarker);
}
// 创建新的中心点标记
state.centerMarker = new AMap.Marker({
position: [state.center.lng, state.center.lat],
content: '<div class="center-marker">⭐</div>',
offset: new AMap.Pixel(-24, -24),
zIndex: 200
});
state.map.add(state.centerMarker);
// 显示中心点信息
document.getElementById('centerInfo').style.display = 'block';
}
function showSearchResults(pois, radius) {
// 清除旧的POI标记
clearPOIMarkers();
// 显示搜索范围圆
if (state.searchCircle) {
state.map.remove(state.searchCircle);
}
state.searchCircle = new AMap.Circle({
center: [state.center.lng, state.center.lat],
radius: radius,
fillColor: '#f59e0b',
fillOpacity: 0.1,
strokeColor: '#f59e0b',
strokeWeight: 2,
strokeOpacity: 0.6
});
state.map.add(state.searchCircle);
// 添加POI标记
pois.forEach((poi, index) => {
const marker = new AMap.Marker({
position: [poi.location.lng, poi.location.lat],
content: `<div class="poi-marker">${index + 1}</div>`,
offset: new AMap.Pixel(-16, -16),
zIndex: 100
});
marker.on('click', () => {
showPOIInfoWindow(marker, poi);
highlightResult(index);
});
state.poiMarkers.push(marker);
state.map.add(marker);
});
// 渲染结果列表
renderResultList(pois);
// 调整视野(右侧留出空间给浮动面板)
const allMarkers = [...state.markers, state.centerMarker, ...state.poiMarkers];
state.map.setFitView(allMarkers, false, [80, 420, 80, 80]);
}
function renderResultList(pois) {
const panel = document.getElementById('floatingResults');
const list = document.getElementById('floatingResultList');
const count = document.getElementById('floatingResultCount');
const filterInput = document.getElementById('resultFilter');
// 保存POI数据供筛选使用
state.currentPOIs = pois;
count.textContent = pois.length;
if (pois.length === 0) {
list.innerHTML = `
<li class="floating-result-empty">
<span class="icon">😕</span>
<span>未找到相关地点</span>
<span style="font-size: 0.8rem; opacity: 0.7;">尝试增大搜索半径或更换关键词</span>
</li>
`;
} else {
list.innerHTML = pois.map((poi, index) => {
const dist = parseDistance(poi.distance);
return `
<li class="floating-result-item" data-index="${index}" data-name="${poi.name}">
<div class="rank">${index + 1}</div>
<div class="info">
<div class="name">${poi.name}</div>
<div class="address">${poi.address || poi.type}</div>
${poi.tel ? `<div class="tel">📞 <a href="tel:${poi.tel}" onclick="event.stopPropagation()">${poi.tel}</a></div>` : ''}
</div>
<div class="distance-badge">
<span class="num">${dist.value}</span>
<span class="unit">${dist.unit}</span>
</div>
</li>
`}).join('');
// 绑定点击事件
list.querySelectorAll('.floating-result-item').forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const index = parseInt(item.dataset.index);
focusPOI(index);
});
});
}
panel.style.display = 'flex';
filterInput.value = '';
// 绑定关闭按钮事件
document.getElementById('closeFloatingResults').onclick = () => {
panel.style.display = 'none';
};
// 绑定筛选事件
filterInput.oninput = (e) => {
const keyword = e.target.value.trim().toLowerCase();
filterResults(keyword);
};
}
// 解析距离为数值和单位
function parseDistance(distance) {
const d = parseInt(distance);
if (d >= 1000) {
return { value: (d / 1000).toFixed(1), unit: '公里' };
}
return { value: d, unit: '米' };
}
// 筛选结果
function filterResults(keyword) {
const items = document.querySelectorAll('.floating-result-item');
let visibleCount = 0;
items.forEach(item => {
const name = item.dataset.name?.toLowerCase() || '';
const address = item.querySelector('.address')?.textContent.toLowerCase() || '';
if (!keyword || name.includes(keyword) || address.includes(keyword)) {
item.classList.remove('hidden');
visibleCount++;
} else {
item.classList.add('hidden');
}
});
// 更新显示数量
document.getElementById('floatingResultCount').textContent = visibleCount;
}
function formatDistance(distance) {
const d = parseInt(distance);
if (d >= 1000) {
return (d / 1000).toFixed(1) + ' 公里';
}
return d + ' 米';
}
function focusPOI(index) {
const marker = state.poiMarkers[index];
const poi = state.currentPOIs[index];
if (marker && poi) {
// 先高亮结果
highlightResult(index);
// 关闭当前信息窗口
state.infoWindow.close();
// 使用 panTo 平滑移动到目标位置
const position = marker.getPosition();
state.map.panTo(position);
// 延迟设置缩放和打开信息窗口,确保地图移动完成
setTimeout(() => {
state.map.setZoom(16);
showPOIInfoWindow(marker, poi);
}, 300);
}
}
function highlightResult(index) {
document.querySelectorAll('.floating-result-item').forEach((item, i) => {
item.classList.toggle('active', i === index);
});
// 滚动到对应项
const activeItem = document.querySelector(`.floating-result-item[data-index="${index}"]`);
if (activeItem) {
activeItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
// ========================================
// 信息窗口
// ========================================
function showInfoWindow(marker, location) {
const content = `
<div class="info-window">
<h3>${location.name}</h3>
<p>${location.address || '暂无地址'}</p>
</div>
`;
state.infoWindow.setContent(content);
state.infoWindow.open(state.map, marker.getPosition());
}
function showPOIInfoWindow(marker, poi) {
const distText = formatDistance(poi.distance);
const navUrl = `https://uri.amap.com/navigation?to=${poi.location.lng},${poi.location.lat},${encodeURIComponent(poi.name)}&mode=car&coordinate=gaode`;
const content = `
<div class="info-window">
<h3>${poi.name}</h3>
<p class="distance">🎯 距中心点: ${distText}</p>
${poi.address ? `<p>📍 ${poi.address}</p>` : ''}
${poi.tel ? `<p class="tel">📞 <a href="tel:${poi.tel}">${poi.tel}</a></p>` : ''}
<a href="${navUrl}" target="_blank" class="nav-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L4.5 20.29l.71.71L12 18l6.79 3 .71-.71L12 2z"/>
</svg>
导航前往
</a>
</div>
`;
state.infoWindow.setContent(content);
state.infoWindow.open(state.map, marker.getPosition());
}
// ========================================
// 工具函数
// ========================================
function clearSearchResults() {
// 隐藏结果区域
document.getElementById('floatingResults').style.display = 'none';
document.getElementById('centerInfo').style.display = 'none';
document.getElementById('resultFilter').value = '';
// 清除标记
clearPOIMarkers();
if (state.centerMarker) {
state.map.remove(state.centerMarker);
state.centerMarker = null;
}
if (state.searchCircle) {
state.map.remove(state.searchCircle);
state.searchCircle = null;
}
state.center = null;
state.currentPOIs = [];
}
function clearPOIMarkers() {
state.poiMarkers.forEach(marker => {
state.map.remove(marker);
});
state.poiMarkers = [];
}
function showLoading(show) {
document.getElementById('loadingOverlay').style.display = show ? 'flex' : 'none';
}
// ========================================
// 暴露全局函数
// ========================================
window.removeLocation = removeLocation;
window.focusPOI = focusPOI;

137
templates/index.html Normal file
View File

@ -0,0 +1,137 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>会面点 - 寻找最佳聚会地点</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
</head>
<body>
<div class="app-container">
<!-- 侧边栏 -->
<aside class="sidebar">
<div class="logo">
<div class="logo-icon">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" fill="currentColor"/>
<circle cx="12" cy="9" r="2.5" fill="#1a1a2e"/>
</svg>
</div>
<h1>会面点</h1>
</div>
<div class="sidebar-content">
<!-- 位置列表 -->
<section class="section locations-section">
<div class="section-header">
<h2>📍 参与者位置</h2>
<span class="badge" id="locationCount">0</span>
</div>
<!-- 搜索添加位置 -->
<div class="search-box">
<div class="search-input-wrapper">
<svg class="search-icon" viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/>
<path d="m16 16 4 4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<input type="text" id="searchInput" placeholder="搜索地址或地点名称..." autocomplete="off">
<button class="clear-btn" id="clearSearch" style="display: none;">×</button>
</div>
<div class="search-tips" id="searchTips"></div>
</div>
<p class="help-text">💡 点击地图也可以添加位置</p>
<ul class="location-list" id="locationList">
<li class="empty-state">
<span class="empty-icon">🗺️</span>
<span>还没有添加位置</span>
<span class="empty-hint">搜索地址或点击地图开始</span>
</li>
</ul>
</section>
<!-- 搜索设置 -->
<section class="section search-section">
<div class="section-header">
<h2>🎯 寻找目的地</h2>
</div>
<div class="form-group">
<label for="poiKeywords">搜索类型</label>
<div class="keywords-input">
<input type="text" id="poiKeywords" placeholder="例如咖啡馆、餐厅、KTV...">
</div>
<div class="quick-tags">
<button class="tag" data-keyword="咖啡馆">☕ 咖啡馆</button>
<button class="tag" data-keyword="餐厅">🍽️ 餐厅</button>
<button class="tag" data-keyword="网咖">🎮 网咖</button>
<button class="tag" data-keyword="棋牌室">🎲 棋牌室</button>
<button class="tag" data-keyword="KTV">🎤 KTV</button>
<button class="tag" data-keyword="电影院">🎬 电影院</button>
<button class="tag" data-keyword="健身房">💪 健身房</button>
<button class="tag" data-keyword="书店">📚 书店</button>
</div>
</div>
<div class="form-group">
<label for="searchRadius">搜索半径</label>
<div class="radius-slider">
<input type="range" id="searchRadius" min="500" max="10000" step="500" value="3000">
<span class="radius-value" id="radiusValue">3 公里</span>
</div>
</div>
<button class="btn btn-primary btn-search" id="searchBtn" disabled>
<svg viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/>
<path d="m16 16 5 5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
搜索最佳会面点
</button>
</section>
</div>
</aside>
<!-- 地图区域 -->
<main class="map-area">
<div id="mapContainer"></div>
<!-- 中心点信息 -->
<div class="center-info" id="centerInfo" style="display: none;">
<div class="center-badge">
<span class="center-icon"></span>
<span>几何中心点</span>
</div>
</div>
<!-- 浮动搜索结果面板 -->
<div class="floating-results" id="floatingResults" style="display: none;">
<div class="floating-results-header">
<h3>🎯 搜索结果</h3>
<span class="result-count" id="floatingResultCount">0</span>
<button class="close-btn" id="closeFloatingResults">×</button>
</div>
<div class="floating-results-filter">
<input type="text" id="resultFilter" placeholder="🔍 筛选结果...">
</div>
<ul class="floating-result-list" id="floatingResultList"></ul>
</div>
<!-- 加载提示 -->
<div class="loading-overlay" id="loadingOverlay" style="display: none;">
<div class="loading-spinner"></div>
<span>正在搜索...</span>
</div>
</main>
</div>
<!-- 应用脚本 -->
<script src="/static/js/app.js"></script>
</body>
</html>