添加根据经纬度获取城市

添加后台统计看板
This commit is contained in:
2025-12-26 16:26:15 +08:00
parent 028886c1fe
commit 6963216a85
9 changed files with 1668 additions and 8 deletions

View File

@@ -42,9 +42,21 @@ class Ad extends AdminBase
}
$this->_error($result);
}
$this->assign('category_list',AdLogic::getGoodsCategory());
// 确保 client 参数有效
if (empty($client)) {
$client = $this->request->param('client', '1');
}
$category_list = AdLogic::getGoodsCategory();
$link_page = \app\common\model\Ad::getLinkPage($client);
$position_list = AdLogic::infoPosition($client);
$this->assign('category_list', AdLogic::getGoodsCategory());
$this->assign('link_page', \app\common\model\Ad::getLinkPage($client));
$this->assign('position_list', AdLogic::infoPosition($client));
$this->assign([
'category_list' => $category_list,
'link_page' => $link_page,
'position_list' => $position_list,
]);
return $this->fetch();
}

View File

@@ -0,0 +1,124 @@
<?php
namespace app\admin\controller;
use app\admin\model\User;
use app\common\server\UrlServer;
use think\facade\Url;
class BulletinBoard extends AdminBase
{
//看板统计
public function index()
{
return $this->fetch();
}
//地图上显示注册用户位置
public function map()
{
return $this->fetch();
}
public function getUserMap()
{
// 查询有经纬度的用户
$userArray = User::where('latitude', '<>', '')
->where('longitude', '<>', '')
->field('id,nickname,mobile,avatar,longitude,latitude')
->select();
// 格式化数据,转换为前端期望的字段名
$result = [];
foreach ($userArray as $user) {
// 处理头像URL使用代理接口避免CORS问题
$avatar = '';
if (!empty($user['avatar'])) {
$originalUrl = UrlServer::getFileUrl($user['avatar']);
// 去掉JSON编码时产生的转义反斜杠
$originalUrl = str_replace('\\/', '/', $originalUrl);
// 如果URL是跨域的使用代理接口
$currentDomain = $this->request->domain();
if (strpos($originalUrl, $currentDomain) === false) {
// 跨域使用代理手动构建URL避免双重编码
$avatar = $this->request->domain() . '/admin/bulletin_board/proxyImage?url=' . rawurlencode($originalUrl);
} else {
// 同域,直接使用
$avatar = $originalUrl;
}
}
$result[] = [
'id' => $user['id'] ?? 0,
'lng' => floatval($user['longitude'] ?? 0),
'lat' => floatval($user['latitude'] ?? 0),
'name' => $user['nickname'] ?? '',
'contact' => $user['nickname'] ?? '', // 兼容两种字段名
'mobile' => $user['mobile'] ?? '',
'phone' => $user['mobile'] ?? '', // 兼容 telephone/phone
'telephone' => $user['mobile'] ?? '',
'avatar' => $avatar
];
}
return $this->_success('获取成功', $result);
}
/**
* 图片代理接口解决CORS跨域问题
*/
public function proxyImage()
{
$url = $this->request->get('url', '');
if (empty($url)) {
header('HTTP/1.1 404 Not Found');
exit;
}
// 解码URL
$url = urldecode($url);
// 验证URL格式
if (!filter_var($url, FILTER_VALIDATE_URL)) {
header('HTTP/1.1 400 Bad Request');
exit;
}
// 只允许图片格式
$allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
$extension = strtolower(pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION));
if (!in_array($extension, $allowedExtensions)) {
header('HTTP/1.1 403 Forbidden');
exit;
}
// 获取图片
$context = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => 10,
'header' => [
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
]
]
]);
$imageData = @file_get_contents($url, false, $context);
if ($imageData === false) {
header('HTTP/1.1 404 Not Found');
exit;
}
// 设置响应头
header('Content-Type: image/' . ($extension === 'jpg' ? 'jpeg' : $extension));
header('Cache-Control: public, max-age=3600');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET');
// 输出图片
echo $imageData;
exit;
}
}

View File

@@ -218,10 +218,37 @@ class AdLogic
*/
public static function infoPosition($client)
{
$position_list = Db::name('ad_position')
->where(['client' => $client, 'status' => 1, 'del' => 0])
->group('name')
->column('id,name', 'id');
$position_list = [];
// 使用错误抑制,避免 unserialize 警告影响页面
$originalErrorReporting = error_reporting(0);
try {
// 使用原始 SQL 查询,完全绕过 ThinkPHP 的类型转换
$prefix = config('database.prefix');
$tableName = $prefix . 'ad_position';
$sql = "SELECT DISTINCT `id`, `name` FROM `{$tableName}` WHERE `client` = " . intval($client) . " AND `status` = 1 AND `del` = 0 GROUP BY `name`";
$result = Db::query($sql);
if (!empty($result) && is_array($result)) {
foreach ($result as $row) {
if (isset($row['id']) && isset($row['name'])) {
$position_list[$row['id']] = $row['name'];
}
}
}
} catch (\Exception $e) {
// 忽略所有异常
} catch (\Error $e) {
// 忽略所有错误
} catch (\Throwable $e) {
// 捕获所有可抛出的对象
} finally {
// 恢复错误报告级别
error_reporting($originalErrorReporting);
}
asort($position_list);
return $position_list;
}

