From d2d27bea854a5d0b48869b8bd87e6202c28f4cd2 Mon Sep 17 00:00:00 2001
From: shinnosuke <tantao@1hoper.com>
Date: 星期四, 17 九月 2026 16:03:26 +0800
Subject: [PATCH] 更新....

---
 talent-card-server/src/main/java/com/talentcard/server/business/portal/ChildrenEntrustmentPortalService.java |   24 +
 talent-card-admin/src/views/EquityImageView.vue                                                              |    1 
 talent-card-server/src/main/resources/db/migration/V149__clear_service_hero_placeholders.sql                 |    4 
 rencaika/pages/merchant/merchant.uvue                                                                        |    7 
 talent-card-admin/src/views/MovieTicketView.vue                                                              |    4 
 talent-card-admin/src/views/TalentPolicyPublishView.vue                                                      |    1 
 talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementService.java            |    2 
 talent-card-admin/src/views/PlatformSettingsView.vue                                                         |   13 +
 talent-card-server/src/test/java/com/talentcard/server/business/portal/PortalServiceTests.java               |   20 +
 talent-card-server/src/main/resources/db/migration/V148__policy_article_top_time.sql                         |    5 
 rencaika/config/development-server.uts                                                                       |    9 
 rencaika/pages/news-detail/news-detail.uvue                                                                  |    8 
 rencaika/pages/news/news.uvue                                                                                |    7 
 talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessController.java           |   24 +
 rencaika/pages/index/index.uvue                                                                              |   15 +
 talent-card-server/src/main/resources/db/migration/V148__merchant_hero_image.sql                             |   14 +
 rencaika_merchant/config/development-server.uts                                                              |    9 
 talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java              |  178 ++++++++++++++
 talent-card-admin/vite.config.ts                                                                             |   12 +
 rencaika/pages/external-webview/external-webview.uvue                                                        |   21 +
 talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantUpdateRequest.java             |    1 
 talent-card-admin/src/views/MerchantCreateView.vue                                                           |   78 +++++-
 talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantCreateRequest.java             |    1 
 talent-card-server/src/main/resources/application.yml                                                        |    2 
 rencaika_merchant/pages/free-stay-confirm/free-stay-confirm.uvue                                             |    3 
 talent-card-server/src/main/java/com/talentcard/server/business/portal/AdminApprovalPortalService.java       |   21 +
 talent-card-admin/src/api/admin.ts                                                                           |    5 
 talent-card-admin/src/views/FamilyCardView.vue                                                               |   42 +--
 talent-card-server/src/main/java/com/talentcard/server/business/admin/ServiceHeroImageRequest.java           |   15 +
 talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java                    |   52 +++
 talent-card-admin/src/types/admin.ts                                                                         |   15 +
 talent-card-admin/src/views/TalentPolicyEditView.vue                                                         |    9 
 talent-card-admin/src/style.css                                                                              |   40 +++
 talent-card-admin/src/layouts/AdminLayout.vue                                                                |   31 ++
 34 files changed, 596 insertions(+), 97 deletions(-)

diff --git a/rencaika/config/development-server.uts b/rencaika/config/development-server.uts
index 5322e91..453db18 100644
--- a/rencaika/config/development-server.uts
+++ b/rencaika/config/development-server.uts
@@ -16,8 +16,13 @@
  * 清了这条 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'
diff --git a/rencaika/pages/external-webview/external-webview.uvue b/rencaika/pages/external-webview/external-webview.uvue
index f1ef6a6..bda3c64 100644
--- a/rencaika/pages/external-webview/external-webview.uvue
+++ b/rencaika/pages/external-webview/external-webview.uvue
@@ -78,9 +78,12 @@
 	}
 	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
@@ -91,6 +94,22 @@
 			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 : ''
