shinnosuke
2026-08-18 99bfc2060840436d5ed06e5f37a2ce6ae0ab44e8
feat(admin): 接入高德地图并完善管理功能
2个文件已添加
13个文件已修改
718 ■■■■■ 已修改文件
talent-card-admin/.env.example 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/package-lock.json 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/package.json 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/layouts/AdminLayout.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/style.css 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/types/admin.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/ChildCardApplicationDetailView.vue 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/ChildCardView.vue 116 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MedicalReservationView.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantCreateView.vue 167 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/ChildInformationAdminController.java 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/ChildInformationAdminService.java 205 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/ProductCreateRequest.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/ProductUpdateRequest.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/.env.example
@@ -3,3 +3,7 @@
# 如果部署到独立域名,可改为后端网关地址。
# VITE_API_BASE_URL=https://api.example.com
# 高德地图 Web 端(JS API)Key 与安全密钥。
VITE_AMAP_KEY=
VITE_AMAP_SECURITY_CODE=
talent-card-admin/package-lock.json
@@ -8,13 +8,12 @@
      "name": "talent-card-admin",
      "version": "0.0.0",
      "dependencies": {
        "@amap/amap-jsapi-loader": "^1.0.1",
        "@element-plus/icons-vue": "^2.3.2",
        "@types/leaflet": "^1.9.22",
        "@wangeditor/editor": "^5.1.23",
        "@wangeditor/editor-for-vue": "^5.1.12",
        "axios": "^1.18.1",
        "element-plus": "^2.14.3",
        "leaflet": "^1.9.4",
        "pinia": "^4.0.2",
        "vue": "^3.5.39",
        "vue-router": "^4.6.4",
@@ -28,6 +27,12 @@
        "vite": "^8.1.1",
        "vue-tsc": "^3.3.5"
      }
    },
    "node_modules/@amap/amap-jsapi-loader": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/@amap/amap-jsapi-loader/-/amap-jsapi-loader-1.0.1.tgz",
      "integrity": "sha512-nPyLKt7Ow/ThHLkSvn2etQlUzqxmTVgK7bIgwdBRTg2HK5668oN7xVxkaiRe3YZEzGzfV2XgH5Jmu2T73ljejw==",
      "license": "MIT"
    },
    "node_modules/@babel/helper-string-parser": {
      "version": "7.29.7",
@@ -511,21 +516,6 @@
      "resolved": "https://registry.npmmirror.com/@types/event-emitter/-/event-emitter-0.3.5.tgz",
      "integrity": "sha512-zx2/Gg0Eg7gwEiOIIh5w9TrhKKTeQh7CPCOPNc0el4pLSwzebA8SmnHwZs2dWlLONvyulykSwGSQxQHLhjGLvQ==",
      "license": "MIT"
    },
    "node_modules/@types/geojson": {
      "version": "7946.0.16",
      "resolved": "https://registry.npmmirror.com/@types/geojson/-/geojson-7946.0.16.tgz",
      "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
      "license": "MIT"
    },
    "node_modules/@types/leaflet": {
      "version": "1.9.22",
      "resolved": "https://registry.npmmirror.com/@types/leaflet/-/leaflet-1.9.22.tgz",
      "integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==",
      "license": "MIT",
      "dependencies": {
        "@types/geojson": "*"
      }
    },
    "node_modules/@types/lodash": {
      "version": "4.17.24",
@@ -1637,12 +1627,6 @@
      "resolved": "https://registry.npmmirror.com/is-url/-/is-url-1.2.4.tgz",
      "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
      "license": "MIT"
    },
    "node_modules/leaflet": {
      "version": "1.9.4",
      "resolved": "https://registry.npmmirror.com/leaflet/-/leaflet-1.9.4.tgz",
      "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
      "license": "BSD-2-Clause"
    },
    "node_modules/lightningcss": {
      "version": "1.32.0",
talent-card-admin/package.json
@@ -9,13 +9,12 @@
    "preview": "vite preview"
  },
  "dependencies": {
    "@amap/amap-jsapi-loader": "^1.0.1",
    "@element-plus/icons-vue": "^2.3.2",
    "@types/leaflet": "^1.9.22",
    "@wangeditor/editor": "^5.1.23",
    "@wangeditor/editor-for-vue": "^5.1.12",
    "axios": "^1.18.1",
    "element-plus": "^2.14.3",
    "leaflet": "^1.9.4",
    "pinia": "^4.0.2",
    "vue": "^3.5.39",
    "vue-router": "^4.6.4",
talent-card-admin/src/layouts/AdminLayout.vue
@@ -81,11 +81,9 @@
]
const reservationMenus: MenuItem[] = [
  { path: '/appointment-management', label: '预约服务管理' },
  { path: '/medical-reservations', label: '体检预约管理' },
  { path: '/medical-applications', label: '就医申请管理' },
  { path: '/parking-plates', label: '停车车牌管理' },
  { path: '/car-wash-benefits', label: '洗车权益管理' },
  { path: '/fuel-services', label: '加油服务管理' },
  { path: '/spouse-employment', label: '配偶就业管理' },
  { path: '/hotel-reservations', label: '酒店预约管理' },
talent-card-admin/src/style.css
@@ -716,11 +716,16 @@
.merchant-school-address-panel {
  margin-top: 12px;
}
.merchant-school-address-panel,
.merchant-parking-address-panel {
  padding: 20px 28px 18px;
  background: #ffffff;
}
.merchant-school-address-title {
.merchant-school-address-title,
.merchant-parking-address-title {
  margin: 0 0 16px;
  color: #111827;
  font-size: 14px;
@@ -728,7 +733,8 @@
  text-align: left;
}
.merchant-school-address-actions {
.merchant-school-address-actions,
.merchant-parking-address-actions {
  display: flex;
  justify-content: flex-end;
  margin-top: 16px;
@@ -990,6 +996,16 @@
  grid-column: 1 / 3;
  display: flex;
  flex-direction: column;
}
.merchant-rights-parking-radio .el-radio-group {
  display: flex;
  align-items: center;
  gap: 24px;
}
.merchant-rights-parking-radio .el-radio {
  margin-right: 0;
}
.merchant-rights-detail-row label {
@@ -2048,7 +2064,9 @@
}
.merchant-coupon-edit-form .coupon-valid-time-picker {
  width: 300px;
  flex: 0 1 280px;
  width: 280px;
  max-width: 100%;
}
.merchant-coupon-edit-form .el-input__wrapper,
@@ -2057,10 +2075,6 @@
  border-radius: 0;
  background: #ffffff;
  box-shadow: 0 0 0 1px #d9dee8 inset;
}
.merchant-coupon-edit-form .el-date-editor.el-input__wrapper {
  width: 280px;
}
.merchant-coupon-edit-form .el-date-editor .el-range-input,
@@ -4553,9 +4567,6 @@
.merchant-map-results button:hover { background: #ecf5ff; color: #409eff; }
.merchant-location-coordinates { display: flex; justify-content: space-between; color: #606266; font-size: 13px; }
.merchant-location-coordinates span { color: #909399; }
.merchant-map-marker { background: transparent; border: 0; }
.merchant-map-marker span { display: block; width: 18px; height: 18px; border: 4px solid #fff; border-radius: 50% 50% 50% 0; background: #409eff; box-shadow: 0 1px 5px rgba(0, 0, 0, .35); transform: rotate(-45deg); }
.merchant-map-marker span::after { display: block; width: 6px; height: 6px; margin: 6px; border-radius: 50%; background: #fff; content: ''; }
.product-audit-preview-image { display: block; max-width: 100%; max-height: 70vh; margin: 0 auto; object-fit: contain; }
.talent-create-page .detail-block {
talent-card-admin/src/types/admin.ts
@@ -629,12 +629,14 @@
  birthDate?: string
  talentName?: string
  talentIdCardNo?: string
  talentGender?: string | number
  talentMobile?: string
  talentCardNo?: string
  cardLevel?: string
  registerTime?: string
  applyTime?: string
  status?: string | number
  rejectReason?: string
  contactAddress?: string
  workAddress?: string
  images?: string[] | string
talent-card-admin/src/views/ChildCardApplicationDetailView.vue
@@ -3,6 +3,7 @@
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Picture } from '@element-plus/icons-vue'
import { auditChildCardApplication, getChildCardApplicationDetail } from '../api/admin'
import type { ChildCardApplication } from '../types/admin'
const route = useRoute()
@@ -15,8 +16,8 @@
}
function genderText(gender?: number | string) {
  if (gender === 1 || gender === '1') return '男'
  if (gender === 2 || gender === '2') return '女'
  if (gender === 1 || gender === '1' || gender === '男') return '男'
  if (gender === 2 || gender === '2' || gender === '女') return '女'
  return '-'
}
@@ -26,7 +27,7 @@
  { label: '联系方式:', value: text(detail.value?.talentMobile) },
  { label: '联系地址:', value: text(detail.value?.contactAddress) },
  { label: '工作地址:', value: text(detail.value?.workAddress) },
  { label: '性别:', value: genderText(detail.value?.gender) }, // 这里的性别是持卡人的性别还是需要额外字段?暂定复用。如果API返回的gender是子女的,可能持卡人性别没有或者从其它字段取。根据图2,持卡人也有性别。假设接口有talentGender,这里先暂用 text 模拟,或者如果是详情接口里有,我们可以从 detail.value 里取。假设后端一并返回。
  { label: '性别:', value: genderText(detail.value?.talentGender) },
  { label: '人才等级:', value: text(detail.value?.cardLevel) },
])
@@ -47,37 +48,12 @@
  }
})
const mockDetail: ChildCardApplication = {
  id: 232,
  childName: '李一',
  gender: 1,
  birthDate: '2026-07-06',
  talentName: '李四',
  talentIdCardNo: '510664491312111',
  talentMobile: '13666666666',
  talentCardNo: '486913',
  cardLevel: '卓越人才卡',
  contactAddress: 'XXXXXXXXXXXXXX',
  workAddress: 'XXXXXXXXXXXXXX',
  status: '待审核',
  images: [
    'https://coresg-normal.trae.ai/api/ide/v1/text_to_image?prompt=birth%20certificate%20document%20photo%20white%20background&image_size=square',
    'https://coresg-normal.trae.ai/api/ide/v1/text_to_image?prompt=id%20card%20front%20side%20document%20photo%20white%20background&image_size=square',
    'https://coresg-normal.trae.ai/api/ide/v1/text_to_image?prompt=id%20card%20back%20side%20document%20photo%20white%20background&image_size=square'
  ]
}
const isPending = computed(() => detail.value?.status === '待审核')
async function loadDetail() {
  loading.value = true
  try {
    // 模拟网络请求延迟
    await new Promise(resolve => setTimeout(resolve, 300))
    detail.value = { ...mockDetail, id: route.params.id as string }
    // 真实的 API 调用逻辑,暂时注释掉
    // detail.value = await getChildCardApplicationDetail(String(route.params.id))
    detail.value = await getChildCardApplicationDetail(String(route.params.id))
  } finally {
    loading.value = false
  }
@@ -86,19 +62,18 @@
async function approveApplication() {
  if (!detail.value) return
  await ElMessageBox.confirm('确认通过该子女管理申请?', '审核通过', { type: 'warning' })
  // 模拟接口调用成功
  // await auditChildCardApplication(detail.value.id, '已审核')
  await auditChildCardApplication(detail.value.id, '已通过')
  ElMessage.success('审核已通过')
  router.back()
}
async function rejectApplication() {
  if (!detail.value) return
  await ElMessageBox.prompt('请输入不通过原因', '审核不通过', {
    inputValue: '资料不完整',
  const { value } = await ElMessageBox.prompt('请输入不通过原因', '审核不通过', {
    inputValue: '',
    inputValidator: input => input.trim() ? true : '请输入不通过原因',
  })
  // 模拟接口调用成功
  // await auditChildCardApplication(detail.value.id, '审核不通过', value)
  await auditChildCardApplication(detail.value.id, '审核不通过', value)
  ElMessage.success('操作成功')
  router.back()
}
talent-card-admin/src/views/ChildCardView.vue
@@ -29,78 +29,10 @@
const rows = ref<ChildCardApplication[]>([])
const mockData: ChildCardApplication[] = [
  {
    id: 232,
    childName: '张三',
    gender: 1,
    birthDate: '2026-07-06',
    talentMobile: '138***4568',
    talentCardNo: '486913',
    cardLevel: '钻石卡',
    registerTime: '2021-02-28 10:30',
    status: '已审核',
  },
  {
    id: 254,
    childName: '李四',
    gender: 1,
    birthDate: '2026-07-06',
    talentMobile: '138***4568',
    talentCardNo: '486913',
    cardLevel: '钻石卡',
    registerTime: '2021-02-28 10:30',
    status: '待审核',
  },
  {
    id: 46,
    childName: '张三',
    gender: 1,
    birthDate: '2026-07-06',
    talentMobile: '138***4568',
    talentCardNo: '486913',
    cardLevel: '钻石卡',
    registerTime: '2021-02-28 10:30',
    status: '已审核',
  },
  {
    id: 577,
    childName: '张三',
    gender: 1,
    birthDate: '2026-07-06',
    talentMobile: '138***4568',
    talentCardNo: '486913',
    cardLevel: '钻石卡',
    registerTime: '2021-02-28 10:30',
    status: '已审核',
  },
  {
    id: 233,
    childName: '张三',
    gender: 1,
    birthDate: '2026-07-06',
    talentMobile: '138***4568',
    talentCardNo: '846511',
    cardLevel: '钻石卡',
    registerTime: '2021-02-28 10:30',
    status: '已审核',
  },
  {
    id: 255,
    childName: '张三',
    gender: 1,
    birthDate: '2026-07-06',
    talentMobile: '138***4568',
    talentCardNo: '846511',
    cardLevel: '钻石卡',
    registerTime: '2021-02-28 10:30',
    status: '已审核',
  },
]
const statusOptions = computed(() => [
  { label: '已审核', value: '已审核' },
  { label: '待审核', value: '待审核' },
  { label: '已通过', value: '已通过' },
  { label: '审核不通过', value: '审核不通过' },
])
function statusText(row: ChildCardApplication) {
@@ -108,49 +40,18 @@
}
function registerTime(row: ChildCardApplication) {
  return row.registerTime || row.applyTime || ''
  return String(row.registerTime || row.applyTime || '').replace('T', ' ')
}
function genderText(gender?: number | string) {
  if (gender === 1 || gender === '1') return '男'
  if (gender === 2 || gender === '2') return '女'
  if (gender === 1 || gender === '1' || gender === '男') return '男'
  if (gender === 2 || gender === '2' || gender === '女') return '女'
  return '-'
}
async function loadData() {
  loading.value = true
  try {
    // 模拟网络请求延迟
    await new Promise(resolve => setTimeout(resolve, 300))
    // 使用 mock 数据
    let filteredData = [...mockData]
    if (filters.childKeyword) {
      filteredData = filteredData.filter(item => item.childName?.includes(filters.childKeyword))
    }
    if (filters.talentKeyword) {
      filteredData = filteredData.filter(item =>
        item.talentName?.includes(filters.talentKeyword) ||
        item.talentMobile?.includes(filters.talentKeyword) ||
        item.talentCardNo?.includes(filters.talentKeyword)
      )
    }
    if (filters.cardLevel) {
      filteredData = filteredData.filter(item => item.cardLevel === filters.cardLevel)
    }
    if (filters.status) {
      filteredData = filteredData.filter(item => item.status === filters.status)
    }
    const startIdx = (pagination.page - 1) * pagination.size
    const endIdx = startIdx + pagination.size
    rows.value = filteredData.slice(startIdx, endIdx)
    pagination.total = filteredData.length
    // 下面是真实的 API 调用逻辑,暂时注释掉
    /*
    const query = {
      page: pagination.page,
      size: pagination.size,
@@ -164,7 +65,6 @@
    const result = await getChildCardApplications(query)
    rows.value = result.records
    pagination.total = result.total
    */
  } catch {
    rows.value = []
    pagination.total = 0
@@ -234,10 +134,10 @@
        <el-form-item label="卡片等级">
          <el-select v-model="filters.cardLevel" placeholder="全部" clearable>
            <el-option label="全部" value="" />
            <el-option label="钻石卡" value="钻石卡" />
            <el-option label="卓越人才卡" value="卓越人才卡" />
            <el-option label="精英人才卡" value="精英人才卡" />
            <el-option label="青年人才卡" value="青年人才卡" />
            <el-option label="杰出人才卡" value="杰出人才卡" />
            <el-option label="菁英人才卡" value="菁英人才卡" />
            <el-option label="普通游客" value="普通游客" />
          </el-select>
        </el-form-item>
        <el-form-item label="状态">
talent-card-admin/src/views/MedicalReservationView.vue
@@ -67,9 +67,13 @@
  search()
}
function formatDateTime(value?: string) {
  return value ? value.replace('T', ' ') : ''
}
async function download() {
  const result = await getServiceApplications({ page: 1, size: 1000, applicationType: 'MEDICAL_RESERVATION', talentKeyword: filters.talentKeyword || undefined, hospitalName: filters.hospitalName || undefined, cardLevel: filters.cardLevel || undefined, status: filters.status || undefined, submittedStart: filters.submittedTime?.[0], submittedEnd: filters.submittedTime?.[1] })
  downloadExcel('体检预约', [{ title: '预约单号', value: row => row.applicationNo }, { title: '持卡人', value: row => row.talentName }, { title: '手机号', value: row => row.talentMobile }, { title: '人才等级', value: row => row.talentLevel }, { title: '医院', value: row => row.hospitalName }, { title: '套餐', value: row => row.packageName }, { title: '预约时间', value: row => row.expectedTime }, { title: '提交时间', value: row => row.submittedAt }, { title: '状态', value: row => row.status }], result.records)
  downloadExcel('体检预约', [{ title: '预约单号', value: row => row.applicationNo }, { title: '持卡人', value: row => row.talentName }, { title: '手机号', value: row => row.talentMobile }, { title: '人才等级', value: row => row.talentLevel }, { title: '医院', value: row => row.hospitalName }, { title: '套餐', value: row => row.packageName }, { title: '预约时间', value: row => row.expectedTime }, { title: '提交时间', value: row => formatDateTime(row.submittedAt) }, { title: '状态', value: row => row.status }], result.records)
  ElMessage.success(`已导出 ${result.records.length} 条预约`)
}
@@ -142,7 +146,9 @@
        <el-table-column prop="applicantName" label="体检人姓名" min-width="96" align="center" />
        <el-table-column prop="applicantMobile" label="手机号" min-width="112" align="center" />
        <el-table-column prop="expectedTime" label="体检期望时间" min-width="118" align="center" />
        <el-table-column prop="submittedAt" label="提交时间" min-width="126" align="center" />
        <el-table-column label="提交时间" min-width="126" align="center">
          <template #default="{ row }">{{ formatDateTime(row.submittedAt) }}</template>
        </el-table-column>
        <el-table-column prop="status" label="状态" min-width="82" align="center" />
        <el-table-column label="操作" width="86" fixed="right" align="center">
          <template #default="{ row }">
talent-card-admin/src/views/MerchantCreateView.vue
@@ -1,8 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import L, { type Map as LeafletMap, type Marker } from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { load as loadAMapApi } from '@amap/amap-jsapi-loader'
import { Minus, Picture, Plus, Refresh, Search } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
@@ -995,9 +994,23 @@
const locationSearching = ref(false)
const locationSearchResults = ref<Array<{ name: string; longitude: number; latitude: number }>>([])
const mapElement = ref<HTMLElement>()
let locationMap: LeafletMap | undefined
let locationMarker: Marker | undefined
const mapMarkerIcon = L.divIcon({ className: 'merchant-map-marker', html: '<span></span>', iconSize: [26, 26], iconAnchor: [13, 26] })
let locationMap: any
let locationMarker: any
let amapApi: any
async function loadAMap() {
  if (amapApi) return amapApi
  const key = import.meta.env.VITE_AMAP_KEY?.trim()
  const securityJsCode = import.meta.env.VITE_AMAP_SECURITY_CODE?.trim()
  if (!key || !securityJsCode) throw new Error('AMap credentials are not configured')
  ;(window as any)._AMapSecurityConfig = { securityJsCode }
  amapApi = await loadAMapApi({
    key,
    version: '2.0',
    plugins: ['AMap.PlaceSearch', 'AMap.Scale', 'AMap.ToolBar'],
  })
  return amapApi
}
interface ParkingAddress {
  name: string
@@ -1162,8 +1175,11 @@
}
function selectedCoordinates() {
  const longitude = Number(pendingLongitude.value)
  const latitude = Number(pendingLatitude.value)
  const longitudeText = pendingLongitude.value.trim()
  const latitudeText = pendingLatitude.value.trim()
  if (!longitudeText || !latitudeText) return undefined
  const longitude = Number(longitudeText)
  const latitude = Number(latitudeText)
  return Number.isFinite(longitude) && Number.isFinite(latitude) ? { longitude, latitude } : undefined
}
@@ -1171,20 +1187,38 @@
  pendingLongitude.value = longitude.toFixed(6)
  pendingLatitude.value = latitude.toFixed(6)
  if (!locationMap) return
  if (!locationMarker) locationMarker = L.marker([latitude, longitude], { icon: mapMarkerIcon }).addTo(locationMap)
  else locationMarker.setLatLng([latitude, longitude])
  if (center) locationMap.setView([latitude, longitude], Math.max(locationMap.getZoom(), 16))
  const position = [longitude, latitude]
  if (!locationMarker) {
    locationMarker = new amapApi.Marker({ position, anchor: 'bottom-center' })
    locationMap.add(locationMarker)
  } else {
    locationMarker.setPosition(position)
  }
  if (center) locationMap.setZoomAndCenter(Math.max(locationMap.getZoom(), 16), position)
}
async function initializeLocationMap() {
  await nextTick()
  if (!mapElement.value || locationMap) return
  const initial = selectedCoordinates() || { longitude: 101.718637, latitude: 26.582347 }
  locationMap = L.map(mapElement.value, { zoomControl: true }).setView([initial.latitude, initial.longitude], selectedCoordinates() ? 16 : 13)
  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '&copy; OpenStreetMap contributors' }).addTo(locationMap)
  locationMap.on('click', (event) => setMapMarker(event.latlng.lat, event.latlng.lng, false))
  if (selectedCoordinates()) setMapMarker(initial.latitude, initial.longitude, false)
  window.setTimeout(() => locationMap?.invalidateSize(), 120)
  try {
    const AMap = await loadAMap()
    const selected = selectedCoordinates()
    const initial = selected || { longitude: 101.718637, latitude: 26.582347 }
    locationMap = new AMap.Map(mapElement.value, {
      viewMode: '2D',
      zoom: selected ? 16 : 13,
      center: [initial.longitude, initial.latitude],
      resizeEnable: true,
    })
    locationMap.addControl(new AMap.Scale())
    locationMap.addControl(new AMap.ToolBar({ position: { right: '10px', top: '10px' } }))
    locationMap.on('click', (event: any) => setMapMarker(event.lnglat.getLat(), event.lnglat.getLng(), false))
    if (selected) setMapMarker(initial.latitude, initial.longitude, false)
    window.setTimeout(() => locationMap?.resize(), 120)
  } catch (error) {
    console.error('Failed to initialize AMap', error)
    ElMessage.error('高德地图加载失败,请检查地图配置或网络后重试')
  }
}
async function searchMapLocation() {
@@ -1192,12 +1226,24 @@
  if (!keyword) { ElMessage.warning('请输入地址或地点关键词'); return }
  locationSearching.value = true
  try {
    const response = await fetch(`https://nominatim.openstreetmap.org/search?format=jsonv2&limit=6&accept-language=zh-CN&q=${encodeURIComponent(keyword)}`)
    if (!response.ok) throw new Error('search failed')
    const records = await response.json() as Array<{ display_name: string; lon: string; lat: string }>
    locationSearchResults.value = records.map((item) => ({ name: item.display_name, longitude: Number(item.lon), latitude: Number(item.lat) })).filter((item) => Number.isFinite(item.longitude) && Number.isFinite(item.latitude))
    const AMap = await loadAMap()
    const placeSearch = new AMap.PlaceSearch({ city: '攀枝花', citylimit: false, pageSize: 6, pageIndex: 1, extensions: 'base' })
    const records = await new Promise<any[]>((resolve, reject) => {
      placeSearch.search(keyword, (status: string, result: any) => {
        if (status === 'complete') resolve(result?.poiList?.pois || [])
        else if (status === 'no_data') resolve([])
        else reject(new Error(result?.info || 'place search failed'))
      })
    })
    locationSearchResults.value = records.map((item) => {
      const longitude = Number(item.location?.getLng?.() ?? item.location?.lng)
      const latitude = Number(item.location?.getLat?.() ?? item.location?.lat)
      const address = [item.name, item.address].filter(Boolean).join(' - ')
      return { name: address, longitude, latitude }
    }).filter((item) => Number.isFinite(item.longitude) && Number.isFinite(item.latitude))
    if (!locationSearchResults.value.length) ElMessage.info('未找到匹配地点,请调整关键词或直接点击地图标点')
  } catch {
  } catch (error) {
    console.error('Failed to search AMap places', error)
    ElMessage.error('地点搜索失败,请检查网络后重试')
  } finally { locationSearching.value = false }
}
@@ -1209,7 +1255,7 @@
}
function closeLocationMap() {
  locationMap?.remove()
  locationMap?.destroy()
  locationMap = undefined
  locationMarker = undefined
  locationSearchResults.value = []
@@ -1656,15 +1702,7 @@
async function saveProduct() {
  const valid = await productFormRef.value?.validate().catch(() => false)
  if (!valid) return
  if (!isHealthCheckMerchant.value && !isHotelMerchant.value) {
    if (productRights.some((item) => item.free && !validFreeLimitCount(item.limitCount))) {
      ElMessage.warning('免费次数请输入 1 至 999 的整数,999 表示不限次数')
      return
    }
    if (productRights.some((item) => item.free && !validateProductExceedDiscount(item))) {
      return
    }
  } else {
  if (isHealthCheckMerchant.value) {
    if (!productValidPeriodRange.value || productValidPeriodRange.value.length !== 2) {
      ElMessage.warning('请选择可预约时间')
      return
@@ -1678,6 +1716,14 @@
        ElMessage.warning('请完善检查项目信息(名称、意义、适用性别均不能为空)')
        return
      }
    }
  } else if (!isHotelMerchant.value) {
    if (productRights.some((item) => item.free && !validFreeLimitCount(item.limitCount))) {
      ElMessage.warning('免费次数请输入 1 至 999 的整数,999 表示不限次数')
      return
    }
    if (productRights.some((item) => item.free && !validateProductExceedDiscount(item))) {
      return
    }
  }
  productSaving.value = true
@@ -1696,7 +1742,9 @@
        availableTime: rangeText(productAvailableTimeRange.value),
      },
      productImages: [...productImages.value],
      rightsConfig: productRights.map((item) => ({ ...item })),
      rightsConfig: isHealthCheckMerchant.value || isHotelMerchant.value
        ? []
        : productRights.map((item) => ({ ...item })),
    }
    if (editingProductId.value) {
      await updateProduct(editingProductId.value, payload)
@@ -2512,37 +2560,15 @@
                </div>
              </div>
            </div>
            <div v-if="isParkingMerchant || isChildcareMerchant" class="merchant-rights-detail-row consume-desc-row">
            <div v-if="isChildcareMerchant" class="merchant-rights-detail-row consume-desc-row">
              <label>到店消费使用描述</label>
              <el-input v-model="item.consumeDesc" type="textarea" :rows="4" resize="none" placeholder="请输入" />
            </div>
          </template>
        </div>
        <div v-if="isParkingMerchant" class="merchant-rights-parking-address-panel" style="padding: 0 100px;">
          <div class="parking-address-header">停车场地址</div>
          <div class="parking-address-table-wrapper">
            <el-table :data="parkingAddresses" border>
              <el-table-column label="名称">
                <template #default="{ row }"><el-input v-model="row.name" placeholder="请输入" /></template>
              </el-table-column>
              <el-table-column label="地址">
                <template #default="{ row }"><el-input v-model="row.address" placeholder="请输入" /></template>
              </el-table-column>
              <el-table-column label="定位标点" width="120" align="center">
                <template #default="{ row }">
                  <el-button type="primary" size="small" @click="markParkingLocation(row)">{{ row.latitude ? '已标点' : '未标点' }}</el-button>
                </template>
              </el-table-column>
              <el-table-column label="操作" width="100" fixed="right" align="center">
                <template #default="{ $index }">
                  <el-button link type="danger" @click="removeParkingAddress($index)">删除</el-button>
                </template>
              </el-table-column>
            </el-table>
            <div style="text-align: center; margin-top: 16px;">
              <el-button plain @click="addParkingAddress">新增</el-button>
            </div>
          <div v-if="isParkingMerchant" class="merchant-rights-detail-row consume-desc-row">
            <label>到店消费使用描述</label>
            <el-input v-model="item.consumeDesc" type="textarea" :rows="4" resize="none" placeholder="请输入" />
          </div>
        </div>
@@ -2599,6 +2625,31 @@
        </div>
      </section>
      <section v-if="isParkingMerchant" class="merchant-parking-address-panel">
        <h3 class="merchant-parking-address-title">停车场地址</h3>
        <el-table :data="parkingAddresses" border>
          <el-table-column label="名称">
            <template #default="{ row }"><el-input v-model="row.name" placeholder="请输入" /></template>
          </el-table-column>
          <el-table-column label="地址">
            <template #default="{ row }"><el-input v-model="row.address" placeholder="请输入" /></template>
          </el-table-column>
          <el-table-column label="定位标点" width="120" align="center">
            <template #default="{ row }">
              <el-button type="primary" size="small" @click="markParkingLocation(row)">{{ row.latitude ? '已标点' : '未标点' }}</el-button>
            </template>
          </el-table-column>
          <el-table-column label="操作" width="100" fixed="right" align="center">
            <template #default="{ $index }">
              <el-button link type="danger" @click="removeParkingAddress($index)">删除</el-button>
            </template>
          </el-table-column>
        </el-table>
        <div class="merchant-parking-address-actions">
          <el-button plain @click="addParkingAddress">新增</el-button>
        </div>
      </section>
      <section v-if="isChildcareMerchant" class="merchant-school-address-panel">
        <h3 class="merchant-school-address-title">学校地址</h3>
        <el-table :data="schoolAddresses" border>
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java
@@ -652,6 +652,7 @@
    @Transactional
    public Map<String, Object> createProduct(ProductCreateRequest request) {
        ensureMerchantExists(request.merchantId());
        validateProductConfiguration(request.merchantId(), request.salePrice(), request.productImages());
        validateProductRights(request.merchantId(), request.rightsConfig());
        String productNo = BusinessIdGenerator.next("P");
        BigDecimal subsidyAmount = valueOrZero(request.subsidyAmount());
@@ -686,6 +687,7 @@
    @Transactional
    public Map<String, Object> updateProduct(Long id, ProductUpdateRequest request) {
        ensureMerchantExists(request.merchantId());
        validateProductConfiguration(request.merchantId(), request.salePrice(), request.productImages());
        validateProductRights(request.merchantId(), request.rightsConfig());
        BigDecimal subsidyAmount = valueOrZero(request.subsidyAmount());
        int updated = jdbcTemplate.update("""
@@ -1057,7 +1059,7 @@
        if (rightsConfig == null) {
            return;
        }
        if (isHotelMerchant(merchantId)) {
        if (isHotelMerchant(merchantId) || isHealthCheckMerchant(merchantId)) {
            return;
        }
        for (Map<String, Object> right : rightsConfig) {
@@ -1076,16 +1078,37 @@
        }
    }
    private void validateProductConfiguration(Long merchantId, BigDecimal salePrice, List<String> productImages) {
        if (isHealthCheckMerchant(merchantId)) {
            return;
        }
        if (salePrice == null) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "商品金额不能为空");
        }
        if (productImages == null || productImages.isEmpty()) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "请至少添加一张商品图片");
        }
    }
    private boolean isHotelMerchant(Long merchantId) {
        String type = merchantType(merchantId);
        return "hotel".equalsIgnoreCase(type) || "酒店服务".equals(type);
    }
    private boolean isHealthCheckMerchant(Long merchantId) {
        String type = merchantType(merchantId);
        return "healthCheck".equalsIgnoreCase(type) || "免费体检".equals(type);
    }
    private String merchantType(Long merchantId) {
        List<String> types = jdbcTemplate.queryForList(
                "select merchant_type from merchant where deleted = 0 and id = ? limit 1",
                String.class,
                merchantId);
        if (types.isEmpty()) {
            return false;
            return "";
        }
        String type = types.get(0) == null ? "" : types.get(0).trim();
        return "hotel".equalsIgnoreCase(type) || "酒店服务".equals(type);
        return types.get(0) == null ? "" : types.get(0).trim();
    }
    private void validateCouponConfiguration(String validStart, String validEnd, String productScope, List<Long> productIds) {
talent-card-server/src/main/java/com/talentcard/server/business/admin/ChildInformationAdminController.java
New file
@@ -0,0 +1,62 @@
package com.talentcard.server.business.admin;
import java.time.LocalDate;
import java.util.Map;
import com.talentcard.server.business.PageResult;
import com.talentcard.server.common.ApiResponse;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/admin/cards/child-applications")
public class ChildInformationAdminController {
    private final ChildInformationAdminService service;
    public ChildInformationAdminController(ChildInformationAdminService service) {
        this.service = service;
    }
    @GetMapping
    public ApiResponse<PageResult<Map<String, Object>>> page(
            @RequestParam(required = false) String childKeyword,
            @RequestParam(required = false) String talentKeyword,
            @RequestParam(required = false) String cardLevel,
            @RequestParam(required = false) String status,
            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate applyStart,
            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate applyEnd,
            @RequestParam(defaultValue = "1") int page,
            @RequestParam(defaultValue = "10") int size
    ) {
        return ApiResponse.success(service.page(
                childKeyword,
                talentKeyword,
                cardLevel,
                status,
                applyStart,
                applyEnd,
                page,
                size
        ));
    }
    @GetMapping("/{id}")
    public ApiResponse<Map<String, Object>> detail(@PathVariable Long id) {
        return ApiResponse.success(service.detail(id));
    }
    @PatchMapping("/{id}/audit")
    public ApiResponse<Map<String, Object>> audit(
            @PathVariable Long id,
            @RequestBody Map<String, String> body
    ) {
        return ApiResponse.success(service.audit(id, body.get("status"), body.get("auditRemark")));
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/admin/ChildInformationAdminService.java
New file
@@ -0,0 +1,205 @@
package com.talentcard.server.business.admin;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.PageResult;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ChildInformationAdminService {
    private static final List<String> AUDIT_STATUSES = List.of("待审核", "已通过", "审核不通过");
    private final JdbcTemplate jdbc;
    public ChildInformationAdminService(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> page(
            String childKeyword,
            String talentKeyword,
            String cardLevel,
            String status,
            LocalDate applyStart,
            LocalDate applyEnd,
            int page,
            int size
    ) {
        validateFilter(status, applyStart, applyEnd);
        int safePage = Math.max(page, 1);
        int safeSize = Math.min(Math.max(size, 1), 100);
        List<Object> parameters = new ArrayList<>();
        String where = where(childKeyword, talentKeyword, cardLevel, status, applyStart, applyEnd, parameters);
        Long total = jdbc.queryForObject("select count(*) " + baseFrom() + where, Long.class, parameters.toArray());
        if (total == null || total == 0) {
            return new PageResult<>(List.of(), safePage, safeSize, 0);
        }
        List<Object> pageParameters = new ArrayList<>(parameters);
        pageParameters.add(safeSize);
        pageParameters.add((safePage - 1) * safeSize);
        List<Map<String, Object>> records = DatabaseRows.normalize(jdbc.queryForList(
                selectFields() + baseFrom() + where + " order by ci.submitted_at desc, ci.id desc limit ? offset ?",
                pageParameters.toArray()
        )).stream().map(this::view).toList();
        return new PageResult<>(records, safePage, safeSize, total);
    }
    @Transactional(readOnly = true)
    public Map<String, Object> detail(Long id) {
        List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList(
                selectFields() + baseFrom() + " where ci.id = ? and ci.deleted = 0",
                id
        ));
        if (rows.isEmpty()) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "子女信息不存在");
        }
        return view(rows.get(0));
    }
    @Transactional
    public Map<String, Object> audit(Long id, String status, String auditRemark) {
        String targetStatus = clean(status);
        if (!List.of("已通过", "审核不通过").contains(targetStatus)) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "审核状态只能为已通过或审核不通过");
        }
        String remark = clean(auditRemark);
        if ("审核不通过".equals(targetStatus) && remark.isBlank()) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "审核不通过时必须填写原因");
        }
        int updated = jdbc.update("""
                update child_information
                set audit_status = ?, reject_reason = ?, updated_at = now()
                where id = ? and deleted = 0 and audit_status = '待审核'
                """, targetStatus, "审核不通过".equals(targetStatus) ? remark : null, id);
        if (updated != 1) {
            Integer exists = jdbc.queryForObject(
                    "select count(*) from child_information where id = ? and deleted = 0",
                    Integer.class,
                    id
            );
            if (exists == null || exists == 0) {
                throw new BusinessException(ErrorCode.NOT_FOUND, "子女信息不存在");
            }
            throw new BusinessException(ErrorCode.CONFLICT, "该子女信息已审核,请刷新后重试");
        }
        return detail(id);
    }
    private String selectFields() {
        return """
                select ci.id,
                       ci.child_name,
                       ci.gender,
                       ci.birth_date,
                       ci.proof_file_ids,
                       ci.audit_status as status,
                       ci.reject_reason,
                       ci.submitted_at as apply_time,
                       ci.submitted_at as register_time,
                       u.name as talent_name,
                       u.id_card_no as talent_id_card_no,
                       u.gender as talent_gender,
                       u.mobile as talent_mobile,
                       u.contact_address,
                       u.work_address,
                       coalesce(tc.card_no, u.talent_card_no) as talent_card_no,
                       coalesce(tc.card_level, u.talent_level) as card_level
                """;
    }
    private String baseFrom() {
        return """
                 from child_information ci
                 join talent_user u on u.id = ci.talent_id and u.deleted = 0
                 left join talent_card tc on tc.id = (
                     select max(card.id)
                     from talent_card card
                     where card.talent_id = ci.talent_id
                       and card.card_type = 'PERSONAL'
                       and card.deleted = 0
                 )
                """;
    }
    private String where(
            String childKeyword,
            String talentKeyword,
            String cardLevel,
            String status,
            LocalDate applyStart,
            LocalDate applyEnd,
            List<Object> parameters
    ) {
        StringBuilder sql = new StringBuilder(" where ci.deleted = 0");
        String child = clean(childKeyword);
        if (!child.isBlank()) {
            sql.append(" and ci.child_name like ?");
            parameters.add("%" + child + "%");
        }
        String talent = clean(talentKeyword);
        if (!talent.isBlank()) {
            sql.append(" and (u.name like ? or u.mobile like ? or u.talent_card_no like ? or tc.card_no like ?)");
            String like = "%" + talent + "%";
            parameters.addAll(List.of(like, like, like, like));
        }
        String level = clean(cardLevel);
        if (!level.isBlank()) {
            sql.append(" and coalesce(tc.card_level, u.talent_level) = ?");
            parameters.add(level);
        }
        String auditStatus = clean(status);
        if (!auditStatus.isBlank()) {
            sql.append(" and ci.audit_status = ?");
            parameters.add(auditStatus);
        }
        if (applyStart != null) {
            sql.append(" and ci.submitted_at >= ?");
            parameters.add(applyStart.atStartOfDay());
        }
        if (applyEnd != null) {
            sql.append(" and ci.submitted_at < ?");
            parameters.add(applyEnd.plusDays(1).atStartOfDay());
        }
        return sql.toString();
    }
    private Map<String, Object> view(Map<String, Object> row) {
        Map<String, Object> result = new LinkedHashMap<>(row);
        String proofFileIds = clean(row.get("proofFileIds"));
        result.remove("proofFileIds");
        result.put("images", proofFileIds.isBlank()
                ? List.of()
                : Arrays.stream(proofFileIds.split(","))
                        .map(String::trim)
                        .filter(value -> !value.isBlank())
                        .toList());
        return result;
    }
    private void validateFilter(String status, LocalDate applyStart, LocalDate applyEnd) {
        String value = clean(status);
        if (!value.isBlank() && !AUDIT_STATUSES.contains(value)) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "子女信息状态不正确");
        }
        if (applyStart != null && applyEnd != null && applyStart.isAfter(applyEnd)) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "申请开始时间不能晚于结束时间");
        }
    }
    private String clean(Object value) {
        return value == null ? "" : String.valueOf(value).trim();
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/admin/ProductCreateRequest.java
@@ -7,7 +7,6 @@
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
@@ -23,7 +22,7 @@
        String subtitle,
        @NotBlank String coverUrl,
        BigDecimal originalPrice,
        @NotNull @DecimalMin(value = "0.01") @Digits(integer = 10, fraction = 2) BigDecimal salePrice,
        @DecimalMin(value = "0.01") @Digits(integer = 10, fraction = 2) BigDecimal salePrice,
        BigDecimal subsidyAmount,
        Integer stock,
        @NotBlank String detailText,
@@ -31,7 +30,7 @@
        Map<String, Object> purchaseNotice,
        String validStartTime,
        String validEndTime,
        @NotEmpty List<@NotBlank String> productImages,
        List<@NotBlank String> productImages,
        List<Map<String, Object>> rightsConfig
) {
}
talent-card-server/src/main/java/com/talentcard/server/business/admin/ProductUpdateRequest.java
@@ -7,7 +7,6 @@
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
@@ -23,7 +22,7 @@
        String subtitle,
        @NotBlank String coverUrl,
        BigDecimal originalPrice,
        @NotNull @DecimalMin(value = "0.01") @Digits(integer = 10, fraction = 2) BigDecimal salePrice,
        @DecimalMin(value = "0.01") @Digits(integer = 10, fraction = 2) BigDecimal salePrice,
        BigDecimal subsidyAmount,
        Integer stock,
        @NotBlank String detailText,
@@ -31,7 +30,7 @@
        Map<String, Object> purchaseNotice,
        String validStartTime,
        String validEndTime,
        @NotEmpty List<@NotBlank String> productImages,
        List<@NotBlank String> productImages,
        List<Map<String, Object>> rightsConfig,
        Byte status,
        Integer sortNo