shinnosuke
2026-08-18 69138ddcf4936098cb3b67688412badfc8f503a0
又又又更新
2个文件已删除
3 文件已重命名
1个文件已添加
62个文件已修改
1143 ■■■■■ 已修改文件
rencaika/manifest.json 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/benefits-assets/all.png 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/benefits-assets/benefits-hero.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/benefits-assets/culture.png 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/benefits-assets/food.png 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/benefits-assets/life.png 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/benefits-assets/premium.png 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/benefits-assets/shopping.png 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/benefits-assets/stay.png 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/benefits-assets/travel.png 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/card-assets/card-elite.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/card-assets/card-excellence.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/card-assets/card-outstanding.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/card-assets/talent-card-background.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/entertainment-assets/entertainment-hero.webp 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/entertainment-assets/wanda-cinema.webp 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/fonts/stay-calligraphy-subset.ttf 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/food-assets/food-1.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/food-assets/food-2.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/home-assets/card.png 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/home-assets/login-mascot.png 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/home-assets/play.svg 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/home-assets/top.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/news-assets/meeting-thumb.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/policy-assets/policy-explanation.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/policy-assets/policy-hero.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/product-assets/payment-success.png 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/stay-assets/hotel-hampton.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/stay-assets/hotel-vienna.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/verification-confirm/verification-confirm.uvue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/services/orders.uts 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/api/admin.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/layouts/AdminLayout.vue 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/router/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/style.css 293 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/BusinessManagementView.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantCreateView.vue 152 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantOnboardingDetailView.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantSettlementView.vue 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantView.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/OrderDetailView.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/OrderView.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/ProductAuditDetailView.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/RefundRecordView.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/RoleFormView.vue 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/StaffManagementView.vue 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/TalentCreateView.vue 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/TalentDetailView.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/TalentNewsEditView.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/TalentNewsPublishView.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/TalentPolicyEditView.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/TalentPolicyPublishView.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/UpgradeApplicationDetailView.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessController.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementService.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantCreateRequest.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/ManualConsumptionService.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/order/OrderService.java 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/AppOrderPortalService.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/application.yml 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V112__news_article_top_time_sorting.sql 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V113__ticket_package_level_rules.sql 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V114__sync_premium_topic_merchant_mappings.sql 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V115__benefit_order_card_level_snapshot.sql 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/manifest.json
@@ -11,7 +11,8 @@
    "mp-weixin": {
        "appid": "wxcfee477acb54d179",
        "setting": {
            "urlCheck": false
            "urlCheck": false,
            "sourceMap": false
        },
        "usingComponents": true
    },
@@ -66,4 +67,4 @@
            "mode": ""
        }
    }
}
}
rencaika/static/benefits-assets/all.png

rencaika/static/benefits-assets/benefits-hero.jpg

rencaika/static/benefits-assets/culture.png

rencaika/static/benefits-assets/food.png

rencaika/static/benefits-assets/life.png

rencaika/static/benefits-assets/premium.png

rencaika/static/benefits-assets/shopping.png

rencaika/static/benefits-assets/stay.png

rencaika/static/benefits-assets/travel.png

rencaika/static/card-assets/card-elite.jpg

rencaika/static/card-assets/card-excellence.jpg

rencaika/static/card-assets/card-outstanding.jpg

rencaika/static/card-assets/talent-card-background.jpg

rencaika/static/entertainment-assets/entertainment-hero.webp
Binary files differ
rencaika/static/entertainment-assets/wanda-cinema.webp
Binary files differ
rencaika/static/fonts/stay-calligraphy-subset.ttf
Binary files differ
rencaika/static/food-assets/food-1.jpg

rencaika/static/food-assets/food-2.jpg

rencaika/static/home-assets/card.png

rencaika/static/home-assets/login-mascot.png

rencaika/static/home-assets/play.svg
File was deleted
rencaika/static/home-assets/top.jpg

rencaika/static/news-assets/meeting-thumb.jpg

rencaika/static/policy-assets/policy-explanation.jpg

rencaika/static/policy-assets/policy-hero.jpg

rencaika/static/product-assets/payment-success.png

rencaika/static/stay-assets/hotel-hampton.jpg

rencaika/static/stay-assets/hotel-vienna.jpg

rencaika_merchant/pages/verification-confirm/verification-confirm.uvue
@@ -48,6 +48,7 @@
    const payAmount = ref(0)
    const subsidyAmount = ref(0)
    const createdAt = ref('')
    const loadError = ref('')
    const submitting = ref(false)
    const formatAmount = (amount: number): string => (amount / 100).toFixed(2)
