From 80b0e5bfeaa9851868fcc13ebbdbd9a8445d4f4d Mon Sep 17 00:00:00 2001
From: shinnosuke <tantao@1hoper.com>
Date: 星期五, 28 八月 2026 09:21:52 +0800
Subject: [PATCH] fix: align merchant list benefit tags

---
 talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java      |   76 ++++++++++++++++++++++++-
 talent-card-server/src/test/java/com/talentcard/server/business/portal/PortalServiceTests.java |   18 ++++++
 rencaika/pages/benefits/benefits.uvue                                                          |   23 ++++++-
 rencaika/services/portal.uts                                                                   |    6 +
 rencaika/pages/benefit-search/benefit-search.uvue                                              |   27 +++++++-
 5 files changed, 135 insertions(+), 15 deletions(-)

diff --git a/rencaika/pages/benefit-search/benefit-search.uvue b/rencaika/pages/benefit-search/benefit-search.uvue
index 0da10c4..9b19c6f 100644
--- a/rencaika/pages/benefit-search/benefit-search.uvue
+++ b/rencaika/pages/benefit-search/benefit-search.uvue
@@ -39,7 +39,7 @@
 						<image class="result-image" :src="item.image" mode="aspectFill" @error="replaceImage(item)"></image>
 						<view class="result-info">
 							<text class="result-name">{{item.name}}</text>
-							<view class="result-tags"><text class="type-tag">{{item.category}}</text><text class="discount-tag">{{item.discount}}</text></view>
+							<view v-if="item.tags != null && item.tags.length > 0" class="result-tags"><text v-for="tag in item.tags" :key="tag" :class="tag == '券' ? 'coupon-tag' : 'benefit-tag'">{{tag}}</text></view>
 							<view class="result-address"><image src="/static/benefits-assets/map-pin.svg"></image><text>{{item.address}}</text></view>
 						</view>
 					</view>
@@ -58,6 +58,7 @@
 	import { apiEnvironment, isApiConfigured } from '../../config/api.uts'
 	import { searchMerchants } from '../../services/portal.uts'
 	import { resolveApiUrl } from '../../services/http.uts'
+	import { getSession } from '../../services/session.uts'
 	import { safeBack } from '../../utils/navigation.uts'
 
 	const navTop = ref(28)
@@ -70,6 +71,8 @@
 	const hasSearched = ref(false)
 	const isAllMode = ref(false)
 	const pageSize = 6
+	const allModePageSize = 100
+	const talentId = ref('')
 	const merchantPool = ref([])
 	const visibleMerchants = ref([])
 	const loadedCount = ref(0)
