shinnosuke
2026-08-24 71a0235a576f96adc37d62c1056fd74a593dc4fa
feat: adapt fuel stores and medical product audit details
8个文件已修改
262 ■■■■■ 已修改文件
rencaika/pages/merchant/merchant.uvue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/style.css 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/types/admin.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantCreateView.vue 116 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/ProductAuditDetailView.vue 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantCreateRequest.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantUpdateRequest.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/merchant/merchant.uvue
@@ -503,11 +503,12 @@
            const stores: any[] = data != null && data.applicableStores instanceof Array ? data.applicableStores as any[] : []
            applicableStores.value = stores.map((item: any) => ({
                id: '' + item.id,
                name: item.storeName != null ? item.storeName as string : '',
                name: item.storeName != null ? item.storeName as string : (item.name != null ? item.name as string : ''),
                address: item.address != null ? item.address as string : '',
                latitude: item.latitude != null ? Number(item.latitude) : 0,
                longitude: item.longitude != null ? Number(item.longitude) : 0,
                canNavigate: item.canNavigate === true || Number(item.canNavigate) == 1
                canNavigate: (item.canNavigate === true || Number(item.canNavigate) == 1)
                    && Number(item.latitude) > 0 && Number(item.longitude) > 0
            }))
            merchantLoading.value = false
            listLoading.value = false
