From 348ea7313f2f6681de2b0693e53c59edaf202bd1 Mon Sep 17 00:00:00 2001
From: shinnosuke <tantao@1hoper.com>
Date: 星期五, 21 八月 2026 17:34:13 +0800
Subject: [PATCH] fix: complete miniapp benefit and coupon flows

---
 talent-card-server/src/main/java/com/talentcard/server/business/portal/UsageRecordPortalService.java    |    2 
 rencaika/docs/pages/51-行政审批.md                                                                          |    8 +-
 rencaika/pages/admin-approval/admin-approval.uvue                                                       |   59 ++++++--------
 rencaika/pages/stay-benefits/stay-benefits.uvue                                                         |    6 +
 talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantTypeCatalog.java       |    4 
 rencaika/pages/coupon-received/coupon-received.uvue                                                     |   12 ++
 rencaika/pages/merchant/merchant.uvue                                                                   |    6 +
 talent-card-server/src/main/resources/db/migration/V120__add_telecom_and_dental_shopping_categories.sql |   11 ++
 rencaika/pages/ticket-package/ticket-package.uvue                                                       |    3 
 rencaika/pages/shopping-benefits/shopping-benefits.uvue                                                 |   11 ++
 talent-card-server/src/main/java/com/talentcard/server/business/portal/AdminApprovalPortalService.java  |   67 +++++++++++++++-
 talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantTypeCatalogTests.java  |    4 +
 talent-card-server/src/main/java/com/talentcard/server/business/portal/CinemaPortalService.java         |   29 +++++++
 13 files changed, 166 insertions(+), 56 deletions(-)

diff --git "a/rencaika/docs/pages/51-\350\241\214\346\224\277\345\256\241\346\211\271.md" "b/rencaika/docs/pages/51-\350\241\214\346\224\277\345\256\241\346\211\271.md"
index cdaaa47..c98885d 100644
--- "a/rencaika/docs/pages/51-\350\241\214\346\224\277\345\256\241\346\211\271.md"
+++ "b/rencaika/docs/pages/51-\350\241\214\346\224\277\345\256\241\346\211\271.md"
@@ -6,7 +6,7 @@
 - 路由:`/pages/admin-approval/admin-approval`
 - 源码:`pages/admin-approval/admin-approval.uvue`
 - 入口:臻享礼遇页“行政审批”分类点击服务项进入。
-- 当前状态:视觉与本地交互首版已完成,真实机构列表、咨询电话与窗口信息接口待接入。
+- 当前状态:已接入行政审批详情接口;窗口信息直接打开后台上传的 PDF 文件。
 
 ## 功能点
 
@@ -16,14 +16,14 @@
 | 权益标识 | 标题“行政审批”与“免费”标签固定展示。 | 后台返回权益名称与免费/付费标识。 |
 | 机构列表 | 展示机构名称、地址、权益内容说明和咨询电话,内容超出时区内滚动;已补加载、空、失败和重试。 | 后台返回机构 ID、名称、地址、经纬度、纯文本权益说明、联系电话、排序和启用状态。 |
 | 咨询电话 | 点击“咨询电话-号码”直接调起系统拨号。 | 号码由后台返回,为空时隐藏电话入口。 |
-| 窗口信息 | 有窗口信息的机构展示“查看窗口信息”按钮,点击弹出窗口与办公时间说明。 | 后台返回窗口数组(窗口名称、楼层位置)、办公时间和是否展示入口。 |
+| 窗口信息 | 有 PDF 文件的机构展示“查看窗口信息”按钮,点击下载并打开 PDF 阅读器。 | 后台返回 `windowInfoUrl`(后台上传 PDF 的 URL);没有文件时隐藏入口。 |
 
 ## 操作说明
 
 1. 用户从臻享礼遇页选择“行政审批”分类,点击任意服务项进入本页。
 2. 浏览各政务服务机构的地址和权益内容,机构较多时上滑浏览。
 3. 点击“咨询电话-号码”调起系统拨号。
-4. 点击“查看窗口信息”查看办理窗口位置和办公时间,点击遮罩或“我知道了”关闭。
+4. 点击“查看窗口信息”下载并打开后台上传的窗口信息 PDF。
 5. 点击左上角返回按钮回到臻享礼遇页。
 
 ## 异常处理
@@ -40,4 +40,4 @@
 
 - 权益信息:权益 ID、名称、免费/付费标识、头图 URL、启用状态。
 - 机构列表:机构 ID、名称、地址、经纬度、纯文本权益说明、联系电话、排序、启用状态。
