Merge remote-tracking branch 'origin/main'
| | |
| | | * 清了这条 key 就回到开发者工具适用的默认值。 |
| | | */ |
| | | |
| | | /** 开发者工具与本机后台:回环地址,跟网络怎么变无关。 */ |
| | | const loopbackDevelopmentBaseUrl = 'http://192.168.110.26:8080' |
| | | /** |
| | | * 开发者工具与本机后台:回环地址,跟网络怎么变无关。 |
| | | * |
| | | * 不要写具体局域网 IP(曾经写成 192.168.110.26:8080,别人拉到代码后所有请求都打到那台机器上, |
| | | * 表现成"小程序读不到数据")。127.0.0.1 在开发者工具里永远指向本机后台。 |
| | | */ |
| | | const loopbackDevelopmentBaseUrl = 'http://127.0.0.1:8080' |
| | | |
| | | /** 覆盖用的 storage key,真机调试时写入开发机局域网地址。 */ |
| | | const overrideStorageKey = 'development-api-base-url' |
| | |
| | | } |
| | | const loadHomeMiniProgram = () => { |
| | | getAppHome((data: any) => { |
| | | const appId = data != null && data.miniProgramAppId != null ? data.miniProgramAppId as string : '' |
| | | const path = data != null && data.miniProgramPath != null ? data.miniProgramPath as string : '' |
| | | if (appId != '') { openTargetMiniProgram(appId, path); return } |
| | | const url = data != null && data.miniProgramUrl != null ? data.miniProgramUrl as string : '' |
| | | if (!isSafeHomeLinkUrl(url)) { |
| | | showInvalid('首页跳转链接不可用', '请在后台配置有效链接;生产环境请使用 HTTPS') |
| | | showInvalid('首页跳转链接不可用', '请在后台配置目标小程序 AppID,或配置 HTTPS 链接') |
| | | return |
| | | } |
| | | targetUrl.value = url |
| | |
| | | stateDescription.value = '请检查网络后重新加载' |
| | | }) |
| | | } |
| | | const openTargetMiniProgram = (appId: string, path: string) => { |
| | | stateTitle.value = '正在打开小程序' |
| | | stateDescription.value = '请在微信弹窗中确认' |
| | | uni.navigateToMiniProgram({ |
| | | appId: appId, |
| | | path: path, |
| | | success: () => { |
| | | pageState.value = 'invalid' |
| | | stateTitle.value = '已打开目标小程序' |
| | | stateDescription.value = '返回后可继续浏览人才服务平台' |
| | | }, |
| | | fail: () => { |
| | | showInvalid('跳转小程序失败', '请确认已在 app.json 声明目标小程序后再试') |
| | | } |
| | | }) |
| | | } |
| | | const loadAdvisorService = () => { |
| | | getTalentCardProfile(talentId.value, (data: any) => { |
| | | const url = data != null && data.advisor != null && data.advisor.consultUrl != null ? data.advisor.consultUrl as string : '' |
| | |
| | | const homeError = ref('') |
| | | const homeVideoUrl = ref('') |
| | | const miniProgramUrl = ref('') |
| | | // 后台可配置目标小程序 AppID,配置后"幸福东区"等入口直接用小程序跳转,不再走 web-view。 |
| | | const miniProgramAppId = ref('') |
| | | const miniProgramPath = ref('') |
| | | |
| | | onLoad(() => { |
| | | // #ifdef MP-WEIXIN |
| | |
| | | homeLoading.value = false |
| | | homeVideoUrl.value = data != null && data.homeVideoUrl != null ? data.homeVideoUrl as string : '' |
| | | miniProgramUrl.value = data != null && data.miniProgramUrl != null ? data.miniProgramUrl as string : '' |
| | | miniProgramAppId.value = data != null && data.miniProgramAppId != null ? data.miniProgramAppId as string : '' |
| | | miniProgramPath.value = data != null && data.miniProgramPath != null ? data.miniProgramPath as string : '' |
| | | const articles: any[] = data != null && data.news instanceof Array ? data.news as any[] : [] |
| | | if (articles.length == 0) return |
| | | const first: any = articles[0] |
| | |
| | | uni.navigateTo({ url: '/pages/home-video/home-video?url=' + encodeURIComponent(resolveApiUrl(homeVideoUrl.value)) }) |
| | | } |
| | | const openIntro = () => { |
| | | if (miniProgramAppId.value != '') { |
| | | uni.navigateToMiniProgram({ |
| | | appId: miniProgramAppId.value, |
| | | path: miniProgramPath.value, |
| | | fail: () => { |
| | | uni.showToast({ title: '跳转小程序失败,请稍后重试', icon: 'none' }) |
| | | } |
| | | }) |
| | | return |
| | | } |
| | | if (miniProgramUrl.value == '') { |
| | | uni.showToast({ title: '首页跳转链接暂未配置', icon: 'none' }) |
| | | return |
| | |
| | | return level |
| | | } |
| | | const sameCardLevel = (configured: any, actual: string): boolean => normalizeCardLevel(configured) == normalizeCardLevel(actual) |
| | | // 商家可以按“卓越人才卡(本地)/(外地)”分开配置权益;人才地区已知时不能拿地区对不上的配置来展示。 |
| | | const levelAreaOf = (value: any): string => { |
| | | const level = value == null ? '' : ('' + value).trim().toUpperCase() |
| | | if (level.indexOf('外地') >= 0 || level.indexOf('OUTSIDE') >= 0) return 'OUTSIDE' |
| | | if (level.indexOf('本地') >= 0 || level.indexOf('LOCAL') >= 0) return 'LOCAL' |
| | | return '' |
| | | } |
| | | const talentAreaOf = (data: any): string => { |
| | | const raw = data != null && data.sourceAreaType != null ? ('' + data.sourceAreaType).trim().toUpperCase() : '' |
| | | if (raw.indexOf('外地') >= 0 || raw.indexOf('OUTSIDE') >= 0) return 'OUTSIDE' |
| | | if (raw.indexOf('本地') >= 0 || raw.indexOf('LOCAL') >= 0) return 'LOCAL' |
| | | return '' |
| | | } |
| | | const isRightEnabled = (value: any): boolean => value === true || Number(value) == 1 || ('' + value).trim().toLowerCase() == 'true' |
| | | const parseRightsConfig = (value: any): any[] => { |
| | | if (value == null || value == '') return [] |
| | |
| | | if (cardLevel == '') return |
| | | let matched: any = null |
| | | let disabledMatched: any = null |
| | | const talentArea = talentAreaOf(data) |
| | | for (let index = 0; index < rights.length; index++) { |
| | | const item: any = rights[index] |
| | | if (!sameCardLevel(rightLevel(item), cardLevel)) continue |
| | | const configuredArea = levelAreaOf(rightLevel(item)) |
| | | if (talentArea != '' && configuredArea != '' && configuredArea != talentArea) continue |
| | | if (isRightEnabled(item != null ? item.enabled : null)) { |
| | | matched = item |
| | | break |
| | |
| | | groupBuyingEnabled.value = false |
| | | merchantType.value = 'fuel' |
| | | const heroes: any[] = data != null && data.heroImages instanceof Array ? data.heroImages as any[] : [] |
| | | if (heroes.length > 0 && heroes[0].imageUrl != null && heroes[0].imageUrl != '') merchantHero.value = resolveApiUrl(heroes[0].imageUrl as string) |
| | | if (heroes.length > 0 && heroes[0].imageUrl != null && heroes[0].imageUrl != '') { |
| | | merchantHero.value = resolveApiUrl(heroes[0].imageUrl as string) |
| | | } else if (data != null && data.coverUrl != null && data.coverUrl != '') { |
| | | // 没配置详情页图片时用商家封面图兜底,避免顶部大图空白。 |
| | | merchantHero.value = resolveApiUrl(data.coverUrl as string) |
| | | } |
| | | fuelBenefitId.value = data != null && data.benefit != null && data.benefit.benefitId != null ? '' + data.benefit.benefitId : '' |
| | | merchantLocation.value = { |
| | | latitude: data != null && data.latitude != null ? Number(data.latitude) : 0, |
| | |
| | | <view class="nav-shell"> |
| | | <view class="safe-space" :style="'height:' + navTop + 'px'"></view> |
| | | <view class="nav-row" :style="'height:' + navHeight + 'px;padding-right:' + navRight + 'px'"> |
| | | <view class="back" @tap="goBack"><image src="/static/news-assets/arrow-left.svg" mode="aspectFit"></image></view> |
| | | <view class="nav-back" @tap="goBack"><image src="/static/news-assets/arrow-left.svg" mode="aspectFit"></image></view> |
| | | <text class="nav-title">资讯详情</text> |
| | | <view class="capsule-reserve" :style="'width:' + navWidth + 'px'"></view> |
| | | </view> |
| | |
| | | .page { display: flex; flex-direction: column; width: 100%; height: 100vh; background-color: #FFFFFF; } |
| | | .nav-shell { flex-shrink: 0; background-image: linear-gradient(180deg, #FFF0B8 0%, #FFF8E4 58%, #FFFFFF 100%); } |
| | | .safe-space { height: 28px; } |
| | | .nav-row { display: flex; position: relative; height: 32px; align-items: center; justify-content: flex-end; box-sizing: border-box; } |
| | | .back { display: flex; position: absolute; z-index: 2; left: 24rpx; top: 0; width: 72rpx; height: 100%; justify-content: center; align-items: center; } |
| | | .back image { width: 38rpx; height: 38rpx; } |
| | | .nav-row { display: flex; position: relative; height: 32px; box-sizing: border-box; align-items: center; justify-content: flex-end; } |
| | | .nav-back { display: flex; position: absolute; z-index: 9; left: 16rpx; top: 0; bottom: 0; width: 88rpx; min-height: 64rpx; justify-content: center; align-items: center; } |
| | | .nav-back image { width: 40rpx; height: 40rpx; } |
| | | .nav-title { position: absolute; left: 190rpx; right: 190rpx; color: #171717; font-size: 35rpx; font-weight: 700; text-align: center; white-space: nowrap; } |
| | | .capsule-reserve { width: 88px; height: 100%; flex-shrink: 0; } |
| | | .nav-bottom-space { height: 34rpx; } |
| | |
| | | <view class="nav-shell"> |
| | | <view class="safe-space" :style="'height:' + navTop + 'px'"></view> |
| | | <view class="nav-row" :style="'height:' + navHeight + 'px;padding-right:' + navRight + 'px'"> |
| | | <view class="back" @tap="goBack"><image src="/static/news-assets/arrow-left.svg" mode="aspectFit"></image></view> |
| | | <view class="nav-back" @tap="goBack"><image src="/static/news-assets/arrow-left.svg" mode="aspectFit"></image></view> |
| | | <text class="nav-title">人才资讯</text> |
| | | <view class="capsule-reserve" :style="'width:' + navWidth + 'px'"></view> |
| | | </view> |
| | |
| | | .page { display: flex; flex-direction: column; width: 100%; height: 100vh; background-color: #FFFFFF; } |
| | | .nav-shell { flex-shrink: 0; background-image: linear-gradient(180deg, #FFF0B8 0%, #FFF8E4 58%, #FFFFFF 100%); } |
| | | .safe-space { height: 28px; } |
| | | .nav-row { display: flex; position: relative; height: 32px; align-items: center; justify-content: flex-end; box-sizing: border-box; } |
| | | .back { display: flex; position: absolute; z-index: 2; left: 24rpx; top: 0; width: 72rpx; height: 100%; justify-content: center; align-items: center; } .back image { width: 38rpx; height: 38rpx; } |
| | | .nav-row { display: flex; position: relative; height: 32px; box-sizing: border-box; align-items: center; justify-content: flex-end; } |
| | | .nav-back { display: flex; position: absolute; z-index: 9; left: 16rpx; top: 0; bottom: 0; width: 88rpx; min-height: 64rpx; justify-content: center; align-items: center; } |
| | | .nav-back image { width: 40rpx; height: 40rpx; } |
| | | .nav-title { position: absolute; left: 190rpx; right: 190rpx; color: #171717; font-size: 35rpx; font-weight: 700; text-align: center; white-space: nowrap; } |
| | | .capsule-reserve { width: 88px; height: 100%; flex-shrink: 0; } |
| | | .nav-bottom-space { height: 34rpx; } |
| | |
| | | <view class="form-content"> |
| | | <view v-if="requiresParking" class="field-group parking-field" @tap.stop> |
| | | <text class="field-label">停车场</text> |
| | | <view class="input-box"><input v-model="parkingKeyword" placeholder="搜索停车场" placeholder-class="input-placeholder" confirm-type="search" @focus="openParkingResults" @input="handleParkingInput" /><image src="/static/benefits-assets/search.svg" mode="aspectFit" @tap="openParkingResults"></image></view> |
| | | <view v-if="showParkingResults" class="parking-results"> |
| | | <view v-if="parkingSearchLoading" class="parking-result-empty"><text>正在搜索停车场</text></view> |
| | | <scroll-view v-else-if="filteredParkingLots.length > 0" class="parking-result-scroll" scroll-y> |
| | | <view class="parking-result-item" v-for="item in filteredParkingLots" :key="item.id" @tap.stop="selectParking(item)"><text class="parking-result-name">{{item.name}}</text><text class="parking-result-address">{{item.address}}</text></view> |
| | | </scroll-view> |
| | | <view v-else class="parking-result-empty"><text>暂无匹配停车场</text></view> |
| | | </view> |
| | | <view class="input-box" @tap.stop="openParkingResults"><input v-model="parkingKeyword" placeholder="搜索停车场" placeholder-class="input-placeholder" confirm-type="search" @focus="openParkingResults" @confirm="openParkingResults" @input="handleParkingInput" /><view class="input-search-button"><image src="/static/benefits-assets/search.svg" mode="aspectFit"></image></view></view> |
| | | <text v-if="parkingError != ''" class="field-error">{{parkingError}}</text> |
| | | </view> |
| | | |
| | | <!-- 搜索结果必须挂在 .form-content 上(它 position:relative 且足够高)。 |
| | | 如果放在“停车场”字段里面,会被该字段自带的 overflow:hidden 裁掉,只会露出半行文字。 --> |
| | | <view v-if="requiresParking && showParkingResults" class="parking-results" :style="'height:' + parkingResultsHeight"> |
| | | <view v-if="parkingSearchLoading" class="parking-result-empty"><text>正在搜索停车场</text></view> |
| | | <view v-else-if="filteredParkingLots.length > 0" class="parking-result-list"> |
| | | <view class="parking-result-item" v-for="item in visibleParkingLots" :key="item.id" @tap.stop="selectParking(item)"><text class="parking-result-name">{{item.name}}</text><text class="parking-result-address">{{item.address}}</text></view> |
| | | <view v-if="filteredParkingLots.length > visibleParkingLots.length" class="parking-result-more"><text>还有 {{filteredParkingLots.length - visibleParkingLots.length}} 个停车场,请继续输入关键词</text></view> |
| | | </view> |
| | | <view v-else class="parking-result-empty"><text>暂无匹配停车场</text></view> |
| | | </view> |
| | | |
| | | <view class="field-group plate-field" :class="requiresParking ? 'with-parking' : ''"> |
| | |
| | | { id: 8, name: '五十四交通银行旁停车场', address: '供销社背后金星巷3号' } |
| | | ] |
| | | const filteredParkingLots = ref(parkingLots) |
| | | // 下拉最多展示 5 条:再多就提示继续输入关键词,避免下拉把页面撑爆或需要内部滚动。 |
| | | const parkingResultLimit = 5 |
| | | const visibleParkingLots = computed((): any[] => { |
| | | const all: any[] = filteredParkingLots.value as any[] |
| | | return all.slice(0, parkingResultLimit) |
| | | }) |
| | | // 下拉高度由 JS 明确算出来(列表 + 可能出现的“还有 N 个”提示行 + 上下边框), |
| | | // 不依赖容器自动撑高,避免部分机型上下拉被压扁只露出半行。 |
| | | const parkingResultsHeight = computed((): string => { |
| | | if (parkingSearchLoading.value) return '116rpx' |
| | | const list: any[] = visibleParkingLots.value as any[] |
| | | if (list.length == 0) return '116rpx' |
| | | let height = list.length * 92 |
| | | if (filteredParkingLots.value.length > list.length) height += 64 |
| | | return '' + (height + 4) + 'rpx' |
| | | }) |
| | | |
| | | onLoad((options: any) => { |
| | | // #ifdef MP-WEIXIN |
| | |
| | | }) |
| | | }, 250) |
| | | } |
| | | const openParkingResults = () => { showParkingResults.value = true; scheduleParkingSearch(parkingKeyword.value.trim()) } |
| | | const closeParkingResults = () => { showParkingResults.value = false } |
| | | // 打开下拉的瞬间,同一次点击可能会冒泡到页面根的关闭处理; |
| | | // 记录打开时间,短时间内到达的关闭请求忽略,避免"点了搜索没反应"。 |
| | | let parkingResultsOpenedAt = 0 |
| | | const openParkingResults = () => { |
| | | parkingResultsOpenedAt = Date.now() |
| | | showParkingResults.value = true |
| | | scheduleParkingSearch(parkingKeyword.value.trim()) |
| | | } |
| | | const closeParkingResults = () => { |
| | | if (Date.now() - parkingResultsOpenedAt < 250) return |
| | | showParkingResults.value = false |
| | | } |
| | | const handleParkingInput = (event: any) => { |
| | | const keyword = event.detail.value as string |
| | | parkingKeyword.value = keyword |
| | |
| | | .back-button image { width: 36rpx; height: 36rpx; } |
| | | .nav-title { position: absolute; left: 50%; color: #171B22; font-size: 37rpx; line-height: 48rpx; font-weight: 700; transform: translateX(-50%); white-space: nowrap; } |
| | | .capsule-space { height: 100%; flex-shrink: 0; } |
| | | .form-content { padding: 47rpx 54rpx 0; } |
| | | /* relative 供搜索结果下拉定位;overflow 放开,避免把下拉裁掉。 */ |
| | | .form-content { position: relative; padding: 47rpx 54rpx 0; overflow: visible; } |
| | | .field-group { display: flex; position: relative; flex-direction: column; } |
| | | .parking-field { z-index: 20; } |
| | | .parking-field { z-index: 20; overflow: visible; } |
| | | .plate-field { z-index: 1; } |
| | | .field-label { color: #70798A; font-size: 25rpx; line-height: 36rpx; font-weight: 600; } |
| | | .input-box { display: flex; height: 86rpx; margin-top: 17rpx; box-sizing: border-box; padding: 0 24rpx; border-width: 2rpx; border-style: solid; border-color: #E1E5EB; border-radius: 14rpx; background-color: #FFFFFF; flex-direction: row; align-items: center; } |
| | | .input-box input { height: 82rpx; min-width: 0; flex: 1; color: #2D333C; font-size: 28rpx; } |
| | | .input-box image { width: 39rpx; height: 39rpx; margin-left: 16rpx; flex-shrink: 0; } |
| | | /* 放大镜的点击区域放大到整块右侧区域,避免小图标点不中。 */ |
| | | .input-search-button { display: flex; width: 72rpx; height: 86rpx; margin-left: 6rpx; margin-right: -14rpx; flex-shrink: 0; justify-content: center; align-items: center; } |
| | | .input-search-button image { width: 39rpx; height: 39rpx; } |
| | | .input-placeholder { color: #BBC1CA; font-size: 27rpx; } |
| | | .field-error { margin-top: 9rpx; color: #F05C3A; font-size: 22rpx; } |
| | | .plate-field { margin-top: 83rpx; } |
| | |
| | | .submit-button { display: flex; height: 86rpx; border-radius: 44rpx; background-image: linear-gradient(90deg, #FF4B32 0%, #FF8A1E 100%); justify-content: center; align-items: center; } |
| | | .submit-button text { color: #FFFFFF; font-size: 29rpx; line-height: 40rpx; font-weight: 600; } |
| | | .submit-tip { display: block; margin-top: 31rpx; color: #A6ADB8; font-size: 21rpx; line-height: 32rpx; text-align: center; } |
| | | .parking-results { position: absolute; z-index: 30; left: 0; right: 0; top: 140rpx; overflow: hidden; border-width: 2rpx; border-style: solid; border-color: #E1E5EB; border-radius: 14rpx; background-color: #FFFFFF; box-shadow: 0 12rpx 30rpx rgba(62,73,90,.12); } |
| | | .parking-result-scroll { width: 100%; height: 312rpx; } |
| | | .parking-result-item { display: flex; min-height: 78rpx; box-sizing: border-box; padding: 13rpx 22rpx; border-bottom-width: 1rpx; border-bottom-style: solid; border-bottom-color: #EEF0F4; flex-direction: column; justify-content: center; } |
| | | .parking-result-name { color: #333A45; font-size: 25rpx; line-height: 34rpx; } |
| | | .parking-result-address { margin-top: 3rpx; color: #929AA7; font-size: 20rpx; line-height: 29rpx; } |
| | | /* 挂在 .form-content 上:left/right 用表单左右内边距,正好和输入框等宽; |
| | | top = 47rpx(表单上边距) + 36rpx(标签) + 17rpx(间距) + 86rpx(输入框) = 186rpx,即输入框正下方。 */ |
| | | .parking-results { position: absolute; z-index: 30; left: 54rpx; right: 54rpx; top: 186rpx; overflow: visible; border-width: 2rpx; border-style: solid; border-color: #E1E5EB; border-radius: 14rpx; background-color: #FFFFFF; box-shadow: 0 12rpx 30rpx rgba(62,73,90,.12); } |
| | | /* 高度由 parkingResultsHeight 内联给出;列表只负责把条目排开。 */ |
| | | .parking-result-list { display: flex; flex-direction: column; } |
| | | .parking-result-item { display: flex; height: 92rpx; min-height: 92rpx; box-sizing: border-box; padding: 13rpx 22rpx; border-bottom-width: 1rpx; border-bottom-style: solid; border-bottom-color: #EEF0F4; flex-direction: column; flex-shrink: 0; justify-content: center; } |
| | | .parking-result-name { color: #333A45; font-size: 25rpx; line-height: 34rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } |
| | | .parking-result-address { margin-top: 3rpx; color: #929AA7; font-size: 20rpx; line-height: 29rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } |
| | | .parking-result-more { display: flex; height: 64rpx; justify-content: center; align-items: center; } |
| | | .parking-result-more text { color: #A4ABB5; font-size: 21rpx; } |
| | | .parking-result-empty { display: flex; height: 112rpx; justify-content: center; align-items: center; } |
| | | .parking-result-empty text { color: #A4ABB5; font-size: 23rpx; } |
| | | </style> |
| | |
| | | .service-section { padding: 48rpx 31rpx 0; } .section-heading { display: flex; width: 132rpx; height: 72rpx; flex-direction: column; align-items: center; } .section-heading text { color: #363636; font-size: 32rpx; font-weight: 700; } .section-heading view { width: 61rpx; height: 7rpx; margin-top: 12rpx; border-radius: 4rpx; background-image: linear-gradient(90deg,#FF4D36,#FFBF3A); } |
| | | .service-item { display: flex; min-height: 205rpx; margin-bottom: 20rpx; flex-direction: row; } .service-item.is-unavailable { opacity: .56; } .service-image { width: 184rpx; height: 184rpx; border-radius: 17rpx; flex-shrink: 0; background-color: #EEE; } .service-info { display: flex; min-width: 0; min-height: 184rpx; flex: 1; padding: 4rpx 0 4rpx 24rpx; flex-direction: column; justify-content: space-between; } .service-name { color: #292929; font-size: 29rpx; font-weight: 700; } .service-tags { display: flex; flex-direction: row; align-items: center; } .coupon-tag { padding: 6rpx 8rpx; border-radius: 6rpx; background-color: #FFF0EC; color: #FF5D37; font-size: 21rpx; } .service-discount { display: block; width: fit-content; margin-left: 0; padding: 6rpx 9rpx; border-radius: 6rpx; background-color: #FFF5E7; color: #FF6A34; font-size: 21rpx; } .service-tags .service-discount { margin-left: 11rpx; } .service-tags .coupon-tag + .service-discount { margin-left: 11rpx; } .address { display: flex; min-width: 0; padding: 7rpx 10rpx; border-radius: 7rpx; background-color: #F1F2F3; } .address image { width: 27rpx; height: 27rpx; margin-right: 8rpx; } .address text { min-width: 0; flex: 1; color: #7E8490; font-size: 21rpx; line-height: 31rpx; } |
| | | .list-state, .no-more { display: flex; height: 150rpx; color: #989DA5; font-size: 23rpx; justify-content: center; align-items: center; } .list-state.compact { height: 90rpx; } .error-state { flex-direction: column; } .retry-text, .load-error text { margin-top: 18rpx; color: #F26A38; } .loading-spinner { width: 26rpx; height: 26rpx; margin-right: 12rpx; box-sizing: border-box; border: 3rpx solid #FFD6C4; border-top-color: #FF6C2B; border-radius: 50%; animation: loading 720ms linear infinite; } @keyframes loading { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .bottom-space { height: calc(30rpx + env(safe-area-inset-bottom)); } |
| | | /* 定位图标与地址必须同一行:uni-app x 的 view 默认纵向排列,这里显式改成横向并做单行省略。 */ |
| | | .address { flex-direction: row; align-items: center; } |
| | | .address image { flex-shrink: 0; } |
| | | .address text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } |
| | | </style> |
| | |
| | | * 清了这条 key 就回到开发者工具适用的默认值。 |
| | | */ |
| | | |
| | | /** 开发者工具与本机后台:回环地址,跟网络怎么变无关。 */ |
| | | const loopbackDevelopmentBaseUrl = 'http://192.168.110.26:8080' |
| | | /** |
| | | * 开发者工具与本机后台:回环地址,跟网络怎么变无关。 |
| | | * |
| | | * 不要写具体局域网 IP(曾经写成 192.168.110.26:8080,别人拉到代码后所有请求都打到那台机器上, |
| | | * 表现成"小程序读不到数据")。127.0.0.1 在开发者工具里永远指向本机后台。 |
| | | */ |
| | | const loopbackDevelopmentBaseUrl = 'http://127.0.0.1:8080' |
| | | |
| | | /** 覆盖用的 storage key,真机调试时写入开发机局域网地址。 */ |
| | | const overrideStorageKey = 'development-api-base-url' |
| | |
| | | page { background-color: #F7F8FA; } |
| | | .page { display: flex; width: 100%; height: 100vh; flex-direction: column; overflow: hidden; background-color: #F7F8FA; } |
| | | .navigation-bar { display: flex; height: 174rpx; flex: none; justify-content: center; align-items: flex-end; padding-bottom: 25rpx; box-sizing: border-box; position: relative; } |
| | | .navigation-title { color: #1F2937; font-size: 35rpx; line-height: 49rpx; font-weight: 700; } |
| | | /* 标题绝对定位居中:bottom 取值让标题中线与返回按钮/胶囊中线对齐(与核销输入页保持一致)。 */ |
| | | .navigation-title { position: absolute; right: 0; bottom: 30rpx; left: 0; z-index: 1; color: #1F2937; font-size: 35rpx; line-height: 49rpx; font-weight: 700; text-align: center; } |
| | | .back-button { display: flex; width: 72rpx; height: 72rpx; justify-content: center; align-items: center; position: absolute; top: 82rpx; left: 28rpx; } |
| | | .back-icon { width: 40rpx; height: 40rpx; } |
| | | .content { height: 0; flex: 1; padding: 20rpx 34rpx 0; box-sizing: border-box; } |
| | |
| | | return http.get<PageResult<Product>, PageResult<Product>>('/api/admin/business/products', { params }) |
| | | } |
| | | |
| | | // 商家编辑页面的团单列表按商品维度返回(一条团单一行),修改团单不会多出一行待审核记录。 |
| | | export function getAdminMerchantProducts(merchantId: number | string, params: ProductQuery = {}) { |
| | | return http.get<PageResult<Product>, PageResult<Product>>(`/api/admin/business/merchants/${merchantId}/products`, { params }) |
| | | } |
| | | |
| | | export function getAdminProductDetail(id: number | string) { |
| | | return http.get<Product, Product>(`/api/admin/business/products/${id}`) |
| | | } |
| | |
| | | .map((group) => ({ ...group, items: group.items.filter((item) => canAccess(item.path)) })) |
| | | .filter((group) => group.items.length > 0)) |
| | | |
| | | // 菜单里出现的所有路径,用于把"列表路径 + /:id"这类详情页路由归到对应菜单项。 |
| | | const menuPaths = (() => { |
| | | const paths: string[] = [] |
| | | const walk = (items: MenuItem[]) => { |
| | | for (const item of items) { |
| | | if (item.path) paths.push(item.path) |
| | | if (item.children) walk(item.children) |
| | | } |
| | | } |
| | | menuGroups.forEach((group) => walk(group.items)) |
| | | return paths |
| | | })() |
| | | |
| | | const defaultOpeneds = computed(() => { |
| | | const currentGroup = menuGroups.find((item) => findMenuLabel(item.items, activeMenu.value)) |
| | | return currentGroup ? [currentGroup.index] : [] |
| | |
| | | return '/talents' |
| | | } |
| | | if (route.path === '/merchants/new') { |
| | | return '/merchants' |
| | | } |
| | | if (route.path.startsWith('/merchant-onboardings')) { |
| | | return '/merchants' |
| | | } |
| | | if (route.path === '/roles/new' || /^\/roles\/[^/]+(?:\/view)?$/.test(route.path)) { |
| | |
| | | if (route.path.startsWith('/merchant-settlement/')) { |
| | | return '/merchant-settlement' |
| | | } |
| | | if (route.path === '/talent-news' || route.path.startsWith('/talent-news-categories/')) { |
| | | if (route.path === '/talent-news' || route.path.startsWith('/talent-news-categories')) { |
| | | return '/talent-news-management' |
| | | } |
| | | if (route.path === '/talent-policies' || route.path === '/talent-policy-categories') { |
| | | if (route.path === '/talent-policies' || route.path.startsWith('/talent-policy-categories')) { |
| | | return '/talent-policy-management' |
| | | } |
| | | if (route.path === '/movie-ticket-records') { |
| | | if (route.path.startsWith('/movie-ticket-records')) { |
| | | return '/movie-tickets' |
| | | } |
| | | return route.path |
| | | // 通用兜底:详情/编辑页的路由是"列表路径 + /:id",按最长前缀归到对应菜单项, |
| | | // 这样左侧分组会跟着保持展开(例如 /family-card-applications/12 → /family-card-applications)。 |
| | | const matched = menuPaths |
| | | .filter((path) => route.path === path || route.path.startsWith(path + '/')) |
| | | .sort((left, right) => right.length - left.length)[0] |
| | | return matched || route.path |
| | | }) |
| | | |
| | | function currentMenuGroupIndex() { |
| | |
| | | |
| | | watch(activeMenu, () => { |
| | | const groupIndex = currentMenuGroupIndex() |
| | | // 找不到归属(例如从其它模块跳进来的子页面)时保持当前展开状态,不要把左侧菜单收起来。 |
| | | if (!groupIndex) return |
| | | nextTick(() => { |
| | | closeOtherMenuGroups(groupIndex) |
| | | if (groupIndex) adminMenuRef.value?.open(groupIndex) |
| | |
| | | text-align: left; |
| | | } |
| | | |
| | | .merchant-fuel-hero-tip { |
| | | margin: -6px 0 14px; |
| | | color: #8a94a6; |
| | | font-size: 12px; |
| | | line-height: 18px; |
| | | } |
| | | |
| | | .merchant-fuel-hero-preview { |
| | | width: 108px; |
| | | height: 84px; |
| | | border: 1px solid #e5e7eb; |
| | | border-radius: 6px; |
| | | object-fit: cover; |
| | | background-color: #f3f4f6; |
| | | } |
| | | |
| | | .merchant-school-address-actions, |
| | | .merchant-parking-address-actions { |
| | | display: flex; |
| | |
| | | |
| | | .movie-merchant-rights-panel .merchant-rights-level-row { |
| | | grid-column: 1 / 3; |
| | | grid-template-columns: 82px 38px 32px 160px; |
| | | /* 第三列放"*标签",32px 放不下两个字,会折成两行;留出足够宽度。 */ |
| | | grid-template-columns: 82px 38px 54px 160px; |
| | | gap: 10px; |
| | | min-width: 0; |
| | | padding-left: 0; |
| | | } |
| | | |
| | | .merchant-rights-level-row > span { |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .movie-merchant-rights-panel .merchant-rights-level-row > .el-switch { |
| | |
| | | } |
| | | |
| | | .coupon-switch-row { |
| | | grid-template-columns: 130px 44px; |
| | | /* 内容列要留足宽度:开关本身靠左对齐,不受影响;而"适用范围"这类纯文案不会被挤成两行。 */ |
| | | grid-template-columns: 130px minmax(0, 1fr); |
| | | gap: 6px; |
| | | margin-bottom: 18px; |
| | | } |
| | |
| | | justify-content: flex-start; |
| | | white-space: nowrap; |
| | | word-break: keep-all; |
| | | } |
| | | |
| | | .coupon-switch-row > span { |
| | | line-height: 30px; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .coupon-switch-row .el-switch { |
| | |
| | | |
| | | .equity-upload-row { |
| | | display: grid; |
| | | grid-template-columns: 42px 180px repeat(2, 72px); |
| | | /* 最后一列放推荐尺寸提示,跟随上传按钮显示。 */ |
| | | grid-template-columns: 42px 180px repeat(2, 72px) minmax(0, 1fr); |
| | | gap: 12px 24px; |
| | | align-items: center; |
| | | margin: 24px 0 0 18px; |
| | | } |
| | | |
| | | .equity-upload-tip { |
| | | color: #8a94a6; |
| | | font-size: 12px; |
| | | line-height: 18px; |
| | | } |
| | | |
| | | .equity-upload-row .el-button { |
| | | width: 72px; |
| | | } |
| | |
| | | export interface PolicyArticle extends NewsArticle { |
| | | policyNo?: string |
| | | subtitle?: string |
| | | // 后台记录的操作人(管理员 ID 由服务端解析成姓名返回,历史数据回落为 author)。 |
| | | createdByName?: string |
| | | updatedByName?: string |
| | | } |
| | | |
| | | export interface PolicyArticlePayload { |
| | |
| | | latitude?: number |
| | | canNavigate?: boolean | number |
| | | }> |
| | | heroImages?: Array<{ |
| | | id?: number | string |
| | | imageUrl?: string |
| | | sortNo?: number |
| | | enabled?: number | boolean |
| | | }> |
| | | usageInstructions?: string |
| | | rightsText?: string |
| | | miniProgramJumpType?: string |
| | |
| | | longitude?: number |
| | | latitude?: number |
| | | }> |
| | | // 加油服务详情页轮播图(按顺序提交,服务端整表替换)。 |
| | | heroImages?: Array<{ |
| | | id?: number | string |
| | | imageUrl: string |
| | | enabled: boolean |
| | | }> |
| | | usageInstructions?: string |
| | | rightsText?: string |
| | | miniProgramJumpType?: string |
| | |
| | | </div> |
| | | <el-button type="primary" :loading="uploadingImageId === item.id" @click="uploadImage(item)">上传</el-button> |
| | | <el-button v-if="currentConfig().images.length > 1" @click="removeImage(index)">移除</el-button> |
| | | <span class="equity-upload-tip">推荐尺寸 750 × 730 px(宽高比约 1:1,与小程序顶部大图一致)</span> |
| | | </div> |
| | | </div> |
| | | |
| | |
| | | return row.relationship || row.relation || '-' |
| | | } |
| | | |
| | | function familyIdCard(row: FamilyCardApplication) { |
| | | return row.idCardNo || row.dependentIdCardNo || '-' |
| | | } |
| | | |
| | | async function loadData() { |
| | | loading.value = true |
| | | try { |
| | |
| | | { title: '状态', value: row => statusText(row) }, |
| | | ] : [ |
| | | { title: '申请编号', value: row => row.applicationNo || row.applyNo }, |
| | | { title: '持卡人姓名', value: row => row.talentName }, |
| | | { title: '持卡人名称', value: row => row.talentName }, |
| | | { title: '持卡人手机号', value: row => row.talentMobile }, |
| | | { title: '持卡人身份证号', value: row => row.talentIdCardNo }, |
| | | { title: '人才卡号', value: row => row.talentCardNo }, |
| | | { title: '卡片等级', value: row => row.cardLevel }, |
| | | { title: '家属姓名', value: row => familyName(row) }, |
| | | { title: '家属名称', value: row => familyName(row) }, |
| | | { title: '家属手机号', value: row => familyMobile(row) }, |
| | | { title: '家属身份证号', value: row => familyIdCard(row) }, |
| | | { title: '关系', value: row => relationship(row) }, |
| | | { title: '家属关系', value: row => relationship(row) }, |
| | | { title: '申请时间', value: row => registerTime(row) }, |
| | | { title: '状态', value: row => statusText(row) }, |
| | | { title: '审核备注', value: row => row.auditRemark || '-' }, |
| | | ], |
| | | result.records, |
| | | ) |
| | |
| | | </div> |
| | | |
| | | <el-form class="family-card-filter-form" :model="filters" label-width="84px"> |
| | | <el-form-item :label="isManagement ? '家属' : '家属信息'"> |
| | | <el-input v-model="filters.familyKeyword" :placeholder="isManagement ? '家属姓名、手机号' : '请输入家属姓名、手机号或身份证号'" clearable /> |
| | | <!-- 家属卡申请按原型只保留「持卡人 / 卡片等级 / 状态」三个筛选;家属与时间筛选只在家属卡管理页使用。 --> |
| | | <el-form-item v-if="isManagement" label="家属"> |
| | | <el-input v-model="filters.familyKeyword" placeholder="家属姓名、手机号" clearable /> |
| | | </el-form-item> |
| | | <el-form-item label="持卡人" :class="{ 'family-card-filter-talent': isManagement }"> |
| | | <el-input v-model="filters.talentKeyword" placeholder="持卡人姓名、手机号、身份证号或卡号" clearable /> |
| | | <el-input v-model="filters.talentKeyword" :placeholder="isManagement ? '持卡人姓名、手机号、身份证号或卡号' : '持卡人姓名、手机号、卡号'" clearable /> |
| | | </el-form-item> |
| | | <el-form-item label="卡片等级"> |
| | | <el-select v-model="filters.cardLevel" placeholder="全部" clearable> |
| | |
| | | <el-option v-for="item in statusOptions" :key="item.label" :label="item.label" :value="item.value" /> |
| | | </el-select> |
| | | </el-form-item> |
| | | <el-form-item :label="isManagement ? '注册时间' : '申请时间'" :class="{ 'family-card-filter-date': isManagement }"> |
| | | <el-form-item v-if="isManagement" label="注册时间" :class="{ 'family-card-filter-date': isManagement }"> |
| | | <el-date-picker |
| | | v-model="filters.applyTime" |
| | | type="daterange" |
| | |
| | | table-layout="fixed" |
| | | scrollbar-always-on |
| | | > |
| | | <el-table-column prop="id" label="编号" width="86" align="center" /> |
| | | <el-table-column prop="applicationNo" label="申请编号" min-width="160" /> |
| | | <el-table-column label="家属姓名" min-width="110"> |
| | | <el-table-column prop="talentName" label="持卡人名称" min-width="110" /> |
| | | <el-table-column prop="talentMobile" label="持卡人手机号" min-width="130" /> |
| | | <el-table-column prop="cardLevel" label="卡片等级" min-width="108" /> |
| | | <el-table-column label="家属名称" min-width="106"> |
| | | <template #default="{ row }">{{ familyName(row) }}</template> |
| | | </el-table-column> |
| | | <el-table-column label="家属手机号" min-width="130"> |
| | | <template #default="{ row }">{{ familyMobile(row) }}</template> |
| | | </el-table-column> |
| | | <el-table-column label="家属身份证号" min-width="180"> |
| | | <template #default="{ row }">{{ familyIdCard(row) }}</template> |
| | | </el-table-column> |
| | | <el-table-column prop="talentName" label="持卡人姓名" min-width="110" /> |
| | | <el-table-column prop="talentMobile" label="持卡人手机号" min-width="130" /> |
| | | <el-table-column prop="talentIdCardNo" label="持卡人身份证号" min-width="180" /> |
| | | <el-table-column prop="talentCardNo" label="人才卡号" min-width="120" /> |
| | | <el-table-column prop="cardLevel" label="卡片等级" min-width="108" /> |
| | | <el-table-column label="关系" min-width="90"> |
| | | <el-table-column label="家属关系" min-width="90"> |
| | | <template #default="{ row }">{{ relationship(row) }}</template> |
| | | </el-table-column> |
| | | <el-table-column prop="auditRemark" label="审核备注" min-width="160" show-overflow-tooltip /> |
| | | <el-table-column label="申请时间" min-width="150"> |
| | | <template #default="{ row }">{{ registerTime(row) }}</template> |
| | | </el-table-column> |
| | | <el-table-column label="状态" width="90"> |
| | | <template #default="{ row }">{{ statusText(row) }}</template> |
| | | </el-table-column> |
| | | <el-table-column label="操作" width="180" fixed="right"> |
| | | <el-table-column label="操作" width="90" fixed="right"> |
| | | <template #default="{ row }"> |
| | | <el-button link type="primary" @click="viewDetail(row)">查看</el-button> |
| | | </template> |
| | |
| | | deleteProduct as deleteProductApi, |
| | | getAdminCouponDetail, |
| | | getAdminCoupons, |
| | | getAdminProducts, |
| | | getAdminMerchantProducts, |
| | | updateCoupon, |
| | | updateCouponStatus, |
| | | updateMerchant, |
| | |
| | | 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 merchantRules = computed<FormRules>(() => ({ |
| | | merchantName: [{ required: true, message: '请输入商家名称', trigger: 'blur' }], |
| | | contactMobile: [{ required: true, message: '请输入联系电话', trigger: 'blur' }], |
| | | address: isApartmentMerchant.value ? [] : [{ required: true, message: '请输入商家地址', trigger: 'blur' }], |
| | | address: isApartmentMerchant.value ? [] : [{ required: true, message: '请输入商家地址', trigger: ['blur', 'change'] }], |
| | | coverUrl: [{ required: true, message: '请上传商家封面图', trigger: 'change' }], |
| | | sortNo: [{ |
| | | validator: (_rule, value, callback) => { |
| | |
| | | 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() |
| | |
| | | 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>() |
| | |
| | | return new Promise((resolve) => { |
| | | const geolocation = window.navigator?.geolocation |
| | | if (!geolocation) { |
| | | ElMessage.info('当前浏览器不支持定位,请直接搜索或点击地图标记') |
| | | console.info('browser geolocation is unavailable, fall back to the default map center') |
| | | resolve(undefined) |
| | | return |
| | | } |
| | | const hostname = window.location.hostname |
| | | // 浏览器只允许在安全上下文(HTTPS 或 localhost)里定位,局域网 IP + HTTP 必然被拒。 |
| | | // 定位只是"把地图居中"的便利功能,这里静默跳过,不再弹提示打断选点流程。 |
| | | if (!window.isSecureContext && hostname !== 'localhost' && hostname !== '127.0.0.1') { |
| | | ElMessage.warning('浏览器定位需要使用 localhost 或 HTTPS 地址访问') |
| | | console.info('browser geolocation requires a secure context; keeping the default map center') |
| | | resolve(undefined) |
| | | return |
| | | } |
| | |
| | | resolve(Number.isFinite(longitude) && Number.isFinite(latitude) ? { longitude, latitude } : undefined) |
| | | }, |
| | | (error) => { |
| | | if (error.code === error.PERMISSION_DENIED) ElMessage.info('未获得浏览器定位权限,请直接搜索或点击地图标记') |
| | | console.info(`browser geolocation unavailable: ${error.code}`) |
| | | resolve(undefined) |
| | | }, |
| | | { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }, |
| | |
| | | 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 |
| | | } |
| | |
| | | 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() |
| | |
| | | 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) |
| | | } |
| | |
| | | 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 |
| | |
| | | |
| | | 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], |
| | |
| | | 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 |
| | | } |
| | | |
| | |
| | | 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 |
| | |
| | | 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) |
| | |
| | | <el-form-item class="merchant-account-field" label="登录账号" prop="loginAccount" :required="merchantOperatorEnabled" :error="requiresMerchantOperator && accountTaken ? '登录账号已存在,请更换' : ''"> |
| | | <el-input v-model="accountForm.loginAccount" maxlength="32" :disabled="isEditMode && merchantHasLoginAccount" :placeholder="isEditMode && !merchantHasLoginAccount ? '请输入登录账号' : (isEditMode ? '已配置登录账号' : '仅支持英文和数字')" @input="normalizeLoginAccount" @blur="checkLoginAccountAvailable" /> |
| | | </el-form-item> |
| | | <el-form-item class="merchant-account-field" label="登录密码" prop="loginPassword" :required="merchantOperatorEnabled"> |
| | | <el-form-item class="merchant-account-field" label="登录密码" prop="loginPassword" :required="passwordFieldsRequired"> |
| | | <el-input v-model="accountForm.loginPassword" type="password" show-password :placeholder="isEditMode ? '留空则不修改,6至64位' : ''" /> |
| | | </el-form-item> |
| | | <el-form-item class="merchant-account-field" label="确认密码" prop="confirmPassword" :required="merchantOperatorEnabled"> |
| | | <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> |
| | |
| | | <div class="merchant-parking-address-actions"><el-button plain @click="addFuelStore">新增</el-button></div> |
| | | </section> |
| | | |
| | | <section v-if="supportsHeroImages" class="merchant-parking-address-panel"> |
| | | <h3 class="merchant-parking-address-title">详情页图片</h3> |
| | | <p class="merchant-fuel-hero-tip">显示在人才端{{ merchantHeroImageTip }}详情页顶部,按顺序展示、只展示已启用的图片;不配置时自动使用商家封面图。推荐 750 × 730 px。</p> |
| | | <el-table :data="fuelHeroImages" border> |
| | | <el-table-column label="图片" width="140" align="center"> |
| | | <template #default="{ row }"> |
| | | <img v-if="row.imageUrl" class="merchant-fuel-hero-preview" :src="row.imageUrl" alt="详情页图片" /> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="启用" width="120" align="center"> |
| | | <template #default="{ row }"><el-switch v-model="row.enabled" /></template> |
| | | </el-table-column> |
| | | <el-table-column label="操作" width="100" fixed="right" align="center"> |
| | | <template #default="{ $index }"><el-button link type="danger" @click="fuelHeroImages.splice($index, 1)">删除</el-button></template> |
| | | </el-table-column> |
| | | </el-table> |
| | | <div class="merchant-parking-address-actions"> |
| | | <el-button plain :loading="imageUploading" @click="changeCover('fuelHero')">上传图片</el-button> |
| | | </div> |
| | | </section> |
| | | |
| | | <section v-if="isChildcareMerchant" class="merchant-school-address-panel"> |
| | | <h3 class="merchant-school-address-title">学校地址</h3> |
| | | <el-table :data="schoolAddresses" border> |
| | |
| | | const activePackage = ref<Record<string, any> | null>(null) |
| | | const selectedPackage = ref<Record<string, any> | null>(null) |
| | | |
| | | const cardLevels = TALENT_CARD_LEVELS |
| | | // 电影票只面向三种人才卡开放,筛选与票包权益都不出现"普通游客"。 |
| | | const ticketRuleLevels = TALENT_CARD_LEVELS.filter(level => level !== '普通游客') |
| | | const resetCycles = ['每天', '每周', '每月', '每年'] |
| | | const packageStatuses = [{ label: '全部', value: '' }, { label: '正常', value: 1 }, { label: '禁用', value: 0 }] |
| | |
| | | <el-form-item label="卡片等级"> |
| | | <el-select v-model="claimFilters.cardLevel" placeholder="全部" clearable> |
| | | <el-option label="全部" value="" /> |
| | | <el-option v-for="level in cardLevels" :key="level" :label="level" :value="level" /> |
| | | <el-option v-for="level in ticketRuleLevels" :key="level" :label="level" :value="level" /> |
| | | </el-select> |
| | | </el-form-item> |
| | | <el-form-item label="领取时间"> |
| | |
| | | videoName: '', |
| | | homeVideoUrl: '', |
| | | miniProgramUrl: '', |
| | | miniProgramAppId: '', |
| | | miniProgramPath: '', |
| | | }) |
| | | |
| | | const saving = ref(false) |
| | |
| | | } |
| | | async function saveSettings() { |
| | | saving.value = true |
| | | try { await savePlatformSettings({ bookstoreDiscount: form.bookstoreDiscount, hotelSubsidy: form.hotelSubsidy, homeVideoUrl: form.homeVideoUrl, miniProgramUrl: form.miniProgramUrl }); ElMessage.success('平台设置已保存') } finally { saving.value = false } |
| | | try { await savePlatformSettings({ bookstoreDiscount: form.bookstoreDiscount, hotelSubsidy: form.hotelSubsidy, homeVideoUrl: form.homeVideoUrl, miniProgramUrl: form.miniProgramUrl, miniProgramAppId: form.miniProgramAppId, miniProgramPath: form.miniProgramPath }); ElMessage.success('平台设置已保存') } finally { saving.value = false } |
| | | } |
| | | onMounted(async () => { Object.assign(form, await getPlatformSettings()) }) |
| | | </script> |
| | |
| | | <el-input v-model="form.miniProgramUrl" placeholder="请输入" /> |
| | | </el-form-item> |
| | | |
| | | <el-form-item class="platform-mini-program-link" label="目标小程序 AppID"> |
| | | <el-input v-model="form.miniProgramAppId" placeholder="如 wx1234567890abcdef,填写后首页入口直接跳转该小程序" /> |
| | | </el-form-item> |
| | | |
| | | <el-form-item class="platform-mini-program-link" label="目标小程序路径"> |
| | | <el-input v-model="form.miniProgramPath" placeholder="选填,如 pages/index/index" /> |
| | | </el-form-item> |
| | | |
| | | |
| | | <el-form-item class="platform-settings-save"> |
| | | <el-button type="primary" :loading="saving" @click="saveSettings">保存</el-button> |
| | | </el-form-item> |
| | |
| | | async function uploadCover(file: UploadFile) { if (!file.raw) return; const result = await uploadAdminImage(file.raw); form.coverUrl = result.url; await formRef.value?.validateField('coverUrl'); ElMessage.success('封面图上传成功') } |
| | | function validateContent() { void formRef.value?.validateField('content') } |
| | | function formatTime(value?: string) { return value ? value.replace('T', ' ').slice(0, 19) : '-' } |
| | | const createdAt = ref('-'); const updatedAt = ref('-') |
| | | async function load() { loading.value = true; try { const item = await getAdminPolicyDetail(String(route.params.id)); form.title = item.title; form.subtitle = item.subtitle || ''; form.author = item.author || ''; form.source = item.source || ''; form.content = item.content || ''; form.coverUrl = item.coverUrl || ''; form.isTop = Boolean(item.isTop); form.sortNo = Number(item.sortNo || 0); form.status = Number(item.status ?? 0); createdAt.value = formatTime(item.createdAt); updatedAt.value = formatTime(item.updatedAt) } finally { loading.value = false } } |
| | | // 发布人取创建该政策的管理员,修改人取最近一次保存的管理员;历史数据回落为发布人字段。 |
| | | const publisher = ref('-'); const editor = ref('-'); const createdAt = ref('-'); const updatedAt = ref('-') |
| | | async function load() { loading.value = true; try { const item = await getAdminPolicyDetail(String(route.params.id)); form.title = item.title; form.subtitle = item.subtitle || ''; form.author = item.author || ''; form.source = item.source || ''; form.content = item.content || ''; form.coverUrl = item.coverUrl || ''; form.isTop = Boolean(item.isTop); form.sortNo = Number(item.sortNo || 0); form.status = Number(item.status ?? 0); publisher.value = item.createdByName || item.author || '-'; editor.value = item.updatedByName || item.author || '-'; createdAt.value = formatTime(item.createdAt); updatedAt.value = formatTime(item.updatedAt) } finally { loading.value = false } } |
| | | async function save() { const valid = await formRef.value?.validate().catch(() => false); if (!valid) return; await updateAdminPolicy(String(route.params.id), { ...form, title: form.title.trim(), subtitle: form.subtitle.trim(), author: form.author.trim(), source: form.source.trim(), coverUrl: form.coverUrl.trim() }); ElMessage.success('政策已保存'); router.push('/talent-policy-management') } |
| | | onMounted(() => void load()) |
| | | </script> |
| | | <template> |
| | | <div class="policy-edit-page" v-loading="loading"><section class="news-meta-panel"><h3>发布信息</h3><div class="news-meta-grid"><div><span>发布时间</span><strong>{{ createdAt }}</strong></div><div><span>修改时间</span><strong>{{ updatedAt }}</strong></div></div></section><section class="policy-publish-panel"><h3>政策内容</h3><el-form ref="formRef" class="policy-publish-form" :model="form" :rules="rules" label-width="88px"> |
| | | <el-form-item label="政策标题" prop="title"><el-input v-model="form.title" /></el-form-item><el-form-item label="政策标签" prop="subtitle"><el-input v-model="form.subtitle" placeholder="请输入政策标签" /></el-form-item><el-form-item class="news-editor-form-item" label="政策内容" prop="content"><AdminRichTextEditor v-model="form.content" @update:model-value="validateContent" /></el-form-item><el-form-item label="封面图" prop="coverUrl"><el-upload class="article-cover-upload" accept="image/*" :auto-upload="false" :show-file-list="false" :on-change="uploadCover"><el-image v-if="form.coverUrl" class="article-cover-preview" :src="form.coverUrl" fit="cover" /><div v-else class="article-cover-upload-card"><span>+</span><small>上传图片</small></div></el-upload></el-form-item><el-form-item label="排序" prop="sortNo"><el-input v-model.number="form.sortNo" type="number" placeholder="请输入非负整数" /></el-form-item><el-form-item><el-button type="primary" @click="save">保存</el-button></el-form-item> |
| | | <div class="policy-edit-page" v-loading="loading"><section class="news-meta-panel"><h3>发布信息</h3><div class="news-meta-grid"><div><span>发布人</span><strong>{{ publisher }}</strong></div><div><span>发布时间</span><strong>{{ createdAt }}</strong></div><div><span>修改人</span><strong>{{ editor }}</strong></div><div><span>修改时间</span><strong>{{ updatedAt }}</strong></div></div></section><section class="policy-publish-panel"><h3>政策内容</h3><el-form ref="formRef" class="policy-publish-form" :model="form" :rules="rules" label-width="88px"> |
| | | <el-form-item label="政策标题" prop="title"><el-input v-model="form.title" /></el-form-item><el-form-item label="政策标签" prop="subtitle"><el-input v-model="form.subtitle" placeholder="请输入政策标签" /></el-form-item><el-form-item class="news-editor-form-item" label="政策内容" prop="content"><AdminRichTextEditor v-model="form.content" @update:model-value="validateContent" /></el-form-item><el-form-item label="封面图" prop="coverUrl"><el-upload class="article-cover-upload" accept="image/*" :auto-upload="false" :show-file-list="false" :on-change="uploadCover"><el-image v-if="form.coverUrl" class="article-cover-preview" :src="form.coverUrl" fit="cover" /><div v-else class="article-cover-upload-card"><span>+</span><small>上传图片</small></div></el-upload></el-form-item><el-form-item label="是否置顶"><el-switch v-model="form.isTop" /></el-form-item><el-form-item label="排序" prop="sortNo"><el-input v-model.number="form.sortNo" type="number" placeholder="请输入非负整数" /></el-form-item><el-form-item><el-button type="primary" @click="save">保存</el-button></el-form-item> |
| | | </el-form></section></div> |
| | | </template> |
| | |
| | | <el-form-item label="政策标签" prop="subtitle" required><el-input v-model="form.subtitle" placeholder="请输入政策标签" /></el-form-item> |
| | | <el-form-item class="news-editor-form-item" label="政策内容" prop="content" required><AdminRichTextEditor v-model="form.content" @update:model-value="validateContent" /></el-form-item> |
| | | <el-form-item label="封面图" prop="coverUrl" required><el-upload class="article-cover-upload" accept="image/*" :auto-upload="false" :show-file-list="false" :on-change="uploadCover"><el-image v-if="form.coverUrl" class="article-cover-preview" :src="form.coverUrl" fit="cover" /><div v-else class="article-cover-upload-card"><span>+</span><small>上传图片</small></div></el-upload></el-form-item> |
| | | <el-form-item label="是否置顶"><el-switch v-model="form.isTop" /></el-form-item> |
| | | <el-form-item label="排序" prop="sortNo" required><el-input v-model.number="form.sortNo" type="number" placeholder="请输入非负整数" /></el-form-item> |
| | | <el-form-item><el-button type="primary" @click="save">保存</el-button></el-form-item> |
| | | </el-form></section></div> |
| | |
| | | import vue from '@vitejs/plugin-vue' |
| | | |
| | | // https://vite.dev/config/ |
| | | // 开发时代理到本机后端:浏览器同源请求不会经过 CORS 校验,但代理会把页面的 |
| | | // Origin(例如局域网 IP)原样带过去,后端白名单没列到就直接 403。这里统一去掉 |
| | | // Origin,让开发环境无论用 localhost 还是局域网地址访问都能登录。 |
| | | const stripOrigin = (proxy: any) => { |
| | | proxy.on('proxyReq', (proxyReq: any) => { |
| | | proxyReq.removeHeader('origin') |
| | | }) |
| | | } |
| | | |
| | | export default defineConfig({ |
| | | plugins: [vue()], |
| | | server: { |
| | |
| | | '/api': { |
| | | target: 'http://localhost:8080', |
| | | changeOrigin: true, |
| | | configure: stripOrigin, |
| | | }, |
| | | '/uploads': { |
| | | target: 'http://localhost:8080', |
| | | changeOrigin: true, |
| | | configure: stripOrigin, |
| | | }, |
| | | '/static': { |
| | | target: 'http://localhost:8080', |
| | | changeOrigin: true, |
| | | configure: stripOrigin, |
| | | }, |
| | | }, |
| | | }, |
| | |
| | | @ResponseStatus(HttpStatus.CREATED) |
| | | public ApiResponse<Map<String, Object>> createPolicy(@Valid @RequestBody PolicyArticleRequest request, HttpServletRequest servletRequest) { |
| | | String operatorName = String.valueOf(servletRequest.getAttribute(AdminAuthInterceptor.OPERATOR_NAME_ATTRIBUTE)); |
| | | return ApiResponse.success(adminBusinessService.createPolicy(request, operatorName)); |
| | | Long operatorId = (Long) servletRequest.getAttribute(AdminAuthInterceptor.OPERATOR_ID_ATTRIBUTE); |
| | | return ApiResponse.success(adminBusinessService.createPolicy(request, operatorId, operatorName)); |
| | | } |
| | | |
| | | @PutMapping("/policies/{id}") |
| | | public ApiResponse<Map<String, Object>> updatePolicy(@PathVariable Long id, @Valid @RequestBody PolicyArticleRequest request) { |
| | | return ApiResponse.success(adminBusinessService.updatePolicy(id, request)); |
| | | public ApiResponse<Map<String, Object>> updatePolicy(@PathVariable Long id, @Valid @RequestBody PolicyArticleRequest request, HttpServletRequest servletRequest) { |
| | | Long operatorId = (Long) servletRequest.getAttribute(AdminAuthInterceptor.OPERATOR_ID_ATTRIBUTE); |
| | | return ApiResponse.success(adminBusinessService.updatePolicy(id, request, operatorId)); |
| | | } |
| | | |
| | | @PatchMapping("/policies/{id}/status") |
| | |
| | | return ApiResponse.success(adminBusinessService.createBenefit(request)); |
| | | } |
| | | |
| | | @GetMapping("/service-hero-images") |
| | | public ApiResponse<Map<String, Object>> serviceHeroImages() { |
| | | return ApiResponse.success(adminBusinessService.serviceHeroImages()); |
| | | } |
| | | |
| | | @PutMapping("/service-hero-images") |
| | | public ApiResponse<Map<String, Object>> saveServiceHeroImage(@Valid @RequestBody ServiceHeroImageRequest request) { |
| | | return ApiResponse.success(adminBusinessService.saveServiceHeroImage(request.type(), request.imageUrl())); |
| | | } |
| | | |
| | | @GetMapping("/products") |
| | | public ApiResponse<PageResult<Map<String, Object>>> products( |
| | | @RequestParam(required = false) Long merchantId, |
| | |
| | | @GetMapping("/merchants/{id}/products") |
| | | public ApiResponse<PageResult<Map<String, Object>>> merchantProducts( |
| | | @PathVariable Long id, |
| | | @RequestParam(required = false) String keyword, |
| | | @RequestParam(required = false) Integer status, |
| | | @RequestParam(required = false) String createdStart, |
| | | @RequestParam(required = false) String createdEnd, |
| | | @RequestParam(defaultValue = "1") int page, |
| | | @RequestParam(defaultValue = "10") int size |
| | | ) { |
| | | return ApiResponse.success(adminBusinessService.products(id, null, null, null, null, null, null, null, page, size)); |
| | | return ApiResponse.success(adminBusinessService.merchantProducts(id, keyword, status, createdStart, createdEnd, page, size)); |
| | | } |
| | | |
| | | @GetMapping("/products/{id}") |
| | |
| | | if (status != null) { sql.append(" and status = ?"); countSql.append(" and status = ?"); args.add(status); } |
| | | if (publishStart != null && !publishStart.isBlank()) { sql.append(" and date(publish_time) >= ?"); countSql.append(" and date(publish_time) >= ?"); args.add(publishStart); } |
| | | if (publishEnd != null && !publishEnd.isBlank()) { sql.append(" and date(publish_time) <= ?"); countSql.append(" and date(publish_time) <= ?"); args.add(publishEnd); } |
| | | sql.append(" order by is_top desc, sort_no desc, publish_time desc, id desc"); |
| | | // 置顶优先,置顶组内按置顶时间倒序(越后置顶越靠前);非置顶按排序值倒序。 |
| | | sql.append(" order by is_top desc, case when is_top = 1 then top_time end desc, sort_no desc, publish_time desc, id desc"); |
| | | return page(sql.toString(), countSql.toString(), page, size, args.toArray()); |
| | | } |
| | | |
| | | @Transactional(readOnly = true) |
| | | public Map<String, Object> policyDetail(Long id) { |
| | | return one("select id, policy_no, title, subtitle, cover_url, author, source, content, view_count, is_top, sort_no, publish_time, status, created_at, updated_at from policy_article where deleted = 0 and id = ?", id); |
| | | // created_by/updated_by 存的是管理员 ID,这里一并查出姓名,前端直接展示发布人/修改人。 |
| | | // 历史数据没有记录操作人时回落到 author(创建人)。 |
| | | return one(""" |
| | | select p.id, p.policy_no, p.title, p.subtitle, p.cover_url, p.author, |
| | | p.created_by, p.updated_by, p.is_top, p.top_time, |
| | | coalesce(creator.display_name, p.author) as created_by_name, |
| | | coalesce(updater.display_name, p.author) as updated_by_name, |
| | | p.source, p.content, p.view_count, p.sort_no, p.publish_time, p.status, |
| | | p.created_at, p.updated_at |
| | | from policy_article p |
| | | left join admin_operator creator on creator.id = p.created_by and creator.deleted = 0 |
| | | left join admin_operator updater on updater.id = p.updated_by and updater.deleted = 0 |
| | | where p.deleted = 0 and p.id = ? |
| | | """, id); |
| | | } |
| | | |
| | | @Transactional |
| | | public Map<String, Object> createPolicy(PolicyArticleRequest request, String operatorName) { |
| | | public Map<String, Object> createPolicy(PolicyArticleRequest request, Long operatorId, String operatorName) { |
| | | int status = defaultNewsStatus(request.status()); |
| | | jdbcTemplate.update("insert into policy_article(policy_no, title, subtitle, cover_url, author, source, content, is_top, sort_no, publish_time, status) values (?, ?, ?, ?, ?, ?, ?, ?, ?, case when ? = 1 then now() else null end, ?)", |
| | | BusinessIdGenerator.next("POLICY"), request.title().trim(), blankToNull(request.subtitle()), blankToNull(request.coverUrl()), blankToNull(operatorName), blankToNull(request.source()), sanitizeNewsContent(request.content()), Boolean.TRUE.equals(request.isTop()) ? 1 : 0, request.sortNo() == null ? 0 : request.sortNo(), status, status); |
| | | // top_time 只在勾选置顶时写入,列表用它做置顶组内的排序依据。 |
| | | jdbcTemplate.update("insert into policy_article(policy_no, title, subtitle, cover_url, author, created_by, updated_by, source, content, is_top, top_time, sort_no, publish_time, status) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, case when ? = 1 then now() else null end, ?)", |
| | | BusinessIdGenerator.next("POLICY"), request.title().trim(), blankToNull(request.subtitle()), blankToNull(request.coverUrl()), blankToNull(operatorName), operatorId, operatorId, blankToNull(request.source()), sanitizeNewsContent(request.content()), Boolean.TRUE.equals(request.isTop()) ? 1 : 0, Boolean.TRUE.equals(request.isTop()) ? 1 : 0, request.sortNo() == null ? 0 : request.sortNo(), status, status); |
| | | return policyDetail(jdbcTemplate.queryForObject("select last_insert_id()", Long.class)); |
| | | } |
| | | |
| | | @Transactional |
| | | public Map<String, Object> updatePolicy(Long id, PolicyArticleRequest request) { |
| | | public Map<String, Object> updatePolicy(Long id, PolicyArticleRequest request, Long operatorId) { |
| | | int status = defaultNewsStatus(request.status()); |
| | | int updated = jdbcTemplate.update("update policy_article set title = ?, subtitle = ?, cover_url = ?, author = ?, source = ?, content = ?, is_top = ?, sort_no = ?, status = ?, publish_time = case when ? = 1 then coalesce(publish_time, now()) else publish_time end where deleted = 0 and id = ?", |
| | | request.title().trim(), blankToNull(request.subtitle()), blankToNull(request.coverUrl()), blankToNull(request.author()), blankToNull(request.source()), sanitizeNewsContent(request.content()), Boolean.TRUE.equals(request.isTop()) ? 1 : 0, request.sortNo() == null ? 0 : request.sortNo(), status, status, id); |
| | | // author 保持原样(发布人只在创建时确定);updated_by 记录本次保存的管理员,空值不覆盖。 |
| | | // 新勾选置顶时记录置顶时间;取消置顶时清空,保证置顶组内按"最近置顶"排序。 |
| | | int updated = jdbcTemplate.update("update policy_article set title = ?, subtitle = ?, cover_url = ?, author = coalesce(nullif(?, ''), author), updated_by = coalesce(?, updated_by), source = ?, content = ?, is_top = ?, top_time = case when ? = 1 and coalesce(is_top, 0) = 0 then now() when ? = 0 then null else top_time end, sort_no = ?, status = ?, publish_time = case when ? = 1 then coalesce(publish_time, now()) else publish_time end where deleted = 0 and id = ?", |
| | | request.title().trim(), blankToNull(request.subtitle()), blankToNull(request.coverUrl()), blankToNull(request.author()), operatorId, blankToNull(request.source()), sanitizeNewsContent(request.content()), Boolean.TRUE.equals(request.isTop()) ? 1 : 0, Boolean.TRUE.equals(request.isTop()) ? 1 : 0, Boolean.TRUE.equals(request.isTop()) ? 1 : 0, request.sortNo() == null ? 0 : request.sortNo(), status, status, id); |
| | | ensureUpdated(updated); |
| | | return policyDetail(id); |
| | | } |
| | |
| | | where merchant_id = ? and deleted = 0 and enabled = 1 |
| | | order by sort_no asc, id asc |
| | | """, id))); |
| | | // 加油服务详情页顶部轮播图:后台在商家编辑页维护,保存时整表替换。 |
| | | result.put("heroImages", DatabaseRows.normalize(jdbcTemplate.queryForList(""" |
| | | select id, image_url, sort_no, enabled |
| | | from transport_fuel_hero_image |
| | | where merchant_id = ? and deleted = 0 |
| | | order by sort_no asc, id asc |
| | | """, id))); |
| | | } else if (HERO_IMAGE_MERCHANT_TYPES.contains(String.valueOf(result.get("merchantTypeCode")))) { |
| | | // 子女托管 / 行政审批的详情页轮播图同样按商家配置。 |
| | | result.put("heroImages", DatabaseRows.normalize(jdbcTemplate.queryForList(""" |
| | | select id, image_url, sort_no, enabled |
| | | from merchant_hero_image |
| | | where merchant_id = ? and deleted = 0 |
| | | order by sort_no asc, id asc |
| | | """, id))); |
| | | } |
| | | return result; |
| | | } |
| | |
| | | ensureParkingBenefit(id, merchantType.code()); |
| | | ensureFuelBenefit(id, merchantType.code(), request.serviceLabel()); |
| | | syncFuelStores(id, merchantType.code(), request.applicableStores()); |
| | | syncMerchantHeroImages(id, merchantType.code(), request.heroImages()); |
| | | syncPremiumTopicMerchant(id, merchantType, request.sortNo()); |
| | | Long storeId = createDefaultStore(id, request.merchantName(), request.address()); |
| | | syncMerchantBusinessHours(id, storeId, request.businessHours()); |
| | |
| | | ensureParkingBenefit(id, merchantType.code()); |
| | | ensureFuelBenefit(id, merchantType.code(), request.serviceLabel()); |
| | | syncFuelStores(id, merchantType.code(), request.applicableStores()); |
| | | syncMerchantHeroImages(id, merchantType.code(), request.heroImages()); |
| | | syncPremiumTopicMerchant(id, merchantType, request.sortNo()); |
| | | Long storeId = findOrCreateDefaultStore(id, request.merchantName(), request.address()); |
| | | syncMerchantBusinessHours(id, storeId, request.businessHours()); |
| | |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 子女托管 / 行政审批的详情页图:这两类服务的权益是全局一条,图片也全局共用。 |
| | | */ |
| | | @Transactional(readOnly = true) |
| | | public Map<String, Object> serviceHeroImages() { |
| | | Map<String, Object> result = new LinkedHashMap<>(); |
| | | result.put("children", firstRowOrEmpty("select id, hero_image from children_entrustment_benefit where deleted = 0 order by id asc limit 1")); |
| | | result.put("approval", firstRowOrEmpty("select id, hero_image from admin_approval_benefit where deleted = 0 order by id asc limit 1")); |
| | | return result; |
| | | } |
| | | |
| | | @Transactional |
| | | public Map<String, Object> saveServiceHeroImage(String type, String imageUrl) { |
| | | String table = switch (type == null ? "" : type.trim()) { |
| | | case "children" -> "children_entrustment_benefit"; |
| | | case "approval" -> "admin_approval_benefit"; |
| | | default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的服务类型"); |
| | | }; |
| | | String url = imageUrl == null || imageUrl.isBlank() ? null : imageUrl.trim(); |
| | | int updated = jdbcTemplate.update("update " + table + " set hero_image = ? where deleted = 0 order by id asc limit 1", url); |
| | | if (updated == 0) throw new BusinessException(ErrorCode.NOT_FOUND, "该服务的权益暂未配置"); |
| | | return serviceHeroImages(); |
| | | } |
| | | |
| | | private Map<String, Object> firstRowOrEmpty(String sql, Object... args) { |
| | | List<Map<String, Object>> rows = DatabaseRows.normalize(jdbcTemplate.queryForList(sql, args)); |
| | | return rows.isEmpty() ? new LinkedHashMap<>() : rows.get(0); |
| | | } |
| | | |
| | | private boolean enabledFlag(Object value) { |
| | | if (value == null) return true; |
| | | if (value instanceof Boolean bool) return bool; |
| | | if (value instanceof Number number) return number.intValue() != 0; |
| | | String text = String.valueOf(value).trim(); |
| | | return "true".equalsIgnoreCase(text) || "1".equals(text); |
| | | } |
| | | |
| | | 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 static final List<String> HERO_IMAGE_MERCHANT_TYPES = List.of("children", "approval"); |
| | | |
| | | /** |
| | | * 同步商家详情页轮播图。 |
| | | * |
| | | * 后台提交整份列表,这里按商家整表替换;列表为 null 表示本次提交没有这个字段,保持原样。 |
| | | * 加油服务沿用原有的 transport_fuel_hero_image,子女托管/行政审批使用通用的 merchant_hero_image。 |
| | | */ |
| | | private void syncMerchantHeroImages(Long merchantId, String merchantType, List<Map<String, Object>> images) { |
| | | if (images == null) return; |
| | | boolean fuel = "fuel".equals(merchantType); |
| | | if (!fuel && !HERO_IMAGE_MERCHANT_TYPES.contains(merchantType)) return; |
| | | List<Map<String, Object>> normalized = new ArrayList<>(); |
| | | for (Map<String, Object> image : images) { |
| | | if (image == null) continue; |
| | | Object urlValue = image.containsKey("imageUrl") ? image.get("imageUrl") : image.get("image_url"); |
| | | String url = urlValue == null ? "" : String.valueOf(urlValue).trim(); |
| | | if (url.isBlank()) continue; |
| | | Map<String, Object> item = new LinkedHashMap<>(); |
| | | item.put("imageUrl", url); |
| | | item.put("enabled", enabledFlag(image.get("enabled")) ? 1 : 0); |
| | | normalized.add(item); |
| | | } |
| | | String table = fuel ? "transport_fuel_hero_image" : "merchant_hero_image"; |
| | | jdbcTemplate.update("delete from " + table + " where merchant_id = ?", merchantId); |
| | | String insertSql = "insert into " + table + "(merchant_id, image_url, sort_no, enabled, deleted) values (?, ?, ?, ?, 0)"; |
| | | for (int index = 0; index < normalized.size(); index++) { |
| | | Map<String, Object> image = normalized.get(index); |
| | | jdbcTemplate.update(insertSql, merchantId, image.get("imageUrl"), (index + 1) * 10, image.get("enabled")); |
| | | } |
| | | } |
| | | |
| | | private void syncPremiumTopicMerchant(Long merchantId, MerchantType merchantType, Integer sortNo) { |
| | |
| | | return one("select * from benefit where id = ?", id); |
| | | } |
| | | |
| | | /** |
| | | * 商家编辑页面的团单列表。 |
| | | * |
| | | * 按商品维度展示(一条团单一行),修改团单只更新原记录的状态为待审核; |
| | | * 审核记录(merchant_product_audit_record)仍只在团单审核页面展示。 |
| | | */ |
| | | @Transactional(readOnly = true) |
| | | public PageResult<Map<String, Object>> merchantProducts( |
| | | Long merchantId, |
| | | String keyword, |
| | | Integer auditStatus, |
| | | String createdStart, |
| | | String createdEnd, |
| | | int page, |
| | | int size |
| | | ) { |
| | | StringBuilder sql = new StringBuilder(""" |
| | | select p.id, p.id as product_id, p.merchant_id, m.merchant_name, m.merchant_type, m.merchant_type_code, m.contact_mobile, |
| | | p.product_no, p.product_type, p.product_name, p.subtitle, p.cover_url, |
| | | p.original_price, p.sale_price, p.subsidy_amount, p.is_government_subsidy, |
| | | p.stock, p.sales_count, p.detail_text, p.benefit_desc, p.purchase_notice, |
| | | p.valid_start_time, p.valid_end_time, p.product_images, p.rights_config, |
| | | p.audit_status, p.audit_remark, p.status, p.sort_no, p.submit_time, p.created_at, p.updated_at |
| | | from merchant_product p |
| | | left join merchant m on m.id = p.merchant_id |
| | | where p.deleted = 0 and p.merchant_id = ? |
| | | """); |
| | | StringBuilder countSql = new StringBuilder(""" |
| | | select count(*) |
| | | from merchant_product p |
| | | where p.deleted = 0 and p.merchant_id = ? |
| | | """); |
| | | List<Object> args = new ArrayList<>(); |
| | | args.add(merchantId); |
| | | if (keyword != null && !keyword.isBlank()) { |
| | | sql.append(" and p.product_name like ?"); |
| | | countSql.append(" and p.product_name like ?"); |
| | | args.add("%" + keyword.trim() + "%"); |
| | | } |
| | | if (auditStatus != null) { |
| | | sql.append(" and p.audit_status = ?"); |
| | | countSql.append(" and p.audit_status = ?"); |
| | | args.add(auditStatus); |
| | | } |
| | | if (createdStart != null && !createdStart.isBlank()) { |
| | | sql.append(" and date(coalesce(p.submit_time, p.created_at)) >= ?"); |
| | | countSql.append(" and date(coalesce(p.submit_time, p.created_at)) >= ?"); |
| | | args.add(createdStart); |
| | | } |
| | | if (createdEnd != null && !createdEnd.isBlank()) { |
| | | sql.append(" and date(coalesce(p.submit_time, p.created_at)) <= ?"); |
| | | countSql.append(" and date(coalesce(p.submit_time, p.created_at)) <= ?"); |
| | | args.add(createdEnd); |
| | | } |
| | | sql.append(" order by coalesce(p.updated_at, p.submit_time, p.created_at) desc, p.id desc"); |
| | | return page(sql.toString(), countSql.toString(), page, size, args.toArray()); |
| | | } |
| | | |
| | | @Transactional(readOnly = true) |
| | | public PageResult<Map<String, Object>> products( |
| | | Long merchantId, |
| | |
| | | public AdminManagementService(JdbcTemplate jdbc) { this.jdbc = jdbc; } |
| | | |
| | | public Map<String, Object> settings() { Map<String, Object> result = new LinkedHashMap<>(); for (Map<String, Object> row : rows("select setting_key, setting_value from admin_setting")) result.put(String.valueOf(row.get("settingKey")), row.get("settingValue")); return result; } |
| | | @Transactional public Map<String, Object> saveSettings(Map<String, String> body) { for (String key : List.of("bookstoreDiscount", "hotelSubsidy", "homeVideoUrl", "miniProgramUrl")) jdbc.update("insert into admin_setting(setting_key, setting_value) values (?, ?) on duplicate key update setting_value = values(setting_value)", key, body.getOrDefault(key, "")); return settings(); } |
| | | @Transactional public Map<String, Object> saveSettings(Map<String, String> body) { for (String key : List.of("bookstoreDiscount", "hotelSubsidy", "homeVideoUrl", "miniProgramUrl", "miniProgramAppId", "miniProgramPath")) jdbc.update("insert into admin_setting(setting_key, setting_value) values (?, ?) on duplicate key update setting_value = values(setting_value)", key, body.getOrDefault(key, "")); return settings(); } |
| | | |
| | | public List<Map<String, Object>> roles() { return rows("select id, role_name, role_code, remark, permission_codes, status, created_at from admin_role order by created_at desc, id desc"); } |
| | | @Transactional public Map<String, Object> createRole(AdminRoleRequest request) { String code = roleCode(request.roleCode()); jdbc.update("insert into admin_role(role_name,role_code,remark,permission_codes,status) values (?,?,?,?,?)", request.roleName().trim(), code, request.remark(), permissions(request.permissionCodes()), request.status()); return one("select id, role_name, role_code, remark, permission_codes, status, created_at from admin_role where id=last_insert_id()"); } |
| | |
| | | Double latitude, |
| | | Byte groupBuyingEnabled, |
| | | List<Map<String, Object>> applicableStores, |
| | | List<Map<String, Object>> heroImages, |
| | | String usageInstructions, |
| | | String rightsText, |
| | | List<Map<String, Object>> merchantRightsConfig, |
| | |
| | | Double latitude, |
| | | Byte groupBuyingEnabled, |
| | | List<Map<String, Object>> applicableStores, |
| | | List<Map<String, Object>> heroImages, |
| | | String usageInstructions, |
| | | String rightsText, |
| | | List<Map<String, Object>> merchantRightsConfig, |
| New file |
| | |
| | | package com.talentcard.server.business.admin; |
| | | |
| | | import jakarta.validation.constraints.NotBlank; |
| | | |
| | | /** |
| | | * 子女托管 / 行政审批详情页图的保存请求。 |
| | | * |
| | | * type 取值 children(子女托管)或 approval(行政审批)。 |
| | | */ |
| | | public record ServiceHeroImageRequest( |
| | | @NotBlank(message = "请选择服务类型") |
| | | String type, |
| | | String imageUrl |
| | | ) { |
| | | } |
| | |
| | | result.put("serviceId", serviceId); |
| | | result.put("title", benefit.get("title")); |
| | | result.put("freeLabel", benefit.get("freeLabel")); |
| | | result.put("heroImage", text(merchant.get("coverUrl"), text(benefit.get("heroImage")))); |
| | | // 详情页图优先用该商家自己配置的轮播图,没配时先回落商家封面图,再回落服务权益里的全局图。 |
| | | result.put("heroImage", merchantHeroImage(serviceId, text(merchant.get("coverUrl"), text(benefit.get("heroImage"))))); |
| | | result.put("heroImages", merchantHeroImages(serviceId)); |
| | | result.put("available", number(benefit.get("enabled")) == 1 && number(merchant.get("status")) == 1); |
| | | result.put("unavailableReason", text(benefit.get("unavailableReason"), "该权益暂未开放")); |
| | | List<Map<String, Object>> configuredOrganizations = configuredOrganizations(merchant); |
| | |
| | | return result; |
| | | } |
| | | |
| | | private List<String> merchantHeroImages(Long merchantId) { |
| | | return query(""" |
| | | select image_url |
| | | from merchant_hero_image |
| | | where merchant_id = ? and deleted = 0 and enabled = 1 |
| | | order by sort_no asc, id asc |
| | | """, merchantId).stream() |
| | | .map(row -> text(row.get("imageUrl"))) |
| | | .filter(url -> !url.isBlank()) |
| | | .toList(); |
| | | } |
| | | |
| | | private String merchantHeroImage(Long merchantId, String fallback) { |
| | | List<String> images = merchantHeroImages(merchantId); |
| | | return images.isEmpty() ? fallback : images.get(0); |
| | | } |
| | | |
| | | private List<Map<String, Object>> configuredOrganizations(Map<String, Object> merchant) { |
| | | List<Map<String, Object>> items = parseApprovalItems(merchant.get("usageInstructions")); |
| | | if (items.isEmpty()) { |
| | |
| | | result.put("serviceId", serviceId); |
| | | result.put("title", benefit.get("title")); |
| | | result.put("applicableCards", text(matchedRight.get("tag"), "")); |
| | | result.put("heroImage", benefit.get("heroImage")); |
| | | // 详情页图优先用该商家自己配置的轮播图,没配时回落商家封面图,最后才是服务权益里的全局图。 |
| | | result.put("heroImage", merchantHeroImage(serviceId, |
| | | text(merchant.get("coverUrl"), text(benefit.get("heroImage"), "")))); |
| | | result.put("heroImages", merchantHeroImages(serviceId)); |
| | | result.put("rightsText", text(matchedRight.get("detail"), text(benefit.get("rightsText"), "暂无使用说明"))); |
| | | result.put("available", available); |
| | | result.put("unavailableReason", available ? "" : text(benefit.get("unavailableReason"), "该权益暂未开放")); |
| | |
| | | school.put("detailAvailable", number(row.get("detailAvailable")) == 1); |
| | | return school; |
| | | }).toList(); |
| | | } |
| | | |
| | | private List<String> merchantHeroImages(Long merchantId) { |
| | | return query(""" |
| | | select image_url |
| | | from merchant_hero_image |
| | | where merchant_id = ? and deleted = 0 and enabled = 1 |
| | | order by sort_no asc, id asc |
| | | """, merchantId).stream() |
| | | .map(row -> text(row.get("imageUrl"), "")) |
| | | .filter(url -> !url.isBlank()) |
| | | .toList(); |
| | | } |
| | | |
| | | private String merchantHeroImage(Long merchantId, String fallback) { |
| | | List<String> images = merchantHeroImages(merchantId); |
| | | return images.isEmpty() ? fallback : images.get(0); |
| | | } |
| | | |
| | | private List<Map<String, Object>> configuredSchools(Object value) { |
| | |
| | | private Map<String, Object> approvalService(Long serviceId) { |
| | | if (serviceId == null || serviceId <= 0) throw new BusinessException(ErrorCode.BAD_REQUEST, "学生托付服务信息不能为空"); |
| | | List<Map<String, Object>> rows = query(""" |
| | | select m.id, m.merchant_rights_config, m.usage_instructions |
| | | select m.id, m.cover_url, m.merchant_rights_config, m.usage_instructions |
| | | from merchant m |
| | | where m.id = ? and m.deleted = 0 and m.merchant_type_code = 'children' |
| | | """, serviceId); |
| | |
| | | select id, policy_no, title, subtitle, cover_url, is_top, publish_time |
| | | from policy_article |
| | | where deleted = 0 and status = 1 |
| | | order by is_top desc, sort_no desc, publish_time desc, id desc |
| | | order by is_top desc, case when is_top = 1 then top_time end desc, sort_no desc, publish_time desc, id desc |
| | | limit 5 |
| | | """)); |
| | | result.put("benefits", query(""" |
| | |
| | | Map<String, Object> homeSettings = new LinkedHashMap<>(); |
| | | for (Map<String, Object> row : query(""" |
| | | select setting_key, setting_value from admin_setting |
| | | where setting_key in ('homeVideoUrl', 'miniProgramUrl') |
| | | where setting_key in ('homeVideoUrl', 'miniProgramUrl', 'miniProgramAppId', 'miniProgramPath') |
| | | """)) { |
| | | homeSettings.put(String.valueOf(row.get("settingKey")), row.get("settingValue")); |
| | | } |
| | | result.put("homeVideoUrl", String.valueOf(homeSettings.getOrDefault("homeVideoUrl", ""))); |
| | | result.put("miniProgramUrl", String.valueOf(homeSettings.getOrDefault("miniProgramUrl", ""))); |
| | | // 首页入口指向其它小程序时必须给出目标 AppID,前端用 navigateToMiniProgram 直接跳转。 |
| | | result.put("miniProgramAppId", String.valueOf(homeSettings.getOrDefault("miniProgramAppId", ""))); |
| | | result.put("miniProgramPath", String.valueOf(homeSettings.getOrDefault("miniProgramPath", ""))); |
| | | if (talentId != null) { |
| | | result.put("cards", query(""" |
| | | select id, talent_id, card_no, card_type, card_level, valid_start, valid_end, status |
| | |
| | | select id, policy_no, title, subtitle, category_name, cover_url, tags, author, source, content, view_count, is_top, publish_time |
| | | from policy_article |
| | | where deleted = 0 and status = 1 |
| | | order by is_top desc, sort_no desc, publish_time desc, id desc |
| | | order by is_top desc, case when is_top = 1 then top_time end desc, sort_no desc, publish_time desc, id desc |
| | | """, "select count(*) from policy_article where deleted = 0 and status = 1", page, size); |
| | | List<Map<String, Object>> records = result.records().stream().map(row -> { |
| | | Map<String, Object> item = new LinkedHashMap<>(row); |
| | |
| | | String merchantWhere = " where deleted = 0 and merchant_type_code = 'food'"; |
| | | PageResult<Map<String, Object>> result = page(""" |
| | | select id, merchant_name, merchant_type_code as merchant_type, contact_name, contact_mobile, logo_url, cover_url, address, |
| | | status, sort_no, longitude, latitude, business_status, |
| | | status, sort_no, longitude, latitude, business_status, merchant_rights_config, |
| | | (select min(p.sale_price) from merchant_product p where p.merchant_id = merchant.id and p.deleted = 0 and p.status = 1 and p.audit_status = 2) as sale_price, |
| | | (select min(p.original_price) from merchant_product p where p.merchant_id = merchant.id and p.deleted = 0 and p.status = 1 and p.audit_status = 2) as original_price |
| | | from merchant |
| | |
| | | Long merchantId = item.get("id") instanceof Number id ? id.longValue() : null; |
| | | boolean couponAvailable = merchantId != null && hasActiveMerchantCoupon(merchantId); |
| | | String discountText = merchantId == null ? "" : merchantLowestTalentDiscount(merchantId, talentId); |
| | | Map<String, Object> right = matchingMerchantRight( |
| | | merchantRightsConfigs(row.get("merchantRightsConfig")), |
| | | activeProductCardLevel(talentId), |
| | | activeTalentSourceAreaType(talentId), |
| | | true); |
| | | item.put("couponAvailable", couponAvailable); |
| | | item.put("discountText", discountText); |
| | | item.put("benefitTags", merchantBenefitTags("food", couponAvailable, discountText, null)); |
| | | item.put("benefitTags", merchantBenefitTags(merchantId, activeProductCardLevel(talentId), "food", couponAvailable, discountText, right)); |
| | | item.remove("merchantRightsConfig"); |
| | | return item; |
| | | }).toList(); |
| | | return new PageResult<>(records, result.page(), result.size(), result.total()); |
| | |
| | | String merchantWhere = " where deleted = 0 and merchant_type_code = 'scenic'"; |
| | | PageResult<Map<String, Object>> result = page(""" |
| | | select id, merchant_name, merchant_type_code as merchant_type, contact_name, contact_mobile, logo_url, cover_url, address, |
| | | status, sort_no, longitude, latitude, business_status, |
| | | status, sort_no, longitude, latitude, business_status, merchant_rights_config, |
| | | (select min(p.sale_price) from merchant_product p where p.merchant_id = merchant.id and p.deleted = 0 and p.status = 1 and p.audit_status = 2) as sale_price, |
| | | (select min(p.original_price) from merchant_product p where p.merchant_id = merchant.id and p.deleted = 0 and p.status = 1 and p.audit_status = 2) as original_price |
| | | from merchant |
| | |
| | | Long merchantId = item.get("id") instanceof Number id ? id.longValue() : null; |
| | | boolean couponAvailable = merchantId != null && hasActiveMerchantCoupon(merchantId); |
| | | String discountText = merchantId == null ? "" : merchantLowestTalentDiscount(merchantId, talentId); |
| | | Map<String, Object> right = matchingMerchantRight( |
| | | merchantRightsConfigs(row.get("merchantRightsConfig")), |
| | | activeProductCardLevel(talentId), |
| | | activeTalentSourceAreaType(talentId), |
| | | true); |
| | | item.put("couponAvailable", couponAvailable); |
| | | item.put("discountText", discountText); |
| | | item.put("benefitTags", merchantBenefitTags("scenic", couponAvailable, discountText, null)); |
| | | item.put("benefitTags", merchantBenefitTags(merchantId, activeProductCardLevel(talentId), "scenic", couponAvailable, discountText, right)); |
| | | item.remove("merchantRightsConfig"); |
| | | return item; |
| | | }).toList(); |
| | | return new PageResult<>(records, result.page(), result.size(), result.total()); |
| | |
| | | true); |
| | | item.put("couponAvailable", couponAvailable); |
| | | item.put("discountText", discountText); |
| | | item.put("benefitTags", merchantBenefitTags(responseMerchantType, couponAvailable, discountText, right)); |
| | | item.put("benefitTags", merchantBenefitTags(merchantId, activeProductCardLevel(talentId), responseMerchantType, couponAvailable, discountText, right)); |
| | | item.remove("merchantRightsConfig"); |
| | | return item; |
| | | }).toList(); |
| | |
| | | String sourceAreaType = activeTalentSourceAreaType(talentId); |
| | | List<Map<String, Object>> rights = merchantRightsConfigs(row.get("merchantRightsConfig")); |
| | | Map<String, Object> right = matchingMerchantRight(rights, cardLevel, sourceAreaType, true); |
| | | Long merchantId = item.get("id") instanceof Number number ? number.longValue() : null; |
| | | if ("parking".equals(merchantType)) { |
| | | Long merchantId = item.get("id") instanceof Number number ? number.longValue() : null; |
| | | return parkingBenefitTags(merchantId, rights, cardLevel, sourceAreaType); |
| | | } |
| | | if ("carWash".equals(merchantType)) { |
| | | String tag = carWashListDiscountText(carWashExternalLinkType(rights), right); |
| | | String tag = carWashDiscountTag(merchantId, talentId, rights, right); |
| | | return tag.isBlank() ? List.of() : List.of(tag); |
| | | } |
| | | if (usesMerchantDetailHeaderTags(merchantType) || "cinema".equals(merchantType)) { |
| | | return merchantBenefitTags(merchantType, couponAvailable, discountText, right); |
| | | return merchantBenefitTags(merchantId, cardLevel, merchantType, couponAvailable, discountText, right); |
| | | } |
| | | |
| | | boolean shoppingType = MerchantTypeCatalog.find(merchantType) |
| | |
| | | if (shoppingType) { |
| | | List<String> tags = new ArrayList<>(); |
| | | if (couponAvailable) tags.add("券"); |
| | | if (right != null) { |
| | | String configuredDiscount = merchantRightDiscountText(right); |
| | | if (!configuredDiscount.isBlank()) tags.add(configuredDiscount); |
| | | } |
| | | // 购物类同样取「团单折扣」与「商家权益折扣」中更低的一档。 |
| | | String configuredDiscount = right == null ? "" : merchantRightDiscountText(right); |
| | | String productDiscount = merchantId == null ? "" : merchantLowestTalentDiscount(merchantId, talentId); |
| | | String discountTag = lowerDiscountTag(productDiscount, configuredDiscount); |
| | | if (!discountTag.isBlank()) tags.add(discountTag); |
| | | return tags; |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | private List<String> merchantBenefitTags( |
| | | Long merchantId, |
| | | String cardLevel, |
| | | String merchantType, |
| | | boolean couponAvailable, |
| | | String productDiscountText, |
| | |
| | | } |
| | | List<String> tags = new ArrayList<>(); |
| | | if (couponAvailable) tags.add("券"); |
| | | String discount = textOrDefault(productDiscountText, "").trim(); |
| | | if ("人才专享".equals(discount)) discount = ""; |
| | | boolean hotelLike = "hotel".equals(type) || "apartment".equals(type); |
| | | // 免费次数:酒店/公寓用商家权益里的免费券次数,其它类型用团单的免单次数。 |
| | | String freeTag = ""; |
| | | if (hotelLike && right != null && booleanValue(right.get("freeVoucherEnabled"))) { |
| | | String times = textOrDefault(right.get("freeVoucherTimes"), "").trim(); |
| | | discount = times.isBlank() ? "免费" : "免费" + times + "次"; |
| | | freeTag = times.isBlank() ? "免费" : "免费" + times + "次"; |
| | | } else if (!hotelLike) { |
| | | freeTag = productFreeTimesTag(merchantId, cardLevel); |
| | | } |
| | | if (!discount.isBlank() && !tags.contains(discount)) tags.add(discount); |
| | | if (!freeTag.isBlank() && !tags.contains(freeTag)) tags.add(freeTag); |
| | | // 折扣标签统一取「团单折扣」与「商家权益到店消费折扣」中更低的那档(数值越小折扣越大)。 |
| | | String discount = textOrDefault(productDiscountText, "").trim(); |
| | | if ("人才专享".equals(discount)) discount = ""; |
| | | String configuredDiscount = hotelLike ? consumeDiscountText(right) : (right == null ? "" : merchantRightDiscountText(right)); |
| | | String discountTag = lowerDiscountTag(discount, configuredDiscount); |
| | | if (!discountTag.isBlank() && !tags.contains(discountTag)) tags.add(discountTag); |
| | | // 酒店/公寓的到店消费如果配成"优惠"(减 X 元),它不是折扣,单独展示。 |
| | | if (hotelLike && right != null && booleanValue(right.get("storeConsume")) && booleanValue(right.get("consumeEnabled"))) { |
| | | String consumeType = textOrDefault(right.get("consumeType"), ""); |
| | | String consumeDiscount = textOrDefault(right.get("consumeDiscount"), "").trim(); |
| | | String consumeCoupon = textOrDefault(right.get("consumeCoupon"), "").trim(); |
| | | String consumeTag = ""; |
| | | if ("折扣".equals(consumeType) && !consumeDiscount.isBlank()) consumeTag = consumeDiscount + "折"; |
| | | else if ("优惠".equals(consumeType) && !consumeCoupon.isBlank()) consumeTag = "减" + consumeCoupon; |
| | | if (!consumeTag.isBlank() && !tags.contains(consumeTag)) tags.add(consumeTag); |
| | | if ("优惠".equals(consumeType) && !consumeCoupon.isBlank()) { |
| | | String consumeTag = "减" + consumeCoupon; |
| | | if (!tags.contains(consumeTag)) tags.add(consumeTag); |
| | | } |
| | | } |
| | | return tags; |
| | | } |
| | |
| | | boolean couponAvailable = booleanValue(item.get("couponAvailable")); |
| | | item.put("couponAvailable", couponAvailable); |
| | | item.put("discountText", couponAvailable ? "券" : ""); |
| | | item.put("benefitTags", merchantBenefitTags("cinema", couponAvailable, "", null)); |
| | | item.put("benefitTags", merchantBenefitTags(null, null, "cinema", couponAvailable, "", null)); |
| | | return item; |
| | | }).toList(); |
| | | return new PageResult<>(records, result.page(), result.size(), result.total()); |
| | |
| | | List<Map<String, Object>> rights = merchantRightsConfigs(item.get("merchantRightsConfig")); |
| | | String externalLinkType = carWashExternalLinkType(rights); |
| | | Map<String, Object> right = matchingMerchantRight(rights, cardLevel, sourceAreaType, true); |
| | | String discountText = carWashListDiscountText(externalLinkType, right); |
| | | Long merchantId = item.get("id") instanceof Number number ? number.longValue() : null; |
| | | String discountText = carWashDiscountTag(merchantId, talentId, rights, right); |
| | | item.put("externalLinkType", externalLinkType); |
| | | item.put("discountText", discountText); |
| | | item.put("benefitTagVisible", !discountText.isBlank()); |
| | |
| | | } catch (NumberFormatException ignored) { |
| | | return ""; |
| | | } |
| | | } |
| | | |
| | | /** 酒店、公寓的到店消费折扣:配在 consumeType=折扣 的 consumeDiscount 上。 */ |
| | | private String consumeDiscountText(Map<String, Object> right) { |
| | | if (right == null) return ""; |
| | | if (!booleanValue(right.get("storeConsume")) || !booleanValue(right.get("consumeEnabled"))) return ""; |
| | | if (!"折扣".equals(textOrDefault(right.get("consumeType"), ""))) return ""; |
| | | String discount = textOrDefault(right.get("consumeDiscount"), "").trim(); |
| | | if (discount.isBlank()) return ""; |
| | | BigDecimal value = discountValue(discount + "折"); |
| | | return value == null ? "" : formatDiscount(value); |
| | | } |
| | | |
| | | /** |
| | | * 自助洗车折扣标签。 |
| | | * |
| | | * 小程序跳转模式不打折扣标签(由外部小程序自己展示权益);普通模式取「团单折扣」与 |
| | | * 「商家权益折扣」中更低的那档。 |
| | | */ |
| | | private String carWashDiscountTag(Long merchantId, Long talentId, List<Map<String, Object>> rights, Map<String, Object> right) { |
| | | String configured = carWashListDiscountText(carWashExternalLinkType(rights), right); |
| | | if (configured.isBlank()) return ""; |
| | | String productDiscount = merchantId == null ? "" : merchantLowestTalentDiscount(merchantId, talentId); |
| | | return lowerDiscountTag(productDiscount, configured); |
| | | } |
| | | |
| | | /** |
| | | * 取两个折扣标签里更低的那档(数值越小折扣越大),统一格式化成 "X.Y折"。 |
| | | * |
| | | * 团单折扣来自商品配置(如"超出免单次数后 7 折"),商家权益折扣来自后台"折扣"字段; |
| | | * 两者都配了时按更低的一档展示,只有一个时用那一个,都不是数字则返回空。 |
| | | */ |
| | | private String lowerDiscountTag(String first, String second) { |
| | | BigDecimal firstValue = discountValue(first); |
| | | BigDecimal secondValue = discountValue(second); |
| | | if (firstValue == null && secondValue == null) return ""; |
| | | if (firstValue == null) return formatDiscount(secondValue); |
| | | if (secondValue == null) return formatDiscount(firstValue); |
| | | return formatDiscount(firstValue.min(secondValue)); |
| | | } |
| | | |
| | | private BigDecimal discountValue(String tag) { |
| | | if (tag == null) return null; |
| | | String text = tag.trim().replace("折", "").trim(); |
| | | if (text.isBlank()) return null; |
| | | try { |
| | | BigDecimal value = new BigDecimal(text); |
| | | return value.compareTo(BigDecimal.ZERO) > 0 && value.compareTo(BigDecimal.TEN) <= 0 ? value : null; |
| | | } catch (NumberFormatException ignored) { |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | private String formatDiscount(BigDecimal value) { |
| | | return value.setScale(1, RoundingMode.HALF_UP).toPlainString() + "折"; |
| | | } |
| | | |
| | | private List<String> parkingBenefitTags( |
| | |
| | | merchant.put("consumeEnabled", consumeEnabled); |
| | | merchant.put("benefitTags", merchantListBenefitTags(merchant, merchant, talentId)); |
| | | merchant.put("usageInstructions", merchantUsageInstructions( |
| | | id, merchant.get("merchantRightsConfig"), cardLevel, merchant.get("usageInstructions"))); |
| | | id, merchant.get("merchantRightsConfig"), cardLevel, sourceAreaType, merchant.get("usageInstructions"))); |
| | | List<String> heroImages = new ArrayList<>(); |
| | | String coverUrl = textOrDefault(merchant.get("coverUrl"), ""); |
| | | if (!coverUrl.isBlank()) heroImages.add(coverUrl); |
| | |
| | | private Map<String, Object> matchingMerchantRight(List<Map<String, Object>> rights, String cardLevel, String sourceAreaType, boolean requireEnabled) { |
| | | if (cardLevel == null) return null; |
| | | List<String> candidates = merchantRightCandidates(cardLevel, sourceAreaType); |
| | | // 第一轮:完整等级名匹配。商家可以按“卓越人才卡(本地)/(外地)”分开配置,必须命中对应地区的那一条。 |
| | | for (String candidate : candidates) { |
| | | for (Map<String, Object> right : rights) { |
| | | if (requireEnabled && !booleanValue(right.get("enabled"))) continue; |
| | | if (candidate.equals(textOrDefault(right.get("level"), ""))) return right; |
| | | if (sameCardLevel(right.get("level"), candidate)) return right; |
| | | } |
| | | } |
| | | // 第二轮:退化成只比等级。人才地区已知时,不再使用地区对不上的配置 |
| | | // (否则本地人才会命中“(外地)”配置,显示外地权益内容)。 |
| | | String area = normalizedSourceAreaType(sourceAreaType); |
| | | for (String candidate : candidates) { |
| | | for (Map<String, Object> right : rights) { |
| | | if (requireEnabled && !booleanValue(right.get("enabled"))) continue; |
| | | if (!sameCardLevel(right.get("level"), candidate)) continue; |
| | | if (!areaMatchesConfiguredLevel(right.get("level"), area)) continue; |
| | | return right; |
| | | } |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | /** 人才地区已知时,带“(本地)/(外地)”后缀的商家配置必须与人才地区一致;没带后缀的配置不受限制。 */ |
| | | private boolean areaMatchesConfiguredLevel(Object configuredLevel, String area) { |
| | | if (area == null || area.isBlank()) return true; |
| | | String level = textOrDefault(configuredLevel, "").toUpperCase(Locale.ROOT); |
| | | boolean configuredOutside = level.contains("外地") || level.contains("OUTSIDE"); |
| | | boolean configuredLocal = level.contains("本地") || level.contains("LOCAL"); |
| | | if (!configuredOutside && !configuredLocal) return true; |
| | | return "OUTSIDE".equals(area) ? configuredOutside : ("LOCAL".equals(area) && configuredLocal); |
| | | } |
| | | |
| | | private List<String> merchantRightCandidates(String cardLevel, String sourceAreaType) { |
| | |
| | | }; |
| | | } |
| | | |
| | | private String merchantUsageInstructions(Long merchantId, Object rightsConfig, String cardLevel, Object fallback) { |
| | | private String merchantUsageInstructions(Long merchantId, Object rightsConfig, String cardLevel, String sourceAreaType, Object fallback) { |
| | | if (cardLevel != null) { |
| | | String area = normalizedSourceAreaType(sourceAreaType); |
| | | for (Map<String, Object> right : merchantRightsConfigs(rightsConfig)) { |
| | | if (!booleanValue(right.get("enabled")) || !booleanValue(right.get("consumeEnabled")) |
| | | || !booleanValue(right.get("storeConsume")) || !sameCardLevel(right.get("level"), cardLevel)) continue; |
| | | // “(本地)/(外地)”分开配置的商家,说明文案也要跟着人才地区走。 |
| | | if (!areaMatchesConfiguredLevel(right.get("level"), area)) continue; |
| | | String description = textOrDefault(right.get("consumeDesc"), ""); |
| | | if (!description.isBlank()) return description; |
| | | } |
| | |
| | | return count != null && count > 0; |
| | | } |
| | | |
| | | /** |
| | | * 团单按人才等级配置了免单时的“免费/免费N次”标签。 |
| | | * |
| | | * 多个团单免费次数不一致时取最大值;999 表示不限次数,只展示“免费”。 |
| | | */ |
| | | private String productFreeTimesTag(Long merchantId, String cardLevel) { |
| | | if (merchantId == null || cardLevel == null || cardLevel.isBlank()) return ""; |
| | | int maxTimes = 0; |
| | | boolean freeAvailable = false; |
| | | for (Map<String, Object> product : query(""" |
| | | select id, original_price, sale_price, rights_config |
| | | from merchant_product |
| | | where merchant_id = ? and deleted = 0 and status = 1 and audit_status = 2 |
| | | """, merchantId)) { |
| | | ProductPriceCalculator.ProductRight right = |
| | | ProductPriceCalculator.productRight(product.get("rightsConfig"), cardLevel); |
| | | if (right == null || !right.free()) continue; |
| | | freeAvailable = true; |
| | | if (right.limitCount() > maxTimes) maxTimes = right.limitCount(); |
| | | } |
| | | if (!freeAvailable) return ""; |
| | | if (maxTimes <= 0 || maxTimes >= 999) return "免费"; |
| | | return "免费" + maxTimes + "次"; |
| | | } |
| | | |
| | | private String merchantLowestTalentDiscount(Long merchantId, Long talentId) { |
| | | String cardLevel = activeProductCardLevel(talentId); |
| | | if (cardLevel == null) return ""; |
| | |
| | | List<String> candidates = cardLevelCandidates(cardLevel, primary.sourceAreaType()); |
| | | try { |
| | | List<Map<String, Object>> rights = JSON_MAPPER.readValue(config, RIGHTS_TYPE); |
| | | matched = matchingMerchantRight(rights, candidates, true); |
| | | matched = matchingMerchantRight(rights, candidates, true, primary.sourceAreaType()); |
| | | if (matched == null) { |
| | | disabledMatched = matchingMerchantRight(rights, candidates, false); |
| | | disabledMatched = matchingMerchantRight(rights, candidates, false, primary.sourceAreaType()); |
| | | } |
| | | } catch (Exception exception) { |
| | | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "酒店商户权益配置格式不正确"); |
| | |
| | | return cardLevel == null || cardLevel.isBlank() ? List.of() : List.of(cardLevel); |
| | | } |
| | | |
| | | private Map<String, Object> matchingMerchantRight(List<Map<String, Object>> rights, List<String> candidates, boolean requireEnabled) { |
| | | private Map<String, Object> matchingMerchantRight(List<Map<String, Object>> rights, List<String> candidates, boolean requireEnabled, String sourceAreaType) { |
| | | if (rights == null || rights.isEmpty() || candidates == null || candidates.isEmpty()) { |
| | | return null; |
| | | } |
| | | // 第一轮:完整等级名匹配,按“(本地)/(外地)”分开配置时命中对应地区的那一条。 |
| | | for (String candidate : candidates) { |
| | | for (Map<String, Object> right : rights) { |
| | | if (requireEnabled && !booleanValue(right.get("enabled"))) { |
| | | continue; |
| | | } |
| | | String configured = text(right.get("level")); |
| | | if (candidate.equals(configured) || sameCardLevel(configured, candidate)) { |
| | | return right; |
| | | if (candidate.equals(configured)) return right; |
| | | } |
| | | } |
| | | // 第二轮:退化成只比等级,但人才地区已知时不能用地区对不上的配置。 |
| | | String area = normalizedSourceAreaType(sourceAreaType); |
| | | for (String candidate : candidates) { |
| | | for (Map<String, Object> right : rights) { |
| | | if (requireEnabled && !booleanValue(right.get("enabled"))) { |
| | | continue; |
| | | } |
| | | String configured = text(right.get("level")); |
| | | if (!sameCardLevel(configured, candidate)) continue; |
| | | if (!areaMatchesConfiguredLevel(configured, area)) continue; |
| | | return right; |
| | | } |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | /** 人才地区已知时,带“(本地)/(外地)”后缀的商家配置必须与人才地区一致;没带后缀的配置不受限制。 */ |
| | | private boolean areaMatchesConfiguredLevel(String configuredLevel, String area) { |
| | | if (area == null || area.isBlank()) return true; |
| | | String level = text(configuredLevel).toUpperCase(Locale.ROOT); |
| | | boolean configuredOutside = level.contains("外地") || level.contains("OUTSIDE"); |
| | | boolean configuredLocal = level.contains("本地") || level.contains("LOCAL"); |
| | | if (!configuredOutside && !configuredLocal) return true; |
| | | return "OUTSIDE".equals(area) ? configuredOutside : ("LOCAL".equals(area) && configuredLocal); |
| | | } |
| | | |
| | | private Object firstValue(Map<String, Object> values, String... keys) { |
| | | for (String key : keys) { |
| | | Object value = values.get(key); |
| | |
| | | cors: |
| | | # 后台管理端(vite dev,默认 5273)与 HBuilderX Web 调试来源;部署时用 CORS_ALLOWED_ORIGINS 覆盖为真实域名。 |
| | | # localhost 与局域网地址都要列:换网段后局域网 IP 会变,localhost 是最稳的调试入口。 |
| | | allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5273,http://127.0.0.1:5273,http://localhost:7893,http://localhost:7894,http://127.0.0.1:7893,http://127.0.0.1:7894,http://192.168.110.26:5273,http://192.168.110.26:,http://192.168.110.15:5273,http://192.168.110.15:5173} |
| | | allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5273,http://127.0.0.1:5273,http://localhost:7893,http://localhost:7894,http://127.0.0.1:7893,http://127.0.0.1:7894,http://192.168.110.26:5273,http://192.168.110.26:,http://192.168.110.15:5273,http://192.168.110.15:5173,http://192.168.110.13:5273,http://192.168.110.13:5173} |
| | | # 本机调试来源的端口是随机的:微信开发者工具每次启动都换一个端口(见过 42665), |
| | | # HBuilderX 内置浏览器同理。写死端口必然漏,所以按 origin 通配匹配任意端口。 |
| | | # 只放行 localhost / 127.0.0.1,不对公网来源开口子;真实域名请加进上面的精确白名单。 |
| New file |
| | |
| | | -- 政策列表需要「置顶优先,置顶组内按置顶时间倒序」,原来只有 is_top 标记,没有置顶时间。 |
| | | alter table policy_article add column top_time datetime default null comment '置顶时间:勾选置顶时写入,用于置顶组内排序' after is_top; |
| | | |
| | | -- 历史置顶数据回填:没有记录过置顶时间,先用最后更新时间兜底。 |
| | | update policy_article set top_time = coalesce(updated_at, created_at) where is_top = 1 and top_time is null; |
| New file |
| | |
| | | -- 子女托管 / 行政审批的详情页图改为按商家配置后,服务级权益里种下的占位图 |
| | | -- (/static/stay-assets/hotel-vienna.jpg)不再作为默认展示,清空后回落商家封面图。 |
| | | update children_entrustment_benefit set hero_image = null where hero_image = '/static/stay-assets/hotel-vienna.jpg'; |
| | | update admin_approval_benefit set hero_image = null where hero_image = '/static/stay-assets/hotel-vienna.jpg'; |
| New file |
| | |
| | | -- 子女托管 / 行政审批等服务的详情页轮播图改为按商家配置。 |
| | | -- 未单独配置的商家仍回落到服务权益里原有的全局图片,行为不变。 |
| | | create table if not exists merchant_hero_image ( |
| | | id bigint unsigned not null auto_increment, |
| | | merchant_id bigint unsigned not null, |
| | | image_url varchar(500) not null, |
| | | sort_no int not null default 0, |
| | | enabled tinyint not null default 1, |
| | | created_at datetime not null default current_timestamp, |
| | | updated_at datetime not null default current_timestamp on update current_timestamp, |
| | | deleted tinyint not null default 0, |
| | | primary key (id), |
| | | key idx_merchant_hero_image_merchant (merchant_id, deleted, sort_no) |
| | | ) engine=InnoDB default charset=utf8mb4 comment='商家详情页轮播图'; |
| | |
| | | @SuppressWarnings("unchecked") |
| | | void merchantDetailHeaderTagsDriveListTags() throws Exception { |
| | | Method method = PortalService.class.getDeclaredMethod( |
| | | "merchantBenefitTags", String.class, boolean.class, String.class, Map.class); |
| | | "merchantBenefitTags", Long.class, String.class, String.class, boolean.class, String.class, Map.class); |
| | | method.setAccessible(true); |
| | | |
| | | List<String> foodTags = (List<String>) method.invoke(service, "food", true, "8.8折", null); |
| | | List<String> hotelTags = (List<String>) method.invoke(service, "hotel", true, "7折", Map.of( |
| | | List<String> foodTags = (List<String>) method.invoke(service, null, null, "food", true, "8.8折", null); |
| | | List<String> hotelTags = (List<String>) method.invoke(service, null, null, "hotel", true, "7折", Map.of( |
| | | "freeVoucherEnabled", true, |
| | | "freeVoucherTimes", "2", |
| | | "storeConsume", true, |
| | |
| | | "consumeDiscount", "8.5")); |
| | | |
| | | assertThat(foodTags).containsExactly("券", "8.8折"); |
| | | assertThat(hotelTags).containsExactly("券", "免费2次", "8.5折"); |
| | | // 酒店折扣同样取「团单折扣 7 折」与「商家权益到店消费折扣 8.5 折」中更低的一档。 |
| | | assertThat(hotelTags).containsExactly("券", "免费2次", "7.0折"); |
| | | } |
| | | |
| | | @Test |
| | | void productFreeRightsProduceFreeTimesTag() throws Exception { |
| | | when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(List.of( |
| | | Map.of("rightsConfig", "[{\"level\": \"卓越人才卡\", \"enabled\": true, \"free\": true," |
| | | + " \"limitCount\": 10, \"exceedDiscount\": 7}]"), |
| | | Map.of("rightsConfig", "[{\"level\": \"卓越人才卡\", \"enabled\": true, \"free\": true," |
| | | + " \"limitCount\": 1, \"exceedDiscount\": 6}]"))); |
| | | Method method = PortalService.class.getDeclaredMethod("productFreeTimesTag", Long.class, String.class); |
| | | method.setAccessible(true); |
| | | |
| | | assertThat((String) method.invoke(service, 83L, "卓越人才卡")).isEqualTo("免费10次"); |
| | | assertThat((String) method.invoke(service, 83L, "杰出人才卡")).isEmpty(); |
| | | } |
| | | |
| | | @Test |
| | |
| | | assertThat(freeTags).containsExactly("免费"); |
| | | assertThat(paidTags).containsExactly("8.5折"); |
| | | } |
| | | |
| | | @Test |
| | | @SuppressWarnings("unchecked") |
| | | void merchantRightMatchingKeepsLocalAndOutsideRightsApart() throws Exception { |
| | | Method method = PortalService.class.getDeclaredMethod( |
| | | "matchingMerchantRight", List.class, String.class, String.class, boolean.class); |
| | | method.setAccessible(true); |
| | | List<Map<String, Object>> rights = List.of( |
| | | Map.of("level", "卓越人才卡(外地)", "tag", "卓越人才卡(外地)", "detail", "外地权益", "enabled", true), |
| | | Map.of("level", "卓越人才卡(本地)", "tag", "卓越人才卡(本地)", "detail", "本地权益", "enabled", true)); |
| | | |
| | | Map<String, Object> local = (Map<String, Object>) method.invoke(service, rights, "卓越人才卡", "LOCAL", true); |
| | | Map<String, Object> outside = (Map<String, Object>) method.invoke(service, rights, "卓越人才卡", "OUTSIDE", true); |
| | | |
| | | assertThat(local.get("detail")).isEqualTo("本地权益"); |
| | | assertThat(outside.get("detail")).isEqualTo("外地权益"); |
| | | } |
| | | |
| | | @Test |
| | | void merchantRightMatchingNeverFallsBackToAnotherAreaScope() throws Exception { |
| | | Method method = PortalService.class.getDeclaredMethod( |
| | | "matchingMerchantRight", List.class, String.class, String.class, boolean.class); |
| | | method.setAccessible(true); |
| | | // 只配了“(外地)”时,本地人才拿不到这份权益;不带地区后缀的配置两边都能用。 |
| | | List<Map<String, Object>> outsideOnly = List.of( |
| | | Map.of("level", "卓越人才卡(外地)", "detail", "外地权益", "enabled", true)); |
| | | List<Map<String, Object>> plain = List.of(Map.of("level", "卓越人才卡", "detail", "通用权益", "enabled", true)); |
| | | |
| | | assertThat(method.invoke(service, outsideOnly, "卓越人才卡", "LOCAL", true)).isNull(); |
| | | assertThat(method.invoke(service, plain, "卓越人才卡", "LOCAL", true)).isNotNull(); |
| | | assertThat(method.invoke(service, plain, "卓越人才卡", "OUTSIDE", true)).isNotNull(); |
| | | } |
| | | } |