diff --git a/.env.production b/.env.production index 5317c51..2df819c 100644 --- a/.env.production +++ b/.env.production @@ -1,4 +1,5 @@ # 生产构建默认 API(与 szkp-map-h5 同域部署) # 注意:.env.local 会覆盖本文件,请用 npm run build:prod 或去掉 .env.local 里的 VITE_API_BASE_URL 后再 build +VITE_TIANDITU_TK=ebd6693edf7730d8f8f9a7ba1719f5c1 VITE_API_BASE_URL=https://szkp-map.langye.net/api VITE_PEOPLE_COUNTING_URL=https://hik.pdc.langye.net:18080/api/people-counting diff --git a/.env.testing b/.env.testing index e61580e..110cfcd 100644 --- a/.env.testing +++ b/.env.testing @@ -1,4 +1,5 @@ VITE_API_BASE_URL=https://szkp-map.langye.net/api +VITE_TIANDITU_TK=ebd6693edf7730d8f8f9a7ba1719f5c1 VITE_TENCENT_MAP_KEY=CRFBZ-NTART-YU4XX-LCDGK-3J456-VKBK2 VITE_TENCENT_MAP_REFERER=szkp-map-admin VITE_PEOPLE_COUNTING_URL=https://hik.pdc.langye.net:18080/api/people-counting diff --git a/src/utils/coordChina.ts b/src/utils/coordChina.ts new file mode 100644 index 0000000..543b987 --- /dev/null +++ b/src/utils/coordChina.ts @@ -0,0 +1,66 @@ +/** + * WGS84 ↔ GCJ-02。库内/腾讯/微信为 GCJ-02;天地图经纬度瓦片为 CGCS2000(与 WGS84 差异极小)。 + */ + +const PI = Math.PI + +function outOfChina(lng: number, lat: number): boolean { + return lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271 +} + +function transformLat(lng: number, lat: number): number { + let ret = + -100.0 + + 2.0 * lng + + 3.0 * lat + + 0.2 * lat * lat + + 0.1 * lng * lat + + 0.2 * Math.sqrt(Math.abs(lng)) + ret += ((20.0 * Math.sin(6.0 * lng * PI) + 20.0 * Math.sin(2.0 * lng * PI)) * 2.0) / 3.0 + ret += ((20.0 * Math.sin(lat * PI) + 40.0 * Math.sin((lat / 3.0) * PI)) * 2.0) / 3.0 + ret += ((160.0 * Math.sin((lat / 12.0) * PI) + 320 * Math.sin((lat * PI) / 30.0)) * 2.0) / 3.0 + return ret +} + +function transformLng(lng: number, lat: number): number { + let ret = + 300.0 + + lng + + 2.0 * lat + + 0.1 * lng * lng + + 0.1 * lng * lat + + 0.1 * Math.sqrt(Math.abs(lng)) + ret += ((20.0 * Math.sin(6.0 * lng * PI) + 20.0 * Math.sin(2.0 * lng * PI)) * 2.0) / 3.0 + ret += ((20.0 * Math.sin(lng * PI) + 40.0 * Math.sin((lng / 3.0) * PI)) * 2.0) / 3.0 + ret += ((150.0 * Math.sin((lng / 12.0) * PI) + 300.0 * Math.sin((lng / 30.0) * PI)) * 2.0) / 3.0 + return ret +} + +/** WGS84 → GCJ-02 */ +export function wgs84ToGcj02(lng: number, lat: number): { lng: number; lat: number } { + if (outOfChina(lng, lat)) return { lng, lat } + const a = 6378245.0 + const ee = 0.00669342162296594323 + let dLat = transformLat(lng - 105.0, lat - 35.0) + let dLng = transformLng(lng - 105.0, lat - 35.0) + const radLat = (lat / 180.0) * PI + let magic = Math.sin(radLat) + magic = 1 - ee * magic * magic + const sqrtMagic = Math.sqrt(magic) + dLat = (dLat * 180.0) / (((a * (1 - ee)) / (magic * sqrtMagic)) * PI) + dLng = (dLng * 180.0) / ((a / sqrtMagic) * Math.cos(radLat) * PI) + return { lng: lng + dLng, lat: lat + dLat } +} + +/** GCJ-02 → WGS84(天地图 vec_c 底图) */ +export function gcj02ToWgs84(lng: number, lat: number): { lng: number; lat: number } { + if (outOfChina(lng, lat)) return { lng, lat } + let wgsLng = lng + let wgsLat = lat + for (let i = 0; i < 2; i++) { + const g = wgs84ToGcj02(wgsLng, wgsLat) + wgsLng = lng - (g.lng - wgsLng) + wgsLat = lat - (g.lat - wgsLat) + } + return { lng: wgsLng, lat: wgsLat } +} diff --git a/src/utils/mapGeo.ts b/src/utils/mapGeo.ts new file mode 100644 index 0000000..fc6533e --- /dev/null +++ b/src/utils/mapGeo.ts @@ -0,0 +1,37 @@ +/** + * 后台地图选点:仅浏览器端天地图(JS SDK / 同源代理),不请求后端 /api/map/* + */ +import type { TiandituMap } from './tiandituMap' +import { + tiandituBrowserReverseGeocode, + tiandituMapLocalSearch, +} from './tiandituMap' + +export type MapPlaceRow = { + title: string + address: string + lat: number + lng: number + province?: string + city?: string + district?: string +} + +export type MapReverseRow = { + address: string + province?: string + city?: string + district?: string +} + +/** 地名搜索(需已 init 的地图实例,走 T.LocalSearch) */ +export async function searchMapPlaces(map: TiandituMap, keyword: string): Promise { + const kw = keyword.trim() + if (!kw) return [] + return tiandituMapLocalSearch(map, kw) +} + +/** 逆地理(浏览器天地图 geocoder 接口,不走 Laravel) */ +export async function reverseMapGeocode(lat: number, lng: number): Promise { + return tiandituBrowserReverseGeocode(lat, lng) +} diff --git a/src/utils/tiandituMap.ts b/src/utils/tiandituMap.ts new file mode 100644 index 0000000..522f83f --- /dev/null +++ b/src/utils/tiandituMap.ts @@ -0,0 +1,291 @@ +/** + * 天地图 JS API 4.0:库内 GCJ-02(腾讯选点)↔ 底图 CGCS2000 经纬度(vec_c + EPSG:4326) + */ + +import { gcj02ToWgs84, wgs84ToGcj02 } from './coordChina' + +declare global { + interface Window { + T?: { + Map: new ( + el: HTMLElement | string, + opts?: { projection?: string; layers?: TiandituOverlay[] }, + ) => TiandituMap + TileLayer: new (url: string, opts?: { minZoom?: number; maxZoom?: number }) => TiandituOverlay + LngLat: new (lng: number, lat: number) => TiandituLngLat + Marker: new (lnglat: TiandituLngLat, opts?: { icon?: TiandituIcon }) => TiandituOverlay + Icon: new (opts: { iconUrl: string; iconSize: TiandituPoint; iconAnchor: TiandituPoint }) => TiandituIcon + Point: new (x: number, y: number) => TiandituPoint + Polygon: new ( + points: TiandituLngLat[], + opts?: Record, + ) => TiandituOverlay + } + } +} + +type TiandituLngLat = { getLat(): number; getLng(): number } +type TiandituPoint = unknown +type TiandituIcon = unknown +type TiandituOverlay = unknown +export type TiandituMap = { + centerAndZoom(lnglat: TiandituLngLat, zoom: number): void + setZoom(zoom: number): void + getZoom(): number + addOverLay(overlay: TiandituOverlay): void + removeOverLay(overlay: TiandituOverlay): void + addEventListener(type: string, handler: (evt: TiandituClickEvent) => void): void + setViewport(lnglats: TiandituLngLat[]): void + checkResize?(): void +} + +type TiandituClickEvent = { lnglat: TiandituLngLat } + +let loadPromise: Promise | null = null + +function useTiandituDevProxy(): boolean { + return !!import.meta.env.DEV && import.meta.env.VITE_TIANDITU_DEV_PROXY !== '0' +} + +function tiandituTileOrigin(): string { + return useTiandituDevProxy() ? '/tianditu-tile-proxy' : 'https://t0.tianditu.gov.cn' +} + +function wmtsVecC(tk: string): string { + return `${tiandituTileOrigin()}/vec_c/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=c&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tk}` +} + +function wmtsCvaC(tk: string): string { + return `${tiandituTileOrigin()}/cva_c/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=c&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tk}` +} + +export function getTiandituTk(): string { + return String(import.meta.env.VITE_TIANDITU_TK || '').trim() +} + +function createTiandituBaseLayers(tk: string, simple = false): TiandituOverlay[] { + const T = window.T! + const vec = new T.TileLayer(wmtsVecC(tk), { minZoom: 1, maxZoom: 18 }) + if (simple) return [vec] + const cva = new T.TileLayer(wmtsCvaC(tk), { minZoom: 1, maxZoom: 18 }) + return [vec, cva] +} + +/** 库内 GCJ-02 → 天地图 LngLat(底图坐标系) */ +export function tiandituLngLat(gcjLng: number, gcjLat: number): TiandituLngLat { + const w = gcj02ToWgs84(gcjLng, gcjLat) + return new window.T!.LngLat(w.lng, w.lat) +} + +export async function loadTiandituApi(): Promise { + if (typeof window === 'undefined') { + throw new Error('天地图仅支持浏览器环境') + } + if (window.T) return + const tk = getTiandituTk() + if (!tk) { + throw new Error('请先配置 VITE_TIANDITU_TK(天地图开放平台应用 Key)') + } + if (!loadPromise) { + loadPromise = new Promise((resolve, reject) => { + const script = document.createElement('script') + const apiBase = useTiandituDevProxy() ? '/tianditu-api-proxy' : 'https://api.tianditu.gov.cn' + script.src = `${apiBase}/api?v=4.0&tk=${encodeURIComponent(tk)}` + script.async = true + script.onload = () => resolve() + script.onerror = () => reject(new Error('天地图 SDK 加载失败')) + document.head.appendChild(script) + }) + } + await loadPromise + if (!window.T) { + throw new Error('天地图 SDK 未就绪') + } +} + +export function createTiandituMap( + el: HTMLElement, + lng: number, + lat: number, + zoom: number, + opts?: { simple?: boolean }, +): TiandituMap { + const tk = getTiandituTk() + const simple = opts?.simple === true + const map = new window.T!.Map(el, { + projection: 'EPSG:4326', + layers: createTiandituBaseLayers(tk, simple), + }) + map.centerAndZoom(tiandituLngLat(lng, lat), zoom) + return map +} + +export function setTiandituPickMarker( + map: TiandituMap, + holder: { overlay: TiandituOverlay | null }, + lat: number, + lng: number, +): void { + const T = window.T! + if (holder.overlay) { + map.removeOverLay(holder.overlay) + holder.overlay = null + } + const marker = new T.Marker(tiandituLngLat(lng, lat)) + map.addOverLay(marker) + holder.overlay = marker +} + +export function clearTiandituOverlay(map: TiandituMap, overlay: TiandituOverlay | null) { + if (overlay) map.removeOverLay(overlay) +} + +export function refreshTiandituViewport(map: TiandituMap, lng: number, lat: number, zoom = 13) { + map.checkResize?.() + map.centerAndZoom(tiandituLngLat(lng, lat), zoom) + setTimeout(() => { + map.checkResize?.() + map.centerAndZoom(tiandituLngLat(lng, lat), zoom) + }, 120) +} + +/** 地图点击 → 库内 GCJ-02 */ +export function lngLatFromTiandituClick(evt: TiandituClickEvent): { lat: number; lng: number } { + const mapLng = evt.lnglat.getLng() + const mapLat = evt.lnglat.getLat() + const g = wgs84ToGcj02(mapLng, mapLat) + return { + lat: Number(g.lat.toFixed(6)), + lng: Number(g.lng.toFixed(6)), + } +} + +export function bindTiandituMapClick(map: TiandituMap, handler: (lat: number, lng: number) => void) { + map.addEventListener('click', (evt) => { + const { lat, lng } = lngLatFromTiandituClick(evt) + handler(lat, lng) + }) +} + +function tiandituApiBase(): string { + return useTiandituDevProxy() ? '/tianditu-api-proxy' : 'https://api.tianditu.gov.cn' +} + +function tiandituJsonError(json: Record): string | null { + const code = json.code ?? json.status + const msg = String(json.msg ?? json.message ?? json.resolve ?? '').trim() + if (code === 301012 || msg.includes('权限')) { + return '天地图 Key 权限不足:请在控制台为该应用开通「地名搜索」并在浏览器访问(本地开发需 Vite 代理)。' + } + if (code === 1000 || code === '1000') return null + if (code !== undefined && code !== null && String(code) !== '0' && Number(code) !== 0) { + return msg || '天地图请求失败' + } + return null +} + +export function tiandituMapLocalSearch( + map: TiandituMap, + keyword: string, +): Promise< + Array<{ + title: string + address: string + lat: number + lng: number + province?: string + city?: string + district?: string + }> +> { + const kw = keyword.trim() + if (!kw) return Promise.resolve([]) + + const T = window.T as + | { + LocalSearch: new ( + map: TiandituMap, + opts: { pageCapacity?: number; onSearchComplete: (result: TiandituLocalSearchResult) => void }, + ) => { search: (k: string) => void } + } + | undefined + + if (!T?.LocalSearch) { + return Promise.reject(new Error('天地图 LocalSearch 未加载,请确认 SDK 已初始化')) + } + + return new Promise((resolve, reject) => { + try { + const ls = new T.LocalSearch(map, { + pageCapacity: 15, + onSearchComplete(result) { + try { + const type = parseInt(String(result.getResultType?.() ?? '1'), 10) + if (type !== 1) { + resolve([]) + return + } + const pois = result.getPois?.() ?? [] + const rows: Array<{ + title: string + address: string + lat: number + lng: number + }> = [] + for (const p of pois) { + const lonlat = String(p.lonlat ?? '') + let mapLng: number | null = null + let mapLat: number | null = null + if (lonlat.includes(',')) { + const [lngRaw, latRaw] = lonlat.split(',').map((s) => s.trim()) + mapLng = Number(lngRaw) + mapLat = Number(latRaw) + } + if (!Number.isFinite(mapLat!) || !Number.isFinite(mapLng!)) continue + const g = wgs84ToGcj02(mapLng!, mapLat!) + rows.push({ + title: String(p.name ?? ''), + address: String(p.address ?? ''), + lat: g.lat, + lng: g.lng, + }) + } + resolve(rows) + } catch (e) { + reject(e) + } + }, + }) + ls.search(kw) + } catch (e) { + reject(e) + } + }) +} + +type TiandituLocalSearchPoi = { name?: string; address?: string; lonlat?: string } +type TiandituLocalSearchResult = { + getResultType?: () => string | number + getPois?: () => TiandituLocalSearchPoi[] +} + +/** 逆地理:入参/出参均为库内 GCJ-02 */ +export async function tiandituBrowserReverseGeocode(lat: number, lng: number) { + const tk = getTiandituTk() + if (!tk) throw new Error('未配置 VITE_TIANDITU_TK') + const w = gcj02ToWgs84(lng, lat) + const postStr = JSON.stringify({ lon: w.lng, lat: w.lat, ver: 1 }) + const url = `${tiandituApiBase()}/geocoder?postStr=${encodeURIComponent(postStr)}&type=geocode&tk=${encodeURIComponent(tk)}` + const resp = await fetch(url) + const json = (await resp.json()) as Record + const err = tiandituJsonError(json) + if (err) throw new Error(err) + const result = (json.result ?? {}) as Record + const comp = (result.addressComponent ?? {}) as Record + return { + address: String(result.formatted_address ?? result.address ?? '').trim(), + province: comp.province ? String(comp.province) : undefined, + city: comp.city ? String(comp.city) : undefined, + district: comp.district ? String(comp.district) : comp.county ? String(comp.county) : undefined, + } +} diff --git a/src/views/Dashboard.vue b/src/views/Dashboard.vue index a5485eb..b40bda2 100644 --- a/src/views/Dashboard.vue +++ b/src/views/Dashboard.vue @@ -9,6 +9,7 @@ import { type HikPeopleCountingResponse, type HikVenueRangeTotalRow, } from '../api/hikPdcClient' +import * as XLSX from 'xlsx' import { http } from '../api/http' const router = useRouter() @@ -18,6 +19,7 @@ const isSuperAdmin = ref(false) const isVenueAdmin = ref(false) type PendingAuditActivityItem = { id: number; title: string; venue_name: string; updated_at?: string | null } +type PendingAuditVenueItem = { id: number; name: string; updated_at?: string | null } type ScheduleCountBlock = { total: number @@ -60,6 +62,9 @@ const stats = ref({ ticket_grab_sessions: 0, user_count: 0, reservation_order_count: 0, + wechat_user_count: 0, + home_visit_total: 0, + home_visit_today: 0, }, activity_schedule_counts: { total: 0, @@ -80,6 +85,7 @@ const stats = ref({ } as TicketGrabScheduleCountBlock, pending_audits: null as null | { activities: { count: number; items: PendingAuditActivityItem[] } + venues?: { count: number; items: PendingAuditVenueItem[] } }, activity_publish_ranking: [] as ActivityPublishRankRow[], live_people_ranking: [] as LivePeopleRankRow[], @@ -315,13 +321,46 @@ const ticketGrabStats = ref<{ const dailyVerifyLabels = computed(() => ticketGrabStats.value?.daily_verify_matrix?.date_labels ?? []) const dailyVerifyRows = computed(() => ticketGrabStats.value?.daily_verify_matrix?.rows ?? []) +const exportingVenuePc = ref(false) + +function exportVenuePeopleExcel() { + const picked = pickVenuePcRangeFromPicker() + if (!picked) { + Message.warning('请先选择日期区间并查询数据') + return + } + const rows = dashboardVenuePcRows.value + if (!rows.length) { + Message.warning('暂无数据可导出') + return + } + exportingVenuePc.value = true + try { + const table = [['场馆ID', '场馆名称', '入馆总人数'], ...rows.map((r) => [r.venueId, r.venueName, r.enter])] + const enterSum = rows.reduce((s, r) => s + (Number(r.enter) || 0), 0) + table.push(['合计', '—', enterSum]) + const ws = XLSX.utils.aoa_to_sheet(table) + const wb = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(wb, ws, '各场馆人数统计') + const filename = `${picked.start}至${picked.end}各场馆人数统计.xlsx` + XLSX.writeFile(wb, filename) + Message.success('已导出') + } finally { + exportingVenuePc.value = false + } +} + const exportingDailyVerify = ref(false) const pendingActivityCount = computed(() => stats.value.pending_audits?.activities.count ?? 0) -const totalPendingTodoCount = computed(() => pendingActivityCount.value) +const pendingVenueCount = computed(() => stats.value.pending_audits?.venues?.count ?? 0) +const totalPendingTodoCount = computed(() => pendingActivityCount.value + pendingVenueCount.value) const pendingActivityItems = computed(() => stats.value.pending_audits?.activities.items ?? []) -const hasPendingTodoRows = computed(() => pendingActivityItems.value.length > 0) +const pendingVenueItems = computed(() => stats.value.pending_audits?.venues?.items ?? []) +const hasPendingTodoRows = computed( + () => pendingActivityItems.value.length > 0 || pendingVenueItems.value.length > 0, +) const dashOverviewSplit = computed(() => isSuperAdmin.value || isVenueAdmin.value) @@ -339,7 +378,15 @@ function gotoVenueRejectedActivities() { void router.push({ path: '/activities', query: { audit_status: 'rejected' } }) } -function gotoTodoFromDashboard() { +function gotoVenueAuditList() { + void router.push({ path: '/venues', query: { audit_status: 'pending' } }) +} + +function gotoTodoFromDashboard(kind: 'activity' | 'venue') { + if (kind === 'venue') { + gotoVenueAuditList() + return + } if (isVenueAdmin.value) { gotoVenueRejectedActivities() } else { @@ -541,10 +588,21 @@ onMounted(async () => {
{{ stats.summary.reservation_order_count ?? 0 }}
总预约次数
+
+
{{ stats.summary.wechat_user_count ?? 0 }}
+
总用户数
+
{{ stats.summary.user_count }}
-
用户数
-
{{ isVenueAdmin ? '预约本场馆活动用户' : '预约用户' }}
+
预约用户数
+
+
+
{{ stats.summary.home_visit_total ?? 0 }}
+
总访问量
+
+
+
{{ stats.summary.home_visit_today ?? 0 }}
+
今日访问量
@@ -575,12 +633,23 @@ onMounted(async () => { :key="'pa-' + a.id" type="button" class="dash-todo-line" - @click="gotoTodoFromDashboard" + @click="gotoTodoFromDashboard('activity')" > {{ todoLineKindLabel }} {{ a.title }} 去处理 + @@ -757,6 +826,9 @@ onMounted(async () => { size="small" /> 查询 + + 导出 Excel + 本周 本月 @@ -1316,6 +1388,12 @@ onMounted(async () => { } } +@media (min-width: 1100px) { + .dash-stat-grid.dash-stat-grid--core { + grid-template-columns: repeat(6, minmax(0, 1fr)); + } +} + .dash-stat-cell { border-radius: 10px; padding: 12px 10px 14px; @@ -1387,6 +1465,20 @@ onMounted(async () => { color: #3c7eef; } +.dash-stat-cell--gold { + background: linear-gradient(180deg, #fffbeb 0%, #fffdf5 100%); +} +.dash-stat-cell--gold .dash-stat-cell__value { + color: #d48806; +} + +.dash-stat-cell--teal { + background: linear-gradient(180deg, #f0fdfa 0%, #f8fffe 100%); +} +.dash-stat-cell--teal .dash-stat-cell__value { + color: #0d9488; +} + .dash-todo-sheet { --dash-todo-row-h: 44px; flex: 1; diff --git a/src/views/Login.vue b/src/views/Login.vue index a3e84ac..6a459c8 100644 --- a/src/views/Login.vue +++ b/src/views/Login.vue @@ -8,8 +8,8 @@ const router = useRouter() const route = useRoute() const loading = ref(false) const form = reactive({ - username: 'admin', - password: 'admin123456', + username: '', + password: '', }) async function onSubmit() { diff --git a/src/views/activities/ActivityList.vue b/src/views/activities/ActivityList.vue index 8cb619e..dff8b3b 100644 --- a/src/views/activities/ActivityList.vue +++ b/src/views/activities/ActivityList.vue @@ -10,10 +10,21 @@ import { useUnsavedChangesGuard } from '../../composables/useUnsavedChangesGuard import { adminUploadImageTooLargeMessage, ADMIN_IMAGE_RECOMMEND_LABEL } from '../../utils/adminMediaLimits' import { listTableRowIndex } from '../../utils/listTableRowIndex' import { downloadActivityListXlsx } from '../../utils/exportActivityListXlsx' +import { reverseMapGeocode, searchMapPlaces } from '../../utils/mapGeo' +import { + bindTiandituMapClick, + clearTiandituOverlay, + createTiandituMap, + loadTiandituApi, + refreshTiandituViewport, + setTiandituPickMarker, +} from '../../utils/tiandituMap' type Venue = { id: number name: string + audit_status?: 'approved' | 'pending' | 'rejected' + last_approved_snapshot?: Record | null address?: string appointment_type?: string | null lat?: number | string | null @@ -57,7 +68,12 @@ type Activity = { is_active: boolean /** 活动进度:未开始 / 进行中 / 已结束 */ schedule_status?: 'not_started' | 'ongoing' | 'ended' - venue?: { id: number; name: string } + venue?: { + id: number + name: string + audit_status?: 'approved' | 'pending' | 'rejected' + last_approved_snapshot?: Record | null + } /** 已通过审核的记录条数(来自 activity_audit_logs,用于删除按钮等) */ approve_audit_logs_count?: number audit_status?: 'approved' | 'pending' | 'rejected' @@ -80,6 +96,30 @@ type Activity = { const rows = ref([]) const venues = ref([]) + +/** 活动举办场馆等引用场景:待审/退回时展示已通过快照中的名称 */ +function venueReferenceDisplayName(v: Pick | null | undefined): string { + if (!v) return '' + const snap = v.last_approved_snapshot + if ( + (v.audit_status === 'pending' || v.audit_status === 'rejected') + && snap + && typeof snap === 'object' + && !Array.isArray(snap) + && snap.name != null + && String(snap.name).trim() !== '' + ) { + return String(snap.name) + } + return v.name || '' +} + +function activityVenueDisplayName(record: Activity | null | undefined): string { + if (!record) return '—' + const name = venueReferenceDisplayName(record.venue) + return name || record.venue?.name || '—' +} + const currentUser = ref(null) const loading = ref(false) const saving = ref(false) @@ -253,8 +293,8 @@ const mapLoading = ref(false) const mapKeyword = ref('') const mapResults = ref>([]) const mapContainerRef = ref(null) -let mapInstance: any = null -let markerLayer: any = null +let mapInstance: ReturnType | null = null +const pickMarkerHolder = { overlay: null as unknown } const pickedPoint = ref<{ lat: number; lng: number; address: string } | null>(null) const DEFAULT_CENTER = { lat: 31.299379, lng: 120.585315 } @@ -1366,10 +1406,6 @@ function parseCoord(v: unknown): number | undefined { return Number.isFinite(n) ? n : undefined } -function mapJsKey() { - return (import.meta.env.VITE_TENCENT_MAP_KEY as string) || '' -} - function useVenueAddress() { const vid = form.venue_id if (!vid) { @@ -1394,77 +1430,38 @@ function useVenueAddress() { } } -async function ensureMapSdkLoaded() { - const w = window as any - if (w.TMap) return - const key = mapJsKey() - if (!key) { - throw new Error('请先配置 VITE_TENCENT_MAP_KEY') - } - await new Promise((resolve, reject) => { - const script = document.createElement('script') - script.src = `https://map.qq.com/api/gljs?v=1.exp&key=${key}` - script.async = true - script.onload = () => resolve() - script.onerror = () => reject(new Error('腾讯地图SDK加载失败')) - document.head.appendChild(script) - }) -} - function renderMarker(lat: number, lng: number) { - const TMap = (window as any).TMap if (!mapInstance) return - if (markerLayer) markerLayer.setMap(null) - markerLayer = new TMap.MultiMarker({ - map: mapInstance, - styles: { - marker: new TMap.MarkerStyle({ width: 24, height: 35 }), - }, - geometries: [{ id: 'picked', styleId: 'marker', position: new TMap.LatLng(lat, lng) }], - }) - mapInstance.setCenter(new TMap.LatLng(lat, lng)) -} - -function refreshMapViewport(lat: number, lng: number) { - const TMap = (window as any).TMap - if (!mapInstance || !TMap) return - const center = new TMap.LatLng(lat, lng) - mapInstance.resize?.() - mapInstance.setCenter(center) - mapInstance.setZoom(13) - setTimeout(() => { - mapInstance.resize?.() - mapInstance.setCenter(center) - }, 120) + setTiandituPickMarker(mapInstance, pickMarkerHolder, lat, lng) + refreshTiandituViewport(mapInstance, lng, lat, 13) } async function activityReverseGeocode(lat: number, lng: number) { - const { data } = await http.get('/map/reverse-geocode', { params: { lat, lng } }) - pickedPoint.value = { lat, lng, address: (data as { address?: string }).address || '' } + const fallback = pickedPoint.value?.address || '' + const data = await reverseMapGeocode(lat, lng) + pickedPoint.value = { lat, lng, address: (data.address || '').trim() || fallback } } async function initMapPicker() { - await ensureMapSdkLoaded() - const TMap = (window as any).TMap + await loadTiandituApi() const la0 = parseCoord(form.lat) ?? DEFAULT_CENTER.lat const ln0 = parseCoord(form.lng) ?? DEFAULT_CENTER.lng - const center = new TMap.LatLng(la0, ln0) + if (!mapContainerRef.value) return if (!mapInstance) { - mapInstance = new TMap.Map(mapContainerRef.value, { center, zoom: 13 }) - mapInstance.on('click', async (evt: any) => { - const lat = Number(evt.latLng.getLat().toFixed(6)) - const lng = Number(evt.latLng.getLng().toFixed(6)) + mapInstance = createTiandituMap(mapContainerRef.value, ln0, la0, 13) + bindTiandituMapClick(mapInstance, async (lat, lng) => { renderMarker(lat, lng) + pickedPoint.value = { lat, lng, address: '' } try { await activityReverseGeocode(lat, lng) } catch (error: any) { - Message.error(error?.response?.data?.message ?? '逆地理编码失败') + Message.error(error?.message ?? error?.response?.data?.message ?? '逆地理编码失败') } }) } else { - mapInstance.setCenter(center) + refreshTiandituViewport(mapInstance, ln0, la0, 13) } - refreshMapViewport(la0, ln0) + refreshTiandituViewport(mapInstance, ln0, la0, 13) const hasPt = parseCoord(form.lat) != null && parseCoord(form.lng) != null if (hasPt) { const la = parseCoord(form.lat)! @@ -1472,10 +1469,8 @@ async function initMapPicker() { renderMarker(la, ln) pickedPoint.value = { lat: la, lng: ln, address: (form.location || '').trim() } } else { - if (markerLayer) { - markerLayer.setMap(null) - markerLayer = null - } + clearTiandituOverlay(mapInstance, pickMarkerHolder.overlay as never) + pickMarkerHolder.overlay = null pickedPoint.value = null } } @@ -1508,10 +1503,10 @@ async function searchMapKeyword() { } mapLoading.value = true try { - const { data } = await http.get('/map/search', { params: { keyword: mapKeyword.value, region: '苏州' } }) - mapResults.value = data + if (!mapInstance) throw new Error('地图未初始化') + mapResults.value = await searchMapPlaces(mapInstance, mapKeyword.value) } catch (error: any) { - Message.error(error?.response?.data?.message ?? '地图搜索失败') + Message.error(error?.message ?? error?.response?.data?.message ?? '地图搜索失败') } finally { mapLoading.value = false } @@ -1519,11 +1514,12 @@ async function searchMapKeyword() { async function pickSearchResult(item: { title: string; address: string; lat: number; lng: number }) { renderMarker(item.lat, item.lng) - pickedPoint.value = { lat: item.lat, lng: item.lng, address: item.address || '' } + const addr = [item.title, item.address].filter(Boolean).join(' · ') + pickedPoint.value = { lat: item.lat, lng: item.lng, address: addr } try { await activityReverseGeocode(item.lat, item.lng) } catch { - // ignore + // 保留搜索结果地址 } } @@ -1850,11 +1846,27 @@ function onCancelUnifiedModal() { } } +function extractApiErrorMessage(error: unknown, fallback = '保存失败'): string { + const e = error as { response?: { data?: { message?: string; errors?: Record } }; message?: string } + const data = e?.response?.data + if (data?.errors && typeof data.errors === 'object') { + for (const msgs of Object.values(data.errors)) { + if (Array.isArray(msgs) && msgs[0]) return String(msgs[0]) + } + } + if (typeof data?.message === 'string' && data.message.trim()) return data.message + if (typeof e?.message === 'string' && e.message.trim()) return e.message + return fallback +} + /** 已通过校验后的实际保存(由确认弹窗的 onBeforeOk 调用) */ async function executeUnifiedActivitySave(): Promise { const shouldSyncBooking = canSaveSessionsWithActivity.value && reservationTypeSupportsSessionSettings(form.reservation_type) + if (shouldSyncBooking && !validateBookingFormInternal()) { + throw new Error('场次信息校验未通过') + } const rawRt = String(form.reservation_type || '').trim() const effectiveRt = reservationKindEffective(rawRt) const isUnifiedNature = reservationTypeSupportsSessionSettings(rawRt) @@ -1899,10 +1911,12 @@ async function executeUnifiedActivitySave(): Promise { } let returned: { id?: number; audit_status?: string } | null = null let actId = editId.value + let createdInThisSave = false if (isCreate.value) { const { data } = await http.post<{ id: number; audit_status?: string }>('/activities', payload) returned = data actId = data?.id ?? null + createdInThisSave = true } else { const { data } = await http.put<{ id?: number; audit_status?: string }>( `/activities/${editId.value}`, @@ -1912,7 +1926,18 @@ async function executeUnifiedActivitySave(): Promise { actId = editId.value } if (shouldSyncBooking && actId) { - await http.put(`/activities/${actId}/booking-settings`, buildBookingPayload()) + try { + await http.put(`/activities/${actId}/booking-settings`, buildBookingPayload()) + } catch (bookingError) { + if (createdInThisSave && actId) { + try { + await http.delete(`/activities/${actId}`) + } catch { + // 尽力回滚新建活动 + } + } + throw bookingError + } } if (!isSuperAdmin() && returned?.audit_status === 'pending') { Message.success('信息已保存,请等待管理员审核') @@ -1948,8 +1973,8 @@ function submitUnifiedActivity() { try { await executeUnifiedActivitySave() return true - } catch (error: any) { - Message.error(error?.response?.data?.message ?? '保存失败') + } catch (error: unknown) { + Message.error(extractApiErrorMessage(error)) return false } finally { saving.value = false @@ -2004,7 +2029,7 @@ async function removeActivity(row: Activity) { placeholder="筛选场馆" style="width: 220px" > - {{ v.name }} + {{ venueReferenceDisplayName(v) }} {{ formatActivityTableDateRange(record as Activity) }} - + @@ -2188,7 +2213,7 @@ async function removeActivity(row: Activity) {
举办场馆 -
{{ auditActivityRecord.venue?.name || '—' }}
+
{{ activityVenueDisplayName(auditActivityRecord) }}
联系人 @@ -2279,7 +2304,9 @@ async function removeActivity(row: Activity) { v-if="auditActivityRecord && reservationTypeSupportsSessionSettings(auditActivityRecord.reservation_type)" class="activity-audit-stack" > -
场次信息({{ auditBookingAudienceLabel || '—' }})
+
+ 场次信息 +
- {{ v.name }} + {{ venueReferenceDisplayName(v) }} - diff --git a/src/views/ticket-grab/TicketGrabList.vue b/src/views/ticket-grab/TicketGrabList.vue index 47fd379..2bc9c0f 100644 --- a/src/views/ticket-grab/TicketGrabList.vue +++ b/src/views/ticket-grab/TicketGrabList.vue @@ -7,6 +7,15 @@ import { buildUnifiedActivityVerifyLoginUrl } from '../../api/h5Http' import { listTableRowIndex } from '../../utils/listTableRowIndex' import { resolvePublicMediaUrl } from '../../utils/mediaUrl' import { downloadTicketGrabListXlsx } from '../../utils/exportTicketGrabListXlsx' +import { reverseMapGeocode, searchMapPlaces } from '../../utils/mapGeo' +import { + bindTiandituMapClick, + clearTiandituOverlay, + createTiandituMap, + loadTiandituApi, + refreshTiandituViewport, + setTiandituPickMarker, +} from '../../utils/tiandituMap' /** 与本表列宽匹配;勿用全局 LIST_TABLE_SCROLL_X(3220),否则列会被撑宽 */ const TICKET_GRAB_LIST_SCROLL_X = 1418 @@ -151,8 +160,8 @@ const mapContainerRef = ref(null) const mapTargetRow = ref(null) const pickedPoint = ref<{ lat: number; lng: number; address: string } | null>(null) const DEFAULT_CENTER = { lat: 31.299379, lng: 120.585315 } -let mapInstance: any = null -let markerLayer: any = null +let mapInstance: ReturnType | null = null +const pickMarkerHolder = { overlay: null as unknown } const mediaPreviewVisible = ref(false) const mediaPreviewType = ref<'image' | 'video'>('image') @@ -524,40 +533,16 @@ const detailEditorOptions = { placeholder: '请输入内容', } -function mapJsKey() { - return (import.meta.env.VITE_TENCENT_MAP_KEY as string) || '' -} - -async function ensureMapSdkLoaded() { - const w = window as any - if (w.TMap) return - const key = mapJsKey() - if (!key) throw new Error('请先配置 VITE_TENCENT_MAP_KEY') - await new Promise((resolve, reject) => { - const script = document.createElement('script') - script.src = `https://map.qq.com/api/gljs?v=1.exp&key=${key}` - script.async = true - script.onload = () => resolve() - script.onerror = () => reject(new Error('腾讯地图SDK加载失败')) - document.head.appendChild(script) - }) -} - function renderMarker(lat: number, lng: number) { - const TMap = (window as any).TMap if (!mapInstance) return - if (markerLayer) markerLayer.setMap(null) - markerLayer = new TMap.MultiMarker({ - map: mapInstance, - styles: { marker: new TMap.MarkerStyle({ width: 24, height: 35 }) }, - geometries: [{ id: 'picked', styleId: 'marker', position: new TMap.LatLng(lat, lng) }], - }) - mapInstance.setCenter(new TMap.LatLng(lat, lng)) + setTiandituPickMarker(mapInstance, pickMarkerHolder, lat, lng) + refreshTiandituViewport(mapInstance, lng, lat, 13) } async function reverseGeocode(lat: number, lng: number) { - const { data } = await http.get('/map/reverse-geocode', { params: { lat, lng } }) - pickedPoint.value = { lat, lng, address: (data as { address?: string }).address || '' } + const fallback = pickedPoint.value?.address || '' + const data = await reverseMapGeocode(lat, lng) + pickedPoint.value = { lat, lng, address: (data.address || '').trim() || fallback } } function currentMapRow() { @@ -565,31 +550,34 @@ function currentMapRow() { } async function initMapPicker() { - await ensureMapSdkLoaded() + await loadTiandituApi() if (!mapContainerRef.value) { Message.error('地图容器未就绪,请重试') return } - const TMap = (window as any).TMap const row = currentMapRow() const lat = typeof row?.lat === 'number' ? row.lat : DEFAULT_CENTER.lat const lng = typeof row?.lng === 'number' ? row.lng : DEFAULT_CENTER.lng - const center = new TMap.LatLng(lat, lng) if (!mapInstance) { - mapInstance = new TMap.Map(mapContainerRef.value, { center, zoom: 13 }) - mapInstance.on('click', async (evt: any) => { - const plat = Number(evt.latLng.getLat().toFixed(6)) - const plng = Number(evt.latLng.getLng().toFixed(6)) + mapInstance = createTiandituMap(mapContainerRef.value, lng, lat, 13) + bindTiandituMapClick(mapInstance, async (plat, plng) => { renderMarker(plat, plng) - await reverseGeocode(plat, plng) + pickedPoint.value = { lat: plat, lng: plng, address: '' } + try { + await reverseGeocode(plat, plng) + } catch (error: any) { + Message.error(error?.message ?? error?.response?.data?.message ?? '逆地理编码失败') + } }) } else { - mapInstance.setCenter(center) + refreshTiandituViewport(mapInstance, lng, lat, 13) } if (typeof row?.lat === 'number' && typeof row?.lng === 'number') { renderMarker(row.lat, row.lng) pickedPoint.value = { lat: row.lat, lng: row.lng, address: row.address || '' } } else { + clearTiandituOverlay(mapInstance, pickMarkerHolder.overlay as never) + pickMarkerHolder.overlay = null pickedPoint.value = null } } @@ -623,10 +611,10 @@ async function searchMapKeyword() { } mapLoading.value = true try { - const { data } = await http.get('/map/search', { params: { keyword: mapKeyword.value, region: '苏州' } }) - mapResults.value = data as Array<{ title: string; address: string; lat: number; lng: number }> + if (!mapInstance) throw new Error('地图未初始化') + mapResults.value = await searchMapPlaces(mapInstance, mapKeyword.value) } catch (error: any) { - Message.error(error?.response?.data?.message ?? '地图搜索失败') + Message.error(error?.message ?? error?.response?.data?.message ?? '地图搜索失败') } finally { mapLoading.value = false } @@ -634,7 +622,13 @@ async function searchMapKeyword() { async function pickSearchResult(item: { title: string; address: string; lat: number; lng: number }) { renderMarker(item.lat, item.lng) - await reverseGeocode(item.lat, item.lng) + const addr = [item.title, item.address].filter(Boolean).join(' · ') + pickedPoint.value = { lat: item.lat, lng: item.lng, address: addr } + try { + await reverseGeocode(item.lat, item.lng) + } catch { + // 保留搜索结果地址 + } } function confirmMapPick() { @@ -659,14 +653,10 @@ function onMapModalClose() { onUnmounted(() => { if (mapInstance) { - try { - mapInstance.destroy?.() - } catch { - /* noop */ - } + clearTiandituOverlay(mapInstance, pickMarkerHolder.overlay as never) + pickMarkerHolder.overlay = null mapInstance = null } - markerLayer = null }) function openVenueDetail(row: VenueFormRow) { @@ -1901,7 +1891,7 @@ onMounted(async () => { ([]) const pagination = reactive({ current: 1, pageSize: 15, total: 0 }) const keyword = ref('') +const hasActivityReservation = ref<'' | '1' | '0'>('') +const hasTicketGrabReservation = ref<'' | '1' | '0'>('') + +function reservationBriefText(items?: ReservationBrief[]): string { + if (!items?.length) return '—' + return items.map((it) => `${it.title}(${it.status_label})`).join(';') +} async function loadAll() { loading.value = true @@ -26,6 +44,9 @@ async function loadAll() { page: pagination.current, page_size: pagination.pageSize, keyword: keyword.value.trim() || undefined, + has_activity_reservation: hasActivityReservation.value === '' ? undefined : hasActivityReservation.value === '1', + has_ticket_grab_reservation: + hasTicketGrabReservation.value === '' ? undefined : hasTicketGrabReservation.value === '1', }, }) rows.value = data.data ?? [] @@ -47,14 +68,51 @@ function onPageChange(p: number) { void loadAll() } +async function exportUsers() { + exporting.value = true + try { + const res = await http.get('/wechat-users/export', { + params: { + keyword: keyword.value.trim() || undefined, + has_activity_reservation: hasActivityReservation.value === '' ? undefined : hasActivityReservation.value === '1', + has_ticket_grab_reservation: + hasTicketGrabReservation.value === '' ? undefined : hasTicketGrabReservation.value === '1', + }, + responseType: 'blob', + timeout: 120000, + }) + const blob = res.data as Blob + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `微信用户列表-${new Date().toISOString().slice(0, 10)}.xlsx` + a.click() + URL.revokeObjectURL(url) + Message.success('已导出') + } catch (e: any) { + Message.error(e?.response?.data?.message ?? '导出失败') + } finally { + exporting.value = false + } +} + onMounted(() => void loadAll()) diff --git a/src/views/venues/VenueList.vue b/src/views/venues/VenueList.vue index fd32203..68746b9 100644 --- a/src/views/venues/VenueList.vue +++ b/src/views/venues/VenueList.vue @@ -1,14 +1,24 @@