场馆审核等修改

master
lion 2 weeks ago
parent 5b9d05a0d2
commit 5a42c95965

@ -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

@ -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

@ -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 }
}

@ -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<MapPlaceRow[]> {
const kw = keyword.trim()
if (!kw) return []
return tiandituMapLocalSearch(map, kw)
}
/** 逆地理(浏览器天地图 geocoder 接口,不走 Laravel */
export async function reverseMapGeocode(lat: number, lng: number): Promise<MapReverseRow> {
return tiandituBrowserReverseGeocode(lat, lng)
}

@ -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<string, string | number>,
) => 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<void> | 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<void> {
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<void>((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, unknown>): 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<string, unknown>
const err = tiandituJsonError(json)
if (err) throw new Error(err)
const result = (json.result ?? {}) as Record<string, unknown>
const comp = (result.addressComponent ?? {}) as Record<string, unknown>
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,
}
}

@ -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 () => {
<div class="dash-stat-cell__value">{{ stats.summary.reservation_order_count ?? 0 }}</div>
<div class="dash-stat-cell__label">总预约次数</div>
</div>
<div v-if="isSuperAdmin" class="dash-stat-cell dash-stat-cell--gold">
<div class="dash-stat-cell__value">{{ stats.summary.wechat_user_count ?? 0 }}</div>
<div class="dash-stat-cell__label">总用户数</div>
</div>
<div class="dash-stat-cell dash-stat-cell--sky">
<div class="dash-stat-cell__value">{{ stats.summary.user_count }}</div>
<div class="dash-stat-cell__label">用户数</div>
<div class="dash-stat-cell__hint">{{ isVenueAdmin ? '预约本场馆活动用户' : '预约用户' }}</div>
<div class="dash-stat-cell__label">预约用户数</div>
</div>
<div v-if="isSuperAdmin" class="dash-stat-cell dash-stat-cell--rose">
<div class="dash-stat-cell__value">{{ stats.summary.home_visit_total ?? 0 }}</div>
<div class="dash-stat-cell__label">总访问量</div>
</div>
<div v-if="isSuperAdmin" class="dash-stat-cell dash-stat-cell--teal">
<div class="dash-stat-cell__value">{{ stats.summary.home_visit_today ?? 0 }}</div>
<div class="dash-stat-cell__label">今日访问量</div>
</div>
</div>
</div>
@ -575,12 +633,23 @@ onMounted(async () => {
:key="'pa-' + a.id"
type="button"
class="dash-todo-line"
@click="gotoTodoFromDashboard"
@click="gotoTodoFromDashboard('activity')"
>
<span class="dash-todo-line__kind">{{ todoLineKindLabel }}</span>
<span class="dash-todo-line__name">{{ a.title }}</span>
<span class="dash-todo-line__action">去处理</span>
</button>
<button
v-for="v in pendingVenueItems"
:key="'pv-' + v.id"
type="button"
class="dash-todo-line"
@click="gotoTodoFromDashboard('venue')"
>
<span class="dash-todo-line__kind">场馆审核</span>
<span class="dash-todo-line__name">{{ v.name }}</span>
<span class="dash-todo-line__action">去处理</span>
</button>
</template>
</div>
</div>
@ -757,6 +826,9 @@ onMounted(async () => {
size="small"
/>
<a-button type="primary" size="small" :loading="pcLoading" @click="onVenuePcSearchClick"></a-button>
<a-button size="small" :loading="exportingVenuePc" :disabled="!dashboardVenuePcRows.length" @click="exportVenuePeopleExcel">
导出 Excel
</a-button>
<a-divider direction="vertical" class="dash-venue-pc-filters__vdiv" />
<a-button size="small" :disabled="pcLoading" @click="quickVenuePeopleRangeAndLoad('week')"></a-button>
<a-button size="small" :disabled="pcLoading" @click="quickVenuePeopleRangeAndLoad('month')"></a-button>
@ -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;

@ -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() {

@ -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<string, unknown> | 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<string, unknown> | null
}
/** 已通过审核的记录条数(来自 activity_audit_logs用于删除按钮等 */
approve_audit_logs_count?: number
audit_status?: 'approved' | 'pending' | 'rejected'
@ -80,6 +96,30 @@ type Activity = {
const rows = ref<Activity[]>([])
const venues = ref<Venue[]>([])
/** 活动举办场馆等引用场景:待审/退回时展示已通过快照中的名称 */
function venueReferenceDisplayName(v: Pick<Venue, 'name' | 'audit_status' | 'last_approved_snapshot'> | 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<CurrentUser | null>(null)
const loading = ref(false)
const saving = ref(false)
@ -253,8 +293,8 @@ const mapLoading = ref(false)
const mapKeyword = ref('')
const mapResults = ref<Array<{ title: string; address: string; lat: number; lng: number }>>([])
const mapContainerRef = ref<HTMLElement | null>(null)
let mapInstance: any = null
let markerLayer: any = null
let mapInstance: ReturnType<typeof createTiandituMap> | 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<void>((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<string, string[]> } }; 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<void> {
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<void> {
}
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<void> {
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"
>
<a-option v-for="v in venues" :key="v.id" :value="v.id">{{ v.name }}</a-option>
<a-option v-for="v in venues" :key="v.id" :value="v.id">{{ venueReferenceDisplayName(v) }}</a-option>
</a-select>
<a-select
v-model="filters.reservation_type"
@ -2062,7 +2087,7 @@ async function removeActivity(row: Activity) {
<template #cell="{ record }">{{ formatActivityTableDateRange(record as Activity) }}</template>
</a-table-column>
<a-table-column v-if="!isVenueAdmin()" title="场馆" :width="180" :min-width="120" :ellipsis="true" :tooltip="true">
<template #cell="{ record }">{{ record.venue?.name || '-' }}</template>
<template #cell="{ record }">{{ activityVenueDisplayName(record as Activity) }}</template>
</a-table-column>
<a-table-column title="活动性质" :width="160">
<template #cell="{ record }">{{ reservationTypeLabel((record as any).reservation_type) }}</template>
@ -2188,7 +2213,7 @@ async function removeActivity(row: Activity) {
</div>
<div class="activity-audit-inline-row">
<span class="activity-audit-inline-label">举办场馆</span>
<div class="activity-audit-inline-value">{{ auditActivityRecord.venue?.name || '—' }}</div>
<div class="activity-audit-inline-value">{{ activityVenueDisplayName(auditActivityRecord) }}</div>
</div>
<div class="activity-audit-inline-row">
<span class="activity-audit-inline-label">联系人</span>
@ -2279,7 +2304,9 @@ async function removeActivity(row: Activity) {
v-if="auditActivityRecord && reservationTypeSupportsSessionSettings(auditActivityRecord.reservation_type)"
class="activity-audit-stack"
>
<div class="activity-audit-stack__label">场次信息{{ auditBookingAudienceLabel || '—' }}</div>
<div class="activity-audit-stack__label">
场次信息<template v-if="normalizeReservationKind(auditActivityRecord.reservation_type) === 'online'">{{ auditBookingAudienceLabel || '' }}</template>
</div>
<div class="activity-audit-stack__body">
<a-table
v-if="auditBookingPreview.length"
@ -2510,7 +2537,7 @@ async function removeActivity(row: Activity) {
</a-form-item>
<a-form-item v-if="!isVenueAdmin()" label="举办场馆" required :help="formErrors.venue_id">
<a-select v-model="form.venue_id" allow-search>
<a-option v-for="v in venues" :key="v.id" :value="v.id">{{ v.name }}</a-option>
<a-option v-for="v in venues" :key="v.id" :value="v.id">{{ venueReferenceDisplayName(v) }}</a-option>
</a-select>
<template v-if="formErrors.venue_id" #help>
<span style="color: #f53f3f;">{{ formErrors.venue_id }}</span>
@ -2872,7 +2899,7 @@ async function removeActivity(row: Activity) {
<a-modal
v-model:visible="mapVisible"
title="地图选点(腾讯地图)"
title="地图选点(地图)"
width="70%"
:body-style="modalBodyStyle"
:on-before-ok="confirmMapPick"

@ -1,19 +1,32 @@
<template>
<a-card title="系统设置 / 地图与第三方配置">
<a-alert type="info" style="margin-bottom: 12px">
当前后台场馆地图选点已使用腾讯地图坐标统一为 GCJ-02火星坐标系
展示底图与后台地图选点已切换为<strong>天地图</strong>底图 <code>vec_c</code> / <code>cva_c</code>国家 2000 经纬度库内经纬度仍为<strong>腾讯 GCJ-02</strong>展示时自动换算<strong>无需批量改库</strong>微信导航仍用 GCJ-02
微信内一键导航仍走腾讯/微信原生能力与展示底图无关
</a-alert>
<a-alert type="warning" style="margin-bottom: 12px">
<strong>本地开发localhost无法加入天地图白名单时</strong>已内置 Vite 代理开发时瓦片/SDK
<code>/tianditu-tile-proxy</code><code>/tianditu-api-proxy</code>并伪造已在白名单中的 Referer默认
<code>https://szkp-map.langye.net/</code>
<code>npm run dev</code> 后访问仍用 localhost 即可可选 hosts 绑定
<code>127.0.0.1 dev.szkp-map.langye.net</code> 后用该域名打开项目
</a-alert>
<a-descriptions :column="1" bordered>
<a-descriptions-item label="前端地图Key">
`code/szkp-map-web/.env` 配置 `VITE_TENCENT_MAP_KEY=你的腾讯地图JS_KEY`
<a-descriptions-item label="本地代理 Referer可选">
<code>VITE_TIANDITU_DEV_REFERER=https://szkp-map.langye.net/</code>
<code>VITE_TIANDITU_DEV_PROXY=0</code> 可关闭代理
</a-descriptions-item>
<a-descriptions-item label="H5 / 后台浏览器 Key">
<code>code/szkp-map-h5/.env</code> <code>code/szkp-map-web/.env</code> 配置
<code>VITE_TIANDITU_TK=天地图开放平台应用 Key</code>并在控制台绑定正式/测试域名白名单
</a-descriptions-item>
<a-descriptions-item label="地图外链 referer">
`code/szkp-map-web/.env` 配置 `VITE_TENCENT_MAP_REFERER=你的应用标识`
<a-descriptions-item label="搜索 / 逆地理">
后台地图选点<strong>不经过 Laravel</strong>搜索用天地图 JS <code>T.LocalSearch</code>点击选点逆地理用浏览器请求天地图
<code>geocoder</code><code>VITE_TIANDITU_TK</code><code>/api/map/search</code> 仅保留给其他场景选点弹窗不会调用
</a-descriptions-item>
<a-descriptions-item label="后端服务Key">
`code/szkp-map-service/.env` 配置 `TENCENT_MAP_SERVER_KEY=你的腾讯地图WebService_KEY`
<a-descriptions-item label="导航(可选,保留)">
微信 H5 导航仍可使用 <code>VITE_TENCENT_MAP_KEY</code> / <code>VITE_TENCENT_MAP_REFERER</code>仅外链非首页底图
</a-descriptions-item>
</a-descriptions>
</a-card>
</template>

@ -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<HTMLElement | null>(null)
const mapTargetRow = ref<VenueFormRow | null>(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<typeof createTiandituMap> | 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<void>((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 () => {
<a-modal
v-model:visible="mapVisible"
title="地图选点(腾讯地图)"
title="地图选点(地图)"
width="70%"
:body-style="mapModalBodyStyle"
:on-before-ok="confirmMapPick"

@ -2,8 +2,15 @@
import { onMounted, reactive, ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import { http } from '../../api/http'
import { formatDateTimeZh } from '../../utils/datetime'
import { listTableRowIndex } from '../../utils/listTableRowIndex'
type ReservationBrief = {
title: string
status: string
status_label: string
}
type Row = {
id: number
nickname?: string | null
@ -11,12 +18,23 @@ type Row = {
phone?: string | null
avatar_url?: string | null
created_at?: string
visit_count?: number
activity_reservations?: ReservationBrief[]
ticket_grab_reservations?: ReservationBrief[]
}
const loading = ref(false)
const exporting = ref(false)
const rows = ref<Row[]>([])
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())
</script>
<template>
<a-card title="用户管理 / 用户列表">
<a-space style="margin-bottom: 12px" wrap>
<a-space wrap style="margin-bottom: 12px">
<a-input v-model="keyword" placeholder="搜索手机号或昵称" style="width: 240px" allow-clear @press-enter="onSearch" />
<a-select v-model="hasActivityReservation" allow-clear placeholder="是否预约活动" style="width: 150px">
<a-option value="1"></a-option>
<a-option value="0"></a-option>
</a-select>
<a-select v-model="hasTicketGrabReservation" allow-clear placeholder="是否预约抢票" style="width: 150px">
<a-option value="1"></a-option>
<a-option value="0"></a-option>
</a-select>
<a-button type="primary" @click="onSearch"></a-button>
<a-button :loading="exporting" @click="exportUsers"></a-button>
</a-space>
<a-table
@ -66,7 +124,7 @@ onMounted(() => void loadAll())
@page-change="onPageChange"
>
<template #columns>
<a-table-column title="" :width="52">
<a-table-column title="" :width="72">
<template #cell="{ rowIndex }">{{
listTableRowIndex(rowIndex, pagination.current, pagination.pageSize)
}}</template>
@ -80,9 +138,18 @@ onMounted(() => void loadAll())
</template>
</a-table-column>
<a-table-column title="手机号" data-index="phone" :width="140" />
<a-table-column title="昵称" data-index="nickname" :width="140" ellipsis tooltip />
<a-table-column title="姓名" data-index="real_name" :width="120" ellipsis tooltip />
<a-table-column title="注册时间" data-index="created_at" :width="180" />
<a-table-column title="昵称" data-index="nickname" :width="120" ellipsis tooltip />
<a-table-column title="姓名" data-index="real_name" :width="100" ellipsis tooltip />
<a-table-column title="访问量" data-index="visit_count" :width="90" align="right" />
<a-table-column title="预约活动" :min-width="180" ellipsis tooltip>
<template #cell="{ record }">{{ reservationBriefText((record as Row).activity_reservations) }}</template>
</a-table-column>
<a-table-column title="预约抢票" :min-width="180" ellipsis tooltip>
<template #cell="{ record }">{{ reservationBriefText((record as Row).ticket_grab_reservations) }}</template>
</a-table-column>
<a-table-column title="注册时间" :width="180">
<template #cell="{ record }">{{ formatDateTimeZh((record as Row).created_at) }}</template>
</a-table-column>
</template>
</a-table>
</a-card>

@ -1,14 +1,24 @@
<script setup lang="ts">
import { nextTick, onMounted, reactive, ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import { nextTick, onMounted, reactive, ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import { Message, Modal } from '@arco-design/web-vue'
import { http } from '../../api/http'
import RichEditorField from '../../components/RichEditorField.vue'
import { adminUploadImageTooLargeMessage, ADMIN_IMAGE_RECOMMEND_LABEL } from '../../utils/adminMediaLimits'
import { listTableRowIndex } from '../../utils/listTableRowIndex'
import { resolvePublicMediaUrl } from '../../utils/mediaUrl'
import { reverseMapGeocode, searchMapPlaces } from '../../utils/mapGeo'
import {
bindTiandituMapClick,
clearTiandituOverlay,
createTiandituMap,
loadTiandituApi,
refreshTiandituViewport,
setTiandituPickMarker,
} from '../../utils/tiandituMap'
/** 主列表横向滚动宽度(与各列宽之和大致对齐) */
const VENUE_LIST_SCROLL_X = 2060
const VENUE_LIST_SCROLL_X = 2130
type DictItem = {
id: number
@ -48,6 +58,7 @@ type Venue = {
is_included_in_stats?: boolean
audit_status?: 'approved' | 'pending' | 'rejected'
audit_remark?: string | null
last_approved_snapshot?: Record<string, unknown> | null
}
type CurrentUser = {
role: string
@ -76,11 +87,12 @@ const editorRenderKey = ref(0)
const mediaPreviewVisible = ref(false)
const mediaPreviewType = ref<'image' | 'video'>('image')
const mediaPreviewUrl = ref('')
let mapInstance: any = null
let markerLayer: any = null
let mapInstance: ReturnType<typeof createTiandituMap> | 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 } //
const modalBodyStyle = { maxHeight: '70vh', overflow: 'auto' }
const venueAuditModalBodyStyle = { height: '70vh', overflow: 'auto' as const }
//
const formErrors = reactive<Record<string, string>>({
@ -122,6 +134,7 @@ const filters = reactive({
/** 预约方式富文本是否已填写all=不限 */
is_active: '' as '' | '1' | '0',
is_included_in_stats: '' as '' | '1' | '0',
audit_status: '' as '' | 'pending' | 'approved' | 'rejected',
})
const pagination = reactive({
@ -130,10 +143,114 @@ const pagination = reactive({
total: 0,
})
const route = useRoute()
const rejectVenueId = ref<number | null>(null)
const rejectVenueRemark = ref('')
const rejectVenueVisible = ref(false)
type VenueAuditLogRow = {
id: number
action: string
remark?: string | null
created_at?: string
admin_user?: { name?: string; username?: string } | null
}
const venueAuditVisible = ref(false)
const venueAuditModalMode = ref<'audit' | 'view'>('view')
const venueAuditLoading = ref(false)
const venueAuditVenue = ref<Venue | null>(null)
const venueAuditLogs = ref<VenueAuditLogRow[]>([])
const venueAuditChanges = ref<Array<{ field: string }>>([])
const venueAuditChangedFieldSet = computed(
() => new Set(venueAuditChanges.value.map((c) => c.field)),
)
function venueAuditFieldChanged(field: string): boolean {
return venueAuditChangedFieldSet.value.has(field)
}
function venueAuditShowChanged(field: string): boolean {
return venueAuditModalMode.value === 'audit' && venueAuditFieldChanged(field)
}
const venueAuditModalTitle = computed(() => (
venueAuditModalMode.value === 'audit' ? '场馆审核' : '查看场馆'
))
function stripRichPlain(v: unknown): string {
if (v === null || v === undefined) return '—'
let s = String(v)
s = s.replace(/<[^>]+>/g, ' ')
s = s.replace(/&nbsp;/gi, ' ')
s = s.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"')
s = s.replace(/\s+/g, ' ').trim()
return s || '—'
}
function venueAuditBoolText(v: unknown): string {
return v === true || v === 1 || v === '1' ? '是' : '否'
}
function venueAuditThemeText(v: Partial<Venue> | Venue | null | undefined): string {
if (!v) return '—'
const list = venueTypeValues(v as Venue)
if (!list.length) return '—'
return list.map((tv) => optionLabel(venueTypeOptions.value, tv)).join('、')
}
function venueAuditPlainText(v: unknown): string {
if (v === null || v === undefined) return '—'
const s = String(v).trim()
return s || '—'
}
/** 列表展示:待审/退回且有快照时显示已通过的原值 */
function venueListRowDisplay(record: Venue): Venue {
const snap = record.last_approved_snapshot
if (
(record.audit_status === 'pending' || record.audit_status === 'rejected')
&& snap
&& typeof snap === 'object'
&& !Array.isArray(snap)
) {
return {
...record,
...(snap as Partial<Venue>),
id: record.id,
audit_status: record.audit_status,
audit_remark: record.audit_remark,
last_approved_snapshot: record.last_approved_snapshot,
}
}
return record
}
/** 查看模式展示已通过版本;审核模式展示待审内容 */
const venueAuditDisplayVenue = computed((): Venue | null => {
const v = venueAuditVenue.value
if (!v) return null
if (venueAuditModalMode.value === 'audit') return v
return venueListRowDisplay(v)
})
function venueAuditPendingDisplay(field: string, asRich = false): string {
const v = venueAuditDisplayVenue.value
if (!v) return '—'
if (field === 'venue_types') return venueAuditThemeText(v)
if (field === 'district') return optionLabel(districtOptions.value, v.district) || '—'
if (field === 'ticket_type') return optionLabel(ticketTypeOptions.value, v.ticket_type) || '—'
if (field === 'booking_mode') return optionLabel(bookingModeOptions.value, v.booking_mode) || '—'
if (field === 'open_mode') return optionLabel(openModeOptions.value, v.open_mode) || '—'
if (field === 'is_active') return venueAuditBoolText(v.is_active)
if (field === 'is_included_in_stats') return venueAuditBoolText(v.is_included_in_stats)
const raw = (v as Record<string, unknown>)[field]
if (asRich) return stripRichPlain(raw)
return venueAuditPlainText(raw)
}
function parseCoord(v: unknown): number | undefined {
if (v === null || v === undefined || v === '') return undefined
const n = typeof v === 'number' ? v : parseFloat(String(v).trim())
@ -250,10 +367,51 @@ function isSuperAdmin() {
return currentUser.value?.full_admin_access === true
}
async function approveVenue(row: Venue) {
function auditStatusLabel(status?: string) {
if (status === 'pending') return '待审核'
if (status === 'rejected') return '已退回'
if (status === 'approved') return '已通过'
return status || '—'
}
function auditLogActionLabel(action: string) {
if (action === 'approve') return '通过'
if (action === 'reject') return '退回'
if (action === 'edit_submit') return '提交审核'
return action
}
async function openVenueAudit(row: Venue, mode: 'audit' | 'view' = 'view') {
const effectiveMode =
mode === 'audit' && row.audit_status === 'pending' && isSuperAdmin() ? 'audit' : 'view'
venueAuditModalMode.value = effectiveMode
venueAuditVisible.value = true
venueAuditLoading.value = true
venueAuditVenue.value = row
venueAuditLogs.value = []
venueAuditChanges.value = []
try {
const [{ data: detail }, { data: logs }] = await Promise.all([
http.get(`/venues/${row.id}/audit-detail`),
http.get(`/venues/${row.id}/audit-logs`),
])
venueAuditVenue.value = detail.venue ?? row
venueAuditChanges.value = Array.isArray(detail.changes) ? detail.changes : []
venueAuditLogs.value = Array.isArray(logs) ? logs : []
} catch (error: any) {
Message.error(error?.response?.data?.message ?? '加载场馆详情失败')
} finally {
venueAuditLoading.value = false
}
}
async function approveVenueFromAudit() {
const row = venueAuditVenue.value
if (!row) return
try {
await http.post(`/venues/${row.id}/audit/approve`)
Message.success('已通过审核')
venueAuditVisible.value = false
await loadRows()
} catch (error: any) {
Message.error(error?.response?.data?.message ?? '操作失败')
@ -356,10 +514,6 @@ function venueTypeValues(record: Venue): string[] {
return record.venue_type ? [String(record.venue_type)] : []
}
function mapJsKey() {
return (import.meta.env.VITE_TENCENT_MAP_KEY as string) || ''
}
function normalizeMediaUrl(rawUrl?: string, rawPath?: string) {
const urlText = String(rawUrl || '').trim()
if (urlText) {
@ -436,6 +590,7 @@ async function loadRows() {
appointment_type: filters.appointment_type || undefined,
is_active: filters.is_active || undefined,
is_included_in_stats: filters.is_included_in_stats || undefined,
audit_status: filters.audit_status || undefined,
},
})
rows.value = data
@ -676,54 +831,16 @@ function onPageChange(p: number) {
pagination.current = p
}
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<void>((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 reverseGeocode(lat: number, lng: number) {
const { data } = await http.get('/map/reverse-geocode', { params: { lat, lng } })
pickedPoint.value = { lat, lng, address: data.address || '' }
const fallback = pickedPoint.value?.address || ''
const data = await reverseMapGeocode(lat, lng)
pickedPoint.value = { lat, lng, address: (data.address || '').trim() || fallback }
if (data.district) {
const exists = districtOptions.value.some((d) => d.item_value === data.district)
if (exists) form.district = data.district
@ -731,41 +848,36 @@ async function reverseGeocode(lat: number, lng: number) {
}
async function initMapPicker() {
await ensureMapSdkLoaded()
const TMap = (window as any).TMap
await loadTiandituApi()
const lat = typeof form.lat === 'number' ? form.lat : DEFAULT_CENTER.lat
const lng = typeof form.lng === 'number' ? form.lng : DEFAULT_CENTER.lng
const center = new TMap.LatLng(lat, lng)
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))
renderMarker(lat, lng)
mapInstance = createTiandituMap(mapContainerRef.value, lng, lat, 13)
bindTiandituMapClick(mapInstance, async (clickLat, clickLng) => {
renderMarker(clickLat, clickLng)
pickedPoint.value = { lat: clickLat, lng: clickLng, address: '' }
try {
await reverseGeocode(lat, lng)
await reverseGeocode(clickLat, clickLng)
} catch (error: any) {
Message.error(error?.response?.data?.message ?? '逆地理编码失败')
Message.error(error?.message ?? error?.response?.data?.message ?? '逆地理编码失败')
}
})
} else {
mapInstance.setCenter(center)
refreshTiandituViewport(mapInstance, lng, lat, 13)
}
refreshMapViewport(lat, lng)
refreshTiandituViewport(mapInstance, lng, lat, 13)
if (typeof form.lat !== 'undefined' && typeof form.lng !== 'undefined') {
renderMarker(form.lat, form.lng)
pickedPoint.value = { lat: form.lat, lng: form.lng, address: form.address || '' }
} else {
if (markerLayer) {
markerLayer.setMap(null)
markerLayer = null
}
clearTiandituOverlay(mapInstance, pickMarkerHolder.overlay as never)
pickMarkerHolder.overlay = null
pickedPoint.value = null
}
}
async function openMapPicker() {
if (!isCreate.value) return
mapVisible.value = true
mapKeyword.value = ''
mapResults.value = []
@ -793,10 +905,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
}
@ -804,11 +916,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 reverseGeocode(item.lat, item.lng)
} catch {
// ignore
//
}
}
@ -932,8 +1045,12 @@ async function submit(): Promise<boolean> {
await http.post('/venues', payload)
Message.success('创建场馆成功')
} else if (editId.value) {
await http.put(`/venues/${editId.value}`, payload)
Message.success('更新场馆成功')
const { data } = await http.put(`/venues/${editId.value}`, payload)
if (!isSuperAdmin() && data?.audit_status === 'pending') {
Message.success('信息已保存,请等待管理员审核')
} else {
Message.success('更新场馆成功')
}
}
await loadRows()
return true
@ -946,10 +1063,34 @@ async function submit(): Promise<boolean> {
}
async function onBeforeOk() {
if (!validateForm()) {
Message.warning('请填写所有必填项')
return false
}
if (!isSuperAdmin() && !isCreate.value) {
return new Promise<boolean>((resolve) => {
Modal.confirm({
title: '提交确认',
content: '请核对本次修改内容信息准确,再确认提交审核。',
okText: '确认提交',
cancelText: '取消',
async onBeforeOk() {
const ok = await submit()
resolve(ok)
return ok
},
onCancel: () => resolve(false),
})
})
}
return await submit()
}
onMounted(async () => {
const qAudit = typeof route.query.audit_status === 'string' ? route.query.audit_status : ''
if (qAudit === 'pending' || qAudit === 'approved' || qAudit === 'rejected') {
filters.audit_status = qAudit
}
await loadMe()
await Promise.all([
loadRows(),
@ -991,6 +1132,11 @@ onMounted(async () => {
<a-option value="1">上架</a-option>
<a-option value="0">下架</a-option>
</a-select>
<a-select v-if="isSuperAdmin()" v-model="filters.audit_status" allow-clear placeholder="审核状态" style="width: 130px">
<a-option value="pending">待审核</a-option>
<a-option value="approved">已通过</a-option>
<a-option value="rejected">已退回</a-option>
</a-select>
<a-button type="primary" @click="onSearch"></a-button>
<a-button type="primary" @click="openCreate"></a-button>
<a-button v-if="isSuperAdmin()" :loading="venueExporting" @click="exportVenues"></a-button>
@ -1007,17 +1153,19 @@ onMounted(async () => {
@page-change="onPageChange"
>
<template #columns>
<a-table-column title="" :width="50" :ellipsis="true" :tooltip="true">
<a-table-column title="" :width="72" :ellipsis="true" :tooltip="true">
<template #cell="{ rowIndex }">{{
listTableRowIndex(rowIndex, pagination.current, pagination.pageSize)
}}</template>
</a-table-column>
<a-table-column title="场馆名称" data-index="name" :width="220" :min-width="160" :ellipsis="true" :tooltip="true" />
<a-table-column title="场馆名称" :width="220" :min-width="160" :ellipsis="true" :tooltip="true">
<template #cell="{ record }">{{ venueListRowDisplay(record as Venue).name }}</template>
</a-table-column>
<a-table-column title="主题" :width="130">
<template #cell="{ record }">
<a-space v-if="venueTypeValues(record).length" wrap :size="4">
<a-space v-if="venueTypeValues(venueListRowDisplay(record as Venue)).length" wrap :size="4">
<a-tag
v-for="(v, i) in venueTypeValues(record)"
v-for="(v, i) in venueTypeValues(venueListRowDisplay(record as Venue))"
:key="record.id + '-vt-' + i"
:color="optionColor(venueTypeOptions, v, 'arcoblue')"
>
@ -1027,43 +1175,67 @@ onMounted(async () => {
<span v-else>-</span>
</template>
</a-table-column>
<a-table-column title="行政区" data-index="district" :width="120" :ellipsis="true" :tooltip="true" />
<a-table-column title="所属单位" data-index="unit_name" :width="200" :min-width="140" :ellipsis="true" :tooltip="true" />
<a-table-column title="行政区" :width="120" :ellipsis="true" :tooltip="true">
<template #cell="{ record }">{{ optionLabel(districtOptions, venueListRowDisplay(record as Venue).district) }}</template>
</a-table-column>
<a-table-column title="所属单位" :width="200" :min-width="140" :ellipsis="true" :tooltip="true">
<template #cell="{ record }">{{ venueListRowDisplay(record as Venue).unit_name || '—' }}</template>
</a-table-column>
<a-table-column title="门票类型" :width="120">
<template #cell="{ record }">
<a-tag :color="optionColor(ticketTypeOptions, record.ticket_type, 'green')">
{{ optionLabel(ticketTypeOptions, record.ticket_type) }}
<a-tag :color="optionColor(ticketTypeOptions, venueListRowDisplay(record as Venue).ticket_type, 'green')">
{{ optionLabel(ticketTypeOptions, venueListRowDisplay(record as Venue).ticket_type) }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="预约模式" :width="180">
<template #cell="{ record }">
<span>{{ optionLabel(bookingModeOptions, record.booking_mode) }}</span>
<span>{{ optionLabel(bookingModeOptions, venueListRowDisplay(record as Venue).booking_mode) }}</span>
</template>
</a-table-column>
<a-table-column title="开放模式" :width="150">
<template #cell="{ record }">
<span>{{ optionLabel(openModeOptions, record.open_mode) }}</span>
<span>{{ optionLabel(openModeOptions, venueListRowDisplay(record as Venue).open_mode) }}</span>
</template>
</a-table-column>
<a-table-column title="开放时间" :width="220" :min-width="160" :ellipsis="true" :tooltip="true">
<template #cell="{ record }">{{ listCellOneLine(record.open_time) }}</template>
<template #cell="{ record }">{{ listCellOneLine(venueListRowDisplay(record as Venue).open_time) }}</template>
</a-table-column>
<a-table-column title="地址" :width="280" :min-width="200" :ellipsis="true" :tooltip="true">
<template #cell="{ record }">{{ venueListRowDisplay(record as Venue).address || '—' }}</template>
</a-table-column>
<a-table-column title="排序" :width="90" :ellipsis="true" :tooltip="true">
<template #cell="{ record }">{{ venueListRowDisplay(record as Venue).sort ?? 0 }}</template>
</a-table-column>
<a-table-column title="地址" data-index="address" :width="280" :min-width="200" :ellipsis="true" :tooltip="true" />
<a-table-column title="排序" data-index="sort" :width="90" :ellipsis="true" :tooltip="true" />
<a-table-column title="上架状态" :width="100">
<template #cell="{ record }">
<a-tag :color="record.is_active ? 'green' : 'gray'">{{ record.is_active ? '上架' : '下架' }}</a-tag>
<a-tag :color="venueListRowDisplay(record as Venue).is_active ? 'green' : 'gray'">{{
venueListRowDisplay(record as Venue).is_active ? '上架' : '下架'
}}</a-tag>
</template>
</a-table-column>
<a-table-column v-if="isSuperAdmin()" title="审核状态" :width="100">
<template #cell="{ record }">
<a-tag :color="record.audit_status === 'approved' ? 'green' : record.audit_status === 'pending' ? 'orange' : 'red'">
{{ auditStatusLabel(record.audit_status) }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="操作" :width="170" fixed="right" align="left">
<a-table-column title="操作" :width="320" fixed="right" align="left">
<template #cell="{ record }">
<a-space wrap justify="start">
<a-button type="text" @click="openEdit(record)"></a-button>
<template v-if="isSuperAdmin() && (record.audit_status === 'pending' || record.audit_status === 'rejected')">
<a-button type="text" status="success" @click="approveVenue(record)"></a-button>
<a-button type="text" status="danger" @click="openRejectVenue(record)">退</a-button>
</template>
<a-button
v-if="isSuperAdmin() && record.audit_status === 'pending'"
type="text"
status="success"
@click="openVenueAudit(record, 'audit')"
>审核</a-button>
<a-button
v-else
type="text"
@click="openVenueAudit(record, 'view')"
>查看</a-button>
<a-popconfirm
v-if="isSuperAdmin()"
content="删除后该场馆关联的活动、预约等数据将一并删除,且不可恢复,确认删除?"
@ -1082,6 +1254,172 @@ onMounted(async () => {
<a-textarea v-model="rejectVenueRemark" placeholder="退回说明(选填)" :auto-size="{ minRows: 3, maxRows: 8 }" />
</a-modal>
<a-modal v-model:visible="venueAuditVisible" :title="venueAuditModalTitle" width="70%" :body-style="venueAuditModalBodyStyle">
<a-spin :loading="venueAuditLoading">
<div v-if="venueAuditVenue" class="venue-audit-panel">
<a-form layout="vertical" class="admin-modal-form venue-audit-form">
<a-form-item label="场馆名称">
<div class="venue-audit-value-box" :class="{ 'venue-audit-value-box--changed': venueAuditShowChanged('name') }">{{ venueAuditPendingDisplay('name') }}</div>
</a-form-item>
<a-form-item label="主题(可多选)">
<div class="venue-audit-value-box" :class="{ 'venue-audit-value-box--changed': venueAuditShowChanged('venue_types') }">{{ venueAuditPendingDisplay('venue_types') }}</div>
</a-form-item>
<a-form-item label="行政区">
<div class="venue-audit-value-box" :class="{ 'venue-audit-value-box--changed': venueAuditShowChanged('district') }">{{ venueAuditPendingDisplay('district') }}</div>
</a-form-item>
<a-form-item label="所属单位">
<div class="venue-audit-value-box" :class="{ 'venue-audit-value-box--changed': venueAuditShowChanged('unit_name') }">{{ venueAuditPendingDisplay('unit_name') }}</div>
</a-form-item>
<a-form-item label="门票类型">
<div class="venue-audit-value-box" :class="{ 'venue-audit-value-box--changed': venueAuditShowChanged('ticket_type') }">{{ venueAuditPendingDisplay('ticket_type') }}</div>
</a-form-item>
<a-form-item label="预约模式">
<div class="venue-audit-value-box" :class="{ 'venue-audit-value-box--changed': venueAuditShowChanged('booking_mode') }">{{ venueAuditPendingDisplay('booking_mode') }}</div>
</a-form-item>
<a-form-item label="开放模式">
<div class="venue-audit-value-box" :class="{ 'venue-audit-value-box--changed': venueAuditShowChanged('open_mode') }">{{ venueAuditPendingDisplay('open_mode') }}</div>
</a-form-item>
<a-form-item label="参观形式">
<div class="venue-audit-value-box" :class="{ 'venue-audit-value-box--changed': venueAuditShowChanged('visit_form') }">{{ venueAuditPendingDisplay('visit_form') }}</div>
</a-form-item>
<a-form-item label="开放时间">
<div class="venue-audit-value-box" :class="{ 'venue-audit-value-box--changed': venueAuditShowChanged('open_time') }">{{ venueAuditPendingDisplay('open_time') }}</div>
</a-form-item>
<a-form-item label="咨询时间">
<div class="venue-audit-value-box" :class="{ 'venue-audit-value-box--changed': venueAuditShowChanged('consultation_hours') }">{{ venueAuditPendingDisplay('consultation_hours') }}</div>
</a-form-item>
<a-form-item label="咨询电话">
<div class="venue-audit-value-box" :class="{ 'venue-audit-value-box--changed': venueAuditShowChanged('contact_phone') }">{{ venueAuditPendingDisplay('contact_phone') }}</div>
</a-form-item>
<a-form-item label="排序">
<div class="venue-audit-value-box" :class="{ 'venue-audit-value-box--changed': venueAuditShowChanged('sort') }">{{ venueAuditPendingDisplay('sort') }}</div>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="上架状态">
<div class="venue-audit-value-box" :class="{ 'venue-audit-value-box--changed': venueAuditShowChanged('is_active') }">{{ venueAuditPendingDisplay('is_active') }}</div>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="纳入市科协人数统计系统">
<div class="venue-audit-value-box" :class="{ 'venue-audit-value-box--changed': venueAuditShowChanged('is_included_in_stats') }">{{ venueAuditPendingDisplay('is_included_in_stats') }}</div>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="预约方式与预约二维码" class="admin-modal-form__full">
<div style="display: flex; gap: 16px; width: 100%">
<div style="flex: 1 1 50%; min-width: 0">
<div class="venue-form-split-label">预约方式</div>
<div class="venue-audit-value-box" :class="{ 'venue-audit-value-box--changed': venueAuditShowChanged('booking_method') }">{{ venueAuditPendingDisplay('booking_method') }}</div>
</div>
<div style="flex: 1 1 50%; min-width: 0">
<div class="venue-form-split-label">预约二维码</div>
<div
v-if="venueAuditDisplayVenue?.booking_qr_media?.length"
class="venue-gallery-grid"
:class="{ 'venue-audit-media--changed': venueAuditShowChanged('booking_qr_media') }"
>
<img
v-for="(m, i) in venueAuditDisplayVenue.booking_qr_media"
:key="`qr-${i}`"
:src="resolvePublicMediaUrl(m.url)"
class="venue-gallery-thumb"
alt=""
/>
</div>
<div v-else class="venue-audit-value-box" :class="{ 'venue-audit-value-box--changed': venueAuditShowChanged('booking_qr_media') }"></div>
</div>
</div>
</a-form-item>
<a-form-item label="门票说明" class="admin-modal-form__full">
<div class="venue-audit-value-box venue-audit-value-box--multiline" :class="{ 'venue-audit-value-box--changed': venueAuditShowChanged('ticket_content') }">{{ venueAuditPendingDisplay('ticket_content') }}</div>
</a-form-item>
<a-form-item label="场馆地址与经纬度" class="admin-modal-form__full">
<div class="venue-address-coord-row venue-audit-address-row">
<div class="venue-audit-value-box venue-address-coord-row__address" :class="{ 'venue-audit-value-box--changed': venueAuditShowChanged('address') }">{{ venueAuditPendingDisplay('address') }}</div>
<div class="venue-audit-value-box venue-address-coord-row__lng" :class="{ 'venue-audit-value-box--changed': venueAuditShowChanged('lng') }">{{ venueAuditPlainText(venueAuditDisplayVenue?.lng) }}</div>
<div class="venue-audit-value-box venue-address-coord-row__lat" :class="{ 'venue-audit-value-box--changed': venueAuditShowChanged('lat') }">{{ venueAuditPlainText(venueAuditDisplayVenue?.lat) }}</div>
</div>
</a-form-item>
<a-form-item label="科普场馆图片" class="admin-modal-form__full">
<div class="venue-cover-carousel-wrap">
<div class="venue-cover-carousel-row__col">
<div class="venue-cover-carousel-row__sub">科普场馆主图</div>
<img
v-if="venueAuditDisplayVenue?.cover_image"
:src="resolvePublicMediaUrl(venueAuditDisplayVenue.cover_image)"
class="venue-cover-thumb"
:class="{ 'venue-audit-media--changed': venueAuditShowChanged('cover_image') }"
alt=""
/>
<div v-else class="venue-audit-value-box" :class="{ 'venue-audit-value-box--changed': venueAuditShowChanged('cover_image') }"></div>
</div>
<div class="venue-cover-carousel-row__col">
<div class="venue-cover-carousel-row__sub">科普场馆展示图片</div>
<div
v-if="venueAuditDisplayVenue?.gallery_media?.length"
class="venue-gallery-grid"
:class="{ 'venue-audit-media--changed': venueAuditShowChanged('gallery_media') }"
>
<template v-for="(item, idx) in venueAuditDisplayVenue.gallery_media" :key="item.url + idx">
<img v-if="item.type === 'image'" :src="resolvePublicMediaUrl(item.url)" class="venue-gallery-thumb" alt="" />
<video v-else :src="resolvePublicMediaUrl(item.url)" controls class="venue-gallery-thumb venue-gallery-thumb--video" />
</template>
</div>
<div v-else class="venue-audit-value-box" :class="{ 'venue-audit-value-box--changed': venueAuditShowChanged('gallery_media') }"></div>
</div>
</div>
</a-form-item>
<a-form-item label="预约须知" class="admin-modal-form__full">
<div class="venue-audit-value-box venue-audit-value-box--multiline" :class="{ 'venue-audit-value-box--changed': venueAuditShowChanged('reservation_notice') }">{{ venueAuditPendingDisplay('reservation_notice', true) }}</div>
</a-form-item>
<a-form-item label="场馆简介" class="admin-modal-form__full">
<div class="venue-audit-value-box venue-audit-value-box--multiline" :class="{ 'venue-audit-value-box--changed': venueAuditShowChanged('detail_html') }">{{ venueAuditPendingDisplay('detail_html', true) }}</div>
</a-form-item>
</a-form>
<div style="margin: 16px 0 8px; color: var(--color-text-2)">审核记录</div>
<a-timeline v-if="venueAuditLogs.length">
<a-timeline-item v-for="log in venueAuditLogs" :key="log.id">
<a-tag :color="log.action === 'approve' ? 'green' : log.action === 'reject' ? 'red' : 'arcoblue'">
{{ auditLogActionLabel(log.action) }}
</a-tag>
<span v-if="log.admin_user?.name" style="margin-left: 8px">{{ log.admin_user.name }}</span>
<div v-if="log.remark" style="margin-top: 4px; color: var(--color-text-3)">{{ log.remark }}</div>
</a-timeline-item>
</a-timeline>
<span v-else style="color: var(--color-text-3)">暂无审核记录</span>
</div>
</a-spin>
<template #footer>
<a-space>
<a-button @click="venueAuditVisible = false">关闭</a-button>
<template v-if="venueAuditModalMode === 'audit'">
<a-button type="primary" @click="approveVenueFromAudit"></a-button>
<a-button status="danger" @click="venueAuditVenue && openRejectVenue(venueAuditVenue)">退回</a-button>
</template>
</a-space>
</template>
</a-modal>
<a-modal
v-model:visible="visible"
:title="isCreate ? '新增场馆' : '编辑场馆'"
@ -1172,7 +1510,7 @@ onMounted(async () => {
</template>
</a-form-item>
<a-form-item label="咨询电话" required :help="formErrors.contact_phone">
<a-input v-model="form.contact_phone" placeholder="前台可点击拨打" />
<a-input v-model="form.contact_phone" placeholder="多个电话请用、分隔" />
<template v-if="formErrors.contact_phone" #help>
<span style="color: #f53f3f;">{{ formErrors.contact_phone }}</span>
</template>
@ -1257,7 +1595,7 @@ onMounted(async () => {
hide-button
disabled
/>
<a-button v-if="isCreate" type="primary" class="venue-address-coord-row__map" @click="openMapPicker"></a-button>
<a-button type="primary" class="venue-address-coord-row__map" @click="openMapPicker"></a-button>
</div>
<template #extra>经纬度不可手动编辑请使用地图选点自动填充</template>
<template #help>
@ -1374,7 +1712,7 @@ onMounted(async () => {
<a-modal
v-model:visible="mapVisible"
title="地图选点(腾讯地图)"
title="地图选点(地图)"
width="70%"
:body-style="modalBodyStyle"
:on-before-ok="confirmMapPick"
@ -1472,4 +1810,54 @@ onMounted(async () => {
.venue-gallery-thumb--video {
display: block;
}
.venue-cover-thumb {
width: 120px;
height: 75px;
object-fit: cover;
border: 1px solid #e5e6eb;
border-radius: 4px;
}
.venue-audit-form :deep(.arco-form-item) {
margin-bottom: 16px;
}
.venue-audit-value-box {
width: 100%;
padding: 8px 12px;
min-height: 32px;
background: var(--color-fill-2);
border: 1px solid var(--color-border-2);
border-radius: 4px;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
color: var(--color-text-1);
}
.venue-audit-value-box--multiline {
min-height: 80px;
}
.venue-audit-value-box--changed {
color: #f53f3f;
border-color: rgba(245, 63, 63, 0.35);
background: rgba(245, 63, 63, 0.04);
}
.venue-audit-media--changed :deep(.venue-gallery-thumb),
img.venue-cover-thumb.venue-audit-media--changed {
outline: 2px solid #f53f3f;
outline-offset: 2px;
}
.venue-audit-address-row {
align-items: stretch;
}
.venue-audit-address-row .venue-audit-value-box {
display: flex;
align-items: center;
}
</style>

@ -1,5 +1,6 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { tiandituDevProxy } from './vite.tianditu-proxy'
// https://vite.dev/config/
export default defineConfig(({ command }) => ({
@ -9,4 +10,7 @@ export default defineConfig(({ command }) => ({
emptyOutDir: true,
},
base: command === 'build' ? '/admin/' : '/',
server: {
proxy: command === 'serve' ? tiandituDevProxy() : undefined,
},
}))

@ -0,0 +1,30 @@
import type { ProxyOptions } from 'vite'
/** 本地 dev 代理天地图时伪造的 Referer须已在天地图控制台白名单中勿填 localhost */
const DEV_REFERER = process.env.VITE_TIANDITU_DEV_REFERER?.trim() || 'https://szkp-map.langye.net/'
const refererOnProxy: ProxyOptions['configure'] = (proxy) => {
proxy.on('proxyReq', (proxyReq) => {
proxyReq.setHeader('Referer', DEV_REFERER)
})
}
/** 供 vite.config 使用localhost 无法入白名单时,走同源代理加载 SDK 与瓦片 */
export function tiandituDevProxy(): Record<string, ProxyOptions> {
return {
'/tianditu-tile-proxy': {
target: 'https://t0.tianditu.gov.cn',
changeOrigin: true,
secure: true,
rewrite: (path) => path.replace(/^\/tianditu-tile-proxy/, ''),
configure: refererOnProxy,
},
'/tianditu-api-proxy': {
target: 'https://api.tianditu.gov.cn',
changeOrigin: true,
secure: true,
rewrite: (path) => path.replace(/^\/tianditu-api-proxy/, ''),
configure: refererOnProxy,
},
}
}
Loading…
Cancel
Save