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