diff --git a/rencaika/pages/index/index.uvue b/rencaika/pages/index/index.uvue
index c753250..11b07f5 100644
--- a/rencaika/pages/index/index.uvue
+++ b/rencaika/pages/index/index.uvue
@@ -56,6 +56,9 @@
 	const homeError = ref('')
 	const homeVideoUrl = ref('')
 	const miniProgramUrl = ref('')
+	// 后台可配置目标小程序 AppID,配置后"幸福东区"等入口直接用小程序跳转,不再走 web-view。
+	const miniProgramAppId = ref('')
+	const miniProgramPath = ref('')
 
 	onLoad(() => {
 		// #ifdef MP-WEIXIN
@@ -89,6 +92,8 @@
 			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]
@@ -118,6 +123,16 @@
 		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
diff --git a/rencaika/pages/merchant/merchant.uvue b/rencaika/pages/merchant/merchant.uvue
index 776e608..ed908ed 100644
--- a/rencaika/pages/merchant/merchant.uvue
+++ b/rencaika/pages/merchant/merchant.uvue
@@ -507,7 +507,12 @@
 			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,
diff --git a/rencaika/pages/news-detail/news-detail.uvue b/rencaika/pages/news-detail/news-detail.uvue
index 82dda02..305dc2b 100644
--- a/rencaika/pages/news-detail/news-detail.uvue
+++ b/rencaika/pages/news-detail/news-detail.uvue
@@ -3,7 +3,7 @@
 		<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>
@@ -243,9 +243,9 @@
 	.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; }
diff --git a/rencaika/pages/news/news.uvue b/rencaika/pages/news/news.uvue
index b61d8ed..5b7656b 100644
--- a/rencaika/pages/news/news.uvue
+++ b/rencaika/pages/news/news.uvue
@@ -3,7 +3,7 @@
 		<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>
@@ -189,8 +189,9 @@
 	.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; }
diff --git a/rencaika_merchant/config/development-server.uts b/rencaika_merchant/config/development-server.uts
index 5322e91..453db18 100644
--- a/rencaika_merchant/config/development-server.uts
+++ b/rencaika_merchant/config/development-server.uts
@@ -16,8 +16,13 @@
  * 清了这条 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'
diff --git a/rencaika_merchant/pages/free-stay-confirm/free-stay-confirm.uvue b/rencaika_merchant/pages/free-stay-confirm/free-stay-confirm.uvue
index 6b1db46..f07809d 100644
--- a/rencaika_merchant/pages/free-stay-confirm/free-stay-confirm.uvue
+++ b/rencaika_merchant/pages/free-stay-confirm/free-stay-confirm.uvue
@@ -87,7 +87,8 @@
 	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; }