@@ -113,12 +116,22 @@
 		for (let index = 0; index < records.length; index++) {
 			const item: any = records[index]
 			const rawType = item.merchantType != null ? item.merchantType as string : 'food'
+			const hasConfiguredTags = item.benefitTags instanceof Array
+			const configuredTags: any[] = hasConfiguredTags ? item.benefitTags as any[] : []
+			const tags: string[] = []
+			for (let tagIndex = 0; tagIndex < configuredTags.length; tagIndex++) {
+				const tag = String(configuredTags[tagIndex]).trim()
+				if (tag != '' && tags.indexOf(tag) < 0) tags.push(tag)
+			}
+			const discount = item.discountText != null ? item.discountText as string : formatRemoteDiscount(item)
+			if (!hasConfiguredTags && discount != '') tags.push(discount)
 			mapped.push({
 				id: '' + item.id,
 				name: item.merchantName != null ? item.merchantName as string : '人才权益商户',
 				category: item.categoryName != null ? item.categoryName as string : '',
 				merchantType: rawType,
-				discount: item.discountText != null ? item.discountText as string : formatRemoteDiscount(item),
+				discount: discount,
+				tags: tags,
 				address: item.address != null ? item.address as string : '',
 				image: item.coverUrl != null && item.coverUrl != '' ? resolveApiUrl(item.coverUrl as string) : '',
 				sort: item.sort != null ? Number(item.sort) : 0,
@@ -143,7 +156,8 @@
 			visibleMerchants.value = []
 			currentPage.value = 0
 		}
-		searchMerchants(keyword.value.trim(), '', isAllMode.value ? 'all' : 'search', currentPage.value + 1, pageSize, (data: any) => {
+		const requestSize = isAllMode.value && !hasSearched.value ? allModePageSize : pageSize
+		searchMerchants(keyword.value.trim(), '', isAllMode.value ? 'all' : 'search', talentId.value, currentPage.value + 1, requestSize, (data: any) => {
 			if (requestVersion != listRequestVersion.value) return
 			const records: any[] = data != null && data.records instanceof Array ? data.records as any[] : []
 			const mapped = mapRemoteMerchants(records)
@@ -181,6 +195,8 @@
 			failLoadMoreOnce.value = previewState == 'loadMoreError'
 		}
 		loadHistories()
+		const session = getSession()
+		if (session != null && session.talentId != null) talentId.value = '' + session.talentId
 		if (options != null && options['mode'] == 'all') {
 			isAllMode.value = true
 			if (isApiConfigured()) loadRemoteMerchants(false)
@@ -400,8 +416,9 @@
 	.result-info { display: flex; height: 138rpx; min-width: 0; flex: 1; box-sizing: border-box; padding: 1rpx 0 1rpx 22rpx; flex-direction: column; justify-content: space-between; }
 	.result-name { color: #292929; font-size: 26rpx; line-height: 36rpx; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
 	.result-tags { display: flex; flex-direction: row; align-items: center; }
-	.type-tag { max-width: 150rpx; padding: 5rpx 8rpx; border-radius: 5rpx; background-color: #FFF0EC; color: #FF5D37; font-size: 19rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-	.discount-tag { margin-left: 10rpx; padding: 5rpx 8rpx; border-radius: 5rpx; background-color: #FFF5E7; color: #FF7C2B; font-size: 19rpx; }
+	.result-tags text { margin-right: 10rpx; padding: 5rpx 8rpx; border-radius: 5rpx; font-size: 19rpx; white-space: nowrap; }
+	.coupon-tag { background-color: #FFF0EC; color: #FF5D37; }
+	.benefit-tag { max-width: 220rpx; overflow: hidden; text-overflow: ellipsis; background-color: #FFF5E7; color: #FF7C2B; }
 	.result-address { display: flex; min-width: 0; flex-direction: row; align-items: center; }
 	.result-address image { width: 25rpx; height: 25rpx; margin-right: 7rpx; flex-shrink: 0; }
 	.result-address text { min-width: 0; flex: 1; color: #7E8490; font-size: 19rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
diff --git a/rencaika/pages/benefits/benefits.uvue b/rencaika/pages/benefits/benefits.uvue
index b0991d9..bbe24fb 100644
--- a/rencaika/pages/benefits/benefits.uvue
+++ b/rencaika/pages/benefits/benefits.uvue
@@ -43,7 +43,7 @@
 					<image class="merchant-image" :src="item.image" mode="aspectFill" @error="replaceImage(item)"></image>
 					<view class="benefit-info">
 						<text class="benefit-name">{{item.name}}</text>
-						<view v-if="item.hasCoupon || item.discount != ''" class="tag-row"><text v-if="item.hasCoupon" class="coupon-tag">券</text><text v-if="item.discount != ''" :class="item.hasCoupon ? 'discount-tag' : 'discount-tag no-coupon'">{{item.discount}}</text></view>
+						<view v-if="item.tags != null && item.tags.length > 0" class="tag-row"><text v-for="tag in item.tags" :key="tag" :class="tag == '券' ? 'coupon-tag' : 'discount-tag'">{{tag}}</text></view>
 						<view class="address-row"><image src="/static/benefits-assets/map-pin.svg"></image><text>{{item.address}}</text></view>
 					</view>
 				</view>
@@ -140,12 +140,26 @@
 			const item: any = records[index]
 			const rawType = item.merchantType != null ? item.merchantType as string : 'food'
 			const merchantType = rawType == '餐饮服务' ? 'food' : rawType
+			const hasCoupon = item.couponAvailable === true || Number(item.couponAvailable) == 1
+			const discount = item.discountText != null ? item.discountText as string : ''
+			const hasConfiguredTags = item.benefitTags instanceof Array
+			const configuredTags: any[] = hasConfiguredTags ? item.benefitTags as any[] : []
+			const tags: string[] = []
+			for (let tagIndex = 0; tagIndex < configuredTags.length; tagIndex++) {
+				const tag = String(configuredTags[tagIndex]).trim()
+				if (tag != '' && tags.indexOf(tag) < 0) tags.push(tag)
+			}
+			if (!hasConfiguredTags) {
+				if (hasCoupon) tags.push('券')
+				if (discount != '' && discount != '券') tags.push(discount)
+			}
 			mapped.push({
 				id: '' + item.id,
 				name: item.merchantName != null ? item.merchantName as string : '人才权益商户',
 				merchantType: merchantType,
-				hasCoupon: item.couponAvailable === true || Number(item.couponAvailable) == 1,
-				discount: item.discountText != null ? item.discountText as string : '',
+				hasCoupon: hasCoupon,
+				discount: discount,
+				tags: tags,
 				address: item.address != null ? item.address as string : '',
 				image: item.coverUrl != null && item.coverUrl != '' ? resolveApiUrl(item.coverUrl as string) : fallbackImage
 			})
@@ -318,8 +332,9 @@
 	.benefit-info { height: 184rpx; flex: 1; min-width: 0; box-sizing: border-box; padding: 8rpx 0 7rpx 26rpx; justify-content: space-between; }
 	.benefit-name { color: #292929; font-size: 29rpx; line-height: 40rpx; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
 	.tag-row { flex-direction: row; align-items: center; }
+	.tag-row text { margin-right: 11rpx; }
 	.coupon-tag { padding: 6rpx 8rpx; border-radius: 6rpx; background-color: #FFF0EC; color: #FF5D37; font-size: 21rpx; }
-	.discount-tag { margin-left: 11rpx; padding: 6rpx 9rpx; border-radius: 6rpx; background-color: #FFF5E7; color: #FF7C2B; font-size: 21rpx; }.discount-tag.no-coupon { margin-left: 0; }
+	.discount-tag { padding: 6rpx 9rpx; border-radius: 6rpx; background-color: #FFF5E7; color: #FF7C2B; font-size: 21rpx; }
 	.address-row { display: flex; min-width: 0; box-sizing: border-box; padding: 7rpx 10rpx; border-radius: 7rpx; background-image: linear-gradient(90deg, rgba(239,240,242,.98) 0%, rgba(242,243,245,.76) 58%, rgba(255,255,255,0) 100%); flex-direction: row; align-items: center; }
 	.address-row image { width: 27rpx; height: 27rpx; margin-right: 9rpx; flex-shrink: 0; }
 	.address-row text { flex: 1; color: #7E8490; font-size: 21rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
diff --git a/rencaika/services/portal.uts b/rencaika/services/portal.uts
index 48e0019..796c385 100644
--- a/rencaika/services/portal.uts
+++ b/rencaika/services/portal.uts
@@ -137,12 +137,14 @@
 export const getTicketPackageClaim = (claimId: string, talentId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appTicketPackageClaims') + encodeURIComponent(claimId), method: 'GET', skipAuth: true, data: { talentId: Number(talentId) } }, onSuccess, onError)
 export const getCinemaCodeImageUrl = (claimId: string): string => resolveApiUrl(getApiEndpoint('appBenefitVerificationCodes') + '/' + encodeURIComponent(claimId) + '/image')
 
-export const searchMerchants = (keyword: string, categoryId: string, mode: string, page: number, size: number, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
+export const searchMerchants = (keyword: string, categoryId: string, mode: string, talentId: string, page: number, size: number, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
+	const data: any = { keyword: keyword, categoryId: categoryId, mode: mode, page: page, size: size, sortField: 'sort', sortOrder: 'asc' }
+	if (talentId != '') data.talentId = Number(talentId)
 	return request<any>({
 		path: getApiEndpoint('appMerchants'),
 		method: 'GET',
 		skipAuth: true,
-		data: { keyword: keyword, categoryId: categoryId, mode: mode, page: page, size: size, sortField: 'sort', sortOrder: 'asc' }
+		data: data
 	}, onSuccess, onError)
 }
 
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java b/talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java
index 84e18d6..54924d1 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java
@@ -229,7 +229,19 @@
 		}
 		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,
-				       is_recommend, sort_no, longitude, latitude, business_status,
+				       is_recommend, sort_no, longitude, latitude, business_status, service_label, merchant_rights_config,
+				       (exists(select 1 from merchant_coupon c
+				               where c.merchant_id = merchant.id and c.deleted = 0 and c.status = 1
+				                 and (c.valid_start is null or c.valid_start <= current_date)
+				                 and (c.valid_end is null or c.valid_end >= current_date))
+				        or (merchant.merchant_type_code = 'cinema' and exists(select 1 from ticket_package tp
+				               where tp.deleted = 0 and tp.status = 1
+				                 and (tp.valid_start is null or tp.valid_start <= current_date)
+				                 and (tp.valid_end is null or tp.valid_end >= current_date)
+				                 and (tp.merchant_id = merchant.id or exists (
+				                     select 1 from ticket_package_merchant tpm
+				                     where tpm.package_id = tp.id and tpm.merchant_id = merchant.id and tpm.deleted = 0
+				                 ))))) as coupon_available,
 				       (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
@@ -238,8 +250,10 @@
 		List<Map<String, Object>> records = result.records().stream().map(row -> {
 			Map<String, Object> item = enrichMerchantSearchRow(row);
 			Long merchantId = item.get("id") instanceof Number id ? id.longValue() : null;
-			item.put("couponAvailable", merchantId != null && hasActiveMerchantCoupon(merchantId));
+			item.put("couponAvailable", booleanValue(row.get("couponAvailable")));
 			item.put("discountText", merchantId == null ? "" : merchantLowestTalentDiscount(merchantId, talentId));
+			item.put("benefitTags", merchantListBenefitTags(item, row, talentId));
+			item.remove("merchantRightsConfig");
 			return item;
 		}).toList();
 		return new PageResult<>(records, result.page(), result.size(), result.total());
@@ -371,9 +385,14 @@
 	}
 
 	private List<String> stayBenefitTags(Map<String, Object> right, Map<String, Object> merchant) {
-		List<String> tags = new ArrayList<>();
 		Object merchantId = merchant.get("id");
-		if (merchantId instanceof Number id && hasActiveMerchantCoupon(id.longValue())) tags.add("券");
+		boolean couponAvailable = merchantId instanceof Number id && hasActiveMerchantCoupon(id.longValue());
+		return stayBenefitTags(right, couponAvailable);
+	}
+
+	private List<String> stayBenefitTags(Map<String, Object> right, boolean couponAvailable) {
+		List<String> tags = new ArrayList<>();
+		if (couponAvailable) tags.add("券");
 		if (right == null) return tags;
 		if (booleanValue(right.get("freeVoucherEnabled"))) {
 			String times = textOrDefault(right.get("freeVoucherTimes"), "");
@@ -387,6 +406,55 @@
 		return tags;
 	}
 
+	private List<String> merchantListBenefitTags(Map<String, Object> item, Map<String, Object> row, Long talentId) {
+		String merchantType = MerchantTypeCatalog.canonicalCode(textOrDefault(item.get("merchantType"), ""));
+		boolean couponAvailable = booleanValue(item.get("couponAvailable"));
+		String discountText = textOrDefault(item.get("discountText"), "").trim();
+		String serviceLabel = textOrDefault(row.get("serviceLabel"), "").trim();
+		List<String> tags = new ArrayList<>();
+
+		if ("food".equals(merchantType)) {
+			if (couponAvailable) tags.add("券");
+			if (!discountText.isBlank()) tags.add(discountText);
+			return tags;
+		}
+		if ("cinema".equals(merchantType)) {
+			if (couponAvailable) tags.add("券");
+			return tags;
+		}
+
+		String cardLevel = activeProductCardLevel(talentId);
+		String sourceAreaType = activeTalentSourceAreaType(talentId);
+		Map<String, Object> right = matchingMerchantRight(
+				merchantRightsConfigs(row.get("merchantRightsConfig")), cardLevel, sourceAreaType, true);
+		if ("hotel".equals(merchantType) || "apartment".equals(merchantType)) {
+			tags.addAll(stayBenefitTags(right, couponAvailable));
+			if (tags.isEmpty()) tags.add(!discountText.isBlank() ? discountText : "人才专享");
+			return tags;
+		}
+
+		boolean shoppingType = MerchantTypeCatalog.find(merchantType)
+				.map(type -> type.shoppingCategoryKey() != null)
+				.orElse(false);
+		if (shoppingType) {
+			if (couponAvailable) tags.add("券");
+			if ("culture".equals(merchantType)) {
+				if (right != null && booleanValue(right.get("storeConsume")) && booleanValue(right.get("consumeEnabled"))) {
+					tags.add("到店消费");
+				}
+			} else if (right != null) {
+				String configuredDiscount = merchantRightDiscountText(right);
+				if (!configuredDiscount.isBlank()) tags.add(configuredDiscount);
+			}
+			return tags;
+		}
+
+		String categoryTag = !serviceLabel.isBlank() ? serviceLabel : discountText;
+		if (categoryTag.isBlank()) categoryTag = "人才专享";
+		tags.add(categoryTag);
+		return tags;
+	}
+
 	@Transactional(readOnly = true)
 	public Map<String, Object> entertainmentTopic() {
 		Map<String, Object> topic = one("""
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/portal/PortalServiceTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/portal/PortalServiceTests.java
index 9a120ab..9d92a8f 100644
--- a/talent-card-server/src/test/java/com/talentcard/server/business/portal/PortalServiceTests.java
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/portal/PortalServiceTests.java
@@ -57,4 +57,22 @@
 
 		assertThat(discount).isEqualTo("8.5折");
 	}
+
+	@Test
+	@SuppressWarnings("unchecked")
+	void recommendedMerchantTagsFollowCategoryListRules() throws Exception {
+		Method method = PortalService.class.getDeclaredMethod(
+				"merchantListBenefitTags", Map.class, Map.class, Long.class);
+		method.setAccessible(true);
+
+		List<String> foodTags = (List<String>) method.invoke(service,
+				Map.of("merchantType", "food", "couponAvailable", true, "discountText", "8.5折"),
+				Map.of(), null);
+		List<String> cinemaTags = (List<String>) method.invoke(service,
+				Map.of("merchantType", "cinema", "couponAvailable", false, "discountText", "7折"),
+				Map.of(), null);
+
+		assertThat(foodTags).containsExactly("券", "8.5折");
+		assertThat(cinemaTags).isEmpty();
+	}
 }

--
Gitblit v1.7.1