|
|
<script setup lang="ts">
|
|
|
import PageTitle from '@/components/PageTitle.vue'
|
|
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
|
|
import { usePageLoad } from '@/composables/usePageLoad'
|
|
|
import {
|
|
|
Aim,
|
|
|
Location,
|
|
|
OfficeBuilding,
|
|
|
Pointer,
|
|
|
Star,
|
|
|
User,
|
|
|
} from '@element-plus/icons-vue'
|
|
|
import TeacherDetailDialog from '@/views/teachers/components/TeacherDetailDialog.vue'
|
|
|
import { fetchRadarMap, type RadarMapData, type RadarSchool } from '@/api/admin/assets'
|
|
|
import { starDisplay } from '@/utils/teacherStar'
|
|
|
import {
|
|
|
applyOverlayPassthrough,
|
|
|
bindMapDragPan,
|
|
|
centerMapOnSuzhou,
|
|
|
configureMapInteraction,
|
|
|
createSchoolMapOverlay,
|
|
|
createTiandituMap,
|
|
|
getTiandituKey,
|
|
|
loadTianditu,
|
|
|
scheduleMapResize,
|
|
|
type TiandituMap,
|
|
|
type TiandituSchoolOverlay,
|
|
|
} from '@/utils/tiandituMap'
|
|
|
import '@/assets/dashboard-page.css'
|
|
|
|
|
|
const loading = ref(false)
|
|
|
const mapLoading = ref(false)
|
|
|
const mapError = ref('')
|
|
|
const mapData = ref<RadarMapData | null>(null)
|
|
|
const activeSchool = ref<RadarSchool | null>(null)
|
|
|
const detailVisible = ref(false)
|
|
|
const detailTeacherId = ref<number | null>(null)
|
|
|
|
|
|
const mapContainerRef = ref<HTMLElement | null>(null)
|
|
|
let mapInstance: TiandituMap | null = null
|
|
|
let unbindDragPan: (() => void) | null = null
|
|
|
let schoolOverlays: { school: RadarSchool; overlay: TiandituSchoolOverlay }[] = []
|
|
|
|
|
|
const summary = computed(() => mapData.value?.summary)
|
|
|
const quality = computed(() => mapData.value?.quality || [])
|
|
|
const researchFields = computed(() => mapData.value?.research_fields || [])
|
|
|
const maxFieldPercent = computed(() =>
|
|
|
Math.max(...researchFields.value.map((f) => f.percent), 1),
|
|
|
)
|
|
|
|
|
|
function trunc(s: string | null | undefined, n: number) {
|
|
|
if (!s) return ''
|
|
|
return s.length > n ? `${s.slice(0, n)}…` : s
|
|
|
}
|
|
|
|
|
|
function starRow(t: RadarSchool['teachers'][number]) {
|
|
|
return starDisplay(t.star_level_item?.value, t.star_level_item?.label)
|
|
|
}
|
|
|
|
|
|
function openTeacher(id: number) {
|
|
|
detailTeacherId.value = id
|
|
|
detailVisible.value = true
|
|
|
}
|
|
|
|
|
|
function selectSchool(school: RadarSchool) {
|
|
|
activeSchool.value = school
|
|
|
for (const item of schoolOverlays) {
|
|
|
item.overlay.setActive?.(item.school.id === school.id)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function loadData() {
|
|
|
loading.value = true
|
|
|
try {
|
|
|
mapData.value = await fetchRadarMap()
|
|
|
if (activeSchool.value) {
|
|
|
activeSchool.value =
|
|
|
mapData.value.schools.find((s) => s.id === activeSchool.value?.id) || null
|
|
|
}
|
|
|
await nextTick()
|
|
|
await initMap()
|
|
|
} finally {
|
|
|
loading.value = false
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function initMap() {
|
|
|
if (!mapContainerRef.value) return
|
|
|
|
|
|
mapError.value = ''
|
|
|
if (!getTiandituKey()) {
|
|
|
mapError.value = '未配置天地图 Key(VITE_TIANDITU_TK)'
|
|
|
return
|
|
|
}
|
|
|
|
|
|
const schools = mapData.value?.schools || []
|
|
|
if (!schools.length) {
|
|
|
destroyMap()
|
|
|
return
|
|
|
}
|
|
|
|
|
|
mapLoading.value = true
|
|
|
try {
|
|
|
const T = await loadTianditu()
|
|
|
destroyMap()
|
|
|
|
|
|
const container = mapContainerRef.value
|
|
|
container.innerHTML = ''
|
|
|
|
|
|
mapInstance = createTiandituMap(T, container)
|
|
|
configureMapInteraction(mapInstance)
|
|
|
centerMapOnSuzhou(mapInstance, T)
|
|
|
|
|
|
schoolOverlays = []
|
|
|
for (const school of schools) {
|
|
|
const overlay = createSchoolMapOverlay(
|
|
|
T,
|
|
|
{ name: school.name, longitude: school.longitude, latitude: school.latitude },
|
|
|
activeSchool.value?.id === school.id,
|
|
|
)
|
|
|
overlay.addEventListener('click', () => selectSchool(school))
|
|
|
mapInstance.addOverLay(overlay)
|
|
|
schoolOverlays.push({ school, overlay })
|
|
|
}
|
|
|
|
|
|
// 覆盖物层不拦截鼠标(仅圆点/校名响应点击),并以手动 panBy 平移确保地图可拖动
|
|
|
applyOverlayPassthrough(mapInstance)
|
|
|
unbindDragPan = bindMapDragPan(mapInstance, T)
|
|
|
|
|
|
scheduleMapResize(mapInstance)
|
|
|
const reapplyPassthrough = () => {
|
|
|
if (mapInstance) applyOverlayPassthrough(mapInstance)
|
|
|
}
|
|
|
mapInstance.addEventListener?.('load', reapplyPassthrough)
|
|
|
window.setTimeout(reapplyPassthrough, 300)
|
|
|
window.setTimeout(reapplyPassthrough, 800)
|
|
|
} catch (e) {
|
|
|
const msg = e instanceof Error ? e.message : '地图初始化失败'
|
|
|
mapError.value =
|
|
|
msg.includes('脚本') || msg.includes('SDK')
|
|
|
? `${msg}。若 Key 已配置域名白名单,请使用 https://slake.ali251.langye.net 访问(本地开发可在 hosts 绑定该域名后访问 http://slake.ali251.langye.net:5173)`
|
|
|
: msg
|
|
|
destroyMap()
|
|
|
} finally {
|
|
|
mapLoading.value = false
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function destroyMap() {
|
|
|
unbindDragPan?.()
|
|
|
unbindDragPan = null
|
|
|
if (mapInstance) {
|
|
|
mapInstance.clearOverLays?.()
|
|
|
mapInstance.destroy?.()
|
|
|
}
|
|
|
schoolOverlays = []
|
|
|
mapInstance = null
|
|
|
}
|
|
|
|
|
|
watch(
|
|
|
() => mapData.value?.schools.length,
|
|
|
() => {
|
|
|
if (mapData.value && !loading.value) {
|
|
|
nextTick(() => initMap())
|
|
|
}
|
|
|
},
|
|
|
)
|
|
|
|
|
|
usePageLoad(loadData)
|
|
|
onBeforeUnmount(destroyMap)
|
|
|
</script>
|
|
|
|
|
|
<template>
|
|
|
<div v-loading="loading" class="dashboard-page executive-dashboard">
|
|
|
<div class="page-head">
|
|
|
<PageTitle variant="radar" />
|
|
|
</div>
|
|
|
|
|
|
<div v-if="summary" class="radar-top-grid">
|
|
|
<div class="radar-kpi">
|
|
|
<span class="kpi-icon"><el-icon><OfficeBuilding /></el-icon></span>
|
|
|
<span>已覆盖高校<em>已配置经纬度的高校</em></span>
|
|
|
<strong>{{ summary.covered_schools }}</strong>
|
|
|
</div>
|
|
|
<div class="radar-kpi">
|
|
|
<span class="kpi-icon"><el-icon><User /></el-icon></span>
|
|
|
<span>关联老师<em>地图点位老师总数</em></span>
|
|
|
<strong>{{ summary.map_teachers }}</strong>
|
|
|
</div>
|
|
|
<div class="radar-kpi">
|
|
|
<span class="kpi-icon"><el-icon><Star /></el-icon></span>
|
|
|
<span>五星老师<em>高价值合作对象</em></span>
|
|
|
<strong>{{ summary.five_star_teachers }}</strong>
|
|
|
</div>
|
|
|
<div class="radar-kpi">
|
|
|
<span class="kpi-icon"><el-icon><Location /></el-icon></span>
|
|
|
<span>待补坐标<em>高校坐标待完善</em></span>
|
|
|
<strong>{{ summary.pending_coords }}</strong>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<section class="radar-map-card">
|
|
|
<div class="radar-map-toolbar">
|
|
|
<h2>高校分布</h2>
|
|
|
<span class="toolbar-hint">以苏州为中心展示,点击圆点或学校名称查看高校详情</span>
|
|
|
</div>
|
|
|
<div class="radar-main">
|
|
|
<div class="radar-map-container-wrap">
|
|
|
<div v-if="mapError" class="radar-map-placeholder">
|
|
|
<el-icon :size="24"><Aim /></el-icon>
|
|
|
<strong>地图暂不可用</strong>
|
|
|
<span>{{ mapError }}</span>
|
|
|
</div>
|
|
|
<div v-else-if="!mapData?.schools.length && !mapLoading" class="radar-map-placeholder">
|
|
|
<el-icon :size="24"><Location /></el-icon>
|
|
|
<strong>暂无地图点位</strong>
|
|
|
<span>请先在「高校坐标库」维护经纬度。</span>
|
|
|
</div>
|
|
|
<div
|
|
|
v-else
|
|
|
v-loading="mapLoading"
|
|
|
class="radar-map-stage"
|
|
|
>
|
|
|
<div ref="mapContainerRef" class="radar-map-container" />
|
|
|
</div>
|
|
|
</div>
|
|
|
<aside class="radar-side">
|
|
|
<div class="radar-side-head">
|
|
|
<h3>{{ activeSchool?.name || '高校详情' }}</h3>
|
|
|
<span v-if="activeSchool" class="radar-side-count">
|
|
|
<el-icon><User /></el-icon>
|
|
|
{{ activeSchool.teachers_count }} 位
|
|
|
</span>
|
|
|
</div>
|
|
|
<div class="radar-side-body">
|
|
|
<template v-if="activeSchool">
|
|
|
<div v-if="!activeSchool.teachers.length" class="radar-empty-panel">
|
|
|
<el-icon :size="20"><User /></el-icon>
|
|
|
<strong>暂无关联老师</strong>
|
|
|
<span>该校已在地图上展示,可在老师库中关联高校后在此查看老师列表。</span>
|
|
|
</div>
|
|
|
<div
|
|
|
v-for="t in activeSchool.teachers"
|
|
|
:key="t.id"
|
|
|
class="radar-teacher-card"
|
|
|
>
|
|
|
<span class="radar-teacher-avatar"><el-icon><User /></el-icon></span>
|
|
|
<span>
|
|
|
<button type="button" class="radar-teacher-name" @click="openTeacher(t.id)">
|
|
|
{{ t.name }}
|
|
|
</button>
|
|
|
<em>{{ trunc(t.research_direction, 16) }}</em>
|
|
|
<i class="stars">{{ starRow(t) }}</i>
|
|
|
</span>
|
|
|
<el-button size="small" plain @click="openTeacher(t.id)">详情</el-button>
|
|
|
</div>
|
|
|
</template>
|
|
|
<template v-else>
|
|
|
<div class="radar-empty-panel">
|
|
|
<el-icon :size="20"><Pointer /></el-icon>
|
|
|
<strong>选择高校点位</strong>
|
|
|
<span>点击地图上的高校圆点,右侧会显示该校信息及关联老师列表。</span>
|
|
|
</div>
|
|
|
<div v-if="summary" class="radar-view-summary">
|
|
|
<span>当前视图点位</span>
|
|
|
<strong>{{ summary.visible_points }}</strong>
|
|
|
<span>最高星级老师</span>
|
|
|
<strong>{{ summary.max_star }} 星</strong>
|
|
|
</div>
|
|
|
</template>
|
|
|
</div>
|
|
|
</aside>
|
|
|
</div>
|
|
|
</section>
|
|
|
|
|
|
<div class="radar-bottom-grid">
|
|
|
<section class="radar-info-card">
|
|
|
<h2>数据质量</h2>
|
|
|
<div class="radar-school-list">
|
|
|
<div v-for="row in quality" :key="row.label" class="radar-school-row">
|
|
|
<span>{{ row.label }}<em>{{ row.detail }}</em></span>
|
|
|
</div>
|
|
|
</div>
|
|
|
</section>
|
|
|
<section class="radar-info-card">
|
|
|
<h2>研究方向分布</h2>
|
|
|
<div class="radar-field-list">
|
|
|
<div v-for="field in researchFields" :key="field.label" class="radar-field-row">
|
|
|
<span>
|
|
|
{{ field.label }}
|
|
|
<em>{{ field.count }} 位老师</em>
|
|
|
<div class="radar-field-bar">
|
|
|
<i :style="{ width: `${Math.round((field.percent / maxFieldPercent) * 100)}%` }" />
|
|
|
</div>
|
|
|
</span>
|
|
|
<strong>{{ field.percent }}%</strong>
|
|
|
</div>
|
|
|
<p v-if="!researchFields.length" class="empty-hint">暂无研究方向统计数据</p>
|
|
|
</div>
|
|
|
</section>
|
|
|
</div>
|
|
|
|
|
|
<TeacherDetailDialog
|
|
|
v-model="detailVisible"
|
|
|
:teacher-id="detailTeacherId"
|
|
|
readonly
|
|
|
@saved="loadData"
|
|
|
/>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<style scoped>
|
|
|
.radar-map-container-wrap {
|
|
|
position: relative;
|
|
|
flex: 1;
|
|
|
min-width: 0;
|
|
|
min-height: 420px;
|
|
|
}
|
|
|
|
|
|
.page-head {
|
|
|
margin-bottom: 12px;
|
|
|
}
|
|
|
|
|
|
.toolbar-hint {
|
|
|
color: var(--el-text-color-secondary);
|
|
|
font-size: var(--workbench-font-sm, 14px);
|
|
|
}
|
|
|
|
|
|
.empty-hint {
|
|
|
margin: 0;
|
|
|
color: var(--el-text-color-secondary);
|
|
|
font-size: var(--workbench-font-sm, 14px);
|
|
|
}
|
|
|
</style>
|