-- 窗口信息:机构 ID、窗口数组(窗口名称、所在楼层/位置)、办公时间、是否展示窗口入口。
+- 窗口信息:机构 ID、`windowInfoUrl`(PDF 地址)、是否展示窗口入口。
diff --git a/rencaika/pages/admin-approval/admin-approval.uvue b/rencaika/pages/admin-approval/admin-approval.uvue
index 220cb09..4366db4 100644
--- a/rencaika/pages/admin-approval/admin-approval.uvue
+++ b/rencaika/pages/admin-approval/admin-approval.uvue
@@ -24,22 +24,13 @@
 					<view class="org-desc"><text>{{item.description}}</text></view>
 					<view class="org-footer">
 						<view v-if="item.phone != ''" class="phone-row" @tap="callOrg(item)"><image src="/static/premium-assets/phone.png" mode="aspectFit"></image><text>咨询电话-{{item.phone}}</text></view>
-						<view v-if="item.hasWindowInfo" class="window-button" hover-class="window-button-active" @tap="showWindowInfo(item)"><text>查看窗口信息</text></view>
+						<view v-if="item.hasWindowInfo" class="window-button" hover-class="window-button-active" @tap="openWindowInfo(item)"><text>查看窗口信息</text></view>
 					</view>
 				</view>
 				<view class="bottom-space"></view>
 			</scroll-view>
 		</view>
 
-		<view v-if="popupVisible" class="popup-mask" @tap="hideWindowInfo">
-			<view class="popup-card" @tap.stop="noop">
-				<text class="popup-title">窗口信息</text>
-				<text class="popup-org">{{popupOrgName}}</text>
-				<view v-for="window in popupWindows" :key="window.id" class="popup-row"><view class="popup-dot"></view><text>{{window.name}}{{window.location != '' ? '(' + window.location + ')' : ''}}</text></view>
-				<text class="popup-time">办公时间:{{popupHours}}</text>
-				<view class="popup-close" hover-class="popup-close-active" @tap="hideWindowInfo"><text>我知道了</text></view>
-			</view>
-		</view>
 	</view>
 </template>
 
@@ -62,10 +53,6 @@
 	const listLoading = ref(true)
 	const listLoaded = ref(false)
 	const listError = ref(false)
-	const popupVisible = ref(false)
-	const popupOrgName = ref('')
-	const popupHours = ref('')
-	const popupWindows = ref<any[]>([])
 
 	onLoad((options: any) => {
 		// #ifdef MP-WEIXIN
@@ -96,17 +83,34 @@
 		}, () => { listLoading.value = false; listError.value = true })
 	}
 	const goBack = () => safeBack('/pages/premium-benefits/premium-benefits')
-	const noop = () => {}
 	const replaceHero = () => { heroImage.value = '' }
 	const callOrg = (item: any) => { if (item.phone == null || item.phone == '') { uni.showToast({ title: '机构暂未提供咨询电话', icon: 'none' }); return }; uni.makePhoneCall({ phoneNumber: item.phone, fail: () => uni.showToast({ title: '暂时无法拨打电话', icon: 'none' }) }) }