talent-card-admin/src/style.css
@@ -2644,6 +2644,11 @@
  height: auto;
}
.health-check-audit-table {
  width: calc(100% - 14px);
  margin-left: 14px;
}
.merchant-product-rich-editor {
  flex: 1;
  width: 100%;
talent-card-admin/src/types/admin.ts
@@ -192,6 +192,15 @@
  canLoginMerchantApp?: number
  loginAccount?: string
  groupBuyingEnabled?: number
  applicableStores?: Array<{
    id?: number | string
    name?: string
    storeName?: string
    address?: string
    longitude?: number
    latitude?: number
    canNavigate?: boolean | number
  }>
  usageInstructions?: string
  rightsText?: string
  miniProgramJumpType?: string
@@ -243,6 +252,14 @@
  loginAccount?: string
  loginPassword?: string
  groupBuyingEnabled?: number
  applicableStores?: Array<{
    id?: number | string
    name?: string
    storeName?: string
    address?: string
    longitude?: number
    latitude?: number
  }>
  usageInstructions?: string
  rightsText?: string
  miniProgramJumpType?: string
talent-card-admin/src/views/MerchantCreateView.vue
@@ -86,6 +86,7 @@
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 isMovieMerchant = computed(() => [
@@ -1011,6 +1012,13 @@
  longitude?: number
  latitude?: number
}
interface FuelStore {
  id?: number | string
  name: string
  address: string
  longitude?: number
  latitude?: number
}
interface SchoolAddress {
  name: string
  address: string
@@ -1031,6 +1039,8 @@
const parkingAddresses = ref<ParkingAddress[]>([])
const activeParkingAddress = ref<ParkingAddress>()
const fuelStores = ref<FuelStore[]>([])
const activeFuelStore = ref<FuelStore>()
const schoolAddresses = ref<SchoolAddress[]>([])
const activeSchoolAddress = ref<SchoolAddress>()
const schoolDialogVisible = ref(false)
@@ -1052,7 +1062,27 @@
  parkingAddresses.value.splice(index, 1)
}
function addFuelStore() {
  fuelStores.value.push({ name: '', address: '' })
}
function removeFuelStore(index: number) {
  fuelStores.value.splice(index, 1)
}
async function markFuelStoreLocation(row: FuelStore) {
  activeParkingAddress.value = undefined
  activeSchoolAddress.value = undefined
  activeFuelStore.value = row
  pendingLongitude.value = String(row.longitude || '')
  pendingLatitude.value = String(row.latitude || '')
  locationDialogVisible.value = true
  locationSearch.value = row.address || ''
  await initializeLocationMap()
}
async function markParkingLocation(row: ParkingAddress) {
  activeFuelStore.value = undefined
  activeSchoolAddress.value = undefined
  activeParkingAddress.value = row
  pendingLongitude.value = String(row.longitude || '')
@@ -1090,6 +1120,7 @@
}
async function markSchoolLocation() {
  activeFuelStore.value = undefined
  activeParkingAddress.value = undefined
  activeSchoolAddress.value = editingSchool
  pendingLongitude.value = String(editingSchool.longitude || '')
@@ -1252,6 +1283,7 @@
async function markLocation() {
  activeParkingAddress.value = undefined
  activeSchoolAddress.value = undefined
  activeFuelStore.value = undefined
  pendingLongitude.value = String(form.longitude || '')
  pendingLatitude.value = String(form.latitude || '')
  locationDialogVisible.value = true
@@ -1272,6 +1304,9 @@
  } else if (activeSchoolAddress.value) {
    activeSchoolAddress.value.longitude = longitude
    activeSchoolAddress.value.latitude = latitude
  } else if (activeFuelStore.value) {
    activeFuelStore.value.longitude = longitude
    activeFuelStore.value.latitude = latitude
  } else {
    form.longitude = longitude
    form.latitude = latitude
@@ -1466,6 +1501,32 @@
    }
  }
  if (isFuelMerchant.value) {
    if (fuelStores.value.length === 0) {
      ElMessage.warning('请至少添加一个适用门店')
      return
    }
    const names = fuelStores.value.map(store => store.name?.trim()).filter(Boolean)
    if (names.length !== fuelStores.value.length) {
      ElMessage.warning('适用门店名称不能为空')
      return
    }
    if (new Set(names).size !== names.length) {
      ElMessage.warning('适用门店名称不能重复')
      return
    }
    const addresses = fuelStores.value.map(store => store.address?.trim()).filter(Boolean)
    if (addresses.length !== fuelStores.value.length) {
      ElMessage.warning('适用门店地址不能为空')
      return
    }
    const unlocated = fuelStores.value.filter(store => !store.latitude || !store.longitude)
    if (unlocated.length > 0) {
      ElMessage.warning('请完成所有适用门店的标点')
      return
    }
  }
  if (isChildcareMerchant.value && schoolAddresses.value.length > 0) {
    const names = schoolAddresses.value.map(s => s.name?.trim()).filter(Boolean)
    if (names.length !== schoolAddresses.value.length) {
@@ -1517,6 +1578,7 @@
      latitude: Number(form.latitude || 0) || undefined,
      canLoginMerchantApp: Number(form.canLoginMerchantApp ?? 1),
      groupBuyingEnabled: form.groupBuyingEnabled,
      applicableStores: isFuelMerchant.value ? fuelStores.value : undefined,
      usageInstructions: isParkingMerchant.value
        ? JSON.stringify(parkingAddresses.value)
        : isChildcareMerchant.value
@@ -2072,6 +2134,28 @@
    }
  }
  if (isFuelMerchant.value && fuelStores.value.length > 0) {
    const names = fuelStores.value.map(store => store.name?.trim()).filter(Boolean)
    if (names.length !== fuelStores.value.length) {
      ElMessage.warning('适用门店名称不能为空')
      return
    }
    if (new Set(names).size !== names.length) {
      ElMessage.warning('适用门店名称不能重复')
      return
    }
    const addresses = fuelStores.value.map(store => store.address?.trim()).filter(Boolean)
    if (addresses.length !== fuelStores.value.length) {
      ElMessage.warning('适用门店地址不能为空')
      return
    }
    const unlocated = fuelStores.value.filter(store => !store.latitude || !store.longitude)
    if (unlocated.length > 0) {
      ElMessage.warning('请完成所有适用门店的标点')
      return
    }
  }
  if (isChildcareMerchant.value && schoolAddresses.value.length > 0) {
    const names = schoolAddresses.value.map(s => s.name?.trim()).filter(Boolean)
    if (names.length !== schoolAddresses.value.length) {
@@ -2120,6 +2204,7 @@
        loginAccount: !isEditMode.value || !merchantHasLoginAccount.value ? accountForm.loginAccount : undefined,
        loginPassword: !isEditMode.value || updatingMerchantPassword || !merchantHasLoginAccount.value ? accountForm.loginPassword : undefined,
        groupBuyingEnabled: form.groupBuyingEnabled,
        applicableStores: isFuelMerchant.value ? fuelStores.value : undefined,
        usageInstructions: isParkingMerchant.value
            ? JSON.stringify(parkingAddresses.value)
            : isChildcareMerchant.value
@@ -2209,6 +2294,16 @@
            merchantHasLoginAccount.value = Boolean(String(merchant.loginAccount || '').trim())
      if (isParkingMerchant.value) {
        parkingAddresses.value = parseJsonValue<ParkingAddress[]>(merchant.usageInstructions, [])
      } else if (isFuelMerchant.value) {
        fuelStores.value = Array.isArray(merchant.applicableStores)
          ? merchant.applicableStores.map((store: any) => ({
            id: store.id,
            name: String(store.storeName ?? store.name ?? ''),
            address: String(store.address ?? ''),
            longitude: store.longitude == null ? undefined : Number(store.longitude),
            latitude: store.latitude == null ? undefined : Number(store.latitude),
          }))
          : []
      } else if (isChildcareMerchant.value) {
        schoolAddresses.value = parseJsonValue<SchoolAddress[]>(merchant.usageInstructions, [])
      } else if (isApprovalMerchant.value) {
@@ -2699,6 +2794,27 @@
        </div>
      </section>
      <section v-if="isFuelMerchant" class="merchant-parking-address-panel">
        <h3 class="merchant-parking-address-title">适用门店</h3>
        <el-table :data="fuelStores" border>
          <el-table-column label="名称" min-width="180">
            <template #default="{ row }"><el-input v-model="row.name" placeholder="请输入" /></template>
          </el-table-column>
          <el-table-column label="地址" min-width="260">
            <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="markFuelStoreLocation(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="removeFuelStore($index)">删除</el-button></template>
          </el-table-column>
        </el-table>
        <div class="merchant-parking-address-actions"><el-button plain @click="addFuelStore">新增</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-admin/src/views/ProductAuditDetailView.vue
@@ -20,6 +20,12 @@
  subsidy: boolean
}
interface HealthCheckItem {
  name: string
  meaning: string
  gender: string[]
}
type LegacyBenefitConfig =
  | { rows?: Array<Partial<BenefitRow> & { levelName?: string; governmentSubsidy?: boolean }> }
  | Array<Partial<BenefitRow>>
@@ -39,6 +45,7 @@
  availableTime: '',
  otherRules: '',
})
const healthCheckItems = ref<HealthCheckItem[]>([])
const benefitRows = reactive<BenefitRow[]>([
  { level: '卓越人才卡', enabled: true, free: true, limitCount: '', discount: '', exceedDiscount: '', resetCycle: '每天', subsidy: true },
@@ -52,6 +59,20 @@
const isHotelProduct = computed(() => {
  const merchantType = product.value?.merchantType || ''
  return merchantType === '酒店服务' || merchantType === '酒店'
})
const isHealthCheckProduct = computed(() => {
  const productData = product.value as (Product & Record<string, unknown>) | undefined
  const values = [
    productData?.merchantType,
    productData?.merchantTypeCode,
    productData?.merchant_type,
    productData?.merchant_type_code,
    productData?.productType,
  ].map(value => String(value || '').trim().toLowerCase().replace(/[\s_-]+/g, '')).filter(Boolean)
  return values.some(value => [
    '医院服务', '医院', '医疗服务', '医疗', '免费体检', '体检服务', '医疗体检',
    'hospital', 'medical', 'healthcheck', 'medicalservice',
  ].includes(value))
})
const hideTalentBenefitPanel = computed(() => {
  const productData = product.value as (Product & Record<string, unknown>) | undefined
@@ -72,12 +93,19 @@
  return merchantTypes.some(type => hiddenTypes.includes(type)) || ['hotel', 'hospital', 'medical', 'healthcheck'].includes(productType)
})
const productAmountText = computed(() => {
  if (isHealthCheckProduct.value) return ''
  const salePrice = product.value?.salePrice
  const originalPrice = product.value?.originalPrice
  if (isHotelProduct.value && salePrice !== undefined && originalPrice !== undefined) {
    return `${amountText(salePrice)} - ${amountText(originalPrice)}`
  }
  return amountText(salePrice ?? originalPrice) || '158'
})
const healthCheckPeriodText = computed(() => {
  const directValue = [product.value?.validStartTime, product.value?.validEndTime].filter(Boolean).join(' 至 ')
  if (directValue) return directValue
  const savedNotice = parseJsonValue<Record<string, unknown>>(product.value?.purchaseNotice, {})
  return String(savedNotice.validPeriod || savedNotice.valid_period || savedNotice.validity || '')
})
const formatDateTime = (value?: string) => value ? value.replace('T', ' ') : ''
const createdAtText = computed(() => formatDateTime(product.value?.createdAt) || '2026-06-18 11:34:25')
@@ -139,6 +167,17 @@
  return coverUrl ? [coverUrl] : [fallbackImage]
}
function parseHealthCheckItems(value?: Product['detailText']) {
  healthCheckItems.value = []
  const parsed = parseJsonValue<unknown>(value, [])
  if (!Array.isArray(parsed)) return
  healthCheckItems.value = parsed.map((item: any) => ({
    name: String(item?.name || ''),
    meaning: String(item?.meaning || ''),
    gender: Array.isArray(item?.gender) ? item.gender.map((entry: unknown) => String(entry)) : [],
  }))
}
function parseBenefits(value?: Product['rightsConfig'], legacyValue?: string) {
  benefitRows.forEach((row) => {
    row.enabled = true
@@ -182,6 +221,7 @@
    return
  } finally {
    galleryImages.value = parseProductImages(product.value?.productImages, product.value?.coverUrl)
    parseHealthCheckItems(product.value?.detailText)
    parseNotice(product.value?.purchaseNotice, product.value)
    parseBenefits(product.value?.rightsConfig, product.value?.benefitDesc)
    loading.value = false
@@ -238,10 +278,14 @@
          <label>商品名称</label>
          <el-input :model-value="productNameText" readonly />
        </div>
        <div class="detail-field detail-money-field">
        <div v-if="!isHealthCheckProduct" class="detail-field detail-money-field">
          <label>商品金额</label>
          <el-input :model-value="productAmountText" readonly />
          <span>元</span>
        </div>
        <div v-else class="detail-field detail-period-field">
          <label>可预约时间</label>
          <el-input :model-value="healthCheckPeriodText" placeholder="未配置" readonly />
        </div>
        <div class="detail-time-pair">
          <label>创建时间</label>
@@ -258,7 +302,7 @@
        </div>
      </div>
      <div class="audit-gallery-row">
      <div v-if="!isHealthCheckProduct" class="audit-gallery-row">
        <label>商品图片</label>
        <div class="audit-thumb-list">
          <div v-for="image in imageList" :key="image" class="audit-thumb-box">
@@ -270,12 +314,24 @@
        </div>
      </div>
      <div class="audit-textarea-row">
      <div v-if="!isHealthCheckProduct" class="audit-textarea-row">
        <label>商品详情</label>
        <div class="audit-rich-text-preview" v-html="product?.detailText || ''"></div>
      </div>
      <div class="purchase-notice-block">
      <div v-else class="audit-textarea-row health-check-audit-row">
        <label>商品详情</label>
        <el-table :data="healthCheckItems" border class="health-check-audit-table">
          <el-table-column prop="name" label="检查项目" min-width="220" />
          <el-table-column prop="meaning" label="项目意义" min-width="320" />
          <el-table-column label="男/女" width="180">
            <template #default="{ row }">{{ row.gender.join('、') }}</template>
          </el-table-column>
          <template #empty>暂无体检项目</template>
        </el-table>
      </div>
      <div v-if="!isHealthCheckProduct" class="purchase-notice-block">
        <p>购买须知设置(非必填)</p>
        <div class="purchase-notice-grid">
          <div>
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java
@@ -322,7 +322,7 @@
                       merchant.logo_url, merchant.cover_url, merchant.address, merchant.business_status, merchant.longitude,
                       merchant.latitude, merchant.can_login_merchant_app, merchant.group_buying_enabled, merchant.usage_instructions,
                       merchant.rights_text, merchant.merchant_rights_config, merchant.service_label, merchant.is_recommend,
                       merchant.sort_no, merchant.status, merchant.created_at, merchant.updated_at,
                       merchant.sort_no, merchant.status, merchant.created_at, merchant.updated_at, merchant.merchant_type_code,
                       s.external_link_type as car_wash_external_link_type,
                       s.external_mini_program_app_id as car_wash_external_mini_program_app_id,
                       s.external_mini_program_path as car_wash_external_mini_program_path,
@@ -340,6 +340,14 @@
                where merchant_id = ?
                order by sort_no, id
                """, id)));
        if ("fuel".equals(String.valueOf(result.get("merchantTypeCode")))) {
            result.put("applicableStores", DatabaseRows.normalize(jdbcTemplate.queryForList("""
                    select id, store_name, address, longitude, latitude
                    from transport_fuel_store
                    where merchant_id = ? and deleted = 0 and enabled = 1
                    order by sort_no asc, id asc
                    """, id)));
        }
        return result;
    }
@@ -388,6 +396,7 @@
        Long id = jdbcTemplate.queryForObject("select last_insert_id()", Long.class);
        ensureParkingBenefit(id, merchantType.code());
        ensureFuelBenefit(id, merchantType.code(), request.serviceLabel());
        syncFuelStores(id, merchantType.code(), request.applicableStores());
        syncPremiumTopicMerchant(id, merchantType, request.sortNo());
        Long storeId = createDefaultStore(id, request.merchantName(), request.address());
        syncMerchantBusinessHours(id, storeId, request.businessHours());
@@ -532,6 +541,7 @@
        ensureUpdated(updated);
        ensureParkingBenefit(id, merchantType.code());
        ensureFuelBenefit(id, merchantType.code(), request.serviceLabel());
        syncFuelStores(id, merchantType.code(), request.applicableStores());
        syncPremiumTopicMerchant(id, merchantType, request.sortNo());
        Long storeId = findOrCreateDefaultStore(id, request.merchantName(), request.address());
        syncMerchantBusinessHours(id, storeId, request.businessHours());
@@ -595,6 +605,47 @@
                """, merchantId, discountText, merchantId);
    }
    private void syncFuelStores(Long merchantId, String merchantType, List<Map<String, Object>> stores) {
        if (!"fuel".equals(merchantType) || stores == null) return;
        List<Map<String, Object>> normalized = new ArrayList<>();
        Set<String> names = new HashSet<>();
        for (Map<String, Object> store : stores) {
            if (store == null) continue;
            Object nameValue = store.containsKey("storeName") ? store.get("storeName") : store.get("name");
            String name = nameValue == null ? "" : String.valueOf(nameValue).trim();
            Object addressValue = store.get("address");
            String address = addressValue == null ? "" : String.valueOf(addressValue).trim();
            if (name.isBlank() || address.isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "请完善适用门店名称和地址");
            if (!names.add(name)) throw new BusinessException(ErrorCode.BAD_REQUEST, "适用门店名称不能重复");
            Double longitude = numberValue(store.get("longitude"));
            Double latitude = numberValue(store.get("latitude"));
            if (longitude == null || longitude < -180 || longitude > 180 || latitude == null || latitude < -90 || latitude > 90
                    || (longitude == 0 && latitude == 0)) {
                throw new BusinessException(ErrorCode.BAD_REQUEST, "请完成所有适用门店的标点");
            }
            Map<String, Object> item = new LinkedHashMap<>();
            item.put("name", name);
            item.put("address", address);
            item.put("longitude", longitude);
            item.put("latitude", latitude);
            normalized.add(item);
        }
        jdbcTemplate.update("delete from transport_fuel_store where merchant_id = ?", merchantId);
        for (int index = 0; index < normalized.size(); index++) {
            Map<String, Object> store = normalized.get(index);
            jdbcTemplate.update("""
                    insert into transport_fuel_store(merchant_id, store_name, address, longitude, latitude, sort_no, enabled, deleted)
                    values (?, ?, ?, ?, ?, ?, 1, 0)
                    """, merchantId, store.get("name"), store.get("address"), store.get("longitude"), store.get("latitude"), (index + 1) * 10);
        }
    }
    private Double numberValue(Object value) {
        if (value instanceof Number number) return number.doubleValue();
        try { return value == null ? null : Double.valueOf(String.valueOf(value)); }
        catch (NumberFormatException ignored) { return null; }
    }
    private void syncPremiumTopicMerchant(Long merchantId, MerchantType merchantType, Integer sortNo) {
        if (merchantType.premiumCategoryKey() == null || merchantType.premiumBusinessType() == null) {
            jdbcTemplate.update("update premium_topic_merchant set deleted = 1 where merchant_id = ? and deleted = 0", merchantId);
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantCreateRequest.java
@@ -34,6 +34,7 @@
        Double longitude,
        Double latitude,
        Byte groupBuyingEnabled,
        List<Map<String, Object>> applicableStores,
        String usageInstructions,
        String rightsText,
        List<Map<String, Object>> merchantRightsConfig,
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantUpdateRequest.java
@@ -29,6 +29,7 @@
        Double longitude,
        Double latitude,
        Byte groupBuyingEnabled,
        List<Map<String, Object>> applicableStores,
        String usageInstructions,
        String rightsText,
        List<Map<String, Object>> merchantRightsConfig,