yeg
2026-09-18 70ea0911ce7519ca802266078624e9fed3559a2c
talent-card-admin/src/views/MerchantCreateView.vue
@@ -15,7 +15,7 @@
  deleteProduct as deleteProductApi,
  getAdminCouponDetail,
  getAdminCoupons,
  getAdminProducts,
  getAdminMerchantProducts,
  updateCoupon,
  updateCouponStatus,
  updateMerchant,
@@ -70,7 +70,7 @@
const merchantHasLoginAccount = ref(false)
const imageInputRef = ref<HTMLInputElement>()
const imageUploading = ref(false)
const imageUploadTarget = ref<'merchant' | 'productCover' | 'productImage' | 'approvalWindow'>('merchant')
const imageUploadTarget = ref<'merchant' | 'productCover' | 'productImage' | 'approvalWindow' | 'fuelHero'>('merchant')
const merchantCoverPreviewFailed = ref(false)
const productCoverPreviewFailed = ref(false)
const routeTab = typeof route.query.tab === 'string' ? route.query.tab : 'settings'
@@ -79,36 +79,68 @@
const hasValidMerchantId = computed(() => /^\d+$/.test(queryString('merchantId')))
const isEditMode = computed(() => route.query.mode === 'edit' && hasValidMerchantId.value)
const hasExistingMerchant = computed(() => isEditMode.value)
const merchantDetailLoaded = ref(!isEditMode.value)
const originalMerchantLoginAccount = ref('')
const normalizedMerchantType = computed(() => String(form.merchantType || '').trim())
const isFoodMerchant = computed(() => normalizedMerchantType.value === '餐饮服务')
const isApartmentMerchant = computed(() => normalizedMerchantType.value === '公寓租赁')
const isHotelMerchant = computed(() => normalizedMerchantType.value === '酒店服务')
const isCarWashMerchant = computed(() => normalizedMerchantType.value === '自助洗车')
const isHealthCheckMerchant = computed(() => normalizedMerchantType.value === '免费体检')
const isParkingMerchant = computed(() => normalizedMerchantType.value === '停车服务')
const isFuelMerchant = computed(() => normalizedMerchantType.value === '加油服务')
const isChildcareMerchant = computed(() => normalizedMerchantType.value === '子女托管')
const isFinancialMerchant = computed(() => normalizedMerchantType.value === '金融服务')
const merchantTypeAliases: Record<string, string> = {
  '餐饮服务': 'food', food: 'food',
  '景区服务': 'scenic', scenic: 'scenic',
  '酒店服务': 'hotel', hotel: 'hotel',
  '公寓租赁': 'apartment', apartment: 'apartment',
  '自助洗车': 'carWash', '自助洗车服务': 'carWash', carWash: 'carWash',
  '免费体检': 'medical', medical: 'medical',
  '停车服务': 'parking', parking: 'parking',
  '加油服务': 'fuel', fuel: 'fuel',
  '子女托管': 'children', children: 'children',
  '学生托付': 'children', '学生托管': 'children', '子女托管服务': 'children',
  '金融服务': 'finance', finance: 'finance',
  '配偶推荐就业': 'employment', employment: 'employment',
  '行政审批': 'approval', approval: 'approval',
  '观影服务': 'cinema', cinema: 'cinema', theatre: 'cinema', '电影院': 'cinema', '影院': 'cinema', '影城': 'cinema', '剧院': 'cinema',
  '购房优惠': 'housing', housing: 'housing',
  '购车服务': 'car', car: 'car',
  '家电服务': 'appliance', appliance: 'appliance',
  '数码服务': 'digital', digital: 'digital',
  '文化服务': 'culture', culture: 'culture', book: 'culture',
  '电信服务': 'telecom', telecom: 'telecom',
  '口腔治疗保健': 'dental', dental: 'dental',
}
const merchantTypeCode = computed(() => {
  const raw = String(form.merchantTypeCode || normalizedMerchantType.value).trim()
  return merchantTypeAliases[raw] || raw
})
const isFoodMerchant = computed(() => merchantTypeCode.value === 'food')
const isApartmentMerchant = computed(() => merchantTypeCode.value === 'apartment')
const isHotelMerchant = computed(() => merchantTypeCode.value === 'hotel')
const isCarWashMerchant = computed(() => merchantTypeCode.value === 'carWash')
const isHealthCheckMerchant = computed(() => merchantTypeCode.value === 'medical')
const isParkingMerchant = computed(() => merchantTypeCode.value === 'parking')
const isFuelMerchant = computed(() => merchantTypeCode.value === 'fuel')
const isChildcareMerchant = computed(() => merchantTypeCode.value === 'children')
const isFinancialMerchant = computed(() => merchantTypeCode.value === 'finance')
const isMovieMerchant = computed(() => [
  '观影服务', 'cinema', 'theatre', '电影院', '影院', '影城', '剧院',
].includes(normalizedMerchantType.value))
const isSpouseEmploymentMerchant = computed(() => normalizedMerchantType.value === '配偶推荐就业')
const isApprovalMerchant = computed(() => normalizedMerchantType.value === '行政审批')
const isServiceMerchant = computed(() => isFoodMerchant.value || normalizedMerchantType.value === '景区服务')
  'cinema',
].includes(merchantTypeCode.value))
const isSpouseEmploymentMerchant = computed(() => merchantTypeCode.value === 'employment')
const isApprovalMerchant = computed(() => merchantTypeCode.value === 'approval')
const isBookMerchant = computed(() => merchantTypeCode.value === 'culture')
const couponWithoutProducts = computed(() => isBookMerchant.value || isChildcareMerchant.value)
const couponStoreConsumeOnly = computed(() => couponWithoutProducts.value || isHotelMerchant.value)
const isServiceMerchant = computed(() => isFoodMerchant.value || merchantTypeCode.value === 'scenic')
const supportsStoreConsumption = computed(() => [
  '酒店服务', '景区服务', '餐饮服务', '口腔治疗保健', '免费体检',
].includes(normalizedMerchantType.value) || (isCarWashMerchant.value && form.miniProgramJumpType === 'normal'))
  'hotel', 'scenic', 'food', 'dental', 'culture', 'children',
].includes(merchantTypeCode.value) || (isCarWashMerchant.value && form.miniProgramJumpType === 'normal'))
const merchantAppLoginOptionalTypes = [
  '购房优惠', '购车服务', '家电服务', '数码服务', '文化服务', '电信服务', '口腔治疗保健', '加油服务',
  'housing', 'car', 'appliance', 'digital', 'culture', 'telecom', 'dental', 'fuel',
]
const simplifiedMerchantSettingsTypes = [
  '购房优惠', '购车服务', '家电服务', '数码服务', '文化服务', '电信服务', '口腔治疗保健', '加油服务',
  'housing', 'car', 'appliance', 'digital', 'culture', 'telecom', 'dental', 'fuel',
]
const usesSimplifiedMerchantSettings = computed(() => simplifiedMerchantSettingsTypes.includes(normalizedMerchantType.value))
const usesSimplifiedMerchantSettings = computed(() => simplifiedMerchantSettingsTypes.includes(merchantTypeCode.value))
const settingsAndRightsOnlyMerchantTypes = [
  '加油服务', '购房优惠', '购车服务', '家电服务', '数码服务', '文化服务', '电信服务',
  'fuel', 'housing', 'car', 'appliance', 'digital', 'culture', 'telecom',
]
const usesSettingsAndRightsOnlyTabs = computed(() => settingsAndRightsOnlyMerchantTypes.includes(normalizedMerchantType.value))
const usesSettingsAndRightsOnlyTabs = computed(() => settingsAndRightsOnlyMerchantTypes.includes(merchantTypeCode.value))
const supportsMerchantAppLogin = computed(() => (
  isServiceMerchant.value
  || isApartmentMerchant.value
@@ -121,12 +153,13 @@
  || isMovieMerchant.value
  || isSpouseEmploymentMerchant.value
  || isApprovalMerchant.value
  || merchantAppLoginOptionalTypes.includes(normalizedMerchantType.value)
  || merchantAppLoginOptionalTypes.includes(merchantTypeCode.value)
))
const requiresMerchantOperator = computed(() => (!isEditMode.value || !merchantHasLoginAccount.value) && (
  normalizedMerchantType.value === '景区服务'
const merchantOperatorEnabled = computed(() => (
  merchantTypeCode.value === 'scenic'
  || (supportsMerchantAppLogin.value && Number(form.canLoginMerchantApp) === 1)
))
const requiresMerchantOperator = computed(() => (!isEditMode.value || !merchantHasLoginAccount.value) && merchantOperatorEnabled.value)
function carWashJumpMeta() {
  if (!isCarWashMerchant.value) return {}
  if (form.miniProgramJumpType !== 'miniprogram') {
@@ -167,7 +200,8 @@
function defaultMerchantRecommend() {
  const merchantType = queryString('merchantType')
  return merchantType === '餐饮服务' || merchantType === '景区服务' ? 1 : 0
  const merchantTypeCode = merchantTypeAliases[merchantType] || merchantType
  return merchantTypeCode === 'food' || merchantTypeCode === 'scenic' ? 1 : 0
}
function applyNewMerchantDefaults() {
@@ -175,11 +209,28 @@
  const merchantType = queryString('merchantType')
  form.merchantType = merchantType
  form.recommend = merchantType === '餐饮服务' || merchantType === '景区服务' ? 1 : 0
  form.merchantTypeCode = ''
  const merchantTypeCode = merchantTypeAliases[merchantType] || merchantType
  form.recommend = merchantTypeCode === 'food' || merchantTypeCode === 'scenic' ? 1 : 0
  form.sortNo = undefined
}
function requiresRightsTag() {
  return !(isCarWashMerchant.value && form.miniProgramJumpType === 'miniprogram') && !isApprovalMerchant.value
}
function hasMissingRightsTag() {
  return requiresRightsTag() && rightsConfigs.some((item) => !String(item.tag || '').trim())
}
const tabs = computed(() => {
  if (couponWithoutProducts.value) {
    return [
      { label: '商家设置', value: 'settings' },
      { label: '商家权益', value: 'rights' },
      { label: '优惠券', value: 'coupons' },
    ]
  }
  if (usesSettingsAndRightsOnlyTabs.value) {
    return [
      { label: '商家设置', value: 'settings' },
@@ -199,7 +250,7 @@
      { label: '团单', value: 'products' },
    ]
  }
  if (isParkingMerchant.value || isChildcareMerchant.value || isFinancialMerchant.value || isMovieMerchant.value || isSpouseEmploymentMerchant.value || isApprovalMerchant.value) {
  if (isParkingMerchant.value || isFinancialMerchant.value || isMovieMerchant.value || isSpouseEmploymentMerchant.value || isApprovalMerchant.value) {
    return [
      { label: '商家设置', value: 'settings' },
      { label: '商家权益', value: 'rights' },
@@ -227,7 +278,7 @@
  ]
})
const form = reactive<MerchantPayload & { createdAt: string }>({
const form = reactive<MerchantPayload & { createdAt: string; merchantTypeCode?: string }>({
  merchantName: '',
  merchantType: queryString('merchantType', ''),
  contactMobile: '',
@@ -252,7 +303,7 @@
const merchantRules = computed<FormRules>(() => ({
  merchantName: [{ required: true, message: '请输入商家名称', trigger: 'blur' }],
  contactMobile: [{ required: true, message: '请输入联系电话', trigger: 'blur' }],
  address: isApartmentMerchant.value ? [] : [{ required: true, message: '请输入商家地址', trigger: 'blur' }],
  address: isApartmentMerchant.value ? [] : [{ required: true, message: '请输入商家地址', trigger: ['blur', 'change'] }],
  coverUrl: [{ required: true, message: '请上传商家封面图', trigger: 'change' }],
  sortNo: [{
    validator: (_rule, value, callback) => {
@@ -270,8 +321,13 @@
  loginPassword: '',
  confirmPassword: '',
})
// 编辑已有登录账号的商家时,密码可以留空(留空表示不修改),此时不标必填、也不校验。
const passwordFieldsRequired = computed(() => requiresMerchantOperator.value || (isEditMode.value && accountForm.loginPassword.length > 0))
const accountRules = computed<FormRules>(() => ({
  loginAccount: [{
    // 规则里必须显式声明 required:否则 el-form-item 上的 required 会让 Element Plus
    // 追加一条只有 required 的空规则,报出框架默认的英文 "loginAccount is required"。
    required: true,
    validator: (_rule, value, callback) => {
      if (!requiresMerchantOperator.value) return callback()
      if (!String(value || '').trim()) return callback(new Error('请输入登录账号'))
@@ -281,6 +337,7 @@
    trigger: 'blur',
  }],
  loginPassword: [{
    required: true,
    validator: (_rule, value, callback) => {
      const updatingPassword = isEditMode.value && String(value || '').length > 0
      if (!requiresMerchantOperator.value && !updatingPassword) return callback()
@@ -291,6 +348,7 @@
    trigger: 'blur',
  }],
  confirmPassword: [{
    required: true,
    validator: (_rule, value, callback) => {
      const needsConfirmation = requiresMerchantOperator.value || (isEditMode.value && accountForm.loginPassword.length > 0)
      if (!needsConfirmation) return callback()
@@ -308,11 +366,26 @@
}
async function checkLoginAccountAvailable() {
  if (!requiresMerchantOperator.value) return true
  if (!/^[A-Za-z0-9]{1,32}$/.test(accountForm.loginAccount)) return false
  // Loading an edit record programmatically updates and may blur this field.
  // Do not validate until its current operator account has been identified.
  if (isEditMode.value && !merchantDetailLoaded.value) return true
  const normalizedAccount = accountForm.loginAccount.trim()
  if (isEditMode.value && originalMerchantLoginAccount.value !== ''
      && normalizedAccount === originalMerchantLoginAccount.value) {
    accountTaken.value = false
    return true
  }
  // An existing merchant keeps its owner account; it must not be checked
  // against itself, and any stale validation message should be cleared.
  if (!requiresMerchantOperator.value) {
    accountTaken.value = false
    return true
  }
  if (!/^[A-Za-z0-9]{1,32}$/.test(normalizedAccount)) return false
  accountChecking.value = true
  try {
    const result = await checkMerchantLoginAccount(accountForm.loginAccount)
    const currentMerchantId = isEditMode.value ? queryString('merchantId') : undefined
    const result = await checkMerchantLoginAccount(normalizedAccount, currentMerchantId)
    accountTaken.value = !result.available
    if (accountTaken.value) ElMessage.warning('登录账号已存在,请更换')
    return result.available
@@ -531,7 +604,7 @@
  }],
  validTime: [{ required: true, message: '请选择优惠券有效期', trigger: 'change' }],
  productScope: [{
    validator: (_rule, value, callback) => value !== 'selected' || selectedCouponProductIds.value.length > 0
    validator: (_rule, value, callback) => couponStoreConsumeOnly.value || value !== 'selected' || selectedCouponProductIds.value.length > 0
      ? callback()
      : callback(new Error('请至少选择一个可用商品')),
    trigger: 'change',
@@ -548,18 +621,18 @@
const businessHours = reactive<BusinessHour[]>([{ id: 1, value: [] }])
function defaultMerchantRights(): MerchantRightConfig[] {
  const currentType = queryString('merchantType', '') || normalizedMerchantType.value
  const supportsDefaultStoreConsumption = ['餐饮服务', '景区服务', '口腔治疗保健', '免费体检'].includes(currentType)
    || (currentType === '自助洗车' && form.miniProgramJumpType === 'normal')
  const isHotel = currentType === '酒店服务'
  const isCarWash = currentType === '自助洗车'
  const isHealthCheck = currentType === '免费体检'
  const isParking = currentType === '停车服务'
  const isChildcare = currentType === '子女托管'
  const isFinancial = currentType === '金融服务'
  const isMovie = currentType === '观影服务'
  const isSpouseEmployment = currentType === '配偶推荐就业'
  const isApproval = currentType === '行政审批'
  const currentType = merchantTypeCode.value
  const supportsDefaultStoreConsumption = ['food', 'scenic', 'dental', 'culture', 'children'].includes(currentType)
    || (currentType === 'carWash' && form.miniProgramJumpType === 'normal')
  const isHotel = currentType === 'hotel'
  const isCarWash = currentType === 'carWash'
  const isHealthCheck = currentType === 'medical'
  const isParking = currentType === 'parking'
  const isChildcare = currentType === 'children'
  const isFinancial = currentType === 'finance'
  const isMovie = currentType === 'cinema'
  const isSpouseEmployment = currentType === 'employment'
  const isApproval = currentType === 'approval'
  if (isHealthCheck) {
    return [
@@ -568,33 +641,33 @@
        enabled: true,
        tag: '',
        detail: '',
        storeConsume: true,
        storeConsume: false,
        discount: '',
        governmentSubsidy: true,
        governmentSubsidy: false,
        consumeDesc: '',
        consumeEnabled: true,
        consumeEnabled: false,
      },
      {
        level: '杰出人才卡',
        enabled: true,
        tag: '',
        detail: '',
        storeConsume: true,
        storeConsume: false,
        discount: '',
        governmentSubsidy: true,
        governmentSubsidy: false,
        consumeDesc: '',
        consumeEnabled: true,
        consumeEnabled: false,
      },
      {
        level: '菁英人才卡',
        enabled: true,
        tag: '',
        detail: '',
        storeConsume: true,
        storeConsume: false,
        discount: '',
        governmentSubsidy: true,
        governmentSubsidy: false,
        consumeDesc: '',
        consumeEnabled: true,
        consumeEnabled: false,
      },
    ]
  }
@@ -762,33 +835,33 @@
          enabled: true,
          tag: '',
          detail: '',
          storeConsume: false,
          storeConsume: true,
          discount: '',
          governmentSubsidy: false,
          governmentSubsidy: true,
          consumeDesc: '',
          consumeEnabled: false,
          consumeEnabled: true,
        },
        {
          level: '杰出人才卡',
          enabled: true,
          tag: '',
          detail: '',
          storeConsume: false,
          storeConsume: true,
          discount: '',
          governmentSubsidy: false,
          governmentSubsidy: true,
          consumeDesc: '',
          consumeEnabled: false,
          consumeEnabled: true,
        },
        {
          level: '菁英人才卡',
          enabled: true,
          tag: '',
          detail: '',
          storeConsume: false,
          storeConsume: true,
          discount: '',
          governmentSubsidy: false,
          governmentSubsidy: true,
          consumeDesc: '',
          consumeEnabled: false,
          consumeEnabled: true,
        },
      ]
    }
@@ -1041,6 +1114,11 @@
const parkingAddresses = ref<ParkingAddress[]>([])
const activeParkingAddress = ref<ParkingAddress>()
const fuelStores = ref<FuelStore[]>([])
// 加油服务详情页轮播图(后台维护,保存时整表替换)。
const fuelHeroImages = ref<Array<{ id?: number | string; imageUrl: string; enabled: boolean }>>([])
// 可配置详情页轮播图的商家类型:加油服务、子女托管、行政审批。
const supportsHeroImages = computed(() => isFuelMerchant.value || isChildcareMerchant.value || isApprovalMerchant.value)
const merchantHeroImageTip = computed(() => isFuelMerchant.value ? '加油服务' : (isChildcareMerchant.value ? '子女托管' : '行政审批'))
const activeFuelStore = ref<FuelStore>()
const schoolAddresses = ref<SchoolAddress[]>([])
const activeSchoolAddress = ref<SchoolAddress>()
@@ -1213,13 +1291,15 @@
  return new Promise((resolve) => {
    const geolocation = window.navigator?.geolocation
    if (!geolocation) {
      ElMessage.info('当前浏览器不支持定位,请直接搜索或点击地图标记')
      console.info('browser geolocation is unavailable, fall back to the default map center')
      resolve(undefined)
      return
    }
    const hostname = window.location.hostname
    // 浏览器只允许在安全上下文(HTTPS 或 localhost)里定位,局域网 IP + HTTP 必然被拒。
    // 定位只是"把地图居中"的便利功能,这里静默跳过,不再弹提示打断选点流程。
    if (!window.isSecureContext && hostname !== 'localhost' && hostname !== '127.0.0.1') {
      ElMessage.warning('浏览器定位需要使用 localhost 或 HTTPS 地址访问')
      console.info('browser geolocation requires a secure context; keeping the default map center')
      resolve(undefined)
      return
    }
@@ -1230,7 +1310,7 @@
        resolve(Number.isFinite(longitude) && Number.isFinite(latitude) ? { longitude, latitude } : undefined)
      },
      (error) => {
        if (error.code === error.PERMISSION_DENIED) ElMessage.info('未获得浏览器定位权限,请直接搜索或点击地图标记')
        console.info(`browser geolocation unavailable: ${error.code}`)
        resolve(undefined)
      },
      { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 },
@@ -1328,10 +1408,14 @@
    activeParkingAddress.value.address = normalizedAddress
  } else if (activeSchoolAddress.value) {
    activeSchoolAddress.value.address = normalizedAddress
    // 地图选点/反查地址是程序写入,不会触发 blur/change,旧的"请输入地址"提示会一直留着。
    schoolFormRef.value?.clearValidate('address')
  } else if (activeFuelStore.value) {
    activeFuelStore.value.address = normalizedAddress
  } else {
    form.address = normalizedAddress
    // 同理:选完地址立刻清掉"请输入商家地址",避免看起来像没选成功。
    merchantFormRef.value?.clearValidate('address')
  }
  locationSearch.value = normalizedAddress
}
@@ -1395,7 +1479,7 @@
  ElMessage.success('地图位置已标记')
}
function changeCover(target: 'merchant' | 'productCover' | 'productImage' | 'approvalWindow') {
function changeCover(target: 'merchant' | 'productCover' | 'productImage' | 'approvalWindow' | 'fuelHero') {
  imageUploadTarget.value = target
  if (imageInputRef.value) imageInputRef.value.accept = target === 'approvalWindow' ? 'application/pdf,.pdf' : 'image/jpeg,image/png,image/webp'
  imageInputRef.value?.click()
@@ -1464,6 +1548,10 @@
      productForm.coverUrl = result.url
      resetProductCoverPreview()
      productFormRef.value?.validateField('coverUrl').catch(() => undefined)
    } else if (imageUploadTarget.value === 'fuelHero') {
      if (!fuelHeroImages.value.some((image) => image.imageUrl === result.url)) {
        fuelHeroImages.value.push({ imageUrl: result.url, enabled: true })
      }
    } else if (!productImages.value.includes(result.url)) {
      productImages.value.push(result.url)
    }
@@ -1494,11 +1582,13 @@
  }
  rightsConfigs.splice(0, rightsConfigs.length, ...defaultMerchantRights().map((item) => {
    const saved = Array.isArray(savedRights) ? savedRights.find((entry) => entry?.level === item.level) : undefined
    return {
    const merged = {
      ...item,
      ...(saved || {}),
      consumeEnabled: item.consumeEnabled
    }
    if (isParkingMerchant.value && merged.parkingType === 'paid') merged.consumeEnabled = true
    if (supportsStoreConsumption.value && merged.level !== '普通游客') merged.consumeEnabled = true
    return merged
  }))
  syncCarWashJumpMeta()
}
@@ -1523,6 +1613,9 @@
    const payload = { ...item }
    const supportsItemStoreConsumption = supportsStoreConsumption.value
      || (isParkingMerchant.value && item.parkingType === 'paid')
    if (isParkingMerchant.value && item.parkingType === 'paid') {
      payload.consumeEnabled = true
    }
    if (!supportsItemStoreConsumption) {
      payload.storeConsume = false
      payload.discount = ''
@@ -1645,6 +1738,11 @@
    return
  }
  if (hasMissingRightsTag()) {
    ElMessage.warning('请填写各个人才卡等级的标签')
    return
  }
  rightsSaving.value = true
  try {
    await updateMerchant(merchantId, {
@@ -1660,6 +1758,7 @@
      canLoginMerchantApp: Number(form.canLoginMerchantApp ?? 1),
      groupBuyingEnabled: form.groupBuyingEnabled,
      applicableStores: isFuelMerchant.value ? fuelStores.value : undefined,
      heroImages: supportsHeroImages.value ? fuelHeroImages.value : undefined,
      usageInstructions: isParkingMerchant.value
        ? JSON.stringify(parkingAddresses.value)
        : isChildcareMerchant.value
@@ -1710,6 +1809,12 @@
  const amount = row.salePrice ?? row.originalPrice
  if (amount === undefined || amount === null) return ''
  return Number(amount).toFixed(2).replace(/\.00$/, '')
}
// 销量是计数:0 也要显示成 0,不能因为 0 变空白让人以为没统计出来。
function salesCountText(row: Product) {
  const count = row.salesCount
  return count === undefined || count === null ? 0 : count
}
function parseJsonValue<T>(value: unknown, fallback: T): T {
@@ -1781,10 +1886,9 @@
  productLoading.value = true
  try {
    const result = await getAdminProducts({
    const result = await getAdminMerchantProducts(merchantId, {
      page: productPagination.page,
      size: productPagination.size,
      merchantId,
      keyword: productFilters.productName || undefined,
      status: productFilters.status,
      createdStart: productFilters.createdTime?.[0],
@@ -2016,12 +2120,17 @@
}
async function loadCouponProducts() {
  if (couponStoreConsumeOnly.value) {
    couponProducts.value = []
    selectedCouponProductIds.value = []
    return
  }
  const merchantId = currentMerchantId()
  if (!merchantId) {
    couponProducts.value = []
    return
  }
  const result = await getAdminProducts({ page: 1, size: 100, merchantId })
  const result = await getAdminMerchantProducts(merchantId, { page: 1, size: 100 })
  couponProducts.value = result.records
}
@@ -2045,7 +2154,7 @@
  couponEditForm.couponAmount = ''
  couponEditForm.talentLevels = ['卓越人才卡', '杰出人才卡', '菁英人才卡']
  couponEditForm.validTime = []
  couponEditForm.productScope = 'selected'
  couponEditForm.productScope = couponStoreConsumeOnly.value ? 'all' : 'selected'
  couponEditForm.storeConsume = true
  couponEditForm.governmentSubsidy = true
  couponEditForm.status = 1
@@ -2073,7 +2182,7 @@
  try { savedQuantities = typeof detail.talentLevelQuantities === 'string' ? JSON.parse(detail.talentLevelQuantities) : (detail.talentLevelQuantities || {}) } catch { savedQuantities = {} }
  couponEditForm.talentLevels.forEach((level) => { couponLevelQuantities[level] = Number(savedQuantities[level] || 1) })
  couponEditForm.validTime = detail.validStart && detail.validEnd ? [detail.validStart, detail.validEnd] : []
  couponEditForm.productScope = detail.productScope === 'ALL' ? 'all' : 'selected'
  couponEditForm.productScope = couponStoreConsumeOnly.value || detail.productScope === 'ALL' ? 'all' : 'selected'
  couponEditForm.storeConsume = detail.storeConsume === true || detail.storeConsume === 1
  couponEditForm.governmentSubsidy = detail.governmentSubsidy === true || detail.governmentSubsidy === 1
  couponEditForm.status = detail.status ?? 1
@@ -2145,9 +2254,9 @@
    talentLevelQuantities: Object.fromEntries(couponEditForm.talentLevels.map((level) => [level, Number(couponLevelQuantities[level] || 1)])),
    validStart: couponEditForm.validTime?.[0],
    validEnd: couponEditForm.validTime?.[1],
    productScope: couponEditForm.productScope === 'all' ? 'ALL' : 'SELECTED',
    productIds: couponEditForm.productScope === 'all' ? [] : selectedCouponProductIds.value,
    storeConsume: couponEditForm.storeConsume,
    productScope: couponStoreConsumeOnly.value || couponEditForm.productScope === 'all' ? 'ALL' : 'SELECTED',
    productIds: couponStoreConsumeOnly.value || couponEditForm.productScope === 'all' ? [] : selectedCouponProductIds.value,
    storeConsume: couponStoreConsumeOnly.value || couponEditForm.storeConsume,
    governmentSubsidy: couponEditForm.governmentSubsidy,
    status: couponEditForm.status,
  }
@@ -2286,6 +2395,7 @@
      loginPassword: !isEditMode.value || updatingMerchantPassword || !merchantHasLoginAccount.value ? accountForm.loginPassword : undefined,
      groupBuyingEnabled: form.groupBuyingEnabled,
      applicableStores: isFuelMerchant.value ? fuelStores.value : undefined,
      heroImages: supportsHeroImages.value ? fuelHeroImages.value : undefined,
      usageInstructions: isParkingMerchant.value
         ? JSON.stringify(parkingAddresses.value)
         : isChildcareMerchant.value
@@ -2357,6 +2467,10 @@
  if (isCarWashMerchant.value) syncCarWashJumpMeta()
})
watch(requiresMerchantOperator, (required) => {
  if (!required) accountTaken.value = false
})
// The create page is reused when the service type changes, so reset these
// create-only defaults from the current route instead of retaining old values.
watch(() => [route.query.mode, route.query.merchantType], applyNewMerchantDefaults, { immediate: true })
@@ -2372,7 +2486,9 @@
         form.canLoginMerchantApp = Number(merchant.canLoginMerchantApp ?? 1)
         form.recommend = Number(merchant.isRecommend ?? 0)
         accountForm.loginAccount = merchant.loginAccount || ''
         originalMerchantLoginAccount.value = String(merchant.loginAccount || '').trim()
         merchantHasLoginAccount.value = Boolean(String(merchant.loginAccount || '').trim())
         accountTaken.value = false
      if (isParkingMerchant.value) {
        parkingAddresses.value = parseJsonValue<ParkingAddress[]>(merchant.usageInstructions, [])
      } else if (isFuelMerchant.value) {
@@ -2390,9 +2506,21 @@
      } else if (isApprovalMerchant.value) {
        approvalItems.value = parseJsonValue<ApprovalItem[]>(merchant.usageInstructions, [])
      }
      // 详情页轮播图(加油 / 子女托管 / 行政审批)统一从 heroImages 读。
      if (supportsHeroImages.value) {
        fuelHeroImages.value = Array.isArray(merchant.heroImages)
          ? merchant.heroImages.map((image: any) => ({
            id: image.id,
            imageUrl: String(image.imageUrl ?? image.image_url ?? ''),
            enabled: Number(image.enabled ?? 1) === 1,
          })).filter((image: any) => image.imageUrl !== '')
          : []
      }
         resetBusinessHours(merchant.businessHours)
         resetMerchantRights(merchant.merchantRightsConfig)
      resetCarWashJumpFromMerchant(merchant as Record<string, any>)
      }).finally(() => {
         merchantDetailLoaded.value = true
      })
   }
  if (activeTab.value === 'products') {
@@ -2455,11 +2583,6 @@
            </el-form-item>
            <template v-if="!isApartmentMerchant && !usesSimplifiedMerchantSettings">
              <div class="merchant-form-row recommend-row">
                <label>开启团购</label>
                <el-switch v-model="form.groupBuyingEnabled" :active-value="1" :inactive-value="0" />
              </div>
              <div class="merchant-form-row">
                <label>服务标签</label>
                <el-input v-model="form.serviceLabel" placeholder="如:到店消费" />
@@ -2542,13 +2665,13 @@
          <el-form-item v-if="supportsMerchantAppLogin" class="merchant-account-login-toggle" label="登录商户端" label-width="100px">
            <el-switch v-model="form.canLoginMerchantApp" :active-value="1" :inactive-value="0" />
          </el-form-item>
          <el-form-item class="merchant-account-field" label="登录账号" prop="loginAccount" :required="requiresMerchantOperator" :error="accountTaken ? '登录账号已存在,请更换' : ''">
          <el-form-item class="merchant-account-field" label="登录账号" prop="loginAccount" :required="merchantOperatorEnabled" :error="requiresMerchantOperator && accountTaken ? '登录账号已存在,请更换' : ''">
            <el-input v-model="accountForm.loginAccount" maxlength="32" :disabled="isEditMode && merchantHasLoginAccount" :placeholder="isEditMode && !merchantHasLoginAccount ? '请输入登录账号' : (isEditMode ? '已配置登录账号' : '仅支持英文和数字')" @input="normalizeLoginAccount" @blur="checkLoginAccountAvailable" />
          </el-form-item>
          <el-form-item class="merchant-account-field" label="登录密码" prop="loginPassword" :required="requiresMerchantOperator || (isEditMode && Boolean(accountForm.loginPassword))">
          <el-form-item class="merchant-account-field" label="登录密码" prop="loginPassword" :required="passwordFieldsRequired">
            <el-input v-model="accountForm.loginPassword" type="password" show-password :placeholder="isEditMode ? '留空则不修改,6至64位' : ''" />
          </el-form-item>
          <el-form-item class="merchant-account-field" label="确认密码" prop="confirmPassword" :required="requiresMerchantOperator || (isEditMode && Boolean(accountForm.loginPassword))">
          <el-form-item class="merchant-account-field" label="确认密码" prop="confirmPassword" :required="passwordFieldsRequired">
            <el-input v-model="accountForm.confirmPassword" type="password" show-password :placeholder="isEditMode ? '再次输入新密码' : ''" />
          </el-form-item>
        </el-form>
@@ -2560,11 +2683,6 @@
    <template v-else-if="activeTab === 'rights'">
      <section class="merchant-rights-panel" :class="{ 'movie-merchant-rights-panel': isMovieMerchant }">
        <h3 v-if="isMovieMerchant" class="merchant-rights-section-title">商家权益</h3>
        <div v-if="!isApartmentMerchant && !isCarWashMerchant && !isHealthCheckMerchant && !isParkingMerchant && !isChildcareMerchant && !isFinancialMerchant && !isMovieMerchant && !isSpouseEmploymentMerchant && !isApprovalMerchant" class="merchant-rights-top-row">
          <strong>团单服务</strong>
          <el-switch v-model="form.groupBuyingEnabled" :active-value="1" :inactive-value="0" />
        </div>
        <div v-if="isCarWashMerchant" class="merchant-rights-top-row carwash-miniprogram-row">
          <el-radio-group v-model="form.miniProgramJumpType" class="carwash-jump-radio">
@@ -2581,8 +2699,10 @@
          <div class="merchant-rights-level-row">
            <strong>{{ item.level }}</strong>
            <el-switch v-model="item.enabled" />
            <span>标签</span>
            <el-input v-model="item.tag" placeholder="请输入" />
            <template v-if="requiresRightsTag()">
              <span><i class="merchant-rights-required">*</i>标签</span>
              <el-input v-model="item.tag" placeholder="请输入" />
            </template>
          </div>
          <div class="merchant-rights-detail-row">
@@ -2617,7 +2737,7 @@
                        <span>券</span>
                      </div>
                    </div>
                    <div class="phone-preview-tag">{{ item.tag || '人才卡专享' }}</div>
                    <div class="phone-preview-tag">{{ item.tag || '请填写标签' }}</div>
                    <div class="phone-preview-address">
                      <span>⌖</span>
                      <p>{{ form.address || '请填写商家地址' }}</p>
@@ -2694,7 +2814,7 @@
                <el-switch v-model="item.freeVoucherEnabled" />
                <el-input v-model="item.freeVoucherTimes" placeholder="请输入" class="hotel-times-input" />
                <span>次</span>
                <span class="hotel-margin-left">重置周期</span>
                <span class="hotel-reset-label">重置周期</span>
                <el-select v-model="item.freeVoucherCycle" class="hotel-cycle-select">
                  <el-option label="每天" value="每天" />
                  <el-option label="每周" value="每周" />
@@ -2784,10 +2904,6 @@
                  </div>
                </div>
              </div>
            </div>
            <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>
@@ -2896,6 +3012,27 @@
        <div class="merchant-parking-address-actions"><el-button plain @click="addFuelStore">新增</el-button></div>
      </section>
      <section v-if="supportsHeroImages" class="merchant-parking-address-panel">
        <h3 class="merchant-parking-address-title">详情页图片</h3>
        <p class="merchant-fuel-hero-tip">显示在人才端{{ merchantHeroImageTip }}详情页顶部,按顺序展示、只展示已启用的图片;不配置时自动使用商家封面图。推荐 750 × 730 px。</p>
        <el-table :data="fuelHeroImages" border>
          <el-table-column label="图片" width="140" align="center">
            <template #default="{ row }">
              <img v-if="row.imageUrl" class="merchant-fuel-hero-preview" :src="row.imageUrl" alt="详情页图片" />
            </template>
          </el-table-column>
          <el-table-column label="启用" width="120" align="center">
            <template #default="{ row }"><el-switch v-model="row.enabled" /></template>
          </el-table-column>
          <el-table-column label="操作" width="100" fixed="right" align="center">
            <template #default="{ $index }"><el-button link type="danger" @click="fuelHeroImages.splice($index, 1)">删除</el-button></template>
          </el-table-column>
        </el-table>
        <div class="merchant-parking-address-actions">
          <el-button plain :loading="imageUploading" @click="changeCover('fuelHero')">上传图片</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>
@@ -2972,7 +3109,7 @@
              <template #default="{ row }">{{ productAmountText(row) }}</template>
            </el-table-column>
            <el-table-column v-if="!isHotelMerchant && !isHealthCheckMerchant" prop="salesCount" label="销量" min-width="90">
              <template #default="{ row }">{{ row.salesCount || '' }}</template>
              <template #default="{ row }">{{ salesCountText(row) }}</template>
            </el-table-column>
            <el-table-column label="修改时间" min-width="90">
              <template #default="{ row }">{{ formatDisplayTime(row.submitTime || row.createdAt) }}</template>
@@ -3399,7 +3536,12 @@
              />
            </el-form-item>
            <el-form-item class="coupon-edit-row coupon-product-scope-row" label="可用商品" prop="productScope" :required="couponEditForm.productScope === 'selected'">
            <div v-if="couponStoreConsumeOnly" class="coupon-edit-row coupon-switch-row">
              <label>适用范围</label>
              <span>到店消费</span>
            </div>
            <el-form-item v-else class="coupon-edit-row coupon-product-scope-row" label="可用商品" prop="productScope" :required="couponEditForm.productScope === 'selected'">
              <el-radio-group v-model="couponEditForm.productScope">
                <el-radio label="all">不限</el-radio>
                <el-radio label="selected">选择</el-radio>
@@ -3407,7 +3549,7 @@
              </el-radio-group>
            </el-form-item>
            <div class="coupon-product-select-table">
            <div v-if="!couponStoreConsumeOnly" class="coupon-product-select-table">
              <el-table :data="couponProducts" border class="coupon-edit-product-table" :show-header="true">
                <el-table-column width="98" align="center">
                  <template #default="{ row }">
@@ -3425,7 +3567,9 @@
                <el-table-column label="商品金额" min-width="98">
                  <template #default="{ row }">{{ productAmountText(row) }}</template>
                </el-table-column>
                <el-table-column prop="salesCount" label="销量" min-width="98" />
                <el-table-column prop="salesCount" label="销量" min-width="98">
                  <template #default="{ row }">{{ salesCountText(row) }}</template>
                </el-table-column>
                <el-table-column label="修改时间" min-width="98">
                  <template #default="{ row }">{{ formatDisplayTime(row.submitTime || row.createdAt) }}</template>
                </el-table-column>
@@ -3435,7 +3579,7 @@
              </el-table>
            </div>
            <div class="coupon-edit-row coupon-switch-row">
            <div v-if="!couponStoreConsumeOnly" class="coupon-edit-row coupon-switch-row">
              <label>是否到店消费可用</label>
              <el-switch v-model="couponEditForm.storeConsume" />
            </div>