-	const showWindowInfo = (item: any) => {
-		if (!(item.windows instanceof Array) || item.windows.length == 0) return
-		popupOrgName.value = item.name || ''
-		popupHours.value = item.officeHours || '以机构公示时间为准'
-		popupWindows.value = item.windows as any[]
-		popupVisible.value = true
+	const openWindowInfo = (item: any) => {
+		const source = item != null && item.windowInfoUrl != null ? String(item.windowInfoUrl) : ''
+		if (source == '') {
+			uni.showToast({ title: '窗口信息文件暂未配置', icon: 'none' })
+			return
+		}
+		const url = resolveApiUrl(source)
+		uni.showLoading({ title: '正在打开文件', mask: true })
+		uni.downloadFile({
+			url: url,
+			success: (result: any) => {
+				if (result.statusCode != 200 || result.tempFilePath == null || result.tempFilePath == '') {
+					uni.showToast({ title: '窗口信息文件加载失败', icon: 'none' })
+					return
+				}
+				uni.openDocument({
+					filePath: result.tempFilePath as string,
+					fileType: 'pdf',
+					showMenu: true,
+					fail: () => uni.showToast({ title: '暂时无法打开 PDF 文件', icon: 'none' })
+				})
+			},
+			fail: () => uni.showToast({ title: '窗口信息文件加载失败', icon: 'none' }),
+			complete: () => uni.hideLoading()
+		})
 	}
-	const hideWindowInfo = () => { popupVisible.value = false }
 </script>
 
 <style>
@@ -148,15 +152,4 @@
 	.window-button text { color:#FF7A27; font-size:24rpx; line-height:32rpx; }
 	.window-button-active { opacity:.7; }
 	.bottom-space { height:calc(30rpx + env(safe-area-inset-bottom)); }
-	.popup-mask { display:flex; position:fixed; z-index:30; left:0; right:0; top:0; bottom:0; background-color:rgba(0,0,0,.5); justify-content:center; align-items:center; }
-	.popup-card { display:flex; width:600rpx; box-sizing:border-box; padding:40rpx; border-radius:24rpx; background-color:#FFFFFF; flex-direction:column; }
-	.popup-title { color:#252525; font-size:32rpx; line-height:44rpx; font-weight:700; text-align:center; }
-	.popup-org { margin-top:24rpx; color:#363636; font-size:27rpx; line-height:38rpx; font-weight:600; }
-	.popup-row { display:flex; margin-top:14rpx; flex-direction:row; align-items:center; }
-	.popup-dot { width:10rpx; height:10rpx; margin-right:12rpx; border-radius:50%; background-color:#FF7A27; flex-shrink:0; }
-	.popup-row text { color:#5A5F66; font-size:25rpx; line-height:36rpx; }
-	.popup-time { margin-top:20rpx; color:#8A8D92; font-size:23rpx; line-height:32rpx; }
-	.popup-close { display:flex; height:76rpx; margin-top:30rpx; border-radius:38rpx; background-image:linear-gradient(90deg,#FF4B32,#FF8A1E); justify-content:center; align-items:center; }
-	.popup-close text { color:#FFFFFF; font-size:28rpx; font-weight:600; }
-	.popup-close-active { opacity:.8; }
 </style>
diff --git a/rencaika/pages/coupon-received/coupon-received.uvue b/rencaika/pages/coupon-received/coupon-received.uvue
index 662c30f..7d86e91 100644
--- a/rencaika/pages/coupon-received/coupon-received.uvue
+++ b/rencaika/pages/coupon-received/coupon-received.uvue
@@ -17,14 +17,20 @@
 	import { getCinemaCouponClaim, getTicketPackageClaim } from '../../services/portal.uts'
 	import { getSession } from '../../services/session.uts'
 	import { safeBack } from '../../utils/navigation.uts'
-	const navTop = ref(28); const navHeight = ref(32); const navWidth = ref(88); const navRight = ref(10)
+	// Keep the first render below the status bar even before the MP capsule metrics arrive.
+	const navTop = ref(44); const navHeight = ref(32); const navWidth = ref(88); const navRight = ref(10)
 	const claimId = ref(''); const merchantId = ref(''); const talentId = ref(''); const claimType = ref('cinemaCoupon'); const loading = ref(true); const loadError = ref(false)
 	const couponName = ref('电影券'); const merchantName = ref(''); const annualTotal = ref(0); const annualClaimed = ref(0); const annualRemaining = ref(0); const hasAnnual = ref(false)
 	const exchangeCode = ref(''); const exchangeCodeStatus = ref(''); const codeStatusText = ref('兑换码生成中,请稍后重试'); const voucherTip = ref('复制后前往影城小程序使用,核销时出示兑换码')
-	onLoad((options: any) => { if (options != null && options['claimId'] != null) claimId.value = String(options['claimId']); if (options != null && options['merchantId'] != null) merchantId.value = String(options['merchantId']); if (options != null && options['type'] != null) claimType.value = String(options['type']); if (options != null && options['name'] != null) couponName.value = decodeURIComponent(String(options['name'])); const session = getSession(); if (session != null && session.talentId != null) talentId.value = String(session.talentId); if (talentId.value == '' && apiEnvironment == 'development') talentId.value = '2'; loadClaim() })
+	onLoad((options: any) => {
+		// #ifdef MP-WEIXIN
+		const capsule = uni.getMenuButtonBoundingClientRect(); const systemInfo = uni.getSystemInfoSync(); navTop.value = capsule.top; navHeight.value = capsule.height; navWidth.value = capsule.width; navRight.value = systemInfo.windowWidth - capsule.right
+		// #endif
+		if (options != null && options['claimId'] != null) claimId.value = String(options['claimId']); if (options != null && options['merchantId'] != null) merchantId.value = String(options['merchantId']); if (options != null && options['type'] != null) claimType.value = String(options['type']); if (options != null && options['name'] != null) couponName.value = decodeURIComponent(String(options['name'])); const session = getSession(); if (session != null && session.talentId != null) talentId.value = String(session.talentId); if (talentId.value == '' && apiEnvironment == 'development') talentId.value = '2'; loadClaim()
+	})
 	const progressPercent = computed(() => annualTotal.value > 0 ? Math.min(100, Math.round(annualClaimed.value * 100 / annualTotal.value)) : 0)
 	const canCopy = computed(() => exchangeCodeStatus.value == 'ACTIVE' && exchangeCode.value != '')
-	const loadClaim = () => { if (claimId.value == '' || talentId.value == '') { loading.value = false; loadError.value = true; return }; loading.value = true; loadError.value = false; const onSuccess = (data: any) => { loading.value = false; couponName.value = data.couponName != null ? String(data.couponName) : couponName.value; merchantName.value = data.merchantName != null ? String(data.merchantName) : ''; const totalValue = data.packageQuotaTotal != null ? data.packageQuotaTotal : (data.quotaTotal != null ? data.quotaTotal : data.annualTotal); const usedValue = data.packageQuotaUsed != null ? data.packageQuotaUsed : (data.quotaUsed != null ? data.quotaUsed : data.annualClaimed); const remainingValue = data.packageQuotaRemaining != null ? data.packageQuotaRemaining : (data.quotaRemaining != null ? data.quotaRemaining : data.annualRemaining); if (totalValue != null && usedValue != null && remainingValue != null) { annualTotal.value = Number(totalValue); annualClaimed.value = Number(usedValue); annualRemaining.value = Number(remainingValue); hasAnnual.value = annualTotal.value > 0 }; exchangeCode.value = data.exchangeCode != null ? String(data.exchangeCode) : ''; exchangeCodeStatus.value = data.exchangeCodeStatus != null ? String(data.exchangeCodeStatus) : ''; if (exchangeCodeStatus.value == 'USED') codeStatusText.value = '兑换码已使用'; else if (exchangeCodeStatus.value == 'EXPIRED') codeStatusText.value = '兑换码已失效'; else if (exchangeCode.value == '') codeStatusText.value = '兑换码生成中,请稍后重试'; else codeStatusText.value = ''; const channel = data.appName != null && String(data.appName) != '' ? String(data.appName) + 'APP' : (merchantName.value == '' ? '影城' : merchantName.value) + '小程序'; voucherTip.value = '复制后前往' + channel + '使用,核销时出示兑换码' }; const onError = () => { loading.value = false; loadError.value = true }; if (claimType.value == 'cinemaCoupon') getCinemaCouponClaim(claimId.value, talentId.value, onSuccess, onError); else getTicketPackageClaim(claimId.value, talentId.value, onSuccess, onError) }
+	const loadClaim = () => { if (claimId.value == '' || talentId.value == '') { loading.value = false; loadError.value = true; return }; loading.value = true; loadError.value = false; const onSuccess = (data: any) => { loading.value = false; couponName.value = data.couponName != null ? String(data.couponName) : couponName.value; merchantName.value = data.merchantName != null ? String(data.merchantName) : ''; const totalValue = data.packageQuotaTotal != null ? data.packageQuotaTotal : (data.quotaTotal != null ? data.quotaTotal : data.annualTotal); const usedValue = data.packageQuotaUsed != null ? data.packageQuotaUsed : (data.quotaUsed != null ? data.quotaUsed : data.annualClaimed); const remainingValue = data.packageQuotaRemaining != null ? data.packageQuotaRemaining : (data.quotaRemaining != null ? data.quotaRemaining : data.annualRemaining); if (totalValue != null && usedValue != null && remainingValue != null) { annualTotal.value = Number(totalValue); annualClaimed.value = Number(usedValue); annualRemaining.value = Number(remainingValue); hasAnnual.value = annualTotal.value > 0 }; exchangeCode.value = data.exchangeCode != null ? String(data.exchangeCode) : ''; exchangeCodeStatus.value = data.exchangeCodeStatus != null ? String(data.exchangeCodeStatus) : ''; if (exchangeCodeStatus.value == 'USED') codeStatusText.value = '兑换码已使用'; else if (exchangeCodeStatus.value == 'EXPIRED') codeStatusText.value = '兑换码已失效'; else if (exchangeCodeStatus.value == 'LEGACY' || exchangeCodeStatus.value == 'UNAVAILABLE') codeStatusText.value = '历史电影券暂无兑换码'; else if (exchangeCode.value == '') codeStatusText.value = '兑换码生成中,请稍后重试'; else codeStatusText.value = ''; const channel = data.appName != null && String(data.appName) != '' ? String(data.appName) + 'APP' : (merchantName.value == '' ? '影城' : merchantName.value) + '小程序'; voucherTip.value = exchangeCode.value == '' ? '该电影券为历史领取记录,当前无需操作' : '复制后前往' + channel + '使用,核销时出示兑换码' }; const onError = () => { loading.value = false; loadError.value = true }; if (claimType.value == 'cinemaCoupon') getCinemaCouponClaim(claimId.value, talentId.value, onSuccess, onError); else getTicketPackageClaim(claimId.value, talentId.value, onSuccess, onError) }
 	const goBack = () => safeBack('/pages/cinema-merchant/cinema-merchant?merchantId=' + encodeURIComponent(merchantId.value))
 	const copyExchangeCode = () => { if (!canCopy.value) return; uni.setClipboardData({ data: exchangeCode.value, success: () => uni.showToast({ title: '兑换码已复制', icon: 'success' }), fail: () => uni.showToast({ title: '复制失败,请重试', icon: 'none' }) }) }
 </script>
diff --git a/rencaika/pages/merchant/merchant.uvue b/rencaika/pages/merchant/merchant.uvue
index 24e999e..a8897c4 100644
--- a/rencaika/pages/merchant/merchant.uvue
+++ b/rencaika/pages/merchant/merchant.uvue
@@ -587,7 +587,11 @@
 			usageInstructions.value = data != null && data.usageInstructions != null && data.usageInstructions != '' ? data.usageInstructions as string : usageInstructions.value
 			if (data != null && data.cardLevel != null && data.cardLevel != '') currentCardLevel.value = data.cardLevel as string
 			applyMedicalRightsConfig(data)
-			groupBuyingEnabled.value = data == null || data.groupBuyingEnabled == null ? groupBuyingEnabled.value : data.groupBuyingEnabled === true || Number(data.groupBuyingEnabled) == 1
+			// 普通自助洗车复用餐饮商家的团单展示;外链洗车不会进入此通用详情页。
+			const isNormalCarWash = merchantType.value == 'carWash'
+			groupBuyingEnabled.value = isNormalCarWash
+				? true
+				: (data == null || data.groupBuyingEnabled == null ? groupBuyingEnabled.value : data.groupBuyingEnabled === true || Number(data.groupBuyingEnabled) == 1)
 			isHotelMerchant.value = merchantType.value == 'hotel'
 			hasFreeVoucher.value = isHotelMerchant.value && data != null && data.rightsStatus != null && String(data.rightsStatus) == 'AVAILABLE' && (data.freeVoucherEnabled === true || Number(data.freeVoucherEnabled) == 1)
 			if (hasFreeVoucher.value) {
diff --git a/rencaika/pages/shopping-benefits/shopping-benefits.uvue b/rencaika/pages/shopping-benefits/shopping-benefits.uvue
index d8d887f..fcc0e3d 100644
--- a/rencaika/pages/shopping-benefits/shopping-benefits.uvue
+++ b/rencaika/pages/shopping-benefits/shopping-benefits.uvue
@@ -70,14 +70,21 @@
 		getShoppingTopicMerchants(activeTab.value, currentPage.value + 1, 4, (data: any) => {
 			if (requestVersion != listRequestVersion.value) return
 			const records: any[] = data != null && data.records instanceof Array ? data.records as any[] : []; const next = append ? visibleServices.value.slice() : []
-			for (let index = 0; index < records.length; index++) { const item = records[index]; next.push({ id: '' + item.id, name: item.merchantName != null ? item.merchantName as string : '购物服务', discount: item.discountText != null ? item.discountText as string : '人才专享', address: item.address != null ? item.address as string : '', image: item.coverUrl != null && item.coverUrl != '' ? resolveApiUrl(item.coverUrl as string) : fallbackImage, available: item.available !== false }) }
+			for (let index = 0; index < records.length; index++) { const item = records[index]; next.push({ id: '' + item.id, merchantType: item.merchantType != null ? item.merchantType as string : '', name: item.merchantName != null ? item.merchantName as string : '购物服务', discount: item.discountText != null ? item.discountText as string : '人才专享', address: item.address != null ? item.address as string : '', image: item.coverUrl != null && item.coverUrl != '' ? resolveApiUrl(item.coverUrl as string) : fallbackImage, available: item.available !== false }) }
 			visibleServices.value = next; currentPage.value++; hasMore.value = data != null && data.total != null ? next.length < data.total : false; initialLoading.value = false; loadingMore.value = false; hasLoaded.value = true
 		}, () => { if (requestVersion != listRequestVersion.value) return; initialLoading.value = false; loadingMore.value = false; if (append) loadMoreError.value = true; else initialError.value = true })
 	}
 	const loadMore = () => { if (initialLoading.value || loadingMore.value || !hasMore.value) return; loadMoreError.value = false; loadingMore.value = true; loadServices(true) }
 	const openDisplayPolicies = () => uni.navigateTo({ url: '/pages/display-policy/display-policy' })
 	const goBack = () => safeBack('/pages/benefits/benefits')
-	const openMerchant = (item: any) => { if (!item.available) { uni.showToast({ title: '该服务已停用', icon: 'none' }); return }; uni.navigateTo({ url: '/pages/shopping-merchant/shopping-merchant?merchantId=' + encodeURIComponent(item.id) }) }
+	const openMerchant = (item: any) => {
+		if (!item.available) { uni.showToast({ title: '该服务已停用', icon: 'none' }); return }
+		if (item.merchantType == 'dental') {
+			uni.navigateTo({ url: '/pages/merchant/merchant?merchantId=' + encodeURIComponent(item.id) + '&merchantType=dental&name=' + encodeURIComponent(item.name) + '&image=' + encodeURIComponent(item.image) + '&address=' + encodeURIComponent(item.address) + '&discount=' + encodeURIComponent(item.discount) })
+			return
+		}
+		uni.navigateTo({ url: '/pages/shopping-merchant/shopping-merchant?merchantId=' + encodeURIComponent(item.id) })
+	}
 </script>
 
 <style>
diff --git a/rencaika/pages/stay-benefits/stay-benefits.uvue b/rencaika/pages/stay-benefits/stay-benefits.uvue
index ed7b27a..b6b9c38 100644
--- a/rencaika/pages/stay-benefits/stay-benefits.uvue
+++ b/rencaika/pages/stay-benefits/stay-benefits.uvue
@@ -164,12 +164,16 @@
 				const merchantType = readText(item.merchantType, item['merchant_type'])
 				const merchantName = readText(item.merchantName, item['merchant_name'])
 				const discountText = readText(item.discountText, item['discount_text'])
+				const configuredTags: any[] = item.benefitTags instanceof Array ? (item.benefitTags as any[]) : []
+				const displayTags: any[] = activeTab.value == 'hotel'
+					? configuredTags.filter((tag: any) => String(tag).trim() != '惠')
+					: configuredTags
 				next.push({
 					id: '' + item.id,
 					merchantType: merchantType != '' ? merchantType : activeTab.value,
 					name: merchantName != '' ? merchantName : '\u4f4f\u5bbf\u670d\u52a1',
 					discount: discountText != '' ? discountText : '\u4eba\u624d\u4e13\u4eab',
-					discountTags: item.benefitTags instanceof Array && (item.benefitTags as any[]).length > 0 ? item.benefitTags as any[] : [discountText != '' ? discountText : '\u4eba\u624d\u4e13\u4eab'],
+					discountTags: displayTags.length > 0 ? displayTags : [discountText != '' ? discountText : '\u4eba\u624d\u4e13\u4eab'],
 					address: readText(item.address, item['merchant_address']),
 					image: coverUrl != '' ? resolveApiUrl(coverUrl) : fallbackImage,
 					available: readAvailable(item)
diff --git a/rencaika/pages/ticket-package/ticket-package.uvue b/rencaika/pages/ticket-package/ticket-package.uvue
index 9b9a7ac..e5c8814 100644
--- a/rencaika/pages/ticket-package/ticket-package.uvue
+++ b/rencaika/pages/ticket-package/ticket-package.uvue
@@ -53,7 +53,8 @@
 	import { getSession } from '../../services/session.uts'
 	import { safeBack } from '../../utils/navigation.uts'
 
-	const navTop = ref(28)
+	// Keep the first render below the status bar even before the MP capsule metrics arrive.
+	const navTop = ref(44)
 	const navHeight = ref(32)
 	const navWidth = ref(88)
 	const navRight = ref(10)
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantTypeCatalog.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantTypeCatalog.java
index be8be00..f0d425d 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantTypeCatalog.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantTypeCatalog.java
@@ -52,9 +52,9 @@
                         "数码产品服务"),
                 type("culture", "文化服务", "惠享好物", "SHOPPING", null, null, "book", true,
                         "图书服务", "文化图书服务"),
-                type("telecom", "电信服务", "惠享好物", "SHOPPING", null, null, "digital", true,
+                type("telecom", "电信服务", "惠享好物", "SHOPPING", null, null, "telecom", true,
                         "通信服务"),
-                type("dental", "口腔治疗保健", "臻享礼遇", "MERCHANT", null, null, null, true,
+                type("dental", "口腔治疗保健", "惠享好物", "MERCHANT", null, null, "dental", true,
                         "口腔服务", "口腔保健"),
                 type("shopping", "购物服务", "惠享好物", "SHOPPING", null, null, null, true,
                         "惠享好物")
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/portal/AdminApprovalPortalService.java b/talent-card-server/src/main/java/com/talentcard/server/business/portal/AdminApprovalPortalService.java
index 0a42c87..ac9091a 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/portal/AdminApprovalPortalService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/portal/AdminApprovalPortalService.java
@@ -10,9 +10,14 @@
 import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
+import tools.jackson.core.type.TypeReference;
+import tools.jackson.databind.ObjectMapper;
 
 @Service
 public class AdminApprovalPortalService {
+
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+    private static final TypeReference<List<Map<String, Object>>> APPROVAL_ITEMS = new TypeReference<>() {};
 
     private final JdbcTemplate jdbc;
 
@@ -22,23 +27,68 @@
 
     @Transactional(readOnly = true)
     public Map<String, Object> detail(Long serviceId) {
-        approvalService(serviceId);
+        Map<String, Object> merchant = approvalService(serviceId);
         Map<String, Object> benefit = benefit();
         Map<String, Object> result = new LinkedHashMap<>();
         result.put("benefitId", benefit.get("id"));
         result.put("serviceId", serviceId);
         result.put("title", benefit.get("title"));
         result.put("freeLabel", benefit.get("freeLabel"));
-        result.put("heroImage", benefit.get("heroImage"));
-        result.put("available", number(benefit.get("enabled")) == 1);
+        result.put("heroImage", text(merchant.get("coverUrl"), text(benefit.get("heroImage"))));
+        result.put("available", number(benefit.get("enabled")) == 1 && number(merchant.get("status")) == 1);
         result.put("unavailableReason", text(benefit.get("unavailableReason"), "该权益暂未开放"));
-        result.put("organizations", organizations(((Number) benefit.get("id")).longValue()));
+        List<Map<String, Object>> configuredOrganizations = configuredOrganizations(merchant);
+        result.put("organizations", configuredOrganizations.isEmpty()
+                ? organizations(((Number) benefit.get("id")).longValue())
+                : configuredOrganizations);
         return result;
+    }
+
+    private List<Map<String, Object>> configuredOrganizations(Map<String, Object> merchant) {
+        List<Map<String, Object>> items = parseApprovalItems(merchant.get("usageInstructions"));
+        if (items.isEmpty()) {
+            String rightsText = text(merchant.get("rightsText"));
+            if (rightsText.isBlank()) return List.of();
+            items = List.of(Map.of(
+                    "name", text(merchant.get("merchantName"), "行政审批服务"),
+                    "content", rightsText,
+                    "address", text(merchant.get("address")),
+                    "phone", text(merchant.get("contactMobile"))));
+        }
+        List<Map<String, Object>> result = new java.util.ArrayList<>();
+        for (int index = 0; index < items.size(); index++) {
+            Map<String, Object> item = items.get(index);
+            Map<String, Object> organization = new LinkedHashMap<>();
+            organization.put("id", merchant.get("id") + "-" + index);
+            organization.put("name", text(item.get("name"), text(merchant.get("merchantName"), "行政审批服务")));
+            organization.put("address", text(item.get("address"), text(merchant.get("address"))));
+            organization.put("latitude", merchant.get("latitude"));
+            organization.put("longitude", merchant.get("longitude"));
+            organization.put("description", text(item.get("content"), text(merchant.get("rightsText"), "暂无权益说明")));
+            organization.put("phone", text(item.get("phone"), text(merchant.get("contactMobile"))));
+            organization.put("officeHours", "");
+            String windowInfoUrl = text(item.get("windowInfoUrl"), text(item.get("pdfFile"), text(item.get("pdf_file"))));
+            organization.put("windowInfoUrl", windowInfoUrl);
+            organization.put("windows", List.of());
+            organization.put("hasWindowInfo", !windowInfoUrl.isBlank());
+            result.add(organization);
+        }
+        return result;
+    }
+
+    private List<Map<String, Object>> parseApprovalItems(Object value) {
+        if (value == null || String.valueOf(value).isBlank()) return List.of();
+        try {
+            return OBJECT_MAPPER.readValue(String.valueOf(value), APPROVAL_ITEMS);
+        } catch (Exception ignored) {
+            return List.of();
+        }
     }
 
     private List<Map<String, Object>> organizations(Long benefitId) {
         return query("""
-                select id, organization_name, address, latitude, longitude, rights_text, consultation_phone, office_hours
+                select id, organization_name, address, latitude, longitude, rights_text, consultation_phone, office_hours,
+                       pdf_file, is_pdf_visible
                 from admin_approval_organization
                 where benefit_id = ? and enabled = 1 and deleted = 0
                 order by sort_no asc, id asc
@@ -61,8 +111,10 @@
             organization.put("description", row.get("rightsText"));
             organization.put("phone", text(row.get("consultationPhone")));
             organization.put("officeHours", text(row.get("officeHours")));
+            String windowInfoUrl = number(row.get("isPdfVisible")) == 1 ? text(row.get("pdfFile")) : "";
+            organization.put("windowInfoUrl", windowInfoUrl);
             organization.put("windows", windows);
-            organization.put("hasWindowInfo", !windows.isEmpty());
+            organization.put("hasWindowInfo", !windowInfoUrl.isBlank());
             return organization;
         }).toList();
     }
@@ -70,7 +122,8 @@
     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
+                select m.id, m.merchant_name, m.address, m.contact_mobile, m.cover_url, m.latitude, m.longitude,
+                       m.status, m.usage_instructions, m.rights_text
                 from merchant m
                 where m.id = ? and m.deleted = 0 and m.merchant_type_code = 'approval'
                 """, serviceId);
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/portal/CinemaPortalService.java b/talent-card-server/src/main/java/com/talentcard/server/business/portal/CinemaPortalService.java
index 60878a0..1bbb206 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/portal/CinemaPortalService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/portal/CinemaPortalService.java
@@ -136,7 +136,7 @@
 				join merchant m on m.id = cc.merchant_id and m.deleted = 0
 				where cc.id = ? and cc.talent_id = ? and cc.deleted = 0
 				""", claimId, talentId);
-		if (claims.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "用户券不存在或无权查看");
+		if (claims.isEmpty()) return legacyClaimDetail(claimId, talentId);
 		Map<String, Object> claim = claims.get(0);
 		int annualTotal = number(claim.get("perUserLimit"));
 		if (annualTotal < 1) annualTotal = number(claim.get("annualQuota"));
@@ -168,6 +168,33 @@
 		return result;
 	}
 
+	private Map<String, Object> legacyClaimDetail(Long recordId, Long talentId) {
+		List<Map<String, Object>> records = query("""
+				select r.id, r.receive_no, r.package_name, r.merchants
+				from movie_ticket_record r
+				where r.id = ? and r.deleted = 0
+				  and exists (
+				      select 1 from talent_card c
+				      where c.talent_id = ? and c.deleted = 0 and c.card_type = 'PERSONAL' and c.card_no = r.card_no
+				  )
+				""", recordId, talentId);
+		if (records.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "用户券不存在或无权查看");
+		Map<String, Object> record = records.get(0);
+		Map<String, Object> result = new LinkedHashMap<>();
+		result.put("claimId", record.get("id"));
+		result.put("couponName", text(record.get("packageName"), "电影券"));
+		result.put("merchantName", text(record.get("merchants"), "影城"));
+		result.put("exchangeCodeStatus", "LEGACY");
+		result.put("exchangeCode", "");
+		result.put("annualTotal", 0);
+		result.put("annualClaimed", 0);
+		result.put("annualRemaining", 0);
+		result.put("channel", "影城小程序");
+		result.put("verificationRule", "历史电影券领取记录");
+		result.put("invalidReason", "历史记录");
+		return result;
+	}
+
 	private Map<String, Object> ticketPackageCouponView(Map<String, Object> row) {
 		int quotaTotal = number(value(row, "packageQuotaTotal", "quotaTotal"));
 		int quotaUsed = number(value(row, "packageQuotaUsed", "quotaUsed"));
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/portal/UsageRecordPortalService.java b/talent-card-server/src/main/java/com/talentcard/server/business/portal/UsageRecordPortalService.java
index 0bb9b48..12f1701 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/portal/UsageRecordPortalService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/portal/UsageRecordPortalService.java
@@ -124,7 +124,7 @@
 				select concat('movie-', r.id) as record_id,
 				       'MOVIE_TICKET' as record_type,
 				       r.receive_no as business_id,
-				       cc.id as target_detail_id,
+				       coalesce(cc.id, r.id) as target_detail_id,
 				       null as merchant_id,
 				       '万达影城' as merchant_name,
 				       'cinema' as merchant_type,
diff --git a/talent-card-server/src/main/resources/db/migration/V120__add_telecom_and_dental_shopping_categories.sql b/talent-card-server/src/main/resources/db/migration/V120__add_telecom_and_dental_shopping_categories.sql
new file mode 100644
index 0000000..45eb6be
--- /dev/null
+++ b/talent-card-server/src/main/resources/db/migration/V120__add_telecom_and_dental_shopping_categories.sql
@@ -0,0 +1,11 @@
+insert into shopping_topic_category(category_key, category_name, sort_no, enabled)
+select 'telecom', '电信服务', 60, 1
+where not exists (
+    select 1 from shopping_topic_category where category_key = 'telecom'
+);
+
+insert into shopping_topic_category(category_key, category_name, sort_no, enabled)
+select 'dental', '口腔治疗保健', 70, 1
+where not exists (
+    select 1 from shopping_topic_category where category_key = 'dental'
+);
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantTypeCatalogTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantTypeCatalogTests.java
index 957093d..16c575f 100644
--- a/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantTypeCatalogTests.java
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantTypeCatalogTests.java
@@ -26,6 +26,10 @@
         assertThat(medical.premiumBusinessType()).isEqualTo("MEDICAL");
 
         assertThat(MerchantTypeCatalog.codesForShoppingCategory("book")).containsExactly("culture");
+        assertThat(MerchantTypeCatalog.codesForShoppingCategory("telecom")).containsExactly("telecom");
+        assertThat(MerchantTypeCatalog.codesForShoppingCategory("dental")).containsExactly("dental");
+        assertThat(MerchantTypeCatalog.find("口腔治疗保健").orElseThrow().topicName()).isEqualTo("惠享好物");
+        assertThat(MerchantTypeCatalog.find("口腔治疗保健").orElseThrow().detailType()).isEqualTo("MERCHANT");
         assertThat(MerchantTypeCatalog.find("酒店服务").orElseThrow().supportsMerchantAppLogin()).isTrue();
     }
 

--
Gitblit v1.7.1