| | |
| | | <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' |
| | |
| | | deleteProduct as deleteProductApi, |
| | | getAdminCouponDetail, |
| | | getAdminCoupons, |
| | | getAdminProducts, |
| | | getAdminMerchantProducts, |
| | | updateCoupon, |
| | | updateCouponStatus, |
| | | updateMerchant, |
| | | updateProduct, |
| | | updateProductStatus, |
| | | uploadAdminImage, |
| | | uploadAdminPdf, |
| | | } from '../api/admin' |
| | | import AdminRichTextEditor from '../components/AdminRichTextEditor.vue' |
| | | import type { Coupon, CouponPayload, MerchantPayload, Product, ProductPayload } from '../types/admin' |
| | |
| | | const rightsSaving = ref(false) |
| | | const accountChecking = ref(false) |
| | | const accountTaken = ref(false) |
| | | 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' |
| | |
| | | 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 isChildcareMerchant = computed(() => normalizedMerchantType.value === '子女托管') |
| | | const isFinancialMerchant = computed(() => normalizedMerchantType.value === '金融服务') |
| | | const isMovieMerchant = computed(() => normalizedMerchantType.value === '观影服务') |
| | | const isSpouseEmploymentMerchant = computed(() => normalizedMerchantType.value === '配偶推荐就业') |
| | | const isApprovalMerchant = computed(() => normalizedMerchantType.value === '行政审批') |
| | | const isServiceMerchant = computed(() => isFoodMerchant.value || normalizedMerchantType.value === '景区服务') |
| | | const requiresFoodMerchantSettings = computed(() => !isEditMode.value && isFoodMerchant.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', |
| | | ].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 |
| | | || isHotelMerchant.value |
| | | || isCarWashMerchant.value |
| | | || isHealthCheckMerchant.value |
| | | || isParkingMerchant.value |
| | |
| | | || isMovieMerchant.value |
| | | || isSpouseEmploymentMerchant.value |
| | | || isApprovalMerchant.value |
| | | || merchantAppLoginOptionalTypes.includes(normalizedMerchantType.value) |
| | | || merchantAppLoginOptionalTypes.includes(merchantTypeCode.value) |
| | | )) |
| | | const requiresMerchantOperator = computed(() => !isEditMode.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') { |
| | |
| | | |
| | | 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() { |
| | |
| | | |
| | | 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' }, |
| | |
| | | { 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' }, |
| | |
| | | ] |
| | | }) |
| | | |
| | | const form = reactive<MerchantPayload & { createdAt: string }>({ |
| | | const form = reactive<MerchantPayload & { createdAt: string; merchantTypeCode?: string }>({ |
| | | merchantName: '', |
| | | merchantType: queryString('merchantType', ''), |
| | | contactMobile: '', |
| | |
| | | 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' }], |
| | | businessHours: [{ |
| | | validator: (_rule, _value, callback) => isApprovalMerchant.value || hasValidBusinessHours() |
| | | ? callback() |
| | | : callback(new Error('请选择有效的营业时间')), |
| | | trigger: 'change', |
| | | }], |
| | | sortNo: [{ |
| | | validator: (_rule, value, callback) => { |
| | | const text = String(value ?? '').trim() |
| | |
| | | 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('请输入登录账号')) |
| | |
| | | trigger: 'blur', |
| | | }], |
| | | loginPassword: [{ |
| | | required: true, |
| | | validator: (_rule, value, callback) => { |
| | | const updatingPassword = isEditMode.value && String(value || '').length > 0 |
| | | if (!requiresMerchantOperator.value && !updatingPassword) return callback() |
| | |
| | | trigger: 'blur', |
| | | }], |
| | | confirmPassword: [{ |
| | | required: true, |
| | | validator: (_rule, value, callback) => { |
| | | const needsConfirmation = requiresMerchantOperator.value || (isEditMode.value && accountForm.loginPassword.length > 0) |
| | | if (!needsConfirmation) return callback() |
| | |
| | | } |
| | | |
| | | 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 |
| | |
| | | productName: [{ required: true, message: '请输入商品名称', trigger: 'blur' }], |
| | | coverUrl: [{ required: true, message: '请上传商品封面图', trigger: 'change' }], |
| | | salePrice: [{ |
| | | validator: (_rule, value, callback) => isHealthCheckMerchant.value || (Number.isFinite(Number(value)) && Number(value) > 0) |
| | | ? callback() |
| | | : callback(new Error('请输入大于 0 的商品金额')), |
| | | validator: (_rule, value, callback) => { |
| | | if (isHealthCheckMerchant.value) return callback() |
| | | const amountText = String(value ?? '').trim() |
| | | return /^\d+(?:\.\d{1,2})?$/.test(amountText) && Number(amountText) >= 0.01 |
| | | ? callback() |
| | | : callback(new Error('商品金额必须为不小于0.01的正数,最多两位小数')) |
| | | }, |
| | | trigger: 'blur', |
| | | }], |
| | | productImages: [{ |
| | |
| | | }], |
| | | 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', |
| | |
| | | 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 [ |
| | |
| | | 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, |
| | | }, |
| | | ] |
| | | } |
| | |
| | | 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, |
| | | }, |
| | | { |
| | | level: '青年人才卡', |
| | | enabled: true, |
| | | tag: '', |
| | | detail: '', |
| | | storeConsume: false, |
| | | discount: '', |
| | | governmentSubsidy: false, |
| | | consumeDesc: '', |
| | | consumeEnabled: false, |
| | | consumeEnabled: true, |
| | | }, |
| | | ] |
| | | } |
| | |
| | | discount: '', |
| | | governmentSubsidy: false, |
| | | consumeDesc: '', |
| | | consumeEnabled: false, |
| | | consumeEnabled: true, |
| | | parkingType: 'paid', |
| | | }, |
| | | { |
| | |
| | | discount: '', |
| | | governmentSubsidy: false, |
| | | consumeDesc: '', |
| | | consumeEnabled: false, |
| | | consumeEnabled: true, |
| | | parkingType: 'paid', |
| | | }, |
| | | ] |
| | |
| | | } |
| | | |
| | | const rightsConfigs = reactive<MerchantRightConfig[]>(defaultMerchantRights()) |
| | | const merchantDiscountErrors = reactive<Record<string, string>>({}) |
| | | |
| | | const rightExampleImage = |
| | | 'https://images.unsplash.com/photo-1559847844-5315695dadae?auto=format&fit=crop&w=640&q=80' |
| | | const showRightsExamples = false |
| | | |
| | | const rightExampleLines = [ |
| | | '营业时间10:00-22:00', |
| | |
| | | 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 |
| | | let browserLocationRequest: Promise<{ longitude: number; latitude: number } | undefined> | undefined |
| | | |
| | | 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.Geocoder', 'AMap.Scale', 'AMap.ToolBar'], |
| | | }) |
| | | return amapApi |
| | | } |
| | | |
| | | interface ParkingAddress { |
| | | name: string |
| | | address: string |
| | | longitude?: number |
| | | latitude?: number |
| | | } |
| | | interface FuelStore { |
| | | id?: number | string |
| | | name: string |
| | | address: string |
| | | longitude?: number |
| | |
| | | |
| | | 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>() |
| | | const schoolDialogVisible = ref(false) |
| | |
| | | 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 || '') |
| | | browserLocationRequest = selectedCoordinates() ? undefined : requestBrowserLocation() |
| | | 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 || '') |
| | | pendingLatitude.value = String(row.latitude || '') |
| | | browserLocationRequest = selectedCoordinates() ? undefined : requestBrowserLocation() |
| | | locationDialogVisible.value = true |
| | | locationSearch.value = row.address || '' |
| | | await initializeLocationMap() |
| | |
| | | } |
| | | |
| | | async function markSchoolLocation() { |
| | | activeFuelStore.value = undefined |
| | | activeParkingAddress.value = undefined |
| | | activeSchoolAddress.value = editingSchool |
| | | pendingLongitude.value = String(editingSchool.longitude || '') |
| | | pendingLatitude.value = String(editingSchool.latitude || '') |
| | | browserLocationRequest = selectedCoordinates() ? undefined : requestBrowserLocation() |
| | | locationDialogVisible.value = true |
| | | locationSearch.value = editingSchool.address || '' |
| | | await initializeLocationMap() |
| | |
| | | businessHours.splice(index, 1) |
| | | } |
| | | |
| | | function hasValidBusinessHours() { |
| | | return businessHours.length > 0 && businessHours.every((item) => { |
| | | const [startTime, endTime] = item.value |
| | | return Boolean(startTime && endTime && startTime !== endTime) |
| | | }) |
| | | } |
| | | |
| | | function businessHoursPayload() { |
| | | return businessHours.map((item) => ({ startTime: item.value[0], endTime: item.value[1] })) |
| | | return businessHours |
| | | .map((item) => ({ startTime: item.value[0], endTime: item.value[1] })) |
| | | .filter((item) => item.startTime && item.endTime && item.startTime !== item.endTime) |
| | | } |
| | | |
| | | function resetBusinessHours(hours?: Array<{ startTime: string; endTime: string }>) { |
| | |
| | | } |
| | | |
| | | 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) |
| | | // The API uses zero for an unset coordinate. Treat it as empty instead of |
| | | // opening the map at 0,0. |
| | | if (longitude === 0 || latitude === 0) return undefined |
| | | return Number.isFinite(longitude) && Number.isFinite(latitude) ? { longitude, latitude } : undefined |
| | | } |
| | | |
| | | function requestBrowserLocation(): Promise<{ longitude: number; latitude: number } | undefined> { |
| | | return new Promise((resolve) => { |
| | | const geolocation = window.navigator?.geolocation |
| | | if (!geolocation) { |
| | | 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') { |
| | | console.info('browser geolocation requires a secure context; keeping the default map center') |
| | | resolve(undefined) |
| | | return |
| | | } |
| | | geolocation.getCurrentPosition( |
| | | (position) => { |
| | | const longitude = position.coords.longitude |
| | | const latitude = position.coords.latitude |
| | | resolve(Number.isFinite(longitude) && Number.isFinite(latitude) ? { longitude, latitude } : undefined) |
| | | }, |
| | | (error) => { |
| | | console.info(`browser geolocation unavailable: ${error.code}`) |
| | | resolve(undefined) |
| | | }, |
| | | { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }, |
| | | ) |
| | | }) |
| | | } |
| | | |
| | | function setMapMarker(latitude: number, longitude: number, center = true) { |
| | | 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: '© 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) => { |
| | | const latitude = event.lnglat.getLat() |
| | | const longitude = event.lnglat.getLng() |
| | | setMapMarker(latitude, longitude, false) |
| | | reverseGeocodeLocation(latitude, longitude) |
| | | }) |
| | | if (selected) setMapMarker(initial.latitude, initial.longitude, false) |
| | | if (!selected) { |
| | | const browserPosition = await (browserLocationRequest || requestBrowserLocation()) |
| | | browserLocationRequest = undefined |
| | | if (browserPosition && locationMap && !locationMarker && !selectedCoordinates()) { |
| | | setMapMarker(browserPosition.latitude, browserPosition.longitude) |
| | | } |
| | | } |
| | | window.setTimeout(() => locationMap?.resize(), 120) |
| | | } catch (error) { |
| | | console.error('Failed to initialize AMap', error) |
| | | ElMessage.error('高德地图加载失败,请检查地图配置或网络后重试') |
| | | } |
| | | } |
| | | |
| | | async function searchMapLocation() { |
| | |
| | | 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 } |
| | | } |
| | | |
| | | function selectSearchLocation(item: { name: string; longitude: number; latitude: number }) { |
| | | setMapMarker(item.latitude, item.longitude) |
| | | if (!form.address?.trim()) form.address = item.name |
| | | setLocationAddress(item.name) |
| | | locationSearchResults.value = [] |
| | | } |
| | | |
| | | function setLocationAddress(address: string) { |
| | | const normalizedAddress = address.trim() |
| | | if (!normalizedAddress) return |
| | | if (activeParkingAddress.value) { |
| | | 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 |
| | | } |
| | | |
| | | async function reverseGeocodeLocation(latitude: number, longitude: number) { |
| | | try { |
| | | const AMap = await loadAMap() |
| | | const geocoder = new AMap.Geocoder({ radius: 1000, extensions: 'all' }) |
| | | geocoder.getAddress([longitude, latitude], (status: string, result: any) => { |
| | | if (status === 'complete' && result?.regeocode?.formattedAddress) { |
| | | setLocationAddress(result.regeocode.formattedAddress) |
| | | } |
| | | }) |
| | | } catch (error) { |
| | | console.warn('Failed to reverse geocode AMap location', error) |
| | | } |
| | | } |
| | | |
| | | function closeLocationMap() { |
| | | locationMap?.remove() |
| | | locationMap?.destroy() |
| | | locationMap = undefined |
| | | locationMarker = undefined |
| | | browserLocationRequest = undefined |
| | | locationSearchResults.value = [] |
| | | } |
| | | |
| | | async function markLocation() { |
| | | activeParkingAddress.value = undefined |
| | | activeSchoolAddress.value = undefined |
| | | activeFuelStore.value = undefined |
| | | pendingLongitude.value = String(form.longitude || '') |
| | | pendingLatitude.value = String(form.latitude || '') |
| | | browserLocationRequest = selectedCoordinates() ? undefined : requestBrowserLocation() |
| | | locationDialogVisible.value = true |
| | | locationSearch.value = form.address || '' |
| | | await initializeLocationMap() |
| | |
| | | } 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 |
| | |
| | | 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() |
| | | } |
| | | |
| | |
| | | input.value = '' |
| | | if (!file) return |
| | | |
| | | if (imageUploadTarget.value === 'approvalWindow') { |
| | | const isPdf = file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf') |
| | | if (!isPdf) { |
| | | ElMessage.warning('窗口信息只能上传 PDF 文件') |
| | | return |
| | | } |
| | | if (file.size > 20 * 1024 * 1024) { |
| | | ElMessage.warning('PDF 文件大小不能超过 20 MB') |
| | | return |
| | | } |
| | | imageUploading.value = true |
| | | try { |
| | | const result = await uploadAdminPdf(file) |
| | | if (approvalUploadIndex.value >= 0) approvalItems.value[approvalUploadIndex.value].windowInfoUrl = result.url |
| | | ElMessage.success('窗口信息上传成功') |
| | | } catch { |
| | | // The shared HTTP interceptor displays the upload error. |
| | | } finally { |
| | | approvalUploadIndex.value = -1 |
| | | imageUploading.value = false |
| | | } |
| | | return |
| | | } |
| | | |
| | | const acceptedTypes = ['image/jpeg', 'image/png', 'image/webp'] |
| | | if (!acceptedTypes.includes(file.type)) { |
| | | ElMessage.warning('请选择 JPG、PNG 或 WEBP 格式的图片') |
| | |
| | | form.coverUrl = result.url |
| | | resetMerchantCoverPreview() |
| | | merchantFormRef.value?.validateField('coverUrl').catch(() => undefined) |
| | | } else if (imageUploadTarget.value === 'approvalWindow') { |
| | | if (approvalUploadIndex.value >= 0) { |
| | | approvalItems.value[approvalUploadIndex.value].windowInfoUrl = result.url |
| | | } |
| | | } else if (imageUploadTarget.value === 'productCover') { |
| | | 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) |
| | | } |
| | |
| | | } |
| | | 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() |
| | | } |
| | |
| | | function serializeMerchantRightsConfig() { |
| | | return rightsConfigs.map((item, index) => { |
| | | const payload = { ...item } |
| | | if (!supportsStoreConsumption.value) { |
| | | 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 = '' |
| | | payload.governmentSubsidy = false |
| | |
| | | } |
| | | } |
| | | |
| | | 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) { |
| | |
| | | return |
| | | } |
| | | |
| | | if (hasMissingRightsTag()) { |
| | | ElMessage.warning('请填写各个人才卡等级的标签') |
| | | return |
| | | } |
| | | |
| | | rightsSaving.value = true |
| | | try { |
| | | await updateMerchant(merchantId, { |
| | |
| | | latitude: Number(form.latitude || 0) || undefined, |
| | | 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 |
| | |
| | | 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 { |
| | | if (!value) return fallback |
| | | if (typeof value !== 'string') return value as T |
| | |
| | | productImages.value = Array.isArray(savedImages) |
| | | ? savedImages.filter((image): image is string => typeof image === 'string' && image.length > 0) |
| | | : [] |
| | | productForm.salePrice = row?.salePrice |
| | | productForm.originalPrice = row?.originalPrice |
| | | productForm.salePrice = isHealthCheckMerchant.value ? undefined : row?.salePrice |
| | | productForm.originalPrice = isHealthCheckMerchant.value ? undefined : row?.originalPrice |
| | | productForm.stock = row?.stock === -1 ? undefined : row?.stock |
| | | productForm.detailText = row?.detailText || '' |
| | | productForm.benefitDesc = row?.benefitDesc || '' |
| | |
| | | |
| | | 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], |
| | |
| | | return amount > 0 && amount <= 10 |
| | | } |
| | | |
| | | function validMerchantDiscount(value: unknown) { |
| | | const text = String(value ?? '').trim() |
| | | if (!/^(?:[0-9]|10)(?:\.\d)?$/.test(text)) return false |
| | | const amount = Number(text) |
| | | return amount > 0 && amount <= 10 |
| | | } |
| | | |
| | | function normalizeMerchantDiscount(item: MerchantRightConfig) { |
| | | let text = String(item.discount ?? '').replace(/[^\d.]/g, '') |
| | | const dotIndex = text.indexOf('.') |
| | | if (dotIndex >= 0) { |
| | | text = `${text.slice(0, dotIndex + 1)}${text.slice(dotIndex + 1).replace(/\./g, '').slice(0, 1)}` |
| | | } |
| | | if (text.startsWith('.')) text = `0${text}` |
| | | item.discount = text |
| | | delete merchantDiscountErrors[item.level] |
| | | } |
| | | |
| | | function validateMerchantDiscount(item: MerchantRightConfig) { |
| | | if (validMerchantDiscount(item.discount)) { |
| | | delete merchantDiscountErrors[item.level] |
| | | return true |
| | | } |
| | | merchantDiscountErrors[item.level] = '\u8bf7\u8f93\u5165\u5927\u4e8e0\u4e14\u4e0d\u8d85\u8fc710\u7684\u4e00\u4f4d\u5c0f\u6570' |
| | | return false |
| | | } |
| | | |
| | | function validateProductExceedDiscount(item: { level: string, exceedDiscount: unknown }) { |
| | | const key = item.level |
| | | if (validExceedDiscount(item.exceedDiscount)) { |
| | |
| | | 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 |
| | |
| | | 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 |
| | | try { |
| | |
| | | ...productForm, |
| | | merchantId: currentMerchantId(), |
| | | detailText: isHealthCheckMerchant.value ? JSON.stringify(healthCheckItems.value) : productForm.detailText, |
| | | salePrice: productForm.salePrice === undefined ? undefined : Number(productForm.salePrice), |
| | | originalPrice: productForm.originalPrice === undefined ? undefined : Number(productForm.originalPrice), |
| | | salePrice: isHealthCheckMerchant.value || productForm.salePrice === undefined ? undefined : Number(productForm.salePrice), |
| | | originalPrice: isHealthCheckMerchant.value || productForm.originalPrice === undefined ? undefined : Number(productForm.originalPrice), |
| | | stock: productForm.stock === undefined ? undefined : Number(productForm.stock), |
| | | sortNo: productForm.sortNo === undefined ? 0 : Number(productForm.sortNo), |
| | | purchaseNotice: { |
| | |
| | | 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) |
| | |
| | | } |
| | | |
| | | 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 |
| | | } |
| | | |
| | |
| | | couponEditForm.couponAmount = '' |
| | | couponEditForm.talentLevels = ['卓越人才卡', '杰出人才卡', '菁英人才卡'] |
| | | couponEditForm.validTime = [] |
| | | couponEditForm.productScope = 'selected' |
| | | couponEditForm.productScope = couponStoreConsumeOnly.value ? 'all' : 'selected' |
| | | couponEditForm.storeConsume = true |
| | | couponEditForm.governmentSubsidy = true |
| | | couponEditForm.status = 1 |
| | |
| | | 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 |
| | |
| | | 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, |
| | | } |
| | |
| | | async function saveMerchant() { |
| | | const merchantValid = await merchantFormRef.value?.validate().catch(() => false) |
| | | if (!merchantValid) return |
| | | if (isServiceMerchant.value && rightsConfigs.some((item) => item.storeConsume && !validExceedDiscount(item.discount))) { |
| | | ElMessage.warning('折扣请输入大于 0 且不超过 10 的一位小数') |
| | | return |
| | | if (isServiceMerchant.value) { |
| | | const invalidDiscount = rightsConfigs.filter((item) => item.storeConsume && !validateMerchantDiscount(item)) |
| | | if (invalidDiscount.length > 0) { |
| | | ElMessage.warning('\u6298\u6263\u8bf7\u8f93\u5165\u5927\u4e8e0\u4e14\u4e0d\u8d85\u8fc710\u7684\u4e00\u4f4d\u5c0f\u6570') |
| | | return |
| | | } |
| | | } |
| | | const updatingMerchantPassword = isEditMode.value && accountForm.loginPassword.length > 0 |
| | | const accountValid = await accountFormRef.value?.validate().catch(() => false) |
| | |
| | | const unlocated = parkingAddresses.value.filter(p => !p.latitude || !p.longitude) |
| | | if (unlocated.length > 0) { |
| | | ElMessage.warning('请完成所有停车场的标点') |
| | | return |
| | | } |
| | | } |
| | | |
| | | 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 |
| | | } |
| | | } |
| | |
| | | longitude: Number(form.longitude || 0) || undefined, |
| | | latitude: Number(form.latitude || 0) || undefined, |
| | | canLoginMerchantApp: requiresMerchantOperator.value ? 1 : Number(form.canLoginMerchantApp ?? 1), |
| | | loginAccount: !isEditMode.value ? accountForm.loginAccount : undefined, |
| | | loginPassword: !isEditMode.value || updatingMerchantPassword ? accountForm.loginPassword : undefined, |
| | | 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, |
| | | heroImages: supportsHeroImages.value ? fuelHeroImages.value : undefined, |
| | | usageInstructions: isParkingMerchant.value |
| | | ? JSON.stringify(parkingAddresses.value) |
| | | : isChildcareMerchant.value |
| | |
| | | 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 }) |
| | |
| | | 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) { |
| | | 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) { |
| | | 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') { |
| | |
| | | <h3>商家信息</h3> |
| | | |
| | | <el-form ref="merchantFormRef" :model="form" :rules="merchantRules" class="merchant-settings-form" label-width="58px" label-position="left"> |
| | | <div class="merchant-create-content" :class="{ 'service-merchant-content': isServiceMerchant || isApartmentMerchant || isHotelMerchant || isCarWashMerchant || isHealthCheckMerchant || isParkingMerchant || isChildcareMerchant || isFinancialMerchant || isMovieMerchant || isSpouseEmploymentMerchant || isApprovalMerchant }"> |
| | | <div class="merchant-create-left" :style="isApartmentMerchant || isFoodMerchant || isHotelMerchant || isCarWashMerchant || isHealthCheckMerchant || isParkingMerchant || isChildcareMerchant || isFinancialMerchant || isMovieMerchant || isSpouseEmploymentMerchant || isApprovalMerchant ? 'flex: 1' : ''"> |
| | | <div class="merchant-create-content" :class="{ 'service-merchant-content': isServiceMerchant || isApartmentMerchant || isHotelMerchant || isCarWashMerchant || isHealthCheckMerchant || isParkingMerchant || isChildcareMerchant || isFinancialMerchant || isMovieMerchant || isSpouseEmploymentMerchant || isApprovalMerchant || usesSimplifiedMerchantSettings }"> |
| | | <div class="merchant-create-left" :style="isApartmentMerchant || isFoodMerchant || isHotelMerchant || isCarWashMerchant || isHealthCheckMerchant || isParkingMerchant || isChildcareMerchant || isFinancialMerchant || isMovieMerchant || isSpouseEmploymentMerchant || isApprovalMerchant || usesSimplifiedMerchantSettings ? 'flex: 1' : ''"> |
| | | <el-form-item class="merchant-form-row three-columns" label="商家名称" prop="merchantName" required> |
| | | <el-input v-model="form.merchantName" /> |
| | | </el-form-item> |
| | |
| | | </el-form-item> |
| | | </div> |
| | | |
| | | <div v-if="!isServiceMerchant && !isApartmentMerchant && !isHotelMerchant && !isCarWashMerchant && !isHealthCheckMerchant && !isParkingMerchant && !isChildcareMerchant && !isFinancialMerchant && !isMovieMerchant && !isSpouseEmploymentMerchant && !isApprovalMerchant" class="merchant-create-middle"> |
| | | <div v-if="!isServiceMerchant && !isApartmentMerchant && !isHotelMerchant && !isCarWashMerchant && !isHealthCheckMerchant && !isParkingMerchant && !isChildcareMerchant && !isFinancialMerchant && !isMovieMerchant && !isSpouseEmploymentMerchant && !isApprovalMerchant && !usesSimplifiedMerchantSettings" class="merchant-create-middle"> |
| | | <el-form-item class="merchant-form-row" label="联系电话" prop="contactMobile" required> |
| | | <el-input v-model="form.contactMobile" /> |
| | | </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="如:到店消费" /> |
| | |
| | | </template> |
| | | </div> |
| | | |
| | | <div class="merchant-create-right" :style="isApartmentMerchant || isFoodMerchant || isHotelMerchant || isCarWashMerchant || isHealthCheckMerchant || isParkingMerchant || isChildcareMerchant || isFinancialMerchant || isMovieMerchant || isSpouseEmploymentMerchant || isApprovalMerchant ? 'flex: 1' : ''"> |
| | | <el-form-item v-if="isServiceMerchant || isApartmentMerchant || isHotelMerchant || isCarWashMerchant || isHealthCheckMerchant || isParkingMerchant || isChildcareMerchant || isFinancialMerchant || isMovieMerchant || isSpouseEmploymentMerchant || isApprovalMerchant" class="merchant-form-row service-contact-row" label="联系电话" prop="contactMobile" required> |
| | | <div class="merchant-create-right" :style="isApartmentMerchant || isFoodMerchant || isHotelMerchant || isCarWashMerchant || isHealthCheckMerchant || isParkingMerchant || isChildcareMerchant || isFinancialMerchant || isMovieMerchant || isSpouseEmploymentMerchant || isApprovalMerchant || usesSimplifiedMerchantSettings ? 'flex: 1' : ''"> |
| | | <el-form-item v-if="isServiceMerchant || isApartmentMerchant || isHotelMerchant || isCarWashMerchant || isHealthCheckMerchant || isParkingMerchant || isChildcareMerchant || isFinancialMerchant || isMovieMerchant || isSpouseEmploymentMerchant || isApprovalMerchant || usesSimplifiedMerchantSettings" class="merchant-form-row service-contact-row" label="联系电话" prop="contactMobile" required> |
| | | <el-input v-model="form.contactMobile" placeholder="请输入联系电话" /> |
| | | </el-form-item> |
| | | |
| | | <div v-if="hasExistingMerchant" class="merchant-form-row"> |
| | | <div v-if="hasExistingMerchant" class="merchant-form-row created-time-row"> |
| | | <label>创建时间</label> |
| | | <el-input v-model="form.createdAt" readonly /> |
| | | </div> |
| | | |
| | | <el-form-item v-if="!isApprovalMerchant" class="merchant-hours-block" label="营业时间" prop="businessHours" required> |
| | | <el-form-item v-if="!isApprovalMerchant" class="merchant-hours-block" label="营业时间" prop="businessHours"> |
| | | <div class="merchant-hours-list"> |
| | | <div v-for="(item, index) in businessHours" :key="item.id" class="merchant-hour-row"> |
| | | <el-time-picker |
| | | v-model="item.value" |
| | | is-range |
| | | arrow-control |
| | | class="merchant-time-range-picker" |
| | | format="HH:mm" |
| | | value-format="HH:mm" |
| | | range-separator="至" |
| | |
| | | <section class="merchant-account-panel"> |
| | | <h3>账户设置</h3> |
| | | <el-form ref="accountFormRef" class="merchant-account-form" :model="accountForm" :rules="accountRules" label-width="72px" label-position="left"> |
| | | <el-form-item v-if="supportsMerchantAppLogin" label="登录商户端" label-width="100px"> |
| | | <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 label="登录账号" prop="loginAccount" :required="requiresMerchantOperator" :error="accountTaken ? '登录账号已存在,请更换' : ''"> |
| | | <el-input v-model="accountForm.loginAccount" maxlength="32" :disabled="isEditMode" :placeholder="isEditMode ? '未配置登录账号' : '仅支持英文和数字'" @input="normalizeLoginAccount" @blur="checkLoginAccountAvailable" /> |
| | | <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 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 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> |
| | |
| | | <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"> |
| | |
| | | <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"> |
| | |
| | | </el-radio-group> |
| | | </div> |
| | | </div> |
| | | <div class="merchant-rights-example" v-if="isMovieMerchant"> |
| | | <div class="merchant-rights-example" v-if="showRightsExamples && isMovieMerchant"> |
| | | <span>示例:</span> |
| | | <div class="merchant-rights-phone-preview"> |
| | | <div class="phone-preview-hero"> |
| | |
| | | <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> |
| | |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <div class="merchant-rights-example" v-else-if="isFinancialMerchant || isSpouseEmploymentMerchant"> |
| | | <div class="merchant-rights-example" v-else-if="showRightsExamples && (isFinancialMerchant || isSpouseEmploymentMerchant)"> |
| | | <span>示例:</span> |
| | | <div class="merchant-rights-phone-preview"> |
| | | <div class="phone-preview-hero"> |
| | |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <div class="merchant-rights-example" v-else-if="!isParkingMerchant && (!isCarWashMerchant || form.miniProgramJumpType === 'normal')"> |
| | | <div class="merchant-rights-example" v-else-if="showRightsExamples && !isParkingMerchant && (!isCarWashMerchant || form.miniProgramJumpType === 'normal')"> |
| | | <span>示例:</span> |
| | | <div class="merchant-rights-phone-preview"> |
| | | <div class="phone-preview-hero"> |
| | |
| | | </div> |
| | | </div> |
| | | |
| | | <template v-if="supportsStoreConsumption && item.consumeEnabled"> |
| | | <template v-if="(supportsStoreConsumption && item.consumeEnabled) || (isParkingMerchant && item.parkingType === 'paid')"> |
| | | <template v-if="isHotelMerchant"> |
| | | <div class="merchant-consume-row hotel-voucher-row"> |
| | | <strong>免费券</strong> |
| | | <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="每周" /> |
| | |
| | | <strong>到店消费</strong> |
| | | <el-switch v-model="item.storeConsume" /> |
| | | <span>折扣</span> |
| | | <el-input v-model="item.discount" placeholder="请输入" /> |
| | | <div class="merchant-discount-input"> |
| | | <el-input |
| | | v-model="item.discount" |
| | | placeholder="请输入" |
| | | :class="{ 'is-error': merchantDiscountErrors[item.level] }" |
| | | @input="normalizeMerchantDiscount(item)" |
| | | @blur="validateMerchantDiscount(item)" |
| | | /> |
| | | <span v-if="merchantDiscountErrors[item.level]" class="field-error">{{ merchantDiscountErrors[item.level] }}</span> |
| | | </div> |
| | | <span>折</span> |
| | | <strong>是否政府补贴</strong> |
| | | <el-switch v-model="item.governmentSubsidy" /> |
| | |
| | | <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 class="merchant-rights-example"> |
| | | <div v-if="showRightsExamples" class="merchant-rights-example"> |
| | | <span>示例:</span> |
| | | <div class="merchant-rights-phone-preview"> |
| | | <div class="phone-preview-hero"> |
| | |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <div v-if="isParkingMerchant || 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 && item.parkingType === 'paid'" 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> |
| | | |
| | |
| | | <el-button plain @click="addApprovalRow">新增</el-button> |
| | | </div> |
| | | </div> |
| | | </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="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="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> |
| | | |
| | |
| | | <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> |
| | |
| | | </el-form-item> |
| | | </div> |
| | | |
| | | <div class="product-info-right" v-if="isEditMode && (isHotelMerchant || isHealthCheckMerchant)"> |
| | | <div class="product-form-row"> |
| | | <label>创建时间</label> |
| | | <span>{{ formatDisplayTime(productForm.createdAt) }}</span> |
| | | </div> |
| | | <div class="product-form-row"> |
| | | <label>修改时间</label> |
| | | <span>{{ formatDisplayTime(productForm.updatedAt) }}</span> |
| | | </div> |
| | | </div> |
| | | |
| | | </div> |
| | | |
| | | <el-form-item v-if="!isHealthCheckMerchant" class="product-detail-row" label="商品详情" prop="detailText" required> |
| | |
| | | <el-time-picker |
| | | v-model="productAvailableTimeRange" |
| | | is-range |
| | | class="merchant-time-range-picker" |
| | | start-placeholder="开始时间" |
| | | end-placeholder="结束时间" |
| | | range-separator="至" |
| | |
| | | </el-form-item> |
| | | |
| | | <el-form-item class="coupon-edit-row coupon-amount-row" label="优惠券金额" prop="couponAmount" required> |
| | | <div class="coupon-amount-control"><el-input-number v-model="couponEditForm.couponAmount" :min="0.01" :precision="2" :step="1" controls-position="right" placeholder="请输入优惠券金额" /><span>元</span></div> |
| | | <div class="coupon-amount-control"> |
| | | <el-input v-model="couponEditForm.couponAmount" inputmode="decimal" maxlength="8" placeholder="请输入优惠券金额" /> |
| | | <span>元</span> |
| | | </div> |
| | | </el-form-item> |
| | | |
| | | <el-form-item class="coupon-edit-row coupon-requirement-row" label="使用要求" prop="fullAmount" :required="couponEditForm.useRequirement === 'full'"> |
| | | <el-radio-group v-model="couponEditForm.useRequirement"> |
| | | <el-radio label="full" class="coupon-full-radio">满</el-radio> |
| | | <el-input v-model="couponEditForm.fullAmount" class="coupon-full-amount"> |
| | | <template #append>元可用</template> |
| | | </el-input> |
| | | <div class="coupon-full-amount-control"> |
| | | <el-input v-model="couponEditForm.fullAmount" class="coupon-full-amount" /> |
| | | <span>元可用</span> |
| | | </div> |
| | | <el-radio label="none" class="coupon-none-radio">不限</el-radio> |
| | | </el-radio-group> |
| | | </el-form-item> |
| | |
| | | /> |
| | | </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> |
| | |
| | | </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 }"> |
| | |
| | | <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> |
| | |
| | | </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> |