@@ -72,11 +73,21 @@
    const loadOrder = () => {
        if (orderNo.value.length == 0) return
        loadMerchantOrderDetail(orderNo.value, (order: MerchantOrderDetail, _traceId: string) => applyOrder(order), () => {})
        loadMerchantOrderDetail(orderNo.value, (order: MerchantOrderDetail, _traceId: string) => {
            loadError.value = ''
            applyOrder(order)
        }, (error: any) => {
            loadError.value = error != null && error.message != null ? '' + error.message : '订单详情加载失败,请返回重新扫码'
            uni.showToast({ title: loadError.value, icon: 'none' })
        })
    }
    const confirm = () => {
        if (submitting.value || verificationId.value.length == 0) return
        if (loadError.value.length > 0) {
            uni.showToast({ title: '订单详情未加载完成,请返回重新扫码', icon: 'none' })
            return
        }
        uni.showModal({
            title: '确认核销',
            content: '确认后将完成本次订单核销,是否继续?',
rencaika_merchant/services/orders.uts
@@ -88,26 +88,41 @@
    }, onError)
}
const numberValue = (value: any): number => value == null ? 0 : Number(value)
const fieldValue = (value: any, camelCaseName: string, snakeCaseName: string): any => {
    if (value == null) return null
    if (value[camelCaseName] != null) return value[camelCaseName]
    return value[snakeCaseName] != null ? value[snakeCaseName] : null
}
const numberValue = (value: any): number => {
    if (value == null || value === '') return 0
    const number = Number(value)
    return isNaN(number) ? 0 : number
}
const textValue = (value: any, camelCaseName: string, snakeCaseName: string, fallback: string = ''): string => {
    const field = fieldValue(value, camelCaseName, snakeCaseName)
    return field != null ? '' + field : fallback
}
const toOrder = (value: any): MerchantOrder => ({
    id: value != null && value.id != null ? '' + value.id : '',
    orderNo: value != null && value.orderNo != null ? '' + value.orderNo : '',
    category: value != null && value.category != null ? '' + value.category : 'group',
    status: value != null && value.status != null ? '' + value.status : 'pending_verification',
    statusText: value != null && value.statusText != null ? '' + value.statusText : '待核销',
    title: value != null && value.title != null ? '' + value.title : '订单',
    customerName: value != null && value.customerName != null ? '' + value.customerName : '',
    customerMobile: value != null && value.customerMobile != null ? '' + value.customerMobile : '',
    cardLevel: value != null && value.cardLevel != null ? '' + value.cardLevel : '卓越人才卡',
    orderAmount: numberValue(value != null ? value.orderAmount : 0),
    basePayAmount: numberValue(value != null ? value.basePayAmount : 0),
    couponAmount: numberValue(value != null ? value.couponAmount : 0),
    talentDiscountAmount: numberValue(value != null ? value.talentDiscountAmount : 0),
    payAmount: numberValue(value != null ? value.payAmount : 0),
    subsidyAmount: numberValue(value != null ? value.subsidyAmount : 0),
    createdAt: value != null && value.createdAt != null ? '' + value.createdAt : '',
    verifyTime: value != null && value.verifyTime != null ? '' + value.verifyTime : ''
    id: textValue(value, 'id', 'id'),
    orderNo: textValue(value, 'orderNo', 'order_no'),
    category: textValue(value, 'category', 'category', 'group'),
    status: textValue(value, 'status', 'status', 'pending_verification'),
    statusText: textValue(value, 'statusText', 'status_text', '待核销'),
    title: textValue(value, 'title', 'title', '订单'),
    customerName: textValue(value, 'customerName', 'customer_name'),
    customerMobile: textValue(value, 'customerMobile', 'customer_mobile'),
    cardLevel: textValue(value, 'cardLevel', 'card_level', '卓越人才卡'),
    orderAmount: numberValue(fieldValue(value, 'orderAmount', 'order_amount')),
    basePayAmount: numberValue(fieldValue(value, 'basePayAmount', 'base_pay_amount')),
    couponAmount: numberValue(fieldValue(value, 'couponAmount', 'coupon_amount')),
    talentDiscountAmount: numberValue(fieldValue(value, 'talentDiscountAmount', 'talent_discount_amount')),
    payAmount: numberValue(fieldValue(value, 'payAmount', 'pay_amount')),
    subsidyAmount: numberValue(fieldValue(value, 'subsidyAmount', 'subsidy_amount')),
    createdAt: textValue(value, 'createdAt', 'created_at'),
    verifyTime: textValue(value, 'verifyTime', 'verify_time')
})
const toOrderDetail = (value: any): MerchantOrderDetail => {
@@ -117,11 +132,11 @@
        basePayAmount: order.basePayAmount != null ? order.basePayAmount : order.orderAmount,
        couponAmount: order.couponAmount != null ? order.couponAmount : 0,
        talentDiscountAmount: order.talentDiscountAmount != null ? order.talentDiscountAmount : 0,
        benefitName: value != null && value.benefitName != null ? '' + value.benefitName : order.title,
        merchantName: value != null && value.merchantName != null ? '' + value.merchantName : '',
        canRevoke: value != null && value.canRevoke == true,
        revokeDeadline: value != null && value.revokeDeadline != null ? '' + value.revokeDeadline : '',
        revokeReason: value != null && value.revokeReason != null ? '' + value.revokeReason : ''
        benefitName: textValue(value, 'benefitName', 'benefit_name', order.title),
        merchantName: textValue(value, 'merchantName', 'merchant_name'),
        canRevoke: fieldValue(value, 'canRevoke', 'can_revoke') == true,
        revokeDeadline: textValue(value, 'revokeDeadline', 'revoke_deadline'),
        revokeReason: textValue(value, 'revokeReason', 'revoke_reason')
    }
}
talent-card-admin/src/api/admin.ts
@@ -73,6 +73,14 @@
}
export function getPlatformSettings() { return http.get<Record<string, string>, Record<string, string>>('/api/admin/management/settings') }
export interface AdminPendingNotice {
  id: string
  category: string
  content: string
  time: string
  path: string
}
export function getAdminPendingNotices() { return http.get<AdminPendingNotice[], AdminPendingNotice[]>('/api/admin/business/pending-notices') }
export function savePlatformSettings(payload: Record<string, string>) { return http.put<Record<string, string>, Record<string, string>>('/api/admin/management/settings', payload) }
export function getProtocolSetting(key: string) { return http.get<Record<string, any>, Record<string, any>>(`/api/admin/management/protocols/${key}`) }
export function saveProtocolSetting(key: string, contentHtml: string) { return http.put<Record<string, any>, Record<string, any>>(`/api/admin/management/protocols/${key}`, { contentHtml }) }
@@ -82,6 +90,7 @@
export function updateAdminRoleStatus(id: number | string, status: number) { return http.patch<AdminRole, AdminRole>(`/api/admin/management/roles/${id}/status`, { status }) }
export function deleteAdminRole(id: number | string) { return http.delete<any, any>(`/api/admin/management/roles/${id}`) }
export function getAdminStaff() { return http.get<AdminStaff[], AdminStaff[]>('/api/admin/management/staff') }
export function checkAdminStaffAccount(account: string) { return http.get<{ available: boolean }, { available: boolean }>('/api/admin/management/staff/account-available', { params: { account } }) }
export function createAdminStaff(payload: Omit<AdminStaff, 'id' | 'createdAt'> & { password: string }) { return http.post<AdminStaff, AdminStaff>('/api/admin/management/staff', payload) }
export function updateAdminStaff(id: number | string, payload: Omit<AdminStaff, 'id' | 'createdAt'> & { password?: string }) { return http.put<AdminStaff, AdminStaff>(`/api/admin/management/staff/${id}`, payload) }
export function resetAdminStaffPassword(id: number | string, password: string) { return http.patch<any, any>(`/api/admin/management/staff/${id}/password`, { password }) }
talent-card-admin/src/layouts/AdminLayout.vue
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import {
@@ -15,6 +15,8 @@
  Wallet,
} from '@element-plus/icons-vue'
import { logoutAdmin } from '../api/auth'
import { getAdminPendingNotices } from '../api/admin'
import type { AdminPendingNotice } from '../api/admin'
import { useAppStore } from '../stores/app'
interface MenuItem {
@@ -36,38 +38,23 @@
const appStore = useAppStore()
const avatarText = computed(() => (appStore.user.name || 'A').trim().slice(0, 1).toUpperCase())
interface HeaderNotice {
  id: number
  content: string
  time: string
  read: boolean
const notices = ref<AdminPendingNotice[]>([])
const adminMenuRef = ref<{ open: (index: string) => void; close: (index: string) => void }>()
function formatNoticeTime(value: string) {
  return value ? value.replace('T', ' ').replace(/\.\d+$/, '') : '-'
}
const notices = ref<HeaderNotice[]>([
  {
    id: 1,
    content: '上午好,欢迎登录攀枝花市东区人才服务平台。',
    time: '2026/6/25 09:50:54',
    read: false,
  },
  {
    id: 2,
    content: '您有一条新的酒店预约,请及时查看。',
    time: '2026/6/25 09:54:08',
    read: false,
  },
])
const unreadNoticeCount = computed(() => notices.value.filter((item) => !item.read).length)
function markNoticeRead(notice: HeaderNotice) {
  notice.read = true
async function loadNotices() {
  try {
    notices.value = await getAdminPendingNotices()
  } catch {
    notices.value = []
  }
}
function markAllNoticesRead() {
  notices.value.forEach((item) => {
    item.read = true
  })
function openNotice(notice: AdminPendingNotice) {
  router.push(notice.path)
}
const cardMenus: MenuItem[] = [
@@ -84,7 +71,7 @@
]
const financeMenus: MenuItem[] = [
  { path: '/merchant-settlement', label: '提现管理' },
  { path: '/merchant-settlement', label: '商户结算审核' },
]
const orderMenus: MenuItem[] = [
@@ -131,11 +118,11 @@
// Keep the navigation in sync with the permissions returned at login.  Newer
// routes without a dedicated permission code intentionally remain visible.
const routePermissions: Record<string, string> = {
  '/talents': 'admin.talents.view', '/family-card-applications': 'admin.family-cards.view', '/upgrade-applications': 'admin.talents.view', '/family-cards': 'admin.family-cards.view', '/child-cards': 'admin.family-cards.view',
  '/merchants': 'admin.merchants.view', '/products': 'admin.products.view', '/orders': 'admin.orders.view', '/refund-records': 'admin.refunds.view', '/hotel-free-verifications': 'admin.orders.view',
  '/medical-reservations': 'admin.medical-reservations.view', '/medical-applications': 'admin.medical-reservations.view', '/parking-plates': 'admin.parking-plates.view', '/fuel-services': 'admin.parking-plates.view', '/spouse-employment': 'admin.parking-plates.view', '/hotel-reservations': 'admin.medical-reservations.view',
  '/merchant-settlement': 'admin.merchants.view', '/equity-images': 'admin.equity-images.view', '/talent-news-management': 'admin.news.view', '/talent-policy-management': 'admin.news.view', '/movie-tickets': 'admin.news.view',
  '/system-settings': 'admin.settings.view', '/protocol-settings': 'admin.settings.view', '/roles': 'admin.roles.view', '/staff': 'admin.staff.view',
  '/talents': 'admin.talents.view', '/family-card-applications': 'admin.family-card-applications.view', '/upgrade-applications': 'admin.upgrade-applications.view', '/family-cards': 'admin.family-cards.view', '/child-cards': 'admin.child-cards.view',
  '/merchants': 'admin.merchants.view', '/products': 'admin.products.view', '/orders': 'admin.orders.view', '/refund-records': 'admin.refunds.view', '/hotel-free-verifications': 'admin.hotel-free-verifications.view',
  '/appointment-management': 'admin.appointment-management.view', '/medical-reservations': 'admin.medical-reservations.view', '/medical-applications': 'admin.medical-applications.view', '/parking-plates': 'admin.parking-plates.view', '/car-wash-benefits': 'admin.car-wash-benefits.view', '/fuel-services': 'admin.fuel-services.view', '/spouse-employment': 'admin.spouse-employment.view', '/hotel-reservations': 'admin.hotel-reservations.view',
  '/merchant-settlement': 'admin.merchant-settlement.view', '/equity-images': 'admin.equity-images.view', '/talent-news-management': 'admin.news.view', '/talent-policy-management': 'admin.policies.view', '/movie-tickets': 'admin.movie-tickets.view',
  '/system-settings': 'admin.settings.view', '/protocol-settings': 'admin.protocol-settings.view', '/roles': 'admin.roles.view', '/staff': 'admin.staff.view',
}
const canAccess = (path?: string) => {
  if (!path || appStore.user.role === '平台管理') return true
@@ -204,6 +191,32 @@
  return route.path
})
function currentMenuGroupIndex() {
  return menuGroups.find((group) => findMenuLabel(group.items, activeMenu.value))?.index
}
function closeOtherMenuGroups(openedGroupIndex?: string) {
  menuGroups.forEach((group) => {
    if (group.index !== openedGroupIndex) adminMenuRef.value?.close(group.index)
  })
}
function handleMenuOpen(_index: string, indexPath: string[]) {
  // indexPath 的第一个值始终是一级模块;无论点击一级或嵌套菜单,均只保留该模块展开。
  const openedGroupIndex = indexPath[0]
  if (menuGroups.some((group) => group.index === openedGroupIndex)) {
    closeOtherMenuGroups(openedGroupIndex)
  }
}
watch(activeMenu, () => {
  const groupIndex = currentMenuGroupIndex()
  nextTick(() => {
    closeOtherMenuGroups(groupIndex)
    if (groupIndex) adminMenuRef.value?.open(groupIndex)
  })
}, { immediate: true })
function findMenuLabel(items: MenuItem[], path: string): string | undefined {
  for (const item of items) {
    if (item.path === path) return item.label
@@ -257,6 +270,8 @@
    router.push('/login')
  }
}
onMounted(loadNotices)
</script>
<template>
@@ -272,31 +287,27 @@
      </div>
      <div class="header-right">
        <el-popover placement="bottom-end" :width="330" trigger="click" popper-class="notice-popover">
        <el-popover placement="bottom-end" :width="330" trigger="click" popper-class="notice-popover" @show="loadNotices">
          <template #reference>
            <el-badge :is-dot="unreadNoticeCount > 0" class="notice-badge">
            <el-badge :is-dot="notices.length > 0" class="notice-badge">
              <el-button :icon="Bell" circle aria-label="消息通知" />
            </el-badge>
          </template>
          <div class="notice-panel">
            <div class="notice-panel-header">
              <strong>通知公告</strong>
              <button type="button" :disabled="unreadNoticeCount === 0" @click="markAllNoticesRead">
                全部已读
              </button>
            </div>
            <div class="notice-panel">
              <div class="notice-panel-header">
              <strong>待办通知</strong>
              </div>
            <div v-if="notices.length" class="notice-list">
              <button
                v-for="notice in notices"
                :key="notice.id"
                type="button"
                class="notice-item"
                @click="markNoticeRead(notice)"
                @click="openNotice(notice)"
              >
                <span class="notice-content">{{ notice.content }}</span>
                <span v-if="!notice.read" class="notice-unread">未读</span>
                <time>{{ notice.time }}</time>
                <time>{{ formatNoticeTime(notice.time) }}</time>
              </button>
            </div>
            <div v-else class="notice-empty">暂无通知</div>
@@ -312,10 +323,12 @@
    <el-container class="admin-body">
      <el-aside width="220px" class="admin-aside">
        <el-menu
          ref="adminMenuRef"
          router
          :default-active="activeMenu"
          :default-openeds="defaultOpeneds"
          unique-opened
          @open="handleMenuOpen"
          class="admin-menu"
        >
          <el-menu-item index="/dashboard">
talent-card-admin/src/router/index.ts
@@ -110,7 +110,7 @@
          path: 'merchant-settlement',
          name: 'merchant-settlement',
          component: () => import('../views/MerchantSettlementView.vue'),
          meta: { title: '提现管理' },
          meta: { title: '商户结算审核' },
        },
        {
          path: 'merchant-onboardings/:id',
talent-card-admin/src/style.css
@@ -963,7 +963,7 @@
.merchant-rights-detail-row {
  display: grid;
  grid-template-columns: 96px minmax(360px, 520px) 270px;
  gap: 14px 24px;
  gap: 6px 24px;
  align-items: start;
  padding: 14px 0 0;
}
@@ -976,8 +976,10 @@
.merchant-rights-detail-row label {
  grid-column: 1 / 3;
  margin: 0;
  color: #334155;
  font-size: 13px;
  line-height: 20px;
}
.merchant-rights-level-row + .merchant-rights-detail-row {
@@ -1032,6 +1034,11 @@
  align-items: start;
  color: #111827;
  font-size: 13px;
}
/* 商家权益仅保留后台配置表单,不展示小程序示例预览。 */
.merchant-rights-panel .merchant-rights-example {
  display: none !important;
}
.merchant-rights-phone-preview {
@@ -1282,7 +1289,7 @@
.consume-desc-row {
  padding-left: 186px;
  padding-top: 14px;
  padding-top: 10px;
}
.merchant-products-panel,
@@ -1339,8 +1346,14 @@
.merchant-coupons-filter .el-select__wrapper {
  min-height: 28px;
  border-radius: 0;
  background: #f7f8fb;
  box-shadow: none;
  background: #ffffff;
  box-shadow: 0 0 0 1px #d9dee8 inset;
}
.merchant-products-filter .el-date-editor.el-input__wrapper,
.merchant-coupons-filter .el-date-editor.el-input__wrapper {
  background: #ffffff;
  box-shadow: 0 0 0 1px #d9dee8 inset;
}
.merchant-products-filter .el-date-editor.el-input__wrapper,
@@ -1516,7 +1529,7 @@
}
.merchant-coupons-filter .coupon-created-time-picker {
  width: 320px;
  width: 320px !important;
}
.merchant-products-filter .product-created-time-picker {
@@ -1544,12 +1557,12 @@
.product-form-grid {
  display: flex;
  gap: 26px;
  gap: 34px;
  align-items: start;
}
.product-info-left {
  width: 360px;
  width: 430px;
  flex-shrink: 0;
}
@@ -1573,14 +1586,29 @@
.product-form-row,
.product-static-row {
  display: grid;
  grid-template-columns: 54px minmax(0, 1fr);
  grid-template-columns: 68px minmax(0, 1fr);
  align-items: center;
  gap: 10px;
  gap: 16px;
  margin-bottom: 18px;
}
.product-form-row.amount {
  grid-template-columns: 54px 134px 24px;
  grid-template-columns: 68px minmax(0, 1fr);
  gap: 16px;
}
.product-form-row.amount .el-form-item__content {
  display: flex;
  flex-wrap: nowrap;
  align-items: center;
  gap: 8px;
  min-width: 0;
  margin-left: 0 !important;
}
.product-form-row.amount .el-form-item__content > span {
  flex: 0 0 auto;
  white-space: nowrap;
}
.product-form-row.amount.hotel-amount {
@@ -1608,6 +1636,11 @@
  white-space: nowrap;
}
.merchant-product-edit-form .el-form-item__label {
  width: 68px !important;
  white-space: nowrap;
}
.product-static-row span {
  color: #475569;
  font-weight: 600;
@@ -1615,15 +1648,16 @@
.product-image-row {
  display: grid;
  grid-template-columns: 54px 256px 78px;
  gap: 10px;
  align-items: end;
  grid-template-columns: 68px 256px 78px;
  column-gap: 16px;
  align-items: center;
  margin-bottom: 20px;
}
.product-image-row.compact {
    grid-template-columns: 54px minmax(172px, 1fr) 78px;
    align-items: start;
    grid-template-columns: 68px minmax(172px, 1fr) 78px;
    column-gap: 16px;
    align-items: center;
}
.product-image-list {
@@ -1634,7 +1668,21 @@
}
.product-image-row > label {
  align-self: start;
  align-self: center;
}
.product-image-row > .el-button {
  align-self: center;
  justify-self: start;
}
.product-image-row .el-form-item__content {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 12px;
  min-width: 0;
  margin-left: 0 !important;
}
.product-cover-preview,
@@ -1707,13 +1755,13 @@
.product-detail-row {
  display: grid;
  grid-template-columns: 54px minmax(0, 1fr);
  gap: 10px;
  grid-template-columns: 68px minmax(0, 1fr);
  gap: 16px;
  margin-top: 4px;
}
.product-detail-row label {
  align-self: start;
  align-self: center;
}
.product-detail-row .el-textarea {
@@ -1732,6 +1780,12 @@
  gap: 8px;
}
.product-notice-field .el-date-editor,
.product-notice-field .el-time-editor,
.product-notice-field .el-input {
  width: 100%;
}
.product-rights-list {
  display: grid;
  gap: 28px;
@@ -1744,57 +1798,79 @@
}
.product-right-main {
  display: flex;
  /*
   * 三种权益状态使用同一套列:避免免费、超过次数、打折三行因为
   * 内容宽度或 margin 不同而把“是否政府补贴”挤到不同的位置。
   */
  display: grid;
  grid-template-columns: 86px 42px 58px 64px 42px 40px 88px 8px 22px 24px 58px 100px 12px 88px 42px;
  align-items: center;
  min-height: 30px;
}
.product-right-sub {
  display: flex;
  display: grid;
  /* 与主行共用列定义,输入框和补贴开关逐列对齐。 */
  grid-template-columns: 86px 42px 58px 64px 42px 40px 88px 8px 22px 24px 58px 100px 12px 88px 42px;
  align-items: center;
  min-height: 28px;
  padding-left: 186px;
}
.product-right-sub > span:first-child {
  width: 64px;
  margin-right: 18px;
.product-right-main .right-level { grid-column: 1; }
.product-right-main .right-enabled { grid-column: 2; margin: 0; }
.product-right-main .right-free-label { grid-column: 4; }
.product-right-main .right-free-switch { grid-column: 5; margin: 0; }
.product-right-main.free .right-limit-field { grid-column: 7; margin: 0; }
.product-right-main.free .right-unit { grid-column: 9; margin: 0; }
.product-right-main.free .right-reset-label { grid-column: 11; margin: 0; }
.product-right-main.free .right-reset-select { grid-column: 12; margin: 0; }
/* 打折行使用一个独立的紧凑控件组,补贴列仍固定在第 14 列。 */
.product-right-main:not(.free) .right-discount-control {
  grid-column: 7 / span 6;
  display: flex;
  align-items: center;
  gap: 8px;
}
.product-right-sub > span:nth-child(3) {
  width: 22px;
  margin-left: 8px;
.product-right-main:not(.free) .right-discount-label {
  flex: 0 0 38px;
}
.product-right-sub > span:nth-child(4) {
  width: 88px;
  margin-left: 10px;
.product-right-main:not(.free) .right-discount-input {
  flex: 0 0 100px;
  width: 100px;
  margin: 0;
}
.product-right-main:not(.free) .right-unit {
  flex: 0 0 22px;
}
.product-right-main .right-subsidy-label { grid-column: 14; margin: 0 !important; white-space: nowrap; }
.product-right-main .right-subsidy-switch { grid-column: 15; margin: 0; }
.product-right-sub .right-exceed-label { grid-column: 4 / span 3; }
.product-right-sub .right-exceed-discount-field { grid-column: 7; }
.product-right-sub .right-exceed-unit { grid-column: 9; }
.product-right-sub .right-exceed-subsidy-label { grid-column: 14; white-space: nowrap; }
.product-right-sub .right-exceed-subsidy-switch { grid-column: 15; }
.product-right-row .right-level {
  flex: 0 0 86px;
  color: #111827;
  font-size: 12px;
}
.product-right-row .right-enabled {
  flex: 0 0 42px;
  margin-right: 58px;
}
.product-right-row .right-free-label {
  flex: 0 0 64px;
}
.product-right-row .right-free-switch {
  flex: 0 0 42px;
  margin-right: 40px;
}
.product-right-row .right-limit-field {
  position: relative;
  flex: 0 0 88px;
  margin-right: 8px;
  width: 88px;
}
.product-right-row .right-limit-field .el-input-number {
@@ -1802,38 +1878,25 @@
}
.product-right-row .right-unit {
  flex: 0 0 22px;
}
.product-right-row .right-reset-label {
  flex: 0 0 58px;
  margin-left: 24px;
}
.product-right-row .right-reset-select {
  flex: 0 0 96px;
  margin-right: 12px;
  width: 100px;
}
.product-right-row .right-discount-label {
  flex: 0 0 38px;
}
.product-right-row .right-discount-input {
  flex: 0 0 100px;
  margin-right: 8px;
}
.product-right-row .right-subsidy-label {
  flex: 0 0 88px;
}
.product-right-row .right-subsidy-switch {
  flex: 0 0 42px;
}
.product-right-main:not(.free) .right-subsidy-label {
  margin-left: 30px;
}
.merchant-product-form-panel .product-right-row .el-input {
@@ -1845,7 +1908,23 @@
}
.merchant-product-form-panel .product-right-sub .right-exceed-discount-input {
  width: 100px;
  width: 88px;
}
.product-right-sub .right-exceed-discount-field {
  position: relative;
  width: 88px;
}
.product-right-sub .field-error {
  position: absolute;
  top: calc(100% + 4px);
  left: 0;
  width: max-content;
  color: #f56c6c;
  font-size: 12px;
  line-height: 16px;
  white-space: nowrap;
}
.merchant-product-form-panel .product-right-row .right-discount-input {
@@ -1854,6 +1933,12 @@
.merchant-product-form-panel .product-right-row .el-select {
  width: 64px;
}
/* 团单免费次数的重置周期需完整展示“每天/每周/每月/每年”。 */
.merchant-product-form-panel .product-right-row .right-reset-select {
  flex-basis: 100px;
  width: 100px;
}
.product-right-tip {
@@ -1881,8 +1966,19 @@
.merchant-product-form-panel .el-textarea__inner {
  min-height: 28px;
  border-radius: 0;
  background: #f7f8fb;
  background: #ffffff;
  box-shadow: 0 0 0 1px #d9dee8 inset;
}
/* Element Plus 会为禁用/只读控件注入灰底;团单编辑页仍需和其他表单控件保持同一白底与边框。 */
.merchant-product-form-panel .el-input.is-disabled .el-input__wrapper,
.merchant-product-form-panel .el-input.is-readonly .el-input__wrapper,
.merchant-product-form-panel .el-input-number.is-disabled .el-input__wrapper,
.merchant-product-form-panel .el-select.is-disabled .el-select__wrapper,
.merchant-product-form-panel .el-date-editor.is-disabled .el-input__wrapper,
.merchant-product-form-panel .el-textarea.is-disabled .el-textarea__inner {
  background: #ffffff !important;
  box-shadow: 0 0 0 1px #d9dee8 inset !important;
}
.merchant-product-form-panel .el-button,
@@ -1975,17 +2071,21 @@
}
.coupon-requirement-row .coupon-full-amount {
  width: 108px;
  margin: 0 6px 0 10px;
  width: 168px;
  margin: 0 50px 0 10px;
}
.coupon-requirement-row .coupon-full-unit {
  display: inline-flex;
  flex: 0 0 44px;
  align-items: center;
  margin: 0 50px 0 0;
  line-height: 28px;
.coupon-requirement-row .coupon-full-amount .el-input-group__append {
  padding: 0 0 0 8px;
  color: #334155;
  background: transparent;
  border: 0;
  box-shadow: none;
  white-space: nowrap;
}
.coupon-requirement-row .coupon-full-amount .el-input__wrapper {
  border-radius: var(--el-border-radius-base);
}
.coupon-requirement-row .coupon-none-radio {
@@ -2487,6 +2587,10 @@
.order-filter-form .el-form-item {
  margin-bottom: 0;
}
.order-filter-form .family-order-filter .el-form-item__label {
  white-space: nowrap;
}
.order-filter-form .el-input,
@@ -3112,6 +3216,11 @@
  margin-bottom: 0;
}
/* 角色管理筛选项与标题左边缘对齐。 */
.role-management-filter-form .el-form-item__label {
  justify-content: flex-start;
}
.business-fill-filter-form .el-input,
.business-fill-filter-form .el-select,
.business-fill-filter-form .el-date-editor {
@@ -3635,6 +3744,7 @@
  color: #334155;
  font-size: 13px;
  line-height: 32px;
  white-space: nowrap;
}
.news-publish-form .el-input {
@@ -3856,22 +3966,44 @@
  padding: 16px 0 10px;
}
.news-cover-preview {
  width: 120px;
  height: 72px;
  margin-left: 12px;
  vertical-align: middle;
  border: 1px solid #d9dee8;
  border-radius: 4px;
.article-cover-upload .el-upload {
  display: block;
}
.policy-cover-preview {
  width: 120px;
  height: 72px;
  margin-left: 12px;
  vertical-align: middle;
  border: 1px solid #d9dee8;
  border-radius: 4px;
.article-cover-upload-card,
.article-cover-preview {
  width: 76px;
  height: 76px;
  box-sizing: border-box;
  border: 1px dashed #d7dce4;
}
.article-cover-upload-card {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  color: #a8b0bb;
  background: #ffffff;
}
.article-cover-upload-card span {
  height: 30px;
  font-size: 31px;
  font-weight: 200;
  line-height: 26px;
}
.article-cover-upload-card small {
  margin-top: 5px;
  font-size: 12px;
  line-height: 16px;
}
.article-cover-preview {
  display: block;
  border-style: solid;
  object-fit: cover;
}
.news-editor-file-input {
@@ -4086,6 +4218,13 @@
  width: 100%;
}
/* 管理后台的日期/时间选择控件统一为 320px,保证范围内容完整且各页面一致。 */
.page-stack .el-date-editor,
.prototype-page .el-date-editor,
.merchant-prototype-page .el-date-editor {
  width: 320px !important;
}
.prototype-table-tools {
  display: flex;
  align-items: center;
talent-card-admin/src/views/BusinessManagementView.vue
@@ -171,7 +171,7 @@
  <div class="page-stack prototype-page business-fill-page">
    <section class="panel">
      <div class="panel-header"><h3>{{ title }}</h3></div>
      <el-form class="business-fill-filter-form" label-width="86px">
      <el-form class="business-fill-filter-form" :class="{ 'role-management-filter-form': isRole }" label-width="86px">
        <el-form-item :label="isUpgrade ? '持卡人' : isRole ? '角色' : '员工'">
          <el-input v-model="filters.keyword" :placeholder="isUpgrade ? '持卡人姓名、手机号、卡号' : '请输入关键词'" clearable />
        </el-form-item>
@@ -210,7 +210,7 @@
        <el-table-column label="操作" width="150" fixed="right" align="center">
          <template #default="{ row }">
            <el-button link type="primary" :icon="View" @click="view(row)">查看</el-button>
            <el-button v-if="!isUpgrade" link type="primary" :icon="Edit" @click="openEdit(row)">编辑</el-button>
            <el-button v-if="!isUpgrade && !(isRole && isPlatformAdminRole(row))" link type="primary" :icon="Edit" @click="openEdit(row)">编辑</el-button>
            <template v-if="isRole && !isPlatformAdminRole(row)">
              <el-button link type="primary" @click="changeRoleStatus(row)">{{ Number(row.status) === 1 ? '停用' : '启用' }}</el-button>
              <el-button link type="danger" @click="removeRole(row)">删除</el-button>
@@ -229,9 +229,9 @@
    </el-dialog>
    <el-dialog v-model="editVisible" :title="`编辑${title}`" width="480px" destroy-on-close :close-on-click-modal="false">
      <el-form ref="formRef" :model="form" :rules="rules" label-width="92px">
        <el-form-item :label="isRole ? '角色名称' : '员工姓名'" prop="name"><el-input v-model="form.name" /></el-form-item>
        <el-form-item :label="isRole ? '角色编码' : '登录账号'" prop="code"><el-input v-model="form.code" :placeholder="isRole ? '' : '仅支持英文和数字'" maxlength="32" /></el-form-item>
        <el-form-item v-if="!isRole" label="登录密码" prop="password"><el-input v-model="form.password" type="password" :placeholder="editingId ? '留空则不修改' : '请输入登录密码'" /></el-form-item>
        <el-form-item :label="isRole ? '角色名称' : '员工姓名'" prop="name" required><el-input v-model="form.name" /></el-form-item>
        <el-form-item :label="isRole ? '角色编码' : '登录账号'" prop="code" required><el-input v-model="form.code" :placeholder="isRole ? '' : '仅支持英文和数字'" maxlength="32" /></el-form-item>
        <el-form-item v-if="!isRole" label="登录密码" prop="password" :required="!editingId"><el-input v-model="form.password" type="password" :placeholder="editingId ? '留空则不修改' : '请输入登录密码'" /></el-form-item>
        <el-form-item v-if="isRole" label="备注"><el-input v-model="form.remark" /></el-form-item>
        <el-form-item label="状态"><el-switch v-model="form.status" :active-value="1" :inactive-value="0" /></el-form-item>
      </el-form>
talent-card-admin/src/views/MerchantCreateView.vue
@@ -75,7 +75,9 @@
const routeTab = typeof route.query.tab === 'string' ? route.query.tab : 'settings'
const activeTab = ref(['settings', 'rights', 'products', 'coupons'].includes(routeTab) ? routeTab : 'settings')
const couponMode = ref<'list' | 'edit'>('list')
const isEditMode = computed(() => route.query.mode === 'edit')
const hasValidMerchantId = computed(() => /^\d+$/.test(queryString('merchantId')))
const isEditMode = computed(() => route.query.mode === 'edit' && hasValidMerchantId.value)
const hasExistingMerchant = computed(() => isEditMode.value)
const normalizedMerchantType = computed(() => String(form.merchantType || '').trim())
const isFoodMerchant = computed(() => normalizedMerchantType.value === '餐饮服务')
const isApartmentMerchant = computed(() => normalizedMerchantType.value === '公寓租赁')
@@ -89,6 +91,7 @@
const isSpouseEmploymentMerchant = computed(() => normalizedMerchantType.value === '配偶推荐就业')
const isApprovalMerchant = computed(() => normalizedMerchantType.value === '行政审批')
const isServiceMerchant = computed(() => isFoodMerchant.value || normalizedMerchantType.value === '景区服务')
const requiresFoodMerchantSettings = computed(() => !isEditMode.value && isFoodMerchant.value)
const supportsStoreConsumption = computed(() => [
  '酒店服务', '景区服务', '餐饮服务', '口腔治疗保健', '免费体检',
].includes(normalizedMerchantType.value) || (isCarWashMerchant.value && form.miniProgramJumpType === 'normal'))
@@ -99,6 +102,10 @@
  '购房优惠', '购车服务', '家电服务', '数码服务', '文化服务', '电信服务', '口腔治疗保健', '加油服务',
]
const usesSimplifiedMerchantSettings = computed(() => simplifiedMerchantSettingsTypes.includes(normalizedMerchantType.value))
const settingsAndRightsOnlyMerchantTypes = [
  '加油服务', '购房优惠', '购车服务', '家电服务', '数码服务', '文化服务', '电信服务',
]
const usesSettingsAndRightsOnlyTabs = computed(() => settingsAndRightsOnlyMerchantTypes.includes(normalizedMerchantType.value))
const supportsMerchantAppLogin = computed(() => (
  isServiceMerchant.value
  || isApartmentMerchant.value
@@ -159,7 +166,22 @@
  return merchantType === '餐饮服务' || merchantType === '景区服务' ? 1 : 0
}
function applyNewMerchantDefaults() {
  if (isEditMode.value) return
  const merchantType = queryString('merchantType')
  form.merchantType = merchantType
  form.recommend = merchantType === '餐饮服务' || merchantType === '景区服务' ? 1 : 0
  form.sortNo = undefined
}
const tabs = computed(() => {
  if (usesSettingsAndRightsOnlyTabs.value) {
    return [
      { label: '商家设置', value: 'settings' },
      { label: '商家权益', value: 'rights' },
    ]
  }
  if (isApartmentMerchant.value) {
    return [
      { label: '商家设置', value: 'settings' },
@@ -235,9 +257,12 @@
    trigger: 'change',
  }],
  sortNo: [{
    validator: (_rule, value, callback) => value === undefined || value === null || String(value).trim() === ''
      ? callback(new Error('请输入排序'))
      : callback(),
    validator: (_rule, value, callback) => {
      const text = String(value ?? '').trim()
      if (!text) return callback(new Error('请输入排序'))
      if (!/^\d+$/.test(text)) return callback(new Error('排序只能输入非负整数'))
      callback()
    },
    trigger: 'blur',
  }],
}))
@@ -376,7 +401,7 @@
  validPeriod: '',
  useMethod: '',
  availableTime: '',
  otherRule: '',
  otherRules: '',
})
const productValidPeriodRange = ref<string[]>([])
const productAvailableTimeRange = ref<string[]>([])
@@ -416,6 +441,7 @@
    exceedSubsidy: true,
  },
])
const productExceedDiscountErrors = reactive<Record<string, string>>({})
function defaultProductRights() {
  return [
@@ -961,6 +987,7 @@
  '提供包间、免费停车、WiFi等服务',
  '菜品以家常菜、特色川菜为主,具体以门店实际为准',
]
const locationDialogVisible = ref(false)
const pendingLongitude = ref('')
const pendingLatitude = ref('')
@@ -1536,7 +1563,7 @@
  productNoticeForm.validPeriod = notice.validPeriod || ''
  productNoticeForm.useMethod = notice.useMethod || ''
  productNoticeForm.availableTime = notice.availableTime || ''
  productNoticeForm.otherRule = notice.otherRule || ''
  productNoticeForm.otherRules = notice.otherRules || notice.otherRule || ''
  productValidPeriodRange.value = rangeValues(productNoticeForm.validPeriod, /\d{4}-\d{2}-\d{2}/g)
  productAvailableTimeRange.value = rangeValues(productNoticeForm.availableTime, /\d{2}:\d{2}/g)
  if (isHealthCheckMerchant.value) {
@@ -1609,6 +1636,16 @@
  return amount > 0 && amount <= 10
}
function validateProductExceedDiscount(item: { level: string, exceedDiscount: unknown }) {
  const key = item.level
  if (validExceedDiscount(item.exceedDiscount)) {
    delete productExceedDiscountErrors[key]
    return true
  }
  productExceedDiscountErrors[key] = '请输入大于 0 且不超过 10 的一位小数'
  return false
}
function validFreeLimitCount(value: unknown) {
  const text = String(value ?? '').trim()
  if (!/^\d+$/.test(text)) return false
@@ -1624,8 +1661,7 @@
      ElMessage.warning('免费次数请输入 1 至 999 的整数,999 表示不限次数')
      return
    }
    if (productRights.some((item) => item.free && !validExceedDiscount(item.exceedDiscount))) {
      ElMessage.warning('超过次数折扣请输入大于 0 且不超过 10 的一位小数')
    if (productRights.some((item) => item.free && !validateProductExceedDiscount(item))) {
      return
    }
  } else {
@@ -2036,6 +2072,12 @@
  }
})
watch(tabs, (availableTabs) => {
  if (!availableTabs.some((item) => item.value === activeTab.value)) {
    activeTab.value = 'settings'
  }
}, { immediate: true })
watch(() => form.coverUrl, resetMerchantCoverPreview)
watch(() => productForm.coverUrl, resetProductCoverPreview)
watch(productValidPeriodRange, (value) => {
@@ -2061,6 +2103,10 @@
watch(() => form.miniProgramJumpUrl, () => {
  if (isCarWashMerchant.value) syncCarWashJumpMeta()
})
// The create page is reused when the service type changes, so reset these
// create-only defaults from the current route instead of retaining old values.
watch(() => [route.query.mode, route.query.merchantType], applyNewMerchantDefaults, { immediate: true })
onMounted(() => {
    if (isEditMode.value && queryString('merchantId')) {
@@ -2116,16 +2162,16 @@
        <el-form ref="merchantFormRef" :model="form" :rules="merchantRules" class="merchant-settings-form" label-width="58px" label-position="left">
        <div class="merchant-create-content" :class="{ 'service-merchant-content': isServiceMerchant || isApartmentMerchant || isHotelMerchant || isCarWashMerchant || isHealthCheckMerchant || isParkingMerchant || isChildcareMerchant || isFinancialMerchant || isMovieMerchant || isSpouseEmploymentMerchant || isApprovalMerchant }">
          <div class="merchant-create-left" :style="isApartmentMerchant || isFoodMerchant || isHotelMerchant || isCarWashMerchant || isHealthCheckMerchant || isParkingMerchant || isChildcareMerchant || isFinancialMerchant || isMovieMerchant || isSpouseEmploymentMerchant || isApprovalMerchant ? 'flex: 1' : ''">
            <el-form-item class="merchant-form-row three-columns" label="商家名称" prop="merchantName">
            <el-form-item class="merchant-form-row three-columns" label="商家名称" prop="merchantName" required>
              <el-input v-model="form.merchantName" />
            </el-form-item>
            <el-form-item v-if="!isApartmentMerchant" class="merchant-form-row address-row" :label="isCarWashMerchant || isHealthCheckMerchant || isFinancialMerchant || isMovieMerchant || isSpouseEmploymentMerchant || isApprovalMerchant ? '服务点' : '地址'" prop="address">
            <el-form-item v-if="!isApartmentMerchant" class="merchant-form-row address-row" :label="isCarWashMerchant || isHealthCheckMerchant || isFinancialMerchant || isMovieMerchant || isSpouseEmploymentMerchant || isApprovalMerchant ? '服务点' : '地址'" prop="address" required>
              <el-input v-model="form.address" />
              <el-button type="primary" @click="markLocation">地图标点</el-button>
            </el-form-item>
            <el-form-item class="merchant-cover-row" label="封面图" prop="coverUrl">
            <el-form-item class="merchant-cover-row" label="封面图" prop="coverUrl" required>
              <div class="merchant-cover-preview">
                <img
                  v-if="form.coverUrl && !merchantCoverPreviewFailed"
@@ -2140,7 +2186,7 @@
          </div>
          <div v-if="!isServiceMerchant && !isApartmentMerchant && !isHotelMerchant && !isCarWashMerchant && !isHealthCheckMerchant && !isParkingMerchant && !isChildcareMerchant && !isFinancialMerchant && !isMovieMerchant && !isSpouseEmploymentMerchant && !isApprovalMerchant" class="merchant-create-middle">
            <el-form-item class="merchant-form-row" label="联系电话" prop="contactMobile">
            <el-form-item class="merchant-form-row" label="联系电话" prop="contactMobile" required>
              <el-input v-model="form.contactMobile" />
            </el-form-item>
@@ -2164,16 +2210,16 @@
          </div>
          <div class="merchant-create-right" :style="isApartmentMerchant || isFoodMerchant || isHotelMerchant || isCarWashMerchant || isHealthCheckMerchant || isParkingMerchant || isChildcareMerchant || isFinancialMerchant || isMovieMerchant || isSpouseEmploymentMerchant || isApprovalMerchant ? 'flex: 1' : ''">
            <el-form-item v-if="isServiceMerchant || isApartmentMerchant || isHotelMerchant || isCarWashMerchant || isHealthCheckMerchant || isParkingMerchant || isChildcareMerchant || isFinancialMerchant || isMovieMerchant || isSpouseEmploymentMerchant || isApprovalMerchant" class="merchant-form-row service-contact-row" label="联系电话" prop="contactMobile">
            <el-form-item v-if="isServiceMerchant || isApartmentMerchant || isHotelMerchant || isCarWashMerchant || isHealthCheckMerchant || isParkingMerchant || isChildcareMerchant || isFinancialMerchant || isMovieMerchant || isSpouseEmploymentMerchant || isApprovalMerchant" class="merchant-form-row service-contact-row" label="联系电话" prop="contactMobile" required>
              <el-input v-model="form.contactMobile" placeholder="请输入联系电话" />
            </el-form-item>
            <div v-if="isEditMode" class="merchant-form-row">
            <div v-if="hasExistingMerchant" class="merchant-form-row">
              <label>创建时间</label>
              <el-input v-model="form.createdAt" readonly />
            </div>
            <el-form-item v-if="!isApprovalMerchant" class="merchant-hours-block" label="营业时间" prop="businessHours">
            <el-form-item v-if="!isApprovalMerchant" class="merchant-hours-block" label="营业时间" prop="businessHours" required>
              <div class="merchant-hours-list">
                <div v-for="(item, index) in businessHours" :key="item.id" class="merchant-hour-row">
                  <el-time-picker
@@ -2206,7 +2252,7 @@
              <el-switch v-model="form.recommend" :active-value="1" :inactive-value="0" />
            </div>
            <el-form-item class="merchant-form-row" label="排序" prop="sortNo">
            <el-form-item class="merchant-form-row" label="排序" prop="sortNo" required>
              <el-input v-model="form.sortNo" placeholder="请输入" />
            </el-form-item>
          </div>
@@ -2673,17 +2719,17 @@
      </template>
      <template v-else>
        <el-form ref="productFormRef" :model="productForm" :rules="productRules" class="merchant-product-edit-form" label-width="54px" label-position="left">
        <el-form ref="productFormRef" :model="productForm" :rules="productRules" class="merchant-product-edit-form" label-width="68px" label-position="left">
        <section class="merchant-product-form-panel">
          <h3>商品信息</h3>
          <div class="product-form-grid">
            <div class="product-info-left">
              <el-form-item class="product-form-row" label="商品名称" prop="productName">
              <el-form-item class="product-form-row" label="商品名称" prop="productName" required>
                <el-input v-model="productForm.productName" placeholder="请输入商品名称" />
              </el-form-item>
              <el-form-item class="product-image-row" label="封面图" prop="coverUrl">
              <el-form-item class="product-image-row" label="封面图" prop="coverUrl" required>
                <div class="product-cover-preview">
                  <img
                    v-if="productForm.coverUrl && !productCoverPreviewFailed"
@@ -2697,7 +2743,7 @@
                <el-button type="primary" :loading="imageUploading" @click="changeCover('productCover')">更换封面</el-button>
              </el-form-item>
              <el-form-item v-if="!isHealthCheckMerchant" class="product-image-row compact" label="商品图片" prop="productImages">
              <el-form-item v-if="!isHealthCheckMerchant" class="product-image-row compact" label="商品图片" prop="productImages" required>
                <div class="product-image-list">
                  <div v-for="(image, index) in productImages" :key="image" class="product-thumb-preview">
                    <img :src="image" alt="商品图片" />
@@ -2725,7 +2771,7 @@
            </div>
            <div v-if="!isHealthCheckMerchant" class="product-info-middle">
              <el-form-item class="product-form-row amount" :class="{ 'hotel-amount': isHotelMerchant }" label="商品金额" prop="salePrice">
              <el-form-item class="product-form-row amount" :class="{ 'hotel-amount': isHotelMerchant }" label="商品金额" prop="salePrice" required>
                <template v-if="isHotelMerchant">
                  <el-input-number
                    v-model="productForm.salePrice"
@@ -2772,7 +2818,7 @@
          </div>
          <el-form-item v-if="!isHealthCheckMerchant" class="product-detail-row" label="商品详情" prop="detailText">
          <el-form-item v-if="!isHealthCheckMerchant" class="product-detail-row" label="商品详情" prop="detailText" required>
            <AdminRichTextEditor :model-value="productForm.detailText || ''" class="merchant-product-rich-editor" placeholder="请输入商品详情" :height="260" @update:model-value="value => { productForm.detailText = value; productFormRef?.validateField('detailText') }" />
          </el-form-item>
@@ -2840,7 +2886,7 @@
            </div>
            <div class="product-notice-field">
              <label>其他规则</label>
              <el-input v-model="productNoticeForm.otherRule" placeholder="请输入" />
              <el-input v-model="productNoticeForm.otherRules" placeholder="请输入" />
            </div>
          </div>
        </section>
@@ -2877,28 +2923,35 @@
                  </el-select>
                </template>
                <template v-else>
                  <span class="right-discount-label">折扣</span>
                  <el-input v-model="item.discount" class="right-discount-input" placeholder="请输入" />
                  <span class="right-unit">折</span>
                  <div class="right-discount-control">
                    <span class="right-discount-label">折扣</span>
                    <el-input v-model="item.discount" class="right-discount-input" placeholder="请输入" />
                    <span class="right-unit">折</span>
                  </div>
                </template>
                <span class="right-subsidy-label">是否政府补贴</span>
                <el-switch v-model="item.subsidy" class="right-subsidy-switch" />
              </div>
              <div v-if="item.free" class="product-right-sub">
                <span>超过次数</span>
                <el-input-number
                  v-model="item.exceedDiscount"
                  class="right-exceed-discount-input"
                  :min="0.1"
                  :max="10"
                  :step="0.1"
                  :precision="1"
                  :controls="false"
                  placeholder="请输入"
                />
                <span>折</span>
                <span>是否政府补贴</span>
                <el-switch v-model="item.exceedSubsidy" />
                <span class="right-exceed-label">超过次数</span>
                <div class="right-exceed-discount-field">
                  <el-input-number
                    v-model="item.exceedDiscount"
                    class="right-exceed-discount-input"
                    :min="0.1"
                    :max="10"
                    :step="0.1"
                    :precision="1"
                    :controls="false"
                    placeholder="请输入"
                    @change="validateProductExceedDiscount(item)"
                    @blur="validateProductExceedDiscount(item)"
                  />
                  <span v-if="productExceedDiscountErrors[item.level]" class="field-error">{{ productExceedDiscountErrors[item.level] }}</span>
                </div>
                <span class="right-exceed-unit">折</span>
                <span class="right-exceed-subsidy-label">是否政府补贴</span>
                <el-switch v-model="item.exceedSubsidy" class="right-exceed-subsidy-switch" />
              </div>
            </div>
          </div>
@@ -3003,19 +3056,20 @@
        <template v-else>
          <el-form ref="couponFormRef" :model="couponEditForm" :rules="couponRules" class="merchant-coupon-edit-form" label-width="72px" label-position="left">
            <el-form-item class="coupon-edit-row" label="优惠券名称" prop="couponName">
            <el-form-item class="coupon-edit-row" label="优惠券名称" prop="couponName" required>
              <el-input v-model="couponEditForm.couponName" placeholder="请输入优惠券名称" />
            </el-form-item>
            <el-form-item class="coupon-edit-row coupon-amount-row" label="优惠券金额" prop="couponAmount">
            <el-form-item class="coupon-edit-row coupon-amount-row" label="优惠券金额" prop="couponAmount" required>
              <div class="coupon-amount-control"><el-input-number v-model="couponEditForm.couponAmount" :min="0.01" :precision="2" :step="1" controls-position="right" placeholder="请输入优惠券金额" /><span>元</span></div>
            </el-form-item>
            <el-form-item class="coupon-edit-row coupon-requirement-row" label="使用要求" prop="fullAmount">
            <el-form-item class="coupon-edit-row coupon-requirement-row" label="使用要求" prop="fullAmount" :required="couponEditForm.useRequirement === 'full'">
              <el-radio-group v-model="couponEditForm.useRequirement">
                <el-radio label="full" class="coupon-full-radio">满</el-radio>
                <el-input v-model="couponEditForm.fullAmount" class="coupon-full-amount" />
                <span class="coupon-full-unit">元可用</span>
                <el-input v-model="couponEditForm.fullAmount" class="coupon-full-amount">
                  <template #append>元可用</template>
                </el-input>
                <el-radio label="none" class="coupon-none-radio">不限</el-radio>
              </el-radio-group>
            </el-form-item>
@@ -3042,7 +3096,7 @@
              </div>
            </div>
            <el-form-item class="coupon-edit-row" label="有效期" prop="validTime">
            <el-form-item class="coupon-edit-row" label="有效期" prop="validTime" required>
              <el-date-picker
                v-model="couponEditForm.validTime"
                class="coupon-valid-time-picker"
@@ -3054,7 +3108,7 @@
              />
            </el-form-item>
            <el-form-item class="coupon-edit-row coupon-product-scope-row" label="可用商品" prop="productScope">
            <el-form-item class="coupon-edit-row coupon-product-scope-row" label="可用商品" prop="productScope" :required="couponEditForm.productScope === 'selected'">
              <el-radio-group v-model="couponEditForm.productScope">
                <el-radio label="all">不限</el-radio>
                <el-radio label="selected">选择</el-radio>
@@ -3113,10 +3167,10 @@
    <el-dialog v-model="schoolDialogVisible" :title="editingSchoolIndex >= 0 ? '编辑学校' : '新增学校'" width="820px" destroy-on-close :close-on-click-modal="false">
      <el-form ref="schoolFormRef" :model="editingSchool" :rules="schoolRules" label-width="100px" style="padding: 20px 20px 0;">
        <el-form-item label="学校名称" prop="name">
        <el-form-item label="学校名称" prop="name" required>
          <el-input v-model="editingSchool.name" placeholder="请输入" style="width: 320px" />
        </el-form-item>
        <el-form-item label="地址" prop="address">
        <el-form-item label="地址" prop="address" required>
          <el-input v-model="editingSchool.address" placeholder="请输入" style="width: 320px" />
        </el-form-item>
        <el-form-item label="定位点">
talent-card-admin/src/views/MerchantOnboardingDetailView.vue
@@ -96,9 +96,9 @@
.detail-panel h3 { margin: 0; color: #1d2129; font-size: 15px; }
.status-value { margin-top: 16px; color: #1d2129; font-size: 13px; font-weight: 600; }
.onboarding-heading { margin-top: 24px !important; }
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 28px 46px; margin-top: 22px; max-width: 860px; }
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 28px 46px; margin-top: 22px; max-width: 1032px; }
.common-grid { margin-bottom: -2px; }
.detail-field { display: grid; grid-template-columns: 118px minmax(0, 1fr); align-items: center; gap: 12px; min-height: 32px; }
.detail-field { display: grid; grid-template-columns: 144px minmax(0, 1fr); align-items: center; gap: 12px; min-height: 32px; }
.detail-field > span { color: #303133; font-size: 13px; font-weight: 500; white-space: nowrap; }
.detail-field > strong { min-height: 32px; box-sizing: border-box; padding: 7px 10px; color: #606266; font-size: 13px; font-weight: 400; line-height: 18px; word-break: break-all; background: #f5f7fa; }
.placeholder-field { visibility: hidden; }
talent-card-admin/src/views/MerchantSettlementView.vue
@@ -1,14 +1,15 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, Search } from '@element-plus/icons-vue'
import { getMerchantSettlement, reviewMerchantSettlement } from '../api/admin'
type Kind = 'onboardings' | 'bank-cards' | 'withdrawals'
const kind = ref<Kind>('onboardings')
const kind = ref<Kind>('withdrawals')
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const rows = ref<Record<string, any>[]>([])
const total = ref(0)
@@ -111,6 +112,13 @@
  load()
}
function applyRouteKind() {
  const value = String(route.query.kind || '')
  if (value === 'onboardings' || value === 'bank-cards' || value === 'withdrawals') {
    kind.value = value
  }
}
function openReview(row: Record<string, any>) {
  if (kind.value === 'onboardings') {
    router.push({ name: 'merchant-onboarding-detail', params: { id: row.id } })
@@ -152,7 +160,17 @@
  }
}
onMounted(load)
watch(() => route.query.kind, () => {
  applyRouteKind()
  page.value = 1
  filters.status = ''
  load()
})
onMounted(() => {
  applyRouteKind()
  load()
})
</script>
<template>
@@ -234,7 +252,7 @@
          <el-table-column prop="bankName" label="开户银行" min-width="145" show-overflow-tooltip />
          <el-table-column prop="branchName" label="开户支行" min-width="180" show-overflow-tooltip />
          <el-table-column prop="cityName" label="开户城市" min-width="120" show-overflow-tooltip />
          <el-table-column prop="cardMasked" label="银行卡号" min-width="150" />
          <el-table-column prop="cardNo" label="银行卡号" min-width="180" />
        </template>
        <el-table-column label="申请时间" min-width="165">
          <template #default="{ row }">{{ formatDate(row.createdAt) }}</template>
talent-card-admin/src/views/MerchantView.vue
@@ -167,7 +167,9 @@
  typeDialogVisible.value = false
  router.push({
    path: '/merchants/new',
    query: { merchantType: selectedCreateType.value },
    // Explicitly mark this as a create route so a previously opened edit page
    // cannot leave its mode or data in the reused route component.
    query: { mode: 'create', merchantType: selectedCreateType.value },
  })
}
@@ -361,7 +363,7 @@
    <el-drawer v-model="drawerVisible" :title="drawerTitle" size="520px" @closed="resetForm">
      <el-form ref="formRef" :model="form" :rules="rules" label-width="96px" class="drawer-form">
        <el-form-item label="商家名称" prop="merchantName">
        <el-form-item label="商家名称" prop="merchantName" required>
          <el-input v-model="form.merchantName" placeholder="请输入商家名称" />
        </el-form-item>
        <el-form-item label="商家类型">
talent-card-admin/src/views/OrderDetailView.vue
@@ -53,10 +53,6 @@
  return value.verifyStatus === 1 ? '已核销' : '待核销'
}
function paymentStatus(value: string) {
  return ({ pending: '待支付', processing: '支付中', paid: '支付成功', failed: '支付失败', closed: '已关闭' } as Record<string, string>)[value] || value || '-'
}
function previewImage(url: string) {
  if (!url) return
  previewUrl.value = url
@@ -197,7 +193,6 @@
            <div><span>支付方式</span><strong>{{ payment.provider === 'HUIFU' ? '微信支付' : payment.provider || '-' }}</strong></div>
            <div><span>支付流水号</span><strong>{{ payment.providerTransactionNo || payment.paymentNo || '-' }}</strong></div>
            <div><span>支付金额</span><strong class="amount-strong">{{ money(payment.amount) }}</strong></div>
            <div><span>支付状态</span><strong>{{ paymentStatus(payment.status) }}</strong></div>
          </div>
        </div>
        <div v-else class="detail-empty">暂无支付流水</div>
talent-card-admin/src/views/OrderView.vue
@@ -12,7 +12,7 @@
const orders = ref<Order[]>([])
const router = useRouter()
const cardLevels = ['钻石卡', '卓越人才卡', '精英人才卡', '青年人才卡']
const cardLevels = ['卓越人才卡', '杰出人才卡', '菁英人才卡', '普通游客']
const filters = reactive({
  talentKeyword: '',
@@ -189,7 +189,7 @@
            <el-option label="已退款" :value="2" />
          </el-select>
        </el-form-item>
        <el-form-item v-if="isGroupOrder" label="是否家属下单">
        <el-form-item v-if="isGroupOrder" class="family-order-filter" label="是否家属下单">
          <el-select v-model="filters.familyOrder" placeholder="全部" clearable>
            <el-option label="全部" :value="undefined" />
            <el-option label="是" :value="1" />
talent-card-admin/src/views/ProductAuditDetailView.vue
@@ -74,10 +74,10 @@
  try {
    const parsed = typeof value === 'string' ? JSON.parse(value) : value
    notice.validPeriod = parsed.validPeriod || parsed.valid_period || ''
    notice.useMethod = parsed.useMethod || parsed.use_method || ''
    notice.validPeriod = parsed.validPeriod || parsed.valid_period || parsed.validity || ''
    notice.useMethod = parsed.useMethod || parsed.use_method || parsed.usage || ''
    notice.availableTime = parsed.availableTime || parsed.available_time || ''
    notice.otherRules = parsed.otherRules || parsed.other_rules || ''
    notice.otherRules = parsed.otherRules || parsed.other_rules || parsed.otherRule || parsed.other_rule || ''
  } catch {
    notice.otherRules = typeof value === 'string' ? value : ''
  }
talent-card-admin/src/views/RefundRecordView.vue
@@ -12,7 +12,7 @@
const loading = ref(false)
const rows = ref<RefundRecord[]>([])
const cardLevels = ['钻石卡', '卓越人才卡', '精英人才卡', '青年人才卡']
const cardLevels = ['卓越人才卡', '杰出人才卡', '菁英人才卡', '普通游客']
const itemNameLabel = computed(() => (activeTab.value === 'GROUP' ? '团购名称' : '消费项目'))
const filters = reactive({
talent-card-admin/src/views/RoleFormView.vue
@@ -10,26 +10,78 @@
type Module = { name: string; pages: Page[] }
const modules: Module[] = [
  { name: '首页', pages: [{ code: 'dashboard', name: '首页', actions: ['查看'] }] },
  { name: '账户权限管理', pages: [{ code: 'staff', name: '账号管理', actions: ['查看', '新增', '编辑', '启用/禁用', '删除', '重置密码'] }] },
  { name: '角色管理', pages: [{ code: 'roles', name: '角色管理', actions: ['查看', '新增', '编辑', '删除'] }] },
  { name: '持卡管理', pages: [{ code: 'talents', name: '持卡人管理', actions: ['查看', '新增', '编辑', '启用/禁用'] }] },
  { name: '家属卡管理', pages: [{ code: 'family-cards', name: '家属卡管理', actions: ['查看', '审核'] }] },
  { name: '商家管理', pages: [{ code: 'merchants', name: '商家查询', actions: ['查看', '新增', '编辑'] }] },
  { name: '团单审核', pages: [{ code: 'products', name: '团单审核', actions: ['查看', '审核'] }] },
  { name: '订单管理', pages: [{ code: 'orders', name: '订单列表', actions: ['查看', '退款处理'] }] },
  { name: '退款记录', pages: [{ code: 'refunds', name: '退款记录', actions: ['查看'] }] },
  { name: '预约管理', pages: [{ code: 'medical-reservations', name: '体检预约管理', actions: ['查看', '审核'] }] },
  { name: '停车车牌管理', pages: [{ code: 'parking-plates', name: '停车车牌管理', actions: ['查看'] }] },
  { name: '权益图片管理', pages: [{ code: 'equity-images', name: '权益图片管理', actions: ['查看', '编辑'] }] },
  { name: '人才资讯管理', pages: [{ code: 'news', name: '人才资讯管理', actions: ['查看', '新增', '编辑'] }] },
  { name: '系统管理', pages: [{ code: 'settings', name: '平台设置', actions: ['查看', '编辑'] }] },
  {
    name: '持卡管理',
    pages: [
      { code: 'talents', name: '持卡人管理', actions: ['查看', '新增', '编辑', '启用/停用'] },
      { code: 'family-card-applications', name: '家属卡申请', actions: ['查看', '审核'] },
      { code: 'upgrade-applications', name: '升级申请', actions: ['查看', '审核'] },
      { code: 'family-cards', name: '家属卡管理', actions: ['查看', '启用/停用'] },
      { code: 'child-cards', name: '子女管理', actions: ['查看', '审核'] },
    ],
  },
  {
    name: '商家管理',
    pages: [
      { code: 'merchants', name: '商家查询', actions: ['查看', '新增', '编辑', '启用/停用'] },
      { code: 'products', name: '团单审核', actions: ['查看', '审核'] },
    ],
  },
  {
    name: '订单管理',
    pages: [
      { code: 'orders', name: '订单列表', actions: ['查看'] },
      { code: 'refunds', name: '退款记录', actions: ['查看'] },
      { code: 'hotel-free-verifications', name: '酒店免费核销记录', actions: ['查看'] },
    ],
  },
  {
    name: '预约管理',
    pages: [
      { code: 'appointment-management', name: '预约服务管理', actions: ['查看', '审核'] },
      { code: 'medical-reservations', name: '体检预约管理', actions: ['查看', '审核'] },
      { code: 'medical-applications', name: '就医申请管理', actions: ['查看', '审核'] },
      { code: 'parking-plates', name: '停车车牌管理', actions: ['查看', '审核'] },
      { code: 'car-wash-benefits', name: '洗车权益管理', actions: ['查看', '审核'] },
      { code: 'fuel-services', name: '加油服务管理', actions: ['查看', '审核'] },
      { code: 'spouse-employment', name: '配偶就业管理', actions: ['查看', '审核'] },
      { code: 'hotel-reservations', name: '酒店预约管理', actions: ['查看', '审核'] },
    ],
  },
  { name: '财务管理', pages: [{ code: 'merchant-settlement', name: '商户结算审核', actions: ['查看', '审核'] }] },
  {
    name: '运营管理',
    pages: [
      { code: 'equity-images', name: '权益图片管理', actions: ['查看', '编辑'] },
      { code: 'news', name: '人才资讯管理', actions: ['查看', '新增', '编辑', '删除'] },
      { code: 'policies', name: '人才政策管理', actions: ['查看', '新增', '编辑', '删除'] },
      { code: 'movie-tickets', name: '电影票管理', actions: ['查看', '新增', '编辑', '审核'] },
    ],
  },
  {
    name: '系统管理',
    pages: [
      { code: 'settings', name: '平台设置', actions: ['查看', '编辑'] },
      { code: 'protocol-settings', name: '协议设置', actions: ['查看', '编辑'] },
      { code: 'roles', name: '角色管理', actions: ['查看', '新增', '编辑', '删除'] },
      { code: 'staff', name: '员工管理', actions: ['查看', '新增', '编辑', '启用/停用', '删除', '重置密码'] },
    ],
  },
]
const route = useRoute(), router = useRouter(), saving = ref(false)
const id = computed(() => route.params.id ? String(route.params.id) : '')
const form = reactive({ roleName: '', remark: '', status: 1, permissionCodes: [] as string[] })
const formRef = ref<FormInstance>()
const rules: FormRules = { roleName: [{ required: true, message: '请输入角色名称', trigger: 'blur' }] }
const code = (page: string, action: string) => `admin.${page}.${action === '启用/禁用' ? 'disable' : action === '重置密码' ? 'reset' : action === '退款处理' ? 'refund' : action === '查看' ? 'view' : action === '新增' ? 'create' : action === '编辑' ? 'edit' : action === '删除' ? 'delete' : 'audit'}`
const code = (page: string, action: string) => `admin.${page}.${({
  '查看': 'view',
  '新增': 'create',
  '编辑': 'edit',
  '启用/停用': 'disable',
  '删除': 'delete',
  '重置密码': 'reset',
  '审核': 'audit',
} as Record<string, string>)[action] || 'view'}`
const pageCodes = (page: Page) => page.actions.map((action) => code(page.code, action))
const moduleCodes = (module: Module) => module.pages.flatMap(pageCodes)
const checked = (values: string[]) => values.every((value) => form.permissionCodes.includes(value))
talent-card-admin/src/views/StaffManagementView.vue
@@ -5,6 +5,7 @@
import { Plus, Refresh, Search } from "@element-plus/icons-vue";
import {
  createAdminStaff,
  checkAdminStaffAccount,
  deleteAdminStaff,
  getAdminRoles,
  getAdminStaff,
@@ -31,6 +32,7 @@
  status: 1,
});
const formRef = ref<FormInstance>();
const accountTaken = ref(false);
const accountPattern = /^[A-Za-z0-9]+$/;
const rules = computed<FormRules>(() => ({
  displayName: [{ required: true, message: "请输入人员名称", trigger: "blur" }],
@@ -50,9 +52,11 @@
  password: [
    {
      validator: (_rule, value, callback) => {
        if (editId.value) return callback();
        if (!value) return callback(new Error("请输入登录密码"));
        if (String(value).length < 6) return callback(new Error("新建员工密码至少 6 位"));
        if (!value) {
          if (editId.value) return callback();
          return callback(new Error("请输入登录密码"));
        }
        if (String(value).length < 6) return callback(new Error("密码至少 6 位"));
        callback();
      },
      trigger: "blur",
@@ -61,7 +65,10 @@
  confirmPassword: [
    {
      validator: (_rule, value, callback) => {
        if (editId.value) return callback();
        if (!form.password) {
          if (editId.value) return callback();
          return callback(new Error("请输入确认密码"));
        }
        if (!value) return callback(new Error("请输入确认密码"));
        if (value !== form.password) return callback(new Error("两次输入的密码不一致"));
        callback();
@@ -101,6 +108,15 @@
const keepMobileDigits = () => {
  form.mobile = form.mobile.replace(/\D/g, "").slice(0, 11);
};
const normalizeAccount = () => {
  form.account = form.account.replace(/[^A-Za-z0-9]/g, "").slice(0, 32);
  accountTaken.value = false;
};
async function checkAccountAvailable() {
  if (editId.value || !accountPattern.test(form.account.trim())) return;
  const result = await checkAdminStaffAccount(form.account.trim());
  accountTaken.value = !result.available;
}
async function load() {
  loading.value = true;
  try {
@@ -117,6 +133,7 @@
}
function create() {
  editId.value = undefined;
  accountTaken.value = false;
  Object.assign(form, {
    displayName: "",
    mobile: "",
@@ -132,6 +149,7 @@
}
function edit(row: AdminStaff) {
  editId.value = row.id;
  accountTaken.value = false;
  Object.assign(form, {
    displayName: row.displayName,
    mobile: row.mobile || "",
@@ -148,6 +166,10 @@
async function save() {
  const valid = await formRef.value?.validate().catch(() => false);
  if (!valid) return;
  if (!editId.value) {
    await checkAccountAvailable();
    if (accountTaken.value) return;
  }
  saving.value = true;
  try {
    const { confirmPassword, ...formPayload } = form;
@@ -267,7 +289,7 @@
          }}</template></el-table-column
        ><el-table-column label="操作" width="225" fixed="right"
          ><template #default="{ row }"
            ><el-button link type="primary" @click="edit(row)">编辑</el-button
            ><el-button v-if="!isDefaultAdmin(row)" link type="primary" @click="edit(row)">编辑</el-button
            ><el-button
              v-if="!isDefaultAdmin(row)"
              link
@@ -304,7 +326,7 @@
      destroy-on-close
      :close-on-click-modal="false"
      ><el-form ref="formRef" :model="form" :rules="rules" label-width="92px"
        ><el-form-item label="人员名称" prop="displayName"
        ><el-form-item label="人员名称" prop="displayName" required
          ><el-input v-model="form.displayName" /></el-form-item
        ><el-form-item label="联系电话"
          ><el-input
@@ -312,13 +334,14 @@
            inputmode="numeric"
            maxlength="11"
            @input="keepMobileDigits" /></el-form-item
        ><el-form-item label="登录账号" prop="account"
        ><el-form-item label="登录账号" prop="account" required :error="accountTaken ? '登录账号已存在,请更换' : ''"
          ><el-input
            v-model="form.account"
            :disabled="Boolean(editId)"
            maxlength="32"
            placeholder="仅支持英文和数字"
            /></el-form-item
            @input="normalizeAccount"
            @blur="checkAccountAvailable" /></el-form-item
        ><el-form-item label="登录密码" prop="password" :required="!editId"
          ><el-input
            v-model="form.password"
@@ -326,19 +349,20 @@
            :placeholder="
              editId ? '留空则不修改' : '至少 6 位'
            " /></el-form-item
        ><el-form-item v-if="!editId" label="确认密码" prop="confirmPassword" required
        ><el-form-item v-if="!editId || form.password" label="确认密码" prop="confirmPassword" :required="!editId || Boolean(form.password)"
          ><el-input
            v-model="form.confirmPassword"
            type="password"
            show-password
            placeholder="请再次输入密码" /></el-form-item
        ><el-form-item label="角色" prop="roleCode"
        ><el-form-item label="角色" prop="roleCode" required
          ><el-select v-model="form.roleCode" class="full"
            ><el-option
              v-for="role in roles"
              :key="role.id"
              :label="role.roleName"
              :value="role.roleCode" /></el-select></el-form-item
        ><el-form-item label="职务" prop="positionName"
        ><el-form-item label="职务" prop="positionName" required
          ><el-input v-model="form.positionName" /></el-form-item
        ><el-form-item label="客服微信"
          ><el-input v-model="form.customerWechat" /></el-form-item
@@ -363,8 +387,6 @@
.filters {
  margin: 14px 20px 0;
  padding: 16px;
  border: 1px solid #dce3ea;
  border-radius: 4px;
}
.filters :deep(.el-input),
.filters :deep(.el-select) {
talent-card-admin/src/views/TalentCreateView.vue
@@ -128,7 +128,7 @@
  <div class="talent-detail-page talent-create-page">
    <el-form ref="formRef" :model="form" :rules="rules" label-width="104px">
    <section class="detail-block create-mobile-summary">
      <el-form-item label="手机号码:" prop="mobile">
      <el-form-item label="手机号码:" prop="mobile" required>
          <el-input
            v-model="form.mobile"
            placeholder="请输入"
@@ -143,10 +143,10 @@
      <h3>持卡人认证</h3>
      <div class="identity-edit-card">
        <div class="identity-edit-form">
          <el-form-item label="持卡人姓名:" prop="name">
          <el-form-item label="持卡人姓名:" prop="name" required>
            <el-input v-model="form.name" placeholder="请输入" />
          </el-form-item>
          <el-form-item label="身份证号:" prop="idCardNo">
          <el-form-item label="身份证号:" prop="idCardNo" required>
            <el-input
              v-model="form.idCardNo"
              placeholder="请输入18位身份证号"
@@ -154,7 +154,7 @@
              @input="normalizeIdCardNo"
            />
          </el-form-item>
          <el-form-item label="联系方式:" prop="mobile">
          <el-form-item label="联系方式:" prop="mobile" required>
            <el-input
              v-model="form.mobile"
              placeholder="请输入"
@@ -163,13 +163,13 @@
              @input="normalizeMobile"
            />
          </el-form-item>
          <el-form-item label="联系地址:" prop="contactAddress">
          <el-form-item label="联系地址:" prop="contactAddress" required>
            <el-input v-model="form.contactAddress" placeholder="请输入" />
          </el-form-item>
          <el-form-item label="工作地址:" prop="workAddress">
          <el-form-item label="工作地址:" prop="workAddress" required>
            <el-input v-model="form.workAddress" placeholder="请输入" />
          </el-form-item>
          <el-form-item label="性别:" prop="gender">
          <el-form-item label="性别:" prop="gender" required>
            <el-select v-model="form.gender" placeholder="选择">
              <el-option label="男" :value="1" />
              <el-option label="女" :value="2" />
@@ -181,7 +181,7 @@
    <section class="detail-block">
      <h3>人才卡管理</h3>
      <el-form-item label="人才等级/卡号" prop="talentCardNo">
      <el-form-item label="人才等级/卡号" prop="talentCardNo" required>
        <div class="card-manage-panel">
          <div class="talent-card-stack">
            <button type="button" class="talent-card-preview create-card-preview" @click="openCardDialog">
@@ -198,7 +198,7 @@
    <section class="detail-block">
      <h3>专属服务人员</h3>
      <el-form-item label="专属服务人员" prop="serviceStaff" class="service-staff-form-item">
      <el-form-item label="专属服务人员" prop="serviceStaff" class="service-staff-form-item" required>
      <div class="service-staff-panel">
        <el-select v-model="serviceStaff" clearable placeholder="请选择专属服务人员" @change="formRef?.validateField('serviceStaff')">
          <el-option
@@ -224,7 +224,7 @@
      :close-on-click-modal="false"
    >
      <el-form ref="cardFormRef" :model="cardForm" :rules="cardRules" label-width="100px">
      <el-form-item label="人才等级" prop="talentLevel">
      <el-form-item label="人才等级" prop="talentLevel" required>
      <div class="talent-level-options">
        <button
          v-for="level in levels"
@@ -245,7 +245,7 @@
      </el-radio-group>
      <div class="talent-card-number-field">
        <el-form-item label="人才卡号" prop="talentCardNo">
        <el-form-item label="人才卡号" prop="talentCardNo" required>
        <el-input
          v-model="cardForm.talentCardNo"
          maxlength="64"
talent-card-admin/src/views/TalentDetailView.vue
@@ -339,7 +339,7 @@
      :close-on-click-modal="false"
    >
      <el-form ref="levelFormRef" :model="{ talentLevel: pendingTalentLevel }" :rules="levelRules" label-width="88px">
      <el-form-item label="人才等级" prop="talentLevel">
      <el-form-item label="人才等级" prop="talentLevel" required>
      <div class="talent-level-options">
        <button
          v-for="level in talentLevels"
talent-card-admin/src/views/TalentNewsEditView.vue
@@ -128,16 +128,16 @@
    <section class="news-publish-panel">
      <h3>资讯内容</h3>
      <el-form ref="formRef" class="news-publish-form" :model="form" :rules="rules" label-width="72px" label-position="left">
        <el-form-item label="资讯标题" prop="title">
      <el-form ref="formRef" class="news-publish-form" :model="form" :rules="rules" label-width="96px" label-position="left">
        <el-form-item label="资讯标题" prop="title" required>
          <el-input v-model="form.title" placeholder="资讯标题" />
        </el-form-item>
        <el-form-item label="资讯标签" prop="summary">
          <el-input v-model="form.summary" placeholder="请输入资讯标签" />
        <el-form-item label="资讯标签" prop="summary" required>
          <el-input v-model="form.summary" placeholder="资讯标签" />
        </el-form-item>
        <el-form-item class="news-editor-form-item" label="资讯内容" prop="content">
        <el-form-item class="news-editor-form-item" label="资讯内容" prop="content" required>
          <AdminRichTextEditor v-model="form.content" placeholder="请输入资讯内容" @update:model-value="validateContent" />
        </el-form-item>
@@ -145,11 +145,11 @@
          <el-switch v-model="form.pinned" />
        </el-form-item>
        <el-form-item label="封面图" prop="coverUrl">
          <el-upload accept="image/*" :auto-upload="false" :show-file-list="false" :on-change="uploadCover">
            <el-button>上传图片</el-button>
        <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-image v-if="form.coverUrl" class="news-cover-preview" :src="form.coverUrl" fit="cover" />
        </el-form-item>
        <el-form-item>
talent-card-admin/src/views/TalentNewsPublishView.vue
@@ -81,16 +81,16 @@
    <section class="news-publish-panel">
      <h3>资讯内容</h3>
      <el-form ref="formRef" class="news-publish-form" :model="form" :rules="rules" label-width="72px" label-position="left">
        <el-form-item label="资讯标题" prop="title">
      <el-form ref="formRef" class="news-publish-form" :model="form" :rules="rules" label-width="96px" label-position="left">
        <el-form-item label="资讯标题" prop="title" required>
          <el-input v-model="form.title" placeholder="资讯标题" />
        </el-form-item>
        <el-form-item label="资讯标签" prop="summary">
          <el-input v-model="form.summary" placeholder="请输入资讯标签" />
        <el-form-item label="资讯标签" prop="summary" required>
          <el-input v-model="form.summary" placeholder="资讯标签" />
        </el-form-item>
        <el-form-item class="news-editor-form-item" label="资讯内容" prop="content">
        <el-form-item class="news-editor-form-item" label="资讯内容" prop="content" required>
          <AdminRichTextEditor v-model="form.content" placeholder="请输入资讯内容" @update:model-value="validateContent" />
        </el-form-item>
@@ -98,11 +98,11 @@
          <el-switch v-model="form.pinned" />
        </el-form-item>
        <el-form-item label="封面图" prop="coverUrl">
          <el-upload accept="image/*" :auto-upload="false" :show-file-list="false" :on-change="uploadCover">
            <el-button>上传图片</el-button>
        <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-image v-if="form.coverUrl" class="news-cover-preview" :src="form.coverUrl" fit="cover" />
        </el-form-item>
        <el-form-item>
talent-card-admin/src/views/TalentPolicyEditView.vue
@@ -41,6 +41,6 @@
</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 accept="image/*" :auto-upload="false" :show-file-list="false" :on-change="uploadCover"><el-button>上传图片</el-button></el-upload><el-image v-if="form.coverUrl" class="policy-cover-preview" :src="form.coverUrl" fit="cover" /></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-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>
  </el-form></section></div>
</template>
talent-card-admin/src/views/TalentPolicyPublishView.vue
@@ -51,11 +51,11 @@
</script>
<template>
  <div class="policy-publish-page"><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" placeholder="请输入政策标题" /></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 accept="image/*" :auto-upload="false" :show-file-list="false" :on-change="uploadCover"><el-button>上传图片</el-button></el-upload><el-image v-if="form.coverUrl" class="policy-cover-preview" :src="form.coverUrl" fit="cover" /></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 label="政策标题" prop="title" required><el-input v-model="form.title" placeholder="请输入政策标题" /></el-form-item>
    <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="排序" 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>
</template>
talent-card-admin/src/views/UpgradeApplicationDetailView.vue
@@ -90,10 +90,10 @@
        <div>申请时间:<strong>{{ formatDate(detail.applyTime) }}</strong></div>
        <div class="full-line">申请理由:<strong>{{ text(detail.applyReason) }}</strong></div>
      </div>
      <div v-for="honor in (detail.honors || [])" :key="honor.id" class="honor-block">
      <div v-for="honor in (detail.honors || [])" :key="honor.id" class="upgrade-detail-grid honor-block">
        <div>新获得的荣誉称号/大奖项:<strong>{{ text(honor.honorName) }}</strong></div>
        <div>获得时间:<strong>{{ text(honor.obtainedDate) }}</strong></div>
        <div class="honor-images">
        <div class="honor-images full-line">
          <el-image
            v-if="imageUrl(honor)"
            :src="resolveImageUrl(imageUrl(honor))"
@@ -125,8 +125,8 @@
.upgrade-detail-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px 36px; padding: 18px; background: #f5f6f8; color: #606266; }
.upgrade-detail-grid strong { color: #303133; font-weight: 500; }
.full-line { grid-column: 1 / -1; }
.honor-block { margin-top: 14px; padding: 16px; background: #f5f6f8; color: #606266; }
.honor-images { display: flex; gap: 20px; min-height: 130px; margin-top: 14px; }
.honor-block { margin-top: 14px; }
.honor-images { display: flex; gap: 20px; align-items: flex-start; }
.honor-image { width: 150px; height: 110px; background: #e9eaec; cursor: zoom-in; }
.honor-images span { display: flex; align-items: center; justify-content: center; width: 150px; height: 110px; color: #909399; background: #e9eaec; }
.upgrade-detail-actions { display: flex; align-items: center; gap: 12px; padding: 18px 0; }
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessController.java
@@ -36,6 +36,11 @@
        return ApiResponse.success(adminBusinessService.dashboard());
    }
    @GetMapping("/pending-notices")
    public ApiResponse<java.util.List<Map<String, Object>>> pendingNotices() {
        return ApiResponse.success(adminBusinessService.pendingNotices());
    }
    @GetMapping("/news")
    public ApiResponse<PageResult<Map<String, Object>>> news(
            @RequestParam(required = false) String title,
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java
@@ -2,9 +2,12 @@
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -228,21 +231,7 @@
        StringBuilder sql = new StringBuilder("""
                select m.id, m.merchant_name, m.merchant_type, m.contact_name, m.contact_mobile,
                       m.logo_url, m.cover_url, m.address, m.is_recommend, m.sort_no, m.status,
                       case
                         when m.status <> 1 then '休息中'
                         when lower(coalesce(m.business_status, 'open')) in ('closed', '休息中') then '休息中'
                         when exists (
                           select 1
                           from merchant_business_hours h
                           join merchant_store s on s.id = h.store_id and s.status = 1 and s.deleted = 0
                           where h.merchant_id = m.id
                             and (
                               (h.start_time <= h.end_time and current_time() >= h.start_time and current_time() < h.end_time)
                               or (h.start_time > h.end_time and (current_time() >= h.start_time or current_time() < h.end_time))
                             )
                         ) then '营业中'
                         else '休息中'
                       end as business_status, m.created_at,
                       m.created_at,
                       (select count(*) from merchant_product p where p.merchant_id = m.id and p.deleted = 0) as product_count,
                       (select count(*) from benefit_order o where o.merchant_id = m.id and o.deleted = 0) as order_count,
                       0 as reservation_count
@@ -290,7 +279,45 @@
            args.add(createdEnd);
        }
        sql.append(" order by m.is_recommend desc, m.sort_no desc, m.id desc");
        return page(sql.toString(), countSql.toString(), page, size, args.toArray());
        PageResult<Map<String, Object>> result = page(sql.toString(), countSql.toString(), page, size, args.toArray());
        applyBusinessStatus(result.records());
        return result;
    }
    @Transactional(readOnly = true)
    public List<Map<String, Object>> pendingNotices() {
        List<Map<String, Object>> notices = new ArrayList<>();
        addPendingNotice(notices, "product-audit", "团单审核", "您有 %d 条团单待审核", "/products",
                "select count(*) as pending_count, max(coalesce(submit_time, created_at)) as latest_time from merchant_product where deleted = 0 and audit_status = 1");
        addPendingNotice(notices, "family-card", "家属卡申请", "您有 %d 条家属卡申请待审核", "/family-card-applications",
                "select count(*) as pending_count, max(apply_time) as latest_time from family_card_application where deleted = 0 and status = '待审核'");
        addPendingNotice(notices, "upgrade", "升级申请", "您有 %d 条升级申请待审核", "/upgrade-applications",
                "select count(*) as pending_count, max(apply_time) as latest_time from upgrade_application where deleted = 0 and status = '待审核'");
        addPendingNotice(notices, "merchant-onboarding", "商户进件", "您有 %d 条商户进件待审核", "/merchant-settlement?kind=onboardings",
                "select count(*) as pending_count, max(created_at) as latest_time from merchant_onboarding_application where status = 'pending'");
        addPendingNotice(notices, "merchant-bank-card", "银行卡申请", "您有 %d 条银行卡申请待审核", "/merchant-settlement?kind=bank-cards",
                "select count(*) as pending_count, max(created_at) as latest_time from merchant_bank_card where status = 'pending'");
        addPendingNotice(notices, "merchant-withdrawal", "提现审核", "您有 %d 条提现申请待审核", "/merchant-settlement?kind=withdrawals",
                "select count(*) as pending_count, max(created_at) as latest_time from merchant_withdrawal where status = 'pending'");
        addPendingNotice(notices, "medical-reservation", "体检预约", "您有 %d 条体检预约待处理", "/medical-reservations",
                "select count(*) as pending_count, max(submitted_at) as latest_time from service_application where deleted = 0 and application_type = 'MEDICAL_RESERVATION' and status = '待处理'");
        addPendingNotice(notices, "medical-application", "就医申请", "您有 %d 条就医申请待处理", "/medical-applications",
                "select count(*) as pending_count, max(submitted_at) as latest_time from service_application where deleted = 0 and application_type = 'MEDICAL_APPLICATION' and status = '待处理'");
        notices.sort((left, right) -> String.valueOf(right.get("time")).compareTo(String.valueOf(left.get("time"))));
        return notices;
    }
    private void addPendingNotice(List<Map<String, Object>> notices, String id, String category, String template, String path, String sql) {
        Map<String, Object> summary = DatabaseRows.normalize(jdbcTemplate.queryForMap(sql));
        long count = summary.get("pendingCount") instanceof Number number ? number.longValue() : 0;
        if (count == 0) return;
        Map<String, Object> notice = new LinkedHashMap<>();
        notice.put("id", id);
        notice.put("category", category);
        notice.put("content", template.formatted(count));
        notice.put("time", summary.get("latestTime"));
        notice.put("path", path);
        notices.add(notice);
    }
    @Transactional(readOnly = true)
@@ -335,7 +362,7 @@
    @Transactional
    public Map<String, Object> createMerchant(MerchantCreateRequest request) {
        validateMerchantAddress(request.merchantType(), request.address());
        validateScenicMerchantCreate(request);
        validateRequiredMerchantCreateFields(request);
        jdbcTemplate.update("""
                insert into merchant(merchant_name, merchant_type, contact_name, contact_mobile, address,
                                         logo_url, cover_url, business_status, longitude, latitude, can_login_merchant_app, group_buying_enabled,
@@ -396,8 +423,8 @@
        return MERCHANT_APP_LOGIN_TYPES.contains(merchantType);
    }
    private void validateScenicMerchantCreate(MerchantCreateRequest request) {
        if (!"景区服务".equals(request.merchantType())) {
    private void validateRequiredMerchantCreateFields(MerchantCreateRequest request) {
        if (!Set.of("景区服务", "餐饮服务").contains(request.merchantType())) {
            return;
        }
        if (request.businessHours() == null || request.businessHours().isEmpty()
@@ -907,6 +934,41 @@
        return new PageResult<>(records, safePage, safeSize, total == null ? 0 : total);
    }
    private void applyBusinessStatus(List<Map<String, Object>> merchants) {
        if (merchants.isEmpty()) return;
        List<Long> merchantIds = merchants.stream().map(row -> Long.valueOf(String.valueOf(row.get("id")))).toList();
        String placeholders = String.join(",", java.util.Collections.nCopies(merchantIds.size(), "?"));
        List<Map<String, Object>> periods = DatabaseRows.normalize(jdbcTemplate.queryForList("""
                select h.merchant_id, h.start_time, h.end_time
                from merchant_business_hours h
                join merchant_store s on s.id = h.store_id
                where s.status = 1 and s.deleted = 0 and h.merchant_id in (""" + placeholders + ")", merchantIds.toArray()));
        LocalTime now = LocalTime.now(ZoneId.of("Asia/Shanghai"));
        Set<Long> openMerchantIds = new HashSet<>();
        for (Map<String, Object> period : periods) {
            if (isWithinBusinessPeriod(now, String.valueOf(period.get("startTime")), String.valueOf(period.get("endTime")))) {
                openMerchantIds.add(Long.valueOf(String.valueOf(period.get("merchantId"))));
            }
        }
        for (Map<String, Object> merchant : merchants) {
            Long merchantId = Long.valueOf(String.valueOf(merchant.get("id")));
            boolean enabled = "1".equals(String.valueOf(merchant.get("status")));
            merchant.put("businessStatus", enabled && openMerchantIds.contains(merchantId) ? "营业中" : "休息中");
        }
    }
    private boolean isWithinBusinessPeriod(LocalTime now, String startValue, String endValue) {
        try {
            LocalTime start = LocalTime.parse(startValue);
            LocalTime end = LocalTime.parse(endValue);
            return start.isBefore(end)
                    ? !now.isBefore(start) && now.isBefore(end)
                    : !now.isBefore(start) || now.isBefore(end);
        } catch (DateTimeParseException ignored) {
            return false;
        }
    }
    private void ensureUpdated(int updated) {
        if (updated == 0) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "Data not found");
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java
@@ -24,6 +24,7 @@
  @PatchMapping("/roles/{id}/status") public ApiResponse<Map<String,Object>> updateRoleStatus(@PathVariable Long id, @Valid @RequestBody AdminRoleStatusRequest request) { return ApiResponse.success(service.updateRoleStatus(id, request.status())); }
  @DeleteMapping("/roles/{id}") public ApiResponse<Map<String,Object>> deleteRole(@PathVariable Long id) { return ApiResponse.success(service.deleteRole(id)); }
  @GetMapping("/staff") public ApiResponse<List<Map<String,Object>>> staff() { return ApiResponse.success(service.staff()); }
  @GetMapping("/staff/account-available") public ApiResponse<Map<String,Object>> staffAccountAvailable(@RequestParam String account) { return ApiResponse.success(service.staffAccountAvailable(account)); }
  @PostMapping("/staff") public ApiResponse<Map<String,Object>> createStaff(@Valid @RequestBody AdminStaffRequest request) { return ApiResponse.success(service.createStaff(request)); }
  @PutMapping("/staff/{id}") public ApiResponse<Map<String,Object>> updateStaff(@PathVariable Long id, @Valid @RequestBody AdminStaffRequest request) { return ApiResponse.success(service.updateStaff(id, request)); }
  @PatchMapping("/staff/{id}/password") public ApiResponse<Map<String,Object>> resetStaffPassword(@PathVariable Long id, @RequestBody Map<String,String> body) { return ApiResponse.success(service.resetStaffPassword(id, body.get("password"))); }
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementService.java
@@ -24,13 +24,14 @@
    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 id"); }
    @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()"); }
    @Transactional public Map<String, Object> updateRole(Long id, AdminRoleRequest request) { String code = request.roleCode() == null || request.roleCode().isBlank() ? existingRoleCode(id) : request.roleCode().trim(); if (jdbc.update("update admin_role set role_name=?,role_code=?,remark=?,permission_codes=?,status=? where id=?", request.roleName().trim(), code, request.remark(), permissions(request.permissionCodes()), request.status(), id) == 0) throw new BusinessException(ErrorCode.NOT_FOUND, "Role does not exist"); return one("select id, role_name, role_code, remark, permission_codes, status, created_at from admin_role where id=?", id); }
    @Transactional public Map<String, Object> updateRole(Long id, AdminRoleRequest request) { if ("platform_admin".equals(existingRoleCode(id))) throw new BusinessException(ErrorCode.CONFLICT, "超级管理员角色不可编辑"); String code = request.roleCode() == null || request.roleCode().isBlank() ? existingRoleCode(id) : request.roleCode().trim(); if (jdbc.update("update admin_role set role_name=?,role_code=?,remark=?,permission_codes=?,status=? where id=?", request.roleName().trim(), code, request.remark(), permissions(request.permissionCodes()), request.status(), id) == 0) throw new BusinessException(ErrorCode.NOT_FOUND, "Role does not exist"); return one("select id, role_name, role_code, remark, permission_codes, status, created_at from admin_role where id=?", id); }
    @Transactional public Map<String, Object> updateRoleStatus(Long id, Byte status) { String code = existingRoleCode(id); if (status == 0 && "platform_admin".equals(code)) throw new BusinessException(ErrorCode.CONFLICT, "平台管理员角色不可禁用"); jdbc.update("update admin_role set status=? where id=?", status, id); return one("select id, role_name, role_code, remark, permission_codes, status, created_at from admin_role where id=?", id); }
    @Transactional public Map<String, Object> deleteRole(Long id) { String code = existingRoleCode(id); if ("platform_admin".equals(code)) throw new BusinessException(ErrorCode.CONFLICT, "平台管理员角色不可删除"); if (Boolean.TRUE.equals(jdbc.queryForObject("select exists(select 1 from admin_operator where role_code=? and deleted=0)", Boolean.class, code))) throw new BusinessException(ErrorCode.CONFLICT, "该角色仍有员工使用,不能删除"); jdbc.update("delete from admin_role where id=?", id); return Map.of("id", id, "deleted", true); }
    public List<Map<String, Object>> staff() { return rows("select id,account,display_name,mobile,role_code,position_name,customer_wechat,status,created_at from admin_operator where deleted=0 order by id"); }
    @Transactional public Map<String, Object> createStaff(AdminStaffRequest request) { if (request.password() == null || request.password().isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "A password is required for a new staff account"); jdbc.update("insert into admin_operator(account,password_hash,display_name,mobile,role_code,position_name,customer_wechat,status) values (?,?,?,?,?,?,?,?)", request.account().trim(), MerchantPasswordHasher.encode(request.password()), request.displayName().trim(), blankToNull(request.mobile()), request.roleCode(), request.positionName().trim(), blankToNull(request.customerWechat()), request.status()); return staffOne("last_insert_id()"); }
    @Transactional public Map<String, Object> updateStaff(Long id, AdminStaffRequest request) { if (!matchesExistingAccount(id, request.account())) throw new BusinessException(ErrorCode.CONFLICT, "登录账号不可修改"); if (request.status() == 0 && isDefaultAdmin(id)) throw new BusinessException(ErrorCode.CONFLICT, "超级管理员不可禁用"); String sql = request.password() == null || request.password().isBlank() ? "update admin_operator set account=?,display_name=?,mobile=?,role_code=?,position_name=?,customer_wechat=?,status=? where id=?" : "update admin_operator set account=?,password_hash=?,display_name=?,mobile=?,role_code=?,position_name=?,customer_wechat=?,status=? where id=?"; int updated = request.password() == null || request.password().isBlank() ? jdbc.update(sql, request.account().trim(), request.displayName().trim(), blankToNull(request.mobile()), request.roleCode(), request.positionName().trim(), blankToNull(request.customerWechat()), request.status(), id) : jdbc.update(sql, request.account().trim(), MerchantPasswordHasher.encode(request.password()), request.displayName().trim(), blankToNull(request.mobile()), request.roleCode(), request.positionName().trim(), blankToNull(request.customerWechat()), request.status(), id); if (updated == 0) throw new BusinessException(ErrorCode.NOT_FOUND, "Staff account does not exist"); return staffOne("?", id); }
    public Map<String, Object> staffAccountAvailable(String account) { String normalized = account == null ? "" : account.trim(); if (!normalized.matches("^[A-Za-z0-9]{1,32}$")) return Map.of("available", false); Long count = jdbc.queryForObject("select count(*) from admin_operator where account=?", Long.class, normalized); return Map.of("available", count == null || count == 0); }
    @Transactional public Map<String, Object> createStaff(AdminStaffRequest request) { if (request.password() == null || request.password().isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "A password is required for a new staff account"); if (!Boolean.TRUE.equals(staffAccountAvailable(request.account()).get("available"))) throw new BusinessException(ErrorCode.CONFLICT, "登录账号已存在,请更换"); jdbc.update("insert into admin_operator(account,password_hash,display_name,mobile,role_code,position_name,customer_wechat,status) values (?,?,?,?,?,?,?,?)", request.account().trim(), MerchantPasswordHasher.encode(request.password()), request.displayName().trim(), blankToNull(request.mobile()), request.roleCode(), request.positionName().trim(), blankToNull(request.customerWechat()), request.status()); return staffOne("last_insert_id()"); }
    @Transactional public Map<String, Object> updateStaff(Long id, AdminStaffRequest request) { if (isDefaultAdmin(id)) throw new BusinessException(ErrorCode.CONFLICT, "超级管理员账号不可编辑"); if (!matchesExistingAccount(id, request.account())) throw new BusinessException(ErrorCode.CONFLICT, "登录账号不可修改"); String sql = request.password() == null || request.password().isBlank() ? "update admin_operator set account=?,display_name=?,mobile=?,role_code=?,position_name=?,customer_wechat=?,status=? where id=?" : "update admin_operator set account=?,password_hash=?,display_name=?,mobile=?,role_code=?,position_name=?,customer_wechat=?,status=? where id=?"; int updated = request.password() == null || request.password().isBlank() ? jdbc.update(sql, request.account().trim(), request.displayName().trim(), blankToNull(request.mobile()), request.roleCode(), request.positionName().trim(), blankToNull(request.customerWechat()), request.status(), id) : jdbc.update(sql, request.account().trim(), MerchantPasswordHasher.encode(request.password()), request.displayName().trim(), blankToNull(request.mobile()), request.roleCode(), request.positionName().trim(), blankToNull(request.customerWechat()), request.status(), id); if (updated == 0) throw new BusinessException(ErrorCode.NOT_FOUND, "Staff account does not exist"); return staffOne("?", id); }
    @Transactional public Map<String, Object> resetStaffPassword(Long id, String password) { if (password == null || password.length() < 6 || password.length() > 64) throw new BusinessException(ErrorCode.BAD_REQUEST, "Password must be 6-64 characters"); int updated = jdbc.update("update admin_operator set password_hash=?, login_failed_count=0, locked_until=null where id=? and deleted=0", MerchantPasswordHasher.encode(password), id); if (updated == 0) throw new BusinessException(ErrorCode.NOT_FOUND, "Staff account does not exist"); return Map.of("id", id, "reset", true); }
    @Transactional public Map<String, Object> deleteStaff(Long id) { int updated = jdbc.update("update admin_operator set deleted=1, status=0 where id=? and deleted=0 and account <> 'admin'", id); if (updated == 0) throw new BusinessException(ErrorCode.CONFLICT, "The account cannot be deleted or does not exist"); return Map.of("id", id, "deleted", true); }
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantCreateRequest.java
@@ -2,6 +2,7 @@
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Size;
import jakarta.validation.Valid;
@@ -28,6 +29,7 @@
        String businessStatus,
        Byte recommend,
        @NotNull(message = "请输入排序")
        @Min(value = 0, message = "排序只能输入非负整数")
        Integer sortNo,
        Double longitude,
        Double latitude,
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java
@@ -31,10 +31,10 @@
        List<Map<String, Object>> rows = rows("select a.*, m.merchant_name, m.merchant_type, m.contact_mobile, s.store_name from merchant_onboarding_application a join merchant m on m.id = a.merchant_id join merchant_store s on s.id = a.store_id where a.id = ?", id);
        if (rows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "商户进件不存在");
        Map<String, Object> result = rows.get(0);
        if ("individual".equalsIgnoreCase(String.valueOf(result.get("subjectType")))) {
            String encrypted = String.valueOf(result.get("subjectIdNoEncrypted"));
            if (!encrypted.isBlank() && !"null".equals(encrypted)) result.put("subjectIdNo", sensitiveDataCrypto.decrypt(encrypted));
        }
        // 个人主体的历史记录可能使用 individual、personal 或中文类型值;
        // 只要已保存加密身份证号,后台详情均应解密后完整展示。
        String encrypted = String.valueOf(result.get("subjectIdNoEncrypted"));
        if (!encrypted.isBlank() && !"null".equals(encrypted)) result.put("subjectIdNo", sensitiveDataCrypto.decrypt(encrypted));
        return result;
    }
    @Transactional(readOnly = true)
@@ -42,7 +42,8 @@
        PageResult<Map<String, Object>> result = page("select c.*, m.merchant_name, m.merchant_type, m.contact_mobile, s.store_name from merchant_bank_card c join merchant m on m.id = c.merchant_id join merchant_store s on s.id = c.store_id", "select count(*) from merchant_bank_card c join merchant m on m.id = c.merchant_id join merchant_store s on s.id = c.store_id", "c", page, size, merchantName, contactMobile, merchantType, status, startDate, endDate);
        result.records().forEach(row -> {
            String encrypted = String.valueOf(row.remove("cardNoEncrypted"));
            if (!encrypted.isBlank() && !"null".equals(encrypted)) row.put("cardMasked", sensitiveDataCrypto.decrypt(encrypted));
            if (!encrypted.isBlank() && !"null".equals(encrypted)) row.put("cardNo", sensitiveDataCrypto.decrypt(encrypted));
            else row.put("cardNo", row.get("cardMasked"));
            row.put("cityName", bankCardCityName(row.get("cityCode")));
        });
        return result;
talent-card-server/src/main/java/com/talentcard/server/business/merchant/ManualConsumptionService.java
@@ -83,13 +83,13 @@
        Map<String, Object> merchant = merchant(principal);
        String orderNo = BusinessIdGenerator.next("MC");
        jdbc.update("""
                insert into benefit_order(order_no, talent_id, card_id, merchant_id, store_id, order_type,
                insert into benefit_order(order_no, talent_id, card_id, card_level_snapshot, merchant_id, store_id, order_type,
                                          order_amount, subsidy_amount, base_pay_amount, pay_amount, coupon_id, coupon_amount, expires_at, price_version,
                                          merchant_name_snapshot, merchant_address_snapshot, merchant_mobile_snapshot,
                                          merchant_longitude_snapshot, merchant_latitude_snapshot, product_name_snapshot,
                                          order_status, verify_status, merchant_verification_attempt_id, client_idempotency_key)
                values (?, ?, ?, ?, ?, 'STORE_CONSUME', ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)
                """, orderNo, number(source.get("talentId")), number(source.get("cardId")), principal.merchantId(), principal.storeId(),
                values (?, ?, ?, ?, ?, ?, 'STORE_CONSUME', ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)
                """, orderNo, number(source.get("talentId")), number(source.get("cardId")), string(source.get("cardLevel")), principal.merchantId(), principal.storeId(),
                amounts.get("originalAmount"), amounts.get("subsidyAmount"), amounts.get("basePayAmount"), amounts.get("payAmount"), request.couponId(), amounts.get("couponAmount"),
                LocalDateTime.now().plusMinutes(15), merchant.get("merchantName"), merchant.get("address"), merchant.get("contactMobile"),
                merchant.get("longitude"), merchant.get("latitude"), "到店消费 - " + rule.get("ruleName"),
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java
@@ -46,7 +46,7 @@
        String recordSql = """
                select o.id, o.order_no, tu.name as talent_name, tu.mobile as talent_mobile, tu.gender,
                       coalesce(nullif(tu.talent_level, ''), tc.card_level) as card_level,
                       coalesce(nullif(o.card_level_snapshot, ''), tc.card_level) as card_level,
                       b.benefit_name, o.product_name_snapshot, o.order_type, o.order_amount, o.subsidy_amount, o.pay_amount,
                       o.order_status, o.verify_status, o.created_at, verified.verify_time
                from benefit_order o
@@ -71,7 +71,7 @@
    public Map<String, Object> order(String orderNo, Long merchantId, Long storeId) {
        List<Map<String, Object>> rows = query("""
                select o.id, o.order_no, o.talent_id, tu.name as talent_name, tu.mobile as talent_mobile, tu.gender,
                       o.card_id, tc.card_no, coalesce(nullif(tu.talent_level, ''), tc.card_level) as card_level,
                       o.card_id, tc.card_no, coalesce(nullif(o.card_level_snapshot, ''), tc.card_level) as card_level,
                       o.benefit_id, b.benefit_name,
                       o.merchant_id, m.merchant_name, o.product_id, o.product_name_snapshot, p.rights_config,
                       o.order_type, o.order_amount, o.base_pay_amount, o.coupon_amount,
talent-card-server/src/main/java/com/talentcard/server/business/order/OrderService.java
@@ -20,6 +20,9 @@
@Service
public class OrderService {
    private static final String ORDER_CARD_LEVEL_SQL =
            "coalesce(nullif(o.card_level_snapshot, ''), tc.card_level, tu.talent_level)";
    private final JdbcTemplate jdbcTemplate;
    private final HuifuRefundService refundService;
@@ -35,14 +38,16 @@
        long subsidyAmount = MoneyUtils.yuanToCent(request.subsidyAmount());
        long payAmount = MoneyUtils.yuanToCent(request.payAmount());
        LocalDateTime expiresAt = payAmount == 0 ? null : LocalDateTime.now().plusMinutes(15);
        String cardLevel = jdbcTemplate.query("select card_level from talent_card where id = ? and deleted = 0", rs -> rs.next() ? rs.getString(1) : null, request.cardId());
        jdbcTemplate.update("""
                insert into benefit_order(order_no, talent_id, card_id, benefit_id, merchant_id, store_id,
                insert into benefit_order(order_no, talent_id, card_id, card_level_snapshot, benefit_id, merchant_id, store_id,
                                          order_type, order_amount, subsidy_amount, base_pay_amount, pay_amount, expires_at, order_status, verify_status)
                values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
                values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
                """,
                orderNo,
                request.talentId(),
                request.cardId(),
                cardLevel,
                request.benefitId(),
                request.merchantId(),
                request.storeId(),
@@ -110,7 +115,7 @@
    public Map<String, Object> detail(String orderNo) {
        List<Map<String, Object>> rows = query("""
                select o.id, o.order_no, o.talent_id, tu.name as talent_name, tu.mobile as talent_mobile,
                       o.card_id, tc.card_no, coalesce(tc.card_level, tu.talent_level) as talent_level, o.benefit_id, b.benefit_name,
                       o.card_id, tc.card_no, %s as talent_level, o.benefit_id, b.benefit_name,
                       o.product_id, o.product_name_snapshot, o.product_cover_url_snapshot,
                       p.product_name, p.cover_url as product_cover_url,
                       o.merchant_id, m.merchant_name, m.merchant_type, m.contact_mobile,
@@ -126,7 +131,7 @@
                left join merchant_coupon mc on mc.id = o.coupon_id and mc.deleted = 0
                left join merchant m on m.id = o.merchant_id
                where o.deleted = 0 and o.order_no = ?
                """, orderNo);
                """.formatted(ORDER_CARD_LEVEL_SQL), orderNo);
        if (rows.isEmpty()) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "订单不存在");
        }
@@ -137,7 +142,21 @@
                where order_id = ?
                order by id desc limit 1
                """, detail.get("id"));
        detail.put("verifyTime", verificationRows.isEmpty() ? null : verificationRows.get(0).get("verifyTime"));
        Object verifyTime = verificationRows.isEmpty() ? null : verificationRows.get(0).get("verifyTime");
        // 到店消费在支付成功后即自动核销,历史数据未必有人工核销记录;此时以成功支付时间作为核销时间。
        if (verifyTime == null
                && "STORE_CONSUME".equalsIgnoreCase(String.valueOf(detail.get("orderType")))
                && detail.get("verifyStatus") instanceof Number verifyStatus
                && verifyStatus.intValue() == 1) {
            List<Map<String, Object>> paidTransactions = query("""
                    select paid_at as verify_time
                    from payment_transaction
                    where order_no = ? and status = 'SUCCEEDED' and paid_at is not null
                    order by id desc limit 1
                    """, orderNo);
            verifyTime = paidTransactions.isEmpty() ? null : paidTransactions.get(0).get("verifyTime");
        }
        detail.put("verifyTime", verifyTime);
        Object itemId = detail.get("productId") == null ? detail.get("benefitId") : detail.get("productId");
        Object itemName = firstNonNull(detail.get("productNameSnapshot"), detail.get("productName"), detail.get("benefitName"));
        Object itemCover = firstNonNull(detail.get("productCoverUrlSnapshot"), detail.get("productCoverUrl"), detail.get("merchantCoverUrl"));
@@ -197,11 +216,7 @@
            where.append(" and m.merchant_name like ?");
            args.add("%" + merchantName.trim() + "%");
        }
        if (cardLevel != null && !cardLevel.isBlank()) {
            where.append(" and (tc.card_level = ? or tu.talent_level = ?)");
            args.add(cardLevel);
            args.add(cardLevel);
        }
        appendCardLevelFilter(where, args, cardLevel);
        if (hasSubsidy != null) {
            where.append(hasSubsidy == 1 ? " and o.subsidy_amount > 0" : " and o.subsidy_amount = 0");
        }
@@ -225,16 +240,18 @@
                select r.id, r.refund_no, r.order_no, r.order_type, r.paid_amount, r.refund_amount,
                       r.refundable_amount, r.refund_status, r.reason, r.operator_name, r.refund_time,
                       tu.name as talent_name, tu.mobile as talent_mobile,
                       coalesce(tc.card_level, tu.talent_level) as talent_level, tc.card_no,
                       m.merchant_name, m.merchant_type, coalesce(b.benefit_name, o.product_name_snapshot) as benefit_name,
                       %s as talent_level, tc.card_no,
                       m.merchant_name, m.merchant_type,
                       coalesce(nullif(o.product_name_snapshot, ''), nullif(p.product_name, ''), b.benefit_name) as benefit_name,
                       o.order_amount, o.subsidy_amount, o.pay_amount, o.created_at as order_time
                from order_refund r
                left join benefit_order o on o.id = r.order_id
                left join talent_user tu on tu.id = r.talent_id
                left join talent_card tc on tc.id = r.card_id
                left join benefit b on b.id = r.benefit_id
                left join merchant_product p on p.id = o.product_id and p.deleted = 0
                left join merchant m on m.id = r.merchant_id
                """ + where + " order by r.id desc limit ? offset ?", pageArgs.toArray());
                """.formatted(ORDER_CARD_LEVEL_SQL) + where + " order by r.id desc limit ? offset ?", pageArgs.toArray());
        Long total = jdbcTemplate.queryForObject("""
                select count(*)
                from order_refund r
@@ -281,11 +298,7 @@
            where.append(" and m.merchant_name like ?");
            args.add("%" + merchantName.trim() + "%");
        }
        if (cardLevel != null && !cardLevel.isBlank()) {
            where.append(" and (tc.card_level = ? or tu.talent_level = ?)");
            args.add(cardLevel);
            args.add(cardLevel);
        }
        appendCardLevelFilter(where, args, cardLevel);
        if ("未核销".equals(status)) {
            return new PageResult<>(List.of(), safePage, safeSize, 0);
        }
@@ -305,7 +318,7 @@
                select r.id, r.verify_no, r.verify_type, r.verify_amount, r.subsidy_amount, r.verify_time,
                       r.remark, o.order_no, o.order_type,
                       tu.name as talent_name, tu.mobile as talent_mobile,
                       coalesce(tc.card_level, tu.talent_level) as talent_level, tc.card_no,
                       %s as talent_level, tc.card_no,
                       m.merchant_name, m.merchant_type, b.benefit_name,
                       '已核销' as status
                from verify_record r
@@ -314,7 +327,7 @@
                left join talent_card tc on tc.id = r.card_id
                left join benefit b on b.id = r.benefit_id
                left join merchant m on m.id = r.merchant_id
                """ + where + " order by r.id desc limit ? offset ?", pageArgs.toArray());
                """.formatted(ORDER_CARD_LEVEL_SQL) + where + " order by r.id desc limit ? offset ?", pageArgs.toArray());
        Long total = jdbcTemplate.queryForObject("""
                select count(*)
                from verify_record r
@@ -375,11 +388,7 @@
            where.append(" and m.merchant_name like ?");
            args.add("%" + merchantName.trim() + "%");
        }
        if (cardLevel != null && !cardLevel.isBlank()) {
            where.append(" and (tc.card_level = ? or tu.talent_level = ?)");
            args.add(cardLevel);
            args.add(cardLevel);
        }
        appendCardLevelFilter(where, args, cardLevel);
        if (hasSubsidy != null) {
            where.append(hasSubsidy == 1 ? " and o.subsidy_amount > 0" : " and o.subsidy_amount = 0");
        }
@@ -404,7 +413,7 @@
        pageArgs.add((safePage - 1) * safeSize);
        List<Map<String, Object>> records = query("""
                select o.id, o.order_no, o.talent_id, tu.name as talent_name, tu.mobile as talent_mobile,
                       coalesce(tc.card_level, tu.talent_level) as talent_level,
                       %s as talent_level,
                       o.card_id, tc.card_no, tc.card_type,
                       o.merchant_id, m.merchant_name, m.merchant_type,
                       o.benefit_id, b.benefit_name, o.order_type,
@@ -415,7 +424,7 @@
                left join talent_card tc on tc.id = o.card_id
                left join benefit b on b.id = o.benefit_id
                left join merchant m on m.id = o.merchant_id
                """ + where + " order by o.id desc limit ? offset ?", pageArgs.toArray());
                """.formatted(ORDER_CARD_LEVEL_SQL) + where + " order by o.id desc limit ? offset ?", pageArgs.toArray());
        Long total = jdbcTemplate.queryForObject("""
                select count(*)
                from benefit_order o
@@ -439,6 +448,14 @@
        return rows.get(0);
    }
    private void appendCardLevelFilter(StringBuilder where, List<Object> args, String cardLevel) {
        if (cardLevel == null || cardLevel.isBlank()) {
            return;
        }
        where.append(" and ").append(ORDER_CARD_LEVEL_SQL).append(" = ?");
        args.add(cardLevel.trim());
    }
    private void appendOrderTypeFilter(StringBuilder where, List<Object> args, String alias, String orderType) {
        if (orderType == null || orderType.isBlank()) {
            return;
talent-card-server/src/main/java/com/talentcard/server/business/portal/AppOrderPortalService.java
@@ -57,18 +57,19 @@
        try {
            jdbc.update("""
                    insert into benefit_order(order_no, talent_id, card_id, merchant_id, order_type,
                    insert into benefit_order(order_no, talent_id, card_id, card_level_snapshot, merchant_id, order_type,
                                              order_amount, subsidy_amount, base_pay_amount, pay_amount,
                                              coupon_id, coupon_amount, expires_at, price_version,
                                              merchant_name_snapshot, merchant_address_snapshot, merchant_mobile_snapshot,
                                              merchant_longitude_snapshot, merchant_latitude_snapshot,
                                              product_id, product_name_snapshot, product_cover_url_snapshot,
                                              order_status, verify_status)
                    values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                    values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                    """,
                    orderNo,
                    talentId,
                    talentCard.get("id"),
                    talentCard.get("cardLevel"),
                    request.merchantId(),
                    normalizeOrderType(product.get("productType")),
                    originalAmount,
talent-card-server/src/main/resources/application.yml
@@ -57,7 +57,7 @@
      system-id: 6666000212191071
      product-id: KAZX
      merchant-id: 6666000212191071
      mini-program-app-id: wx6ae2cadfea28fce1
      mini-program-app-id: wxcfee477acb54d179
      private-key: MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCcsR3R3nUFCv/4l6vczGk6j4UgZhuMm/fTs2MQQqFkIuoISd38TOiIMaLS3KLYL+D+qhUCps56svOK/3M/kdyXfPsBphJyXB5Ar7il8pJ6BDa38StzTrlRm+IXX+4diJvqxUimetjpx0OfnqRkNYQWc/ZVeoEdQbITqLyxogy0R3rkR/wnOTwxHBbkCYsDCxSon4W0SoM5fLs3o947INbPUbUsf0vL5l6NiqlV9YsZ3qAWvvPoPX4HI1s5maMiVKfUwe27P3ARW9n6n3tadBzbB6lrmAEcFoihXpZjNN9UaRKjYfYjTS9JRkDqRyYGhz5cHp9GthEksMvhLP84oEanAgMBAAECggEAKV6d5+Ya8TzpPiIVTxNtCPgRhu9OgL5BHVS0Z/ufjselEJEoGc/YaPTp71AvcnKZrcMMDwopY73RjTZKoFMLcbeesd296xGlLyD12GKGB88K/1+eQDBdv00v0UoTnRqPR9nUh5fRo09n7EG/V3HFsQPm2sSgSDArKwOW5jNJtWLsGIVCW8gg3RQ9YlZ5HW85neBnjBFnrMA9S/hxwZD336M8CiViIa0v9o5CS7qxAqXMzzoOC7Vr3hZ/X0i/5GrSH9DRO7ougSmP4F/zCEc2dViA0rOJ6m3OuS0osh5FulihOBOjI5uLETEt4K/7PuV9xOd4oAz0OBpJ2bhPf3YpGQKBgQDQdEcmnlULa3jTSrbr0vYA3iMpcls/8F8+I/QymMXHAUeVyc8XKuGzblx+/n3x8ZoAvdVg1ZAO5wgLh/P0BEL74iWhfHedjt8xcdk2R5U0RuOnx1TUTKrmLKRZ5gStNFnGlK4yVwpiNqHn/cr+gYYeZSPARFVi00WdXbqVbEEf2wKBgQDAbmo4uHrmHN5GYvt8lVmT9aha1IpKOOLenjd+Y/vKGJcvHzOVCHnOh8OGO/4p7CxOq0sshER713jW2cFGO3xpFEFpIc45pDQ8Ut0XuSoOjKtwPzid8vKX7A1If6FDLMNy7bYR1qqnIJNJnN/f3lsksX+es7QTuRShhA0X0mnEJQKBgBE5OlPHi1FZPO+AYeLUWH0z3YXkLAP5cXUp9Vhk5RqPUYHf3duVMHbp7HyVvIRMKSRciw/oNXH1xuVlA1QrMfmr8t4H8b4z+t8UABWLDpn2LYNEZg7wksJX9/Bxn/tLswtEZ6xDItmAmBoaDza+Lv2975lITCHRQRRClVMZUbw3AoGAVFrSs6SjM2aqRZ60ccuipgrFT3PmX10Kjt46o2w+/zvCYoTB/u3lP5UiMCDybPw7/Ax4+r3Mq6EsNrrkT95ZzlJzzhtkpL4Hq/4DL5gbCJ+/s4fzUMIkeFahCAvy7FieIS51ZuJ8kqDkEBv1w9RcjfxFXpOWOCMlGRyjBLNXbAUCgYEAjcTEuXctlGEjtO1MBaHc4hkkpiPG7gxV/Bq1XXLszv4LTaOfDEpjGGBpVYtb3URYrif/HzX8T1KHELvPziuqP2ghaJQzbztep1TGQ48A5VpOvHdA/w/+k4JJl0PyKBPrFXiHIw1if7rnvOLt27TymwXSaJquSz7Sz2+wXFxzKJk=
      platform-public-key: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAgxOuXt1UEdZNPk6sntqiIlcs7e+0fZ3CVgKMV2Qv4ixwKmha5etL5WyIONmjoGEHePHpanYkXvm0kHkbbDkjpyHDFdEshuP1eGx5tRUTISRvXY/iFuD3xyeWSsvvjW6hS29OvqVCPcnBUC7jcRj15dJS2IpYR0iDGNS+/M0TyOFwRQME07At+qO+MSjKu4aRIjlglukjD7YbE2s35uV066nsBUCZUNpGPuju1pieUW4zyoIW6K70dyXCQy61oRI6JmA4JRCOJV1BE80N3GrLI4Pbq1C3ZBTgOPi5RWYNq1hfu807BDkPLc3dJKYt22Ea1jPJsg6e/B9Xbzh6JaUNAQIDAQAB
      notify-url: ${HUIFU_NOTIFY_URL:http://221.182.45.100:11451/api/payments/huifu/notify}
@@ -67,9 +67,9 @@
  wechat:
    mini-program:
      # The app id must be the same mini-program that invokes uni.login and uni.requestPayment.
      app-id: ${WECHAT_MINI_PROGRAM_APP_ID:wx6ae2cadfea28fce1}
      app-id: ${WECHAT_MINI_PROGRAM_APP_ID:wxcfee477acb54d179}
      # Keep this only in the deployment environment. It is required to exchange a wx.login code for an OpenID.
      app-secret: ${WECHAT_MINI_PROGRAM_APP_SECRET:7bf743b066523bbb2d6de80decabc15b}
      app-secret: ${WECHAT_MINI_PROGRAM_APP_SECRET:716aae42f04d3c5f17904e894f1c1ee9}
management:
  endpoints:
talent-card-server/src/main/resources/db/migration/V112__news_article_top_time_sorting.sql
talent-card-server/src/main/resources/db/migration/V113__ticket_package_level_rules.sql
talent-card-server/src/main/resources/db/migration/V114__sync_premium_topic_merchant_mappings.sql
talent-card-server/src/main/resources/db/migration/V115__benefit_order_card_level_snapshot.sql
New file
@@ -0,0 +1,9 @@
alter table benefit_order
    add column card_level_snapshot varchar(64) default null comment '下单时人才卡等级快照' after card_id;
update benefit_order o
left join talent_card c on c.id = o.card_id
set o.card_level_snapshot = c.card_level
where (o.card_level_snapshot is null or o.card_level_snapshot = '')
  and c.card_level is not null
  and c.card_level <> '';