You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

335 lines
11 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<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 = '未配置天地图 KeyVITE_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>