View File

@@ -14,3 +14,4 @@ class Invoice extends Model
}
}

View File

@@ -0,0 +1,639 @@
{layout name="layout1" /}
<style>
.dashboard-container {
padding: 15px;
}
.kpi-card {
background: #fff;
padding: 20px;
border-radius: 4px;
margin-bottom: 15px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.kpi-title {
font-size: 14px;
color: #666;
margin-bottom: 10px;
}
.kpi-value {
font-size: 28px;
font-weight: bold;
color: #333;
margin-bottom: 5px;
}
.kpi-compare {
font-size: 12px;
color: #999;
}
.chart-card {
background: #fff;
padding: 20px;
border-radius: 4px;
margin-bottom: 15px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.chart-title {
font-size: 16px;
font-weight: bold;
margin-bottom: 15px;
color: #333;
}
.chart-container {
height: 300px;
}
.chart-container-large {
height: 400px;
}
.progress-item {
margin-bottom: 15px;
}
.progress-label {
display: flex;
justify-content: space-between;
margin-bottom: 5px;
font-size: 14px;
}
.progress-bar-container {
height: 20px;
background: #f0f0f0;
border-radius: 10px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #4A70F4 0%, #6DD047 100%);
transition: width 0.3s;
}
.table-card {
background: #fff;
padding: 20px;
border-radius: 8px;
margin-bottom: 15px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
overflow: hidden;
}
.date-filter {
display: flex;
align-items: center;
gap: 10px;
}
/* 优化表格样式 */
.table-card .layui-table {
margin-top: 0;
border: none;
}
.table-card .layui-table thead tr {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.table-card .layui-table thead th {
color: #fff;
font-weight: 600;
text-align: center;
padding: 15px 10px;
border: none;
font-size: 14px;
}
.table-card .layui-table tbody tr {
transition: all 0.3s ease;
border-bottom: 1px solid #f0f0f0;
}
.table-card .layui-table tbody tr:hover {
background-color: #f8f9ff;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.table-card .layui-table tbody td {
text-align: center;
padding: 12px 10px;
border: none;
color: #333;
font-size: 13px;
}
.table-card .layui-table tbody td:first-child {
font-weight: 600;
color: #4A70F4;
background-color: #f8f9ff;
}
.table-card .layui-table tbody td.empty-data {
color: #999;
font-style: italic;
}
.table-card .chart-title {
display: flex;
align-items: center;
padding-bottom: 15px;
border-bottom: 2px solid #f0f0f0;
margin-bottom: 20px;
}
.table-card .chart-title::before {
content: '';
display: inline-block;
width: 4px;
height: 18px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
margin-right: 10px;
border-radius: 2px;
}
</style>
<div class="layui-fluid dashboard-container">
<!-- 顶部KPI指标 -->
<div class="layui-row layui-col-space15">
<div class="layui-col-md3">
<div class="kpi-card">
<div class="kpi-title">月销售总额</div>
<div class="kpi-value" id="monthly-sales">--</div>
<div class="kpi-compare">同比: <span id="sales-compare">--</span></div>
</div>
</div>
<div class="layui-col-md3">
<div class="kpi-card">
<div class="kpi-title">月订单量</div>
<div class="kpi-value" id="monthly-orders">--</div>
<div class="kpi-compare">同比: <span id="orders-compare">--</span></div>
</div>
</div>
<div class="layui-col-md3">
<div class="kpi-card">
<div class="kpi-title">月客户数</div>
<div class="kpi-value" id="monthly-customers">--</div>
<div class="kpi-compare">同比: <span id="customers-compare">--</span></div>
</div>
</div>
<div class="layui-col-md3">
<div class="kpi-card">
<div class="kpi-title">日期筛选</div>
<div class="date-filter">
<input type="text" class="layui-input" id="date-filter" placeholder="年月日(可筛选)">
</div>
</div>
</div>
</div>
<!-- 第一行图表 -->
<div class="layui-row layui-col-space15">
<!-- 月客户分析 -->
<div class="layui-col-md4">
<div class="chart-card">
<div class="chart-title">月客户分析</div>
<div class="chart-container" id="customer-analysis-chart"></div>
</div>
</div>
<!-- 月销售情况 -->
<div class="layui-col-md4">
<div class="chart-card">
<div class="chart-title">月销售情况</div>
<div class="chart-container" id="sales-situation-chart"></div>
</div>
</div>
<!-- 当月客户渠道分析 -->
<div class="layui-col-md4">
<div class="chart-card">
<div class="chart-title">当月客户渠道分析</div>
<div class="chart-container" id="channel-analysis-chart"></div>
</div>
</div>
</div>
<!-- 第二行:目标完成率 -->
<div class="layui-row layui-col-space15">
<div class="layui-col-md12">
<div class="chart-card">
<div class="chart-title">当月目标完成率</div>
<div style="margin-bottom: 20px;">
<div style="font-size: 14px; color: #666; margin-bottom: 10px;">本月销售目标: <span style="color: #4A70F4; font-weight: bold;">10万</span></div>
<div class="progress-item">
<div class="progress-label">
<span>总体完成率</span>
<span style="color: #4A70F4; font-weight: bold;">84%</span>
</div>
<div class="progress-bar-container">
<div class="progress-bar" style="width: 84%;"></div>
</div>
</div>
</div>
<div style="margin-top: 30px;">
<div class="chart-title" style="font-size: 14px; margin-bottom: 15px;">客服目标完成率(N个客服的目标陈列)</div>
<div id="staff-progress-list">
<!-- 客服进度条列表 -->
<div class="progress-item">
<div class="progress-label">
<span>啦啦</span>
<span>销售额: XXX | 百分比: xx% | 同比: xx%</span>
</div>
<div class="progress-bar-container">
<div class="progress-bar" style="width: 0%;"></div>
</div>
</div>
<div class="progress-item">
<div class="progress-label">
<span>小喵</span>
<span>销售额: XXX | 百分比: xx% | 同比: xx%</span>
</div>
<div class="progress-bar-container">
<div class="progress-bar" style="width: 0%;"></div>
</div>
</div>
<div class="progress-item">
<div class="progress-label">
<span>哆哆</span>
<span>销售额: XXX | 百分比: xx% | 同比: xx%</span>
</div>
<div class="progress-bar-container">
<div class="progress-bar" style="width: 0%;"></div>
</div>
</div>
<div class="progress-item">
<div class="progress-label">
<span>大猫</span>
<span>销售额: XXX | 百分比: xx% | 同比: xx%</span>
</div>
<div class="progress-bar-container">
<div class="progress-bar" style="width: 0%;"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 第三行:趋势图和区域图 -->
<div class="layui-row layui-col-space15">
<!-- 每日销售趋势 -->
<div class="layui-col-md6">
<div class="chart-card">
<div class="chart-title">每日销售趋势</div>
<div style="text-align: right; margin-bottom: 10px; color: #f56c6c; font-size: 14px;">
▼ 29.23% 上月同比
</div>
<div class="chart-container-large" id="daily-sales-trend-chart"></div>
</div>
</div>
<!-- 区域月销售额 -->
<div class="layui-col-md6">
<div class="chart-card">
<div class="chart-title">区域月销售额</div>
<div class="chart-container-large" id="regional-sales-chart"></div>
</div>
</div>
</div>
<!-- 第四行:数据表格 -->
<div class="layui-row layui-col-space15">
<!-- 渠道月销售详情 -->
<div class="layui-col-md12">
<div class="table-card">
<div class="chart-title">渠道月销售详情</div>
<table class="layui-table" lay-size="sm">
<thead>
<tr>
<th>渠道</th>
<th>美团</th>
<th>公众号</th>
<th>抖音</th>
<th>员工</th>
<th>异业</th>
<th>渠道</th>
</tr>
</thead>
<tbody>
<tr>
<td>总额</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
</tr>
<tr>
<td>年卡</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
</tr>
<tr>
<td>次卡</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
</tr>
<tr>
<td>单次服务</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
</tr>
<tr>
<td>其他服务</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 第五行:区域月销售详情 -->
<div class="layui-row layui-col-space15">
<div class="layui-col-md12">
<div class="table-card">
<div class="chart-title">区域月销售详情</div>
<table class="layui-table" lay-size="sm">
<thead>
<tr>
<th>服务类型</th>
<th>南明区</th>
<th>云岩区</th>
<th>白云区</th>
<th>乌当区</th>
<th>花溪区</th>
<th>龙里</th>
</tr>
</thead>
<tbody>
<tr>
<td>总额</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
</tr>
<tr>
<td>年卡</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
</tr>
<tr>
<td>次卡</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
</tr>
<tr>
<td>单次服务</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
</tr>
<tr>
<td>其他服务</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
<td class="empty-data">-</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<script>
layui.config({
version:"{$front_version}",
base: '/static/plug/layui-admin/dist/layuiadmin/'
}).extend({
index: 'lib/index'
}).use(['index', 'laydate'], function(){
var $ = layui.$
,laydate = layui.laydate;
// 日期选择器
laydate.render({
elem: '#date-filter',
type: 'date',
format: 'yyyy-MM-dd'
});
// 初始化所有图表(仅样式,不填充数据)
initCharts();
function initCharts() {
// 月客户分析 - 饼图
var customerChart = echarts.init(document.getElementById('customer-analysis-chart'));
customerChart.setOption({
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
legend: {
orient: 'vertical',
left: 'left'
},
series: [{
name: '客户分析',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: true,
formatter: '{b}: {d}%'
},
data: [
{value: 28, name: '老客', itemStyle: {color: '#FFB6C1'}},
{value: 72, name: '新客', itemStyle: {color: '#FF69B4'}}
]
}]
});
// 月销售情况 - 饼图
var salesChart = echarts.init(document.getElementById('sales-situation-chart'));
salesChart.setOption({
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
legend: {
bottom: '5%',
left: 'center'
},
series: [{
name: '销售情况',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: true,
formatter: '{b}: {c}'
},
data: [
{value: 8.2, name: '年卡销售', itemStyle: {color: '#4A70F4'}},
{value: 3.2, name: '次卡销售', itemStyle: {color: '#6DD047'}},
{value: 1.4, name: '单次服务', itemStyle: {color: '#F6A23F'}},
{value: 1.2, name: '其他服务', itemStyle: {color: '#87CEEB'}}
]
}]
});
// 当月客户渠道分析 - 环形图
var channelChart = echarts.init(document.getElementById('channel-analysis-chart'));
channelChart.setOption({
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
legend: {
orient: 'vertical',
left: 'left'
},
series: [{
name: '客户渠道',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: true,
formatter: '{b}: {d}%'
},
data: [
{value: 0, name: '美团'},
{value: 24, name: '公众号-企业客户'},
{value: 0, name: '抖音'},
{value: 0, name: '渠道'},
{value: 0, name: '员工'},
{value: 0, name: '异业'},
{value: 0, name: '普通客户'}
]
}]
});
// 每日销售趋势 - 折线图
var trendChart = echarts.init(document.getElementById('daily-sales-trend-chart'));
trendChart.setOption({
tooltip: {
trigger: 'axis'
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: []
},
yAxis: {
type: 'value',
name: '销售额',
axisLabel: {
formatter: '{value}K'
}
},
series: [{
name: '销售额',
type: 'line',
smooth: true,
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [{
offset: 0, color: 'rgba(74, 112, 244, 0.3)'
}, {
offset: 1, color: 'rgba(74, 112, 244, 0.1)'
}]
}
},
itemStyle: {
color: '#4A70F4'
},
data: []
}]
});
// 区域月销售额 - 柱状图
var regionalChart = echarts.init(document.getElementById('regional-sales-chart'));
regionalChart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: ['区域1', '区域2', '区域3', '区域4', '区域5', '区域6', '区域7', '区域8']
},
yAxis: {
type: 'value',
name: '销量'
},
series: [{
name: '销量',
type: 'bar',
itemStyle: {
color: '#4A70F4'
},
data: [4668, 3775, 2912, 2200, 1259, 700, 403, 0]
}]
});
// 响应式调整
window.addEventListener('resize', function() {
customerChart.resize();
salesChart.resize();
channelChart.resize();
trendChart.resize();
regionalChart.resize();
});
}
});
</script>