diff --git a/talent-card-admin/src/api/admin.ts b/talent-card-admin/src/api/admin.ts
index 8366cf3..1395d1b 100644
--- a/talent-card-admin/src/api/admin.ts
+++ b/talent-card-admin/src/api/admin.ts
@@ -257,6 +257,11 @@
   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}`)
 }
diff --git a/talent-card-admin/src/layouts/AdminLayout.vue b/talent-card-admin/src/layouts/AdminLayout.vue
index 4394615..a4b8aad 100644
--- a/talent-card-admin/src/layouts/AdminLayout.vue
+++ b/talent-card-admin/src/layouts/AdminLayout.vue
@@ -131,6 +131,19 @@
   .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] : []
@@ -148,6 +161,9 @@
     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)) {
@@ -180,16 +196,21 @@
   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() {
@@ -212,6 +233,8 @@
 
 watch(activeMenu, () => {
   const groupIndex = currentMenuGroupIndex()
+  // 找不到归属(例如从其它模块跳进来的子页面)时保持当前展开状态,不要把左侧菜单收起来。
+  if (!groupIndex) return
   nextTick(() => {
     closeOtherMenuGroups(groupIndex)
     if (groupIndex) adminMenuRef.value?.open(groupIndex)
diff --git a/talent-card-admin/src/style.css b/talent-card-admin/src/style.css
index ef2182d..9148aaf 100644
--- a/talent-card-admin/src/style.css
+++ b/talent-card-admin/src/style.css
@@ -855,6 +855,22 @@
   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;
@@ -895,10 +911,15 @@
 
 .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 {
@@ -2416,7 +2437,8 @@
 }
 
 .coupon-switch-row {
-  grid-template-columns: 130px 44px;
+  /* 内容列要留足宽度:开关本身靠左对齐,不受影响;而"适用范围"这类纯文案不会被挤成两行。 */
+  grid-template-columns: 130px minmax(0, 1fr);
   gap: 6px;
   margin-bottom: 18px;
 }
@@ -2429,6 +2451,11 @@
   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 {
@@ -3656,12 +3683,19 @@
 
 .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;
 }
diff --git a/talent-card-admin/src/types/admin.ts b/talent-card-admin/src/types/admin.ts
index d809e0c..692811b 100644
--- a/talent-card-admin/src/types/admin.ts
+++ b/talent-card-admin/src/types/admin.ts
@@ -65,6 +65,9 @@
 export interface PolicyArticle extends NewsArticle {
   policyNo?: string
   subtitle?: string
+  // 后台记录的操作人(管理员 ID 由服务端解析成姓名返回,历史数据回落为 author)。
+  createdByName?: string
+  updatedByName?: string
 }
 
 export interface PolicyArticlePayload {
@@ -205,6 +208,12 @@
     latitude?: number
     canNavigate?: boolean | number
   }>
+  heroImages?: Array<{
+    id?: number | string
+    imageUrl?: string
+    sortNo?: number
+    enabled?: number | boolean
+  }>
   usageInstructions?: string
   rightsText?: string
   miniProgramJumpType?: string
@@ -264,6 +273,12 @@
     longitude?: number
     latitude?: number
   }>
+  // 加油服务详情页轮播图(按顺序提交,服务端整表替换)。
+  heroImages?: Array<{
+    id?: number | string
+    imageUrl: string
+    enabled: boolean
+  }>
   usageInstructions?: string
   rightsText?: string
   miniProgramJumpType?: string
diff --git a/talent-card-admin/src/views/EquityImageView.vue b/talent-card-admin/src/views/EquityImageView.vue
index c437d12..f36d631 100644
--- a/talent-card-admin/src/views/EquityImageView.vue
+++ b/talent-card-admin/src/views/EquityImageView.vue
@@ -188,6 +188,7 @@
           </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>
 
diff --git a/talent-card-admin/src/views/FamilyCardView.vue b/talent-card-admin/src/views/FamilyCardView.vue
index d702b34..fc2488f 100644
--- a/talent-card-admin/src/views/FamilyCardView.vue
+++ b/talent-card-admin/src/views/FamilyCardView.vue
@@ -72,10 +72,6 @@
   return row.relationship || row.relation || '-'
 }
 
-function familyIdCard(row: FamilyCardApplication) {
-  return row.idCardNo || row.dependentIdCardNo || '-'
-}
-
 async function loadData() {
   loading.value = true
   try {
@@ -141,18 +137,14 @@
       { 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,
   )
@@ -178,11 +170,12 @@
       </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>
@@ -196,7 +189,7 @@
             <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"
@@ -250,33 +243,26 @@
         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>
diff --git a/talent-card-admin/src/views/MerchantCreateView.vue b/talent-card-admin/src/views/MerchantCreateView.vue
index 18daebf..e4d820c 100644
--- a/talent-card-admin/src/views/MerchantCreateView.vue
+++ b/talent-card-admin/src/views/MerchantCreateView.vue
@@ -15,7 +15,7 @@
   deleteProduct as deleteProductApi,
   getAdminCouponDetail,
   getAdminCoupons,
-  getAdminProducts,
+  getAdminMerchantProducts,
   updateCoupon,
   updateCouponStatus,
   updateMerchant,
@@ -70,7 +70,7 @@
 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'
@@ -303,7 +303,7 @@
 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) => {
@@ -321,8 +321,13 @@
   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('请输入登录账号'))
@@ -332,6 +337,7 @@
     trigger: 'blur',
   }],
   loginPassword: [{
+    required: true,
     validator: (_rule, value, callback) => {
       const updatingPassword = isEditMode.value && String(value || '').length > 0
       if (!requiresMerchantOperator.value && !updatingPassword) return callback()
@@ -342,6 +348,7 @@
     trigger: 'blur',
   }],
   confirmPassword: [{
+    required: true,
     validator: (_rule, value, callback) => {
       const needsConfirmation = requiresMerchantOperator.value || (isEditMode.value && accountForm.loginPassword.length > 0)
       if (!needsConfirmation) return callback()
@@ -1107,6 +1114,11 @@
 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>()
@@ -1279,13 +1291,15 @@
   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
     }
@@ -1296,7 +1310,7 @@
         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 },
@@ -1394,10 +1408,14 @@
     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
 }
@@ -1461,7 +1479,7 @@
   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()
@@ -1530,6 +1548,10 @@
       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)
     }
@@ -1736,6 +1758,7 @@
       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
@@ -1857,10 +1880,9 @@
 
   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],
@@ -2102,7 +2124,7 @@
     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
 }
 
@@ -2367,6 +2389,7 @@
 		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
@@ -2476,6 +2499,16 @@
         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)
@@ -2629,10 +2662,10 @@
           <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>
@@ -2973,6 +3006,27 @@
         <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>
diff --git a/talent-card-admin/src/views/MovieTicketView.vue b/talent-card-admin/src/views/MovieTicketView.vue
index 9f9c551..4053511 100644
--- a/talent-card-admin/src/views/MovieTicketView.vue
+++ b/talent-card-admin/src/views/MovieTicketView.vue
@@ -35,7 +35,7 @@
 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 }]
@@ -504,7 +504,7 @@
           <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="领取时间">
diff --git a/talent-card-admin/src/views/PlatformSettingsView.vue b/talent-card-admin/src/views/PlatformSettingsView.vue
index c316bfa..d040df2 100644
--- a/talent-card-admin/src/views/PlatformSettingsView.vue
+++ b/talent-card-admin/src/views/PlatformSettingsView.vue
@@ -9,6 +9,8 @@
   videoName: '',
   homeVideoUrl: '',
   miniProgramUrl: '',
+  miniProgramAppId: '',
+  miniProgramPath: '',
 })
 
 const saving = ref(false)
@@ -25,7 +27,7 @@
 }
 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>
@@ -53,6 +55,15 @@
         <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>
diff --git a/talent-card-admin/src/views/TalentPolicyEditView.vue b/talent-card-admin/src/views/TalentPolicyEditView.vue
index 71db82b..2e667f3 100644
--- a/talent-card-admin/src/views/TalentPolicyEditView.vue
+++ b/talent-card-admin/src/views/TalentPolicyEditView.vue
@@ -34,13 +34,14 @@
 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>
diff --git a/talent-card-admin/src/views/TalentPolicyPublishView.vue b/talent-card-admin/src/views/TalentPolicyPublishView.vue
index 1eb13eb..af6d582 100644
--- a/talent-card-admin/src/views/TalentPolicyPublishView.vue
+++ b/talent-card-admin/src/views/TalentPolicyPublishView.vue
@@ -55,6 +55,7 @@
     <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>
diff --git a/talent-card-admin/vite.config.ts b/talent-card-admin/vite.config.ts
index cd1cbf3..3dfb906 100644
--- a/talent-card-admin/vite.config.ts
+++ b/talent-card-admin/vite.config.ts
@@ -2,6 +2,15 @@
 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: {
@@ -12,14 +21,17 @@
       '/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,
       },
     },
   },
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessController.java b/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessController.java
index 7f7af73..73eb017 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessController.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessController.java
@@ -104,12 +104,14 @@
 	@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")
@@ -189,6 +191,16 @@
 		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,
@@ -218,10 +230,14 @@
 	@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}")
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java b/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java
index b6a4fb1..c02a939 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java
@@ -180,28 +180,45 @@
 		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);
 	}
@@ -348,6 +365,21 @@
 					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;
 	}
@@ -410,6 +442,7 @@
 		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());
@@ -555,6 +588,7 @@
 		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());
@@ -669,10 +703,80 @@
 		}
 	}
 
+	/**
+	 * 子女托管 / 行政审批的详情页图:这两类服务的权益是全局一条,图片也全局共用。
+	 */
+	@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) {
@@ -734,6 +838,64 @@
 		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,
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementService.java b/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementService.java
index 05a69c6..a177e9d 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementService.java
@@ -20,7 +20,7 @@
     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()"); }
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantCreateRequest.java b/talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantCreateRequest.java
index 39faa80..16a0604 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantCreateRequest.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantCreateRequest.java
@@ -35,6 +35,7 @@
 		Double latitude,
 		Byte groupBuyingEnabled,
 		List<Map<String, Object>> applicableStores,
+		List<Map<String, Object>> heroImages,
 		String usageInstructions,
 		String rightsText,
 		List<Map<String, Object>> merchantRightsConfig,
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantUpdateRequest.java b/talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantUpdateRequest.java
index c4a396e..695efeb 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantUpdateRequest.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantUpdateRequest.java
@@ -30,6 +30,7 @@
 		Double latitude,
 		Byte groupBuyingEnabled,
 		List<Map<String, Object>> applicableStores,
+		List<Map<String, Object>> heroImages,
 		String usageInstructions,
 		String rightsText,
 		List<Map<String, Object>> merchantRightsConfig,
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/admin/ServiceHeroImageRequest.java b/talent-card-server/src/main/java/com/talentcard/server/business/admin/ServiceHeroImageRequest.java
new file mode 100644
index 0000000..1f204a7
--- /dev/null
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/admin/ServiceHeroImageRequest.java
@@ -0,0 +1,15 @@
+package com.talentcard.server.business.admin;
+
+import jakarta.validation.constraints.NotBlank;
+
+/**
+ * 子女托管 / 行政审批详情页图的保存请求。
+ *
+ * type 取值 children(子女托管)或 approval(行政审批)。
+ */
+public record ServiceHeroImageRequest(
+		@NotBlank(message = "请选择服务类型")
+		String type,
+		String imageUrl
+) {
+}
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 8270988..2fe6866 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
@@ -34,7 +34,9 @@
         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);
@@ -44,6 +46,23 @@
         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()) {
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/portal/ChildrenEntrustmentPortalService.java b/talent-card-server/src/main/java/com/talentcard/server/business/portal/ChildrenEntrustmentPortalService.java
index 6601611..8b83889 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/portal/ChildrenEntrustmentPortalService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/portal/ChildrenEntrustmentPortalService.java
@@ -32,7 +32,10 @@
         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"), "该权益暂未开放"));
@@ -61,6 +64,23 @@
             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) {
@@ -150,7 +170,7 @@
     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);
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 e0a3d3f..7d38c24 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
@@ -59,7 +59,7 @@
 				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("""
@@ -79,12 +79,15 @@
 		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
@@ -132,7 +135,7 @@
 				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);
@@ -297,7 +300,7 @@
 			String discountText = merchantId == null ? "" : merchantLowestTalentDiscount(merchantId, talentId);
 			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, null));
 			return item;
 		}).toList();
 		return new PageResult<>(records, result.page(), result.size(), result.total());
@@ -344,7 +347,7 @@
 			String discountText = merchantId == null ? "" : merchantLowestTalentDiscount(merchantId, talentId);
 			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, null));
 			return item;
 		}).toList();
 		return new PageResult<>(records, result.page(), result.size(), result.total());
@@ -403,7 +406,7 @@
 					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();
@@ -419,8 +422,8 @@
 		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)) {
@@ -428,7 +431,7 @@
 			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)
@@ -455,6 +458,8 @@
 	}
 
 	private List<String> merchantBenefitTags(
+			Long merchantId,
+			String cardLevel,
 			String merchantType,
 			boolean couponAvailable,
 			String productDiscountText,
@@ -473,6 +478,10 @@
 		if (hotelLike && right != null && booleanValue(right.get("freeVoucherEnabled"))) {
 			String times = textOrDefault(right.get("freeVoucherTimes"), "").trim();
 			discount = times.isBlank() ? "免费" : "免费" + times + "次";
+		} else {
+			// 团单按人才等级配置了免单时,免费次数单独作为一个标签展示。
+			String freeTimes = productFreeTimesTag(merchantId, cardLevel);
+			if (!freeTimes.isBlank() && !tags.contains(freeTimes)) tags.add(freeTimes);
 		}
 		if (!discount.isBlank() && !tags.contains(discount)) tags.add(discount);
 		if (hotelLike && right != null && booleanValue(right.get("storeConsume")) && booleanValue(right.get("consumeEnabled"))) {
@@ -532,7 +541,7 @@
 			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());
@@ -1111,6 +1120,31 @@
 		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 "";
diff --git a/talent-card-server/src/main/resources/application.yml b/talent-card-server/src/main/resources/application.yml
index a514336..439e592 100644
--- a/talent-card-server/src/main/resources/application.yml
+++ b/talent-card-server/src/main/resources/application.yml
@@ -49,7 +49,7 @@
   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,不对公网来源开口子;真实域名请加进上面的精确白名单。
diff --git a/talent-card-server/src/main/resources/db/migration/V148__merchant_hero_image.sql b/talent-card-server/src/main/resources/db/migration/V148__merchant_hero_image.sql
new file mode 100644
index 0000000..3f88180
--- /dev/null
+++ b/talent-card-server/src/main/resources/db/migration/V148__merchant_hero_image.sql
@@ -0,0 +1,14 @@
+-- 子女托管 / 行政审批等服务的详情页轮播图改为按商家配置。
+-- 未单独配置的商家仍回落到服务权益里原有的全局图片,行为不变。
+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='商家详情页轮播图';
diff --git a/talent-card-server/src/main/resources/db/migration/V148__policy_article_top_time.sql b/talent-card-server/src/main/resources/db/migration/V148__policy_article_top_time.sql
new file mode 100644
index 0000000..2373f5a
--- /dev/null
+++ b/talent-card-server/src/main/resources/db/migration/V148__policy_article_top_time.sql
@@ -0,0 +1,5 @@
+-- 政策列表需要「置顶优先,置顶组内按置顶时间倒序」,原来只有 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;
diff --git a/talent-card-server/src/main/resources/db/migration/V149__clear_service_hero_placeholders.sql b/talent-card-server/src/main/resources/db/migration/V149__clear_service_hero_placeholders.sql
new file mode 100644
index 0000000..cbd1521
--- /dev/null
+++ b/talent-card-server/src/main/resources/db/migration/V149__clear_service_hero_placeholders.sql
@@ -0,0 +1,4 @@
+-- 子女托管 / 行政审批的详情页图改为按商家配置后,服务级权益里种下的占位图
+-- (/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';
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 7fe6235..70daeeb 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
@@ -110,11 +110,11 @@
 	@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,
@@ -127,6 +127,20 @@
 	}
 
 	@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
 	@SuppressWarnings("unchecked")
 	void parkingTagsUseFreeForFreeRightsAndDiscountForPaidRights() throws Exception {
 		when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(List.of());

--
Gitblit v1.7.1