View File

@@ -0,0 +1,645 @@
{layout name="layout1" /}
<style>
.map-container {
padding: 15px;
min-height: calc(100vh - 60px);
}
.map-header {
background: #fff;
padding: 15px 20px;
border-radius: 8px;
margin-bottom: 15px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
display: flex;
align-items: center;
justify-content: space-between;
}
.map-header-left {
display: flex;
align-items: center;
gap: 15px;
}
.map-header-title {
font-size: 18px;
font-weight: bold;
color: #333;
}
.map-header-stats {
display: flex;
gap: 20px;
align-items: center;
}
.stat-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 15px;
background: #f8f9ff;
border-radius: 6px;
}
.stat-label {
font-size: 13px;
color: #666;
}
.stat-value {
font-size: 16px;
font-weight: bold;
color: #4A70F4;
}
.map-search-box {
display: flex;
gap: 10px;
align-items: center;
}
.map-search-input {
width: 300px;
}
#maplocation {
width: 100%;
height: calc(100vh - 200px);
min-height: 600px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
background: #f5f5f5;
}
/* 头像标记点样式 */
.tmap-marker-avatar {
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
object-fit: cover;
}
.info-window {
padding: 10px;
min-width: 150px;
}
.info-window-title {
font-size: 14px;
font-weight: bold;
color: #333;
margin-bottom: 8px;
border-bottom: 1px solid #eee;
padding-bottom: 5px;
}
.info-window-item {
font-size: 12px;
color: #666;
margin: 5px 0;
line-height: 1.6;
}
.info-window-item strong {
color: #333;
margin-right: 5px;
}
.map-controls {
position: absolute;
top: 20px;
right: 20px;
z-index: 1000;
background: #fff;
padding: 10px;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.map-control-btn {
display: block;
width: 100%;
margin-bottom: 8px;
padding: 8px 15px;
text-align: center;
background: #4A70F4;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
transition: all 0.3s;
}
.map-control-btn:hover {
background: #3a5fd4;
}
.map-control-btn:last-child {
margin-bottom: 0;
}
.map-legend {
position: absolute;
bottom: 20px;
left: 20px;
z-index: 1000;
background: rgba(255,255,255,0.95);
padding: 12px 15px;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
font-size: 12px;
color: #666;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
margin: 5px 0;
}
.legend-color {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
</style>
<div class="map-container">
<!-- 地图头部 -->
<div class="map-header">
<div class="map-header-left">
<div class="map-header-title">用户位置分布图</div>
<div class="map-header-stats">
<div class="stat-item">
<span class="stat-label">总用户数:</span>
<span class="stat-value" id="total-users">0</span>
</div>
<div class="stat-item">
<span class="stat-label">已定位:</span>
<span class="stat-value" id="located-users">0</span>
</div>
</div>
</div>
<div class="map-search-box">
<input type="text" id="search_key" class="layui-input map-search-input" placeholder="请输入地名搜索">
<!-- <button class="layui-btn layui-btn-normal searchKey">搜索</button>-->
<button class="layui-btn layui-btn-primary" id="reset-map">重置视图</button>
</div>
</div>
<!-- 地图容器 -->
<div style="position: relative;">
<div id="maplocation"></div>
<!-- 地图控制按钮 -->
<div class="map-controls">
<button class="map-control-btn" id="zoom-in">放大</button>
<button class="map-control-btn" id="zoom-out">缩小</button>
<button class="map-control-btn" id="fit-bounds">适应所有标记</button>
<button class="map-control-btn" id="clear-markers">清除标记</button>
</div>
<!-- 图例 -->
<div class="map-legend">
<div style="font-weight: bold; margin-bottom: 8px; color: #333;">图例</div>
<div class="legend-item">
<div class="legend-color" style="background: #4A70F4;"></div>
<span>用户位置</span>
</div>
</div>
</div>
</div>
<script src="/static/webjs/jquery.min.js"></script>
<script charset="utf-8" src="https://map.qq.com/api/gljs?v=1.exp&libraries=service,tools&key=EVOBZ-VX7YU-QKJVR-BVESA-AVFT3-7JBWG" onload="console.log('腾讯地图API脚本加载完成')" onerror="console.error('腾讯地图API脚本加载失败')"></script>
<script>
layui.config({
version:"{$front_version}",
base: '/static/plug/layui-admin/dist/layuiadmin/'
}).extend({
index: 'lib/index'
}).use(['index'], function(){
var $ = layui.$;
// 用户数据(将从后端获取)
var users = [];
var markers = null;
var map = null;
var geocoder = null;
var infoWindows = [];
var defaultCenter = null; // 默认中心点(贵阳)
var defaultZoom = 12;
var markerClickHandler = null; // 保存点击事件处理函数
// 初始化地图
function initMap() {
try {
// 检查TMap是否已加载
if (typeof TMap === 'undefined') {
console.error('腾讯地图API未加载');
layer.msg('地图加载失败,请刷新页面重试', {icon: 2, time: 3000});
return;
}
// 初始化默认中心点
if (!defaultCenter) {
defaultCenter = new TMap.LatLng(26.647, 106.63); // 默认中心点(贵阳)
}
console.log('开始初始化地图...');
map = new TMap.Map('maplocation', {
zoom: defaultZoom,
center: defaultCenter
});
console.log('地图对象创建成功');
// 初始化标记点(样式将在渲染时动态创建)
markers = new TMap.MultiMarker({
map: map,
styles: {},
geometries: []
});
console.log('标记点对象创建成功');
// 初始化地理编码服务
geocoder = new TMap.service.Geocoder();
// 等待地图加载完成
map.on('complete', function() {
console.log('地图加载完成');
// 加载用户数据
setTimeout(function() {
if (typeof loadUsers === 'function') {
loadUsers();
} else {
console.error('loadUsers函数未定义');
}
}, 500);
});
// 备用方案如果complete事件没有触发延迟后直接初始化
setTimeout(function() {
console.log('备用方案延迟调用loadUsers()');
if (typeof loadUsers === 'function') {
loadUsers();
} else {
console.error('loadUsers函数未定义');
}
}, 3000);
// 监听地图错误
map.on('error', function(e) {
console.error('地图错误:', e);
layer.msg('地图加载出错,请刷新页面重试', {icon: 2, time: 3000});
});
} catch (error) {
console.error('地图初始化失败:', error);
layer.msg('地图初始化失败:' + error.message, {icon: 2, time: 3000});
}
}
// 加载用户数据
function loadUsers() {
if (typeof $ === 'undefined') {
console.error('jQuery未加载');
return;
}
$.ajax({
url: '{:url("BulletinBoard/getUserMap")}',
type: 'GET',
dataType: 'json',
success: function(res) {
console.log('用户数据响应:', res);
if (res.code === 1 && res.data) {
users = res.data;
console.log('获取到用户数据:', users.length, '条');
updateStats();
renderMarkers();
} else {
console.warn('获取用户数据失败:', res.msg);
layer.msg(res.msg || '获取用户数据失败', {icon: 2});
users = [];
updateStats();
}
},
error: function(xhr, status, error) {
console.error('请求失败:', xhr, status, error);
console.error('响应内容:', xhr.responseText);
layer.msg('请求失败,请稍后重试', {icon: 2});
users = [];
updateStats();
}
});
}
// 将loadUsers暴露到全局作用域方便调试
window.loadUsers = loadUsers;
// 更新统计信息
function updateStats() {
var totalUsers = users.length;
var locatedUsers = users.filter(function(u) {
return u.lat && u.lng && u.lat != 0 && u.lng != 0;
}).length;
$('#total-users').text(totalUsers);
$('#located-users').text(locatedUsers);
}
// 渲染标记点
function renderMarkers() {
if (!markers || !map) {
console.warn('标记点或地图未初始化');
return;
}
// 清除现有标记和信息窗口
clearMarkers();
var geometries = [];
var validUsers = [];
var markerStyles = {};
// 过滤有效坐标的用户
users.forEach(function(user, index) {
if (!user.lat || !user.lng || user.lat == 0 || user.lng == 0) {
return;
}
var lat = parseFloat(user.lat);
var lng = parseFloat(user.lng);
if (isNaN(lat) || isNaN(lng)) {
console.warn('无效的经纬度:', user);
return;
}
validUsers.push(user);
// 为每个用户创建使用头像的标记点样式
var styleId = 'avatar_' + (user.id || index);
// 如果没有头像,使用默认标记图标
var avatarUrl = user.avatar && user.avatar.trim() !== ''
? user.avatar
: 'https://mapapi.qq.com/web/lbs/javascriptGL/demo/img/markerDefault.png';
// 创建头像标记样式(圆形头像,带边框)
markerStyles[styleId] = new TMap.MarkerStyle({
width: 40,
height: 40,
anchor: {x: 20, y: 20},
src: avatarUrl
});
// 创建标记点
geometries.push({
id: 'user_' + (user.id || index),
position: new TMap.LatLng(lat, lng),
styleId: styleId,
properties: {
title: user.contact || user.name || '用户' + (user.id || index),
user: user
}
});
});
console.log('有效用户数:', validUsers.length, '标记点数:', geometries.length);
// 更新标记样式和几何图形
if (geometries.length > 0) {
// 先更新样式
markers.setStyles(markerStyles);
// 再更新几何图形
markers.updateGeometries(geometries);
// 创建信息窗口(但不自动显示)
createInfoWindows(validUsers);
// 如果只有一个用户,居中显示
if (validUsers.length === 1) {
map.setCenter(new TMap.LatLng(validUsers[0].lat, validUsers[0].lng));
map.setZoom(15);
} else if (validUsers.length > 1) {
// 适应所有标记
fitBounds(validUsers);
}
} else {
console.warn('没有有效的用户位置数据');
}
}
// 创建信息窗口(但不立即添加到地图,只在点击时创建)
function createInfoWindows(users) {
// 先清除之前的事件监听器(如果存在)
if (markers && markerClickHandler) {
try {
markers.off('click', markerClickHandler);
} catch (e) {
console.warn('移除事件监听器失败:', e);
}
markerClickHandler = null;
}
// 点击标记显示信息窗口
markerClickHandler = function(evt) {
var geometry = evt.geometry;
if (geometry && geometry.properties) {
// 关闭所有信息窗口
infoWindows.forEach(function(item) {
if (item.window) {
try {
item.window.close();
if (item.window.destroy) {
item.window.destroy();
}
} catch (e) {
console.warn('关闭信息窗口失败:', e);
}
}
});
infoWindows = [];
// 从 properties 中直接获取用户数据
var user = geometry.properties.user;
// 如果没有,尝试从全局 users 数组查找
if (!user) {
var userId = geometry.id.replace('user_', '');
user = users.find(function(u) {
return (u.id || '').toString() === userId || users.indexOf(u).toString() === userId;
});
}
if (user) {
var lat = parseFloat(user.lat);
var lng = parseFloat(user.lng);
if (isNaN(lat) || isNaN(lng)) {
console.error('无效的经纬度:', user);
return;
}
var content = '<div class="info-window">' +
'<div class="info-window-title">' + (user.contact || user.name || '用户') + '</div>' +
'<div class="info-window-item"><strong>电话:</strong>' + (user.telephone || user.phone || '-') + '</div>' +
'<div class="info-window-item"><strong>地址:</strong>' + (user.address || '-') + '</div>';
content += '</div>';
// 只在点击时创建信息窗口
try {
var infoWindow = new TMap.InfoWindow({
map: map,
position: new TMap.LatLng(lat, lng),
content: content,
visible: true,
zIndex: 100
});
infoWindows.push({
window: infoWindow,
markerId: geometry.id
});
} catch (e) {
console.error('创建信息窗口失败:', e);
layer.msg('显示信息失败', {icon: 2});
}
} else {
console.warn('未找到用户数据:', geometry.id);
}
} else {
console.warn('标记点数据不完整:', geometry);
}
};
// 绑定事件监听器
if (markers && markerClickHandler) {
markers.on('click', markerClickHandler);
}
}
// 适应所有标记
function fitBounds(users) {
if (users.length === 0) return;
var bounds = new TMap.LatLngBounds();
users.forEach(function(user) {
bounds.extend(new TMap.LatLng(parseFloat(user.lat), parseFloat(user.lng)));
});
map.fitBounds(bounds);
}
// 清除标记
function clearMarkers() {
if (markers) {
markers.updateGeometries([]);
}
infoWindows.forEach(function(item) {
if (item.window) {
try {
item.window.close();
if (item.window.destroy) {
item.window.destroy();
} else if (item.window.setMap) {
item.window.setMap(null);
}
} catch (e) {
console.warn('清除信息窗口失败:', e);
}
}
});
infoWindows = [];
}
// 搜索功能
var searchService = {
search: function(name) {
if (!name || !name.trim()) {
layer.msg('请输入搜索关键词', {icon: 0});
return;
}
geocoder.getLocation({ address: name })
.then(function(result) {
if (result && result.result && result.result.location) {
var location = result.result.location;
map.setCenter(location);
map.setZoom(15);
// 添加临时标记
markers.updateGeometries([{
id: 'search_result',
position: location,
styleId: 'marker'
}]);
} else {
layer.msg('未找到该地点', {icon: 0});
}
})
.catch(function(error) {
layer.msg('搜索失败:' + (error.message || '未知错误'), {icon: 2});
});
}
};
// 事件绑定
$(document).on('click', '.searchKey', function() {
var searchKey = $("#search_key").val();
searchService.search(searchKey);
});
$('#search_key').on('keypress', function(e) {
if (e.which === 13) {
$('.searchKey').trigger('click');
}
});
$('#reset-map').on('click', function() {
map.setCenter(defaultCenter);
map.setZoom(defaultZoom);
renderMarkers();
});
$('#zoom-in').on('click', function() {
map.setZoom(map.getZoom() + 1);
});
$('#zoom-out').on('click', function() {
map.setZoom(map.getZoom() - 1);
});
$('#fit-bounds').on('click', function() {
var validUsers = users.filter(function(u) {
return u.lat && u.lng && u.lat != 0 && u.lng != 0;
});
if (validUsers.length > 0) {
fitBounds(validUsers);
} else {
layer.msg('没有可显示的用户位置', {icon: 0});
}
});
$('#clear-markers').on('click', function() {
clearMarkers();
layer.msg('已清除所有标记', {icon: 1});
});
// 等待腾讯地图API加载完成后再初始化
var retryCount = 0;
var maxRetries = 100; // 10秒
function waitForTMap() {
if (typeof TMap !== 'undefined' && typeof TMap.Map !== 'undefined') {
// 延迟一下确保API完全加载
setTimeout(function() {
initMap();
}, 300);
} else {
// 如果还没加载等待一段时间后重试最多等待10秒
if (retryCount < maxRetries) {
retryCount++;
setTimeout(waitForTMap, 100);
} else {
console.error('腾讯地图API加载超时');
layer.msg('地图API加载超时请刷新页面重试', {icon: 2, time: 3000});
}
}
}
// 初始化
$(function() {
// 等待地图API加载
waitForTMap();
});
});
</script>

View File

@@ -5,6 +5,7 @@ namespace app\api\controller;
use app\api\logic\AdLogic;
use app\common\server\UrlServer;
use think\Db;
header("Access-Control-Allow-Origin: *");
@@ -13,7 +14,7 @@ header("Access-Control-Allow-Headers: Content-Type, Authorization");
class Ad extends ApiBase
{
public $like_not_need_login = ['lists','channel','label','add_comost','add_comost','list_comost','follow_comost','comost_add','label_edit','comost_info','notice','position','position_list','vode_type','video_list','video_info','user_wages','user_wages_add','user_leave','fine','recruit','last_leave','last_fine','notice_list','leave','auth'];
public $like_not_need_login = ['lists','popup','channel','label','add_comost','add_comost','list_comost','follow_comost','comost_add','label_edit','comost_info','notice','position','position_list','vode_type','video_list','video_info','user_wages','user_wages_add','user_leave','fine','recruit','last_leave','last_fine','notice_list','leave','auth'];
/**
* @return void
@@ -31,6 +32,21 @@ class Ad extends ApiBase
$this->_success('获取成功', $list);
}
//获取弹窗
public function popup()
{
$list = Db::name('ad')
->where('status',1)
->where('pid',25)
->field('id,name,image,link_type,link')
->find();
if ($list != null){
$list['image'] = UrlServer::getFileUrl($list['image']);
}
$this->_success('获取成功', $list);
}
//获取客户的渠道
public function channel(){
$list=Db::name('staffchannel')->field('id,name')->select();

View File

@@ -3,6 +3,7 @@
namespace app\api\controller;
use app\api\server\CityService;
use app\api\model\User;
class City extends ApiBase
{
@@ -25,4 +26,37 @@ class City extends ApiBase
$array = $this->cityService->list();
return $this->_success('成功',$array);
}
/**
* 根据经纬度获取所在城市
* @return void
*/
public function getCity()
{
$lat = $this->request->param('lat', '');
$lng = $this->request->param('lng', '');
if (empty($lat) || empty($lng)) {
return $this->_error('请提供经纬度参数');
}
$uid = $this->user_id;
//查询用户信息
$userInfo = User::find($uid);
if ($userInfo) {
if ($userInfo->longitude == '' || $userInfo->latitude == '')
{
$userInfo->longitude = $lng; // 经度
$userInfo->latitude = $lat; // 纬度
$userInfo->save();
}
}
$result = $this->cityService->getCityByLocation($lat, $lng);
if ($result['success']) {
return $this->_success($result['msg'], $result['data']);
} else {
return $this->_error($result['msg'], $result['data']);
}
}
}

View File

@@ -8,6 +8,9 @@ use think\facade\Config;
class CityService
{
// 腾讯地图API Key
private $tencentMapKey = 'EVOBZ-VX7YU-QKJVR-BVESA-AVFT3-7JBWG';
public function list()
{
$cacheKey = 'city_cache_keys';
@@ -25,4 +28,163 @@ class CityService
}
return $data;
}
/**
* 根据经纬度获取所在城市
* @param float $lat 纬度
* @param float $lng 经度
* @return array
*/
public function getCityByLocation($lat, $lng)
{
// 参数验证
if (empty($lat) || empty($lng)) {
return [
'success' => false,
'msg' => '经纬度参数不能为空',
'data' => null
];
}
// 验证经纬度范围
if (!is_numeric($lat) || !is_numeric($lng)) {
return [
'success' => false,
'msg' => '经纬度格式错误',
'data' => null
];
}
$lat = floatval($lat);
$lng = floatval($lng);
if ($lat < -90 || $lat > 90 || $lng < -180 || $lng > 180) {
return [
'success' => false,
'msg' => '经纬度范围错误',
'data' => null
];
}
// 生成缓存key
$cacheKey = 'city_location_' . md5($lat . '_' . $lng);
// 尝试从缓存获取
if (Cache::store('redis')->has($cacheKey)) {
$cachedData = Cache::store('redis')->get($cacheKey);
return [
'success' => true,
'msg' => '获取成功',
'data' => $cachedData
];
}
// 调用腾讯地图逆地理编码API
$apiUrl = 'https://apis.map.qq.com/ws/geocoder/v1/';
$params = [
'location' => $lat . ',' . $lng,
'key' => $this->tencentMapKey,
'get_poi' => 0
];
$url = $apiUrl . '?' . http_build_query($params);
// 使用curl请求
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error) {
return [
'success' => false,
'msg' => '请求失败:' . $error,
'data' => null
];
}
if ($httpCode !== 200) {
return [
'success' => false,
'msg' => 'API请求失败HTTP状态码' . $httpCode,
'data' => null
];
}
$result = json_decode($response, true);
if (empty($result) || $result['status'] !== 0) {
return [
'success' => false,
'msg' => isset($result['message']) ? $result['message'] : '获取城市信息失败',
'data' => null
];
}
// 解析返回数据
$addressComponent = $result['result']['address_component'] ?? [];
$cityName = $addressComponent['city'] ?? '';
$provinceName = $addressComponent['province'] ?? '';
$districtName = $addressComponent['district'] ?? '';
$adcode = $addressComponent['adcode'] ?? '';
// 如果城市名称为空,尝试使用区县名称
if (empty($cityName) && !empty($districtName)) {
$cityName = $districtName;
}
// 查询数据库中的城市ID
$cityId = null;
$cityInfo = null;
if (!empty($cityName)) {
// 先尝试精确匹配
$cityInfo = Db::name('dev_region')
->where('name', $cityName)
->where('level', 2) // 市级
->find();
// 如果精确匹配失败,尝试模糊匹配
if (empty($cityInfo)) {
$cityInfo = Db::name('dev_region')
->where('name', 'like', '%' . $cityName . '%')
->where('level', 2)
->find();
}
if ($cityInfo) {
$cityId = $cityInfo['id'];
}
}
$data = [
'city_name' => $cityName,
'province_name' => $provinceName,
'district_name' => $districtName,
'adcode' => $adcode,
'city_id' => $cityId,
'city_info' => $cityInfo,
'formatted_address' => $result['result']['formatted_addresses']['recommend'] ?? '',
'location' => [
'lat' => $lat,
'lng' => $lng
]
];
// 缓存结果缓存1小时
Cache::store('redis')->set($cacheKey, $data, 3600);
return [
'success' => true,
'msg' => '获取成功',
'data' => $data
];
}
}