yeg
2026-09-18 70ea0911ce7519ca802266078624e9fed3559a2c
Merge remote-tracking branch 'origin/main'
1个文件已添加
30个文件已修改
555 ■■■■■ 已修改文件
rencaika/pages/coupon-select/coupon-select.uvue 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/news/news.uvue 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/policy/policy.uvue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/services/business.uts 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/style.css 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/AppointmentManagementView.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/CarWashBenefitView.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/FuelServiceView.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/HotelReservationView.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/HotelVerificationView.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MedicalApplicationView.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MedicalReservationView.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantCreateView.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MovieTicketRecordView.vue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MovieTicketView.vue 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/ProductView.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/SpouseEmploymentView.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/StaffManagementView.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/TalentListView.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/UpgradeApplicationDetailView.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/card/CardAdminService.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/ChildrenEntrustmentPortalService.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/ParkingMerchantPortalService.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/talent/TalentService.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/admin/AdminBusinessServiceTests.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/portal/ChildrenEntrustmentPortalServiceTests.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/portal/ParkingMerchantPortalServiceTests.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/portal/PortalServiceTests.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/talent/TalentServiceTests.java 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/coupon-select/coupon-select.uvue
@@ -25,7 +25,7 @@
            <view v-else-if="coupons.length > 0" class="coupon-list">
                <view class="coupon-card" v-for="item in coupons" :key="item.id" hover-class="coupon-card-active" @tap="selectCoupon(item)">
                    <view class="coupon-amount">
                        <view class="amount-line"><text class="amount-number">{{item.amount}}</text><text class="amount-unit">元</text></view>
                        <view class="amount-line"><text class="amount-number" :class="amountNumberClass(item.amount)">{{item.amount}}</text><text class="amount-unit" :class="amountUnitClass(item.amount)">元</text></view>
                    </view>
                    <view class="coupon-info">
                        <image class="ticket-mark" src="/static/product-assets/coupon-ticket.svg"></image>
@@ -65,6 +65,23 @@
    const loading = ref(false)
    const loadFailed = ref(false)
    const selecting = ref(false)
    // 左侧色块宽度固定 218rpx,金额位数多(如 178.84)时必须缩小字号,
    // 否则文字会超出色块被裁掉(uni-app x 里 view 默认 overflow: hidden)。
    const amountNumberClass = (value: any): string => {
        const length = value == null ? 0 : ('' + value).length
        if (length >= 9) return 'number-xxs'
        if (length >= 7) return 'number-xs'
        if (length >= 5) return 'number-sm'
        return ''
    }
    const amountUnitClass = (value: any): string => {
        const length = value == null ? 0 : ('' + value).length
        if (length >= 9) return 'unit-xxs'
        if (length >= 7) return 'unit-xs'
        if (length >= 5) return 'unit-sm'
        return ''
    }
    onLoad((options: any) => {
        // #ifdef MP-WEIXIN
@@ -164,6 +181,13 @@
    .amount-line { display: flex; margin-top: 5rpx; color: #FFFFFF; flex-direction: row; align-items: flex-end; }
    .amount-number { font-size: 78rpx; font-weight: 700; line-height: 88rpx; }
    .amount-unit { margin: 0 0 11rpx 11rpx; font-size: 27rpx; font-weight: 600; }
    /* 金额位数多的档位:写在这两行之后,同优先级下后面的规则生效,字号按位数逐级缩小。 */
    .number-sm { font-size: 52rpx; line-height: 62rpx; }
    .unit-sm { margin: 0 0 8rpx 8rpx; font-size: 22rpx; }
    .number-xs { font-size: 42rpx; line-height: 52rpx; }
    .unit-xs { margin: 0 0 7rpx 7rpx; font-size: 19rpx; }
    .number-xxs { font-size: 34rpx; line-height: 44rpx; }
    .unit-xxs { margin: 0 0 6rpx 6rpx; font-size: 17rpx; }
    .coupon-info { display: flex; min-width: 0; flex: 1; height: 248rpx; box-sizing: border-box; padding: 27rpx 32rpx 25rpx 45rpx; position: relative; flex-direction: column; justify-content: space-between; }
    .ticket-mark { position: absolute; top: 39rpx; right: 29rpx; width: 48rpx; height: 48rpx; opacity: .15; }
    .coupon-name { color: #1C2028; font-size: 36rpx; font-weight: 700; line-height: 50rpx; }
rencaika/pages/news/news.uvue
@@ -92,7 +92,15 @@
    const contentPreview = (value: any): string => {
        if (value == null) return ''
        const plain = (value as string).replace(/\s+/g, ' ').trim()
        return plain.length > 64 ? plain.substring(0, 64) + '...' : plain
        // 摘要区固定 3 行:按 3 行能放下的字数截断并补省略号,
        // 避免文字被容器高度从中间切断(只露出半行、又没有省略号)。
        return plain.length > 54 ? plain.substring(0, 54) + '...' : plain
    }
    // 标签只占一行:压掉换行/连续空格(text 组件会把 \n 渲染成换行),
    // 放不下时由样式的单行省略号收尾。
    const newsTagText = (value: any): string => {
        if (value == null) return ''
        return ('' + value).replace(/\s+/g, ' ').trim()
    }
    const mapNewsRecords = (records: any[]): any[] => {
        const mapped: any[] = []
@@ -102,7 +110,7 @@
                id: '' + item.id,
                title: item.title != null ? item.title as string : '人才资讯',
                summary: contentPreview(item.contentPreview),
                tag: item.summary != null ? item.summary as string : '',
                tag: newsTagText(item.summary),
                time: formatNewsTime(item.publishTime)
            })
        }
@@ -209,10 +217,14 @@
    .news-title { display: block; color: #252525; font-size: 30rpx; font-weight: 700; line-height: 41rpx; }
    .news-body { display: flex; height: 174rpx; margin-top: 13rpx; flex-direction: row; }
    .news-copy { display: flex; flex: 1; min-width: 0; padding-right: 18rpx; flex-direction: column; justify-content: space-between; }
    .news-summary { display: block; height: 105rpx; overflow: hidden; color: #8E9299; font-size: 23rpx; line-height: 34rpx; }
    .news-meta { display: flex; height: 42rpx; flex-direction: row; align-items: center; }
    .news-tag { padding: 5rpx 9rpx; border-radius: 6rpx; background-color: #FFF8EA; color: #F1A32D; font-size: 19rpx; }
    .news-time { margin-left: 13rpx; color: #A5A8AE; font-size: 20rpx; }
    /* 摘要固定 3 行(3×34rpx=102rpx),超出用省略号,不再从文字中间截断。 */
    .news-summary { display: -webkit-box; height: 102rpx; overflow: hidden; -webkit-line-clamp: 3; -webkit-box-orient: vertical; color: #8E9299; font-size: 23rpx; line-height: 34rpx; }
    .news-meta { display: flex; height: 42rpx; min-width: 0; flex-direction: row; align-items: center; }
    /* 来源/摘要这行可能很长:按内容宽度排,放不下时收缩并省略,不去挤右侧时间。
       flex-shrink 必须显式写 1:uni-app x 的全局样式给 view/text 默认 flex-shrink: 0,
       不覆盖的话标签不会收缩,只会顶出卡片、把时间挤没(省略号也不会出现)。 */
    .news-tag { display: block; flex: 0 1 auto; flex-grow: 0; flex-shrink: 1; flex-basis: auto; min-width: 0; padding: 5rpx 9rpx; border-radius: 6rpx; background-color: #FFF8EA; color: #F1A32D; font-size: 19rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .news-time { margin-left: 13rpx; color: #A5A8AE; font-size: 20rpx; flex-shrink: 0; }
    .news-thumb { width: 236rpx; height: 168rpx; border-radius: 7rpx; flex-shrink: 0; }
    .bottom-space { height: calc(28rpx + env(safe-area-inset-bottom)); }
</style>
rencaika/pages/policy/policy.uvue
@@ -67,6 +67,12 @@
        // #endif
        loadInitialPolicies()
    })
    // 政策内容只展示一行:先把换行/连续空格压成单个空格,再由样式的单行省略号收尾,
    // 否则文本里的 \n 会被 text 组件渲染成换行,一行放不下。
    const policyPreview = (value: any): string => {
        if (value == null) return ''
        return ('' + value).replace(/\s+/g, ' ').trim()
    }
    const mapPolicies = (records: any[]): any[] => {
        const mapped: any[] = []
        for (let index = 0; index < records.length; index++) {
@@ -74,7 +80,7 @@
            mapped.push({
                id: '' + item.id,
                title: item.title != null ? item.title as string : '人才政策',
                description: item.contentPreview != null ? item.contentPreview as string : '',
                description: policyPreview(item.contentPreview),
                tag: item.subtitle != null && (item.subtitle as string).trim() != '' ? item.subtitle as string : '人才政策'
            })
        }
@@ -152,7 +158,8 @@
    .policy-card { min-height: 220rpx; margin-bottom: 25rpx; box-sizing: border-box; padding: 31rpx 34rpx 25rpx; border-radius: 17rpx; background-color: rgba(255,255,255,.97); }
    .policy-card-active { opacity: .78; }
    .policy-title { display: block; color: #222326; font-size: 34rpx; font-weight: 700; }
    .policy-desc { display: block; margin-top: 19rpx; color: #84868A; font-size: 27rpx; line-height: 39rpx; }
    /* 政策内容只显示一行,超出用省略号。 */
    .policy-desc { display: block; margin-top: 19rpx; color: #84868A; font-size: 27rpx; line-height: 39rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .policy-foot { display: flex; margin-top: 18rpx; flex-direction: row; align-items: center; justify-content: space-between; }
    .policy-tag { padding: 7rpx 9rpx; border-radius: 5rpx; background-color: #FFF4E7; color: #FF761D; font-size: 21rpx; }
    .policy-detail { color: #77797D; font-size: 25rpx; }
rencaika/services/business.uts
@@ -5,11 +5,26 @@
export const createIdempotencyKey = (scope: string): string => scope + '-' + Date.now() + '-' + Math.floor(Math.random() * 1000000000)
export const sanitizeRichText = (html: string): string => {
    return html
    if (html == null) return ''
    const base = getApiBaseUrl().replace(/\/+$/, '')
    const content = ('' + html)
        .replace(/<script[\s\S]*?<\/script>/gi, '')
        .replace(/<style[\s\S]*?<\/style>/gi, '')
        .replace(/\son[a-z]+\s*=\s*(['"]).*?\1/gi, '')
        .replace(/\s(href|src)\s*=\s*(['"])(?!https:\/\/).*?\2/gi, '')
        // 危险协议(javascript: 等)直接去掉属性。
        .replace(/\s(href|src)\s*=\s*(['"])\s*javascript:[^'"]*\2/gi, '')
    if (base == '') return content
    // 后台富文本里的图片/链接存的是相对路径(/uploads/xxx.png)。小程序加载不了相对路径,
    // 而原来这里会把所有非 https 的 src 整条删掉,图片就变成破图(只显示 alt 文字)。
    // 这里统一补成接口域名下的绝对地址,http(s) 绝对地址原样保留。
    return content
        .replace(/(src|href)=(["'])\/\//gi, '$1=$2https://')
        .replace(/(\s(?:src|href)\s*=\s*(['"]))\/+/gi, '$1' + base + '/')
        .replace(/(\s(?:src|href)\s*=\s*(['"]))(?!https?:|\/\/|\/|data:|javascript:)/gi, '$1' + base + '/')
        // 小程序 rich-text 不继承页面样式:后台图片带的是原图尺寸,会撑出卡片。
        // 在图片标签上补内联样式,按容器宽度等比缩放并居中。
        .replace(/(<img\b[^>]*?\sstyle=(["']))([^"']*)(\2)/gi, '$1$3max-width:100%;height:auto;display:block;margin:10px auto;$4')
        .replace(/<img\b(?![^>]*\sstyle=)/gi, '<img style="max-width:100%;height:auto;display:block;margin:10px auto;"')
}
export const requestDynamicCode = (codeType: string, businessId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void) => {
talent-card-admin/src/style.css
@@ -3812,7 +3812,8 @@
.movie-ticket-record-tools {
  display: flex;
  justify-content: space-between;
  /* 只剩"下载"一个按钮,靠右对齐(原来左边是"补录发放",该功能已下线)。 */
  justify-content: flex-end;
  padding: 18px 0 16px;
}
talent-card-admin/src/views/AppointmentManagementView.vue
@@ -29,6 +29,8 @@
function search() { pagination.page = 1; loadData() }
function resetFilters() { Object.assign(filters, { applicationType: '', keyword: '', serviceKeyword: '', cardLevel: '', status: '', submittedTime: [] }); search() }
function text(value: unknown) { return value === undefined || value === null || value === '' ? '-' : String(value) }
// 后端返回 ISO 时间(2026-09-01T11:29:35),列表统一显示成 "2026-09-01 11:29:35"。
function formatDateTime(value: unknown) { return typeof value === 'string' && value ? value.replace('T', ' ').slice(0, 19) : '-' }
async function handle(row: Record<string, any>, status = '已处理') { processingId.value = row.id; try { await updateServiceApplicationStatus(row.id, status, '管理员', status === '已驳回' ? '后台驳回' : ''); ElMessage.success('状态已更新'); await loadData() } finally { processingId.value = null } }
async function download() { const blob = await exportServiceApplications(queryParams() as any); saveBlob(blob, '预约申请列表.xlsx') }
onMounted(loadData)
@@ -56,7 +58,9 @@
        <el-table-column label="服务机构" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ text(row.serviceName || row.merchantName || row.hospitalName) }}</template></el-table-column>
        <el-table-column label="预约内容" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ text(row.appointmentContent || row.productName || row.packageName) }}</template></el-table-column>
        <el-table-column label="预约时间" min-width="150"><template #default="{ row }">{{ text(row.appointmentTime || row.expectedTime) }}</template></el-table-column>
        <el-table-column prop="submittedAt" label="提交时间" min-width="170" />
        <el-table-column prop="submittedAt" label="提交时间" min-width="170">
          <template #default="{ row }">{{ formatDateTime(row.submittedAt) }}</template>
        </el-table-column>
        <el-table-column prop="status" label="状态" min-width="110" />
        <el-table-column label="操作" width="170" fixed="right"><template #default="{ row }"><el-button link type="primary" :loading="processingId === row.id" @click="handle(row)">处理</el-button><el-button link type="danger" :loading="processingId === row.id" @click="handle(row, '已驳回')">驳回</el-button></template></el-table-column>
      </el-table>
talent-card-admin/src/views/CarWashBenefitView.vue
@@ -12,6 +12,8 @@
const configDialogVisible = ref(false)
const configForm = reactive({ merchantId: '', externalLinkType: 'miniProgram', externalMiniProgramAppId: '', externalMiniProgramPath: '', externalWebUrl: '', status: 1 })
const statusOptions = [{ label: '待审核', value: 0 }, { label: '已通过', value: 1 }, { label: '已驳回', value: 2 }, { label: '已回滚', value: 3 }]
// 后端返回 ISO 时间(2026-09-01T11:29:35),列表统一显示成 "2026-09-01 11:29:35"。
function formatDateTime(value?: string) { return typeof value === 'string' && value ? value.replace('T', ' ').slice(0, 19) : '' }
async function loadData() { loading.value = true; try { if (activeTab.value === 'configs') { const result = await getCarWashJumpConfigs({ page: pagination.page, size: pagination.size, keyword: filters.keyword || undefined }); rows.value = result.records || []; pagination.total = result.total || 0 } else { const result = await getCarWashCredentials({ page: pagination.page, size: pagination.size, keyword: filters.keyword || undefined, status: filters.status, startDate: filters.dateRange?.[0], endDate: filters.dateRange?.[1] }); rows.value = result.records || []; pagination.total = result.total || 0 } } finally { loading.value = false } }
function search() { pagination.page = 1; loadData() }
function resetFilters() { Object.assign(filters, { keyword: '', status: undefined, dateRange: [] }); search() }
@@ -29,7 +31,7 @@
      <el-tabs v-model="activeTab" @tab-change="switchTab"><el-tab-pane label="跳转配置" name="configs" /><el-tab-pane label="凭证审核" name="credentials" /></el-tabs>
      <el-form class="filter-form" :model="filters" label-width="84px"><el-form-item label="关键词"><el-input v-model="filters.keyword" clearable placeholder="商家、地址、手机号、凭证编号" /></el-form-item><el-form-item v-if="activeTab === 'credentials'" label="状态"><el-select v-model="filters.status" clearable placeholder="全部"><el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item v-if="activeTab === 'credentials'" label="日期"><el-date-picker v-model="filters.dateRange" type="daterange" value-format="YYYY-MM-DD" /></el-form-item><div class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="Refresh" @click="resetFilters">重置</el-button></div></el-form>
      <el-table v-if="activeTab === 'configs'" v-loading="loading" :data="rows" border empty-text="暂无跳转配置"><el-table-column prop="merchantId" label="商户ID" width="100" /><el-table-column prop="merchantName" label="商户名称" min-width="160" /><el-table-column prop="externalLinkType" label="跳转类型" min-width="110" /><el-table-column prop="externalMiniProgramAppId" label="小程序AppId" min-width="160" /><el-table-column prop="externalMiniProgramPath" label="小程序路径" min-width="200" show-overflow-tooltip /><el-table-column prop="externalWebUrl" label="网页地址" min-width="200" show-overflow-tooltip /><el-table-column label="状态" width="90"><template #default="{ row }">{{ Number(row.serviceStatus ?? row.status) === 1 ? '启用' : '停用' }}</template></el-table-column><el-table-column label="操作" width="120" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openConfig(row)">配置</el-button></template></el-table-column></el-table>
      <el-table v-else v-loading="loading" :data="rows" border empty-text="暂无洗车凭证"><el-table-column prop="credentialNo" label="凭证编号" min-width="130" /><el-table-column prop="talentName" label="持卡人" min-width="110" /><el-table-column prop="talentMobile" label="手机号" min-width="120" /><el-table-column prop="merchantName" label="商户" min-width="150" /><el-table-column prop="submitTime" label="提交时间" min-width="170" /><el-table-column prop="statusText" label="状态" min-width="110" /><el-table-column label="操作" width="210" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="audit(row, 1)">通过</el-button><el-button link type="danger" @click="audit(row, 2)">驳回</el-button><el-button link type="warning" @click="rollback(row)">回滚</el-button></template></el-table-column></el-table>
      <el-table v-else v-loading="loading" :data="rows" border empty-text="暂无洗车凭证"><el-table-column prop="credentialNo" label="凭证编号" min-width="130" /><el-table-column prop="talentName" label="持卡人" min-width="110" /><el-table-column prop="talentMobile" label="手机号" min-width="120" /><el-table-column prop="merchantName" label="商户" min-width="150" /><el-table-column prop="submitTime" label="提交时间" min-width="170"><template #default="{ row }">{{ formatDateTime(row.submitTime) }}</template></el-table-column><el-table-column prop="statusText" label="状态" min-width="110" /><el-table-column label="操作" width="210" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="audit(row, 1)">通过</el-button><el-button link type="danger" @click="audit(row, 2)">驳回</el-button><el-button link type="warning" @click="rollback(row)">回滚</el-button></template></el-table-column></el-table>
      <div class="pagination-row"><el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.size" background layout="total, prev, pager, next" :total="pagination.total" @change="loadData" /></div>
    </section>
    <el-dialog v-model="configDialogVisible" title="洗车跳转配置" width="620px"><el-form :model="configForm" label-width="130px"><el-form-item label="商户ID"><el-input v-model="configForm.merchantId" disabled /></el-form-item><el-form-item label="跳转类型"><el-select v-model="configForm.externalLinkType"><el-option label="第三方小程序" value="miniProgram" /><el-option label="网页" value="web" /><el-option label="不跳转" value="none" /></el-select></el-form-item><el-form-item label="小程序AppId"><el-input v-model="configForm.externalMiniProgramAppId" /></el-form-item><el-form-item label="小程序路径"><el-input v-model="configForm.externalMiniProgramPath" /></el-form-item><el-form-item label="网页地址"><el-input v-model="configForm.externalWebUrl" /></el-form-item><el-form-item label="状态"><el-switch v-model="configForm.status" :active-value="1" :inactive-value="0" /></el-form-item></el-form><template #footer><el-button @click="configDialogVisible = false">取消</el-button><el-button type="primary" @click="saveConfig">保存</el-button></template></el-dialog>
talent-card-admin/src/views/FuelServiceView.vue
@@ -155,7 +155,9 @@
        <el-table-column prop="talentMobile" label="持卡人电话" min-width="118" align="center" />
        <el-table-column prop="talentLevel" label="人才等级" min-width="100" align="center" />
        <el-table-column prop="alipayMobile" label="支付宝绑定手机号" min-width="132" align="center" />
        <el-table-column prop="submittedAt" label="提交时间" min-width="126" align="center" />
        <el-table-column prop="submittedAt" label="提交时间" min-width="126" align="center">
          <template #default="{ row }">{{ formatDateTime(row.submittedAt) }}</template>
        </el-table-column>
        <el-table-column prop="submissionType" label="提交类型" min-width="108" align="center" />
        <el-table-column prop="status" label="状态" min-width="108" align="center" />
        <el-table-column label="操作" width="86" fixed="right" align="center">
talent-card-admin/src/views/HotelReservationView.vue
@@ -159,8 +159,12 @@
        <el-table-column prop="merchantName" label="酒店等级" min-width="96" align="center" />
        <el-table-column prop="productName" label="商品名称" min-width="96" align="center" />
        <el-table-column prop="guestCount" label="入住人数" min-width="90" align="center" />
        <el-table-column prop="expectedTime" label="预约入住时间" min-width="132" align="center" />
        <el-table-column prop="submittedAt" label="提交时间" min-width="126" align="center" />
        <el-table-column prop="expectedTime" label="预约入住时间" min-width="132" align="center">
          <template #default="{ row }">{{ formatDateTime(row.expectedTime) }}</template>
        </el-table-column>
        <el-table-column prop="submittedAt" label="提交时间" min-width="126" align="center">
          <template #default="{ row }">{{ formatDateTime(row.submittedAt) }}</template>
        </el-table-column>
        <el-table-column prop="status" label="状态" min-width="108" align="center" />
        <el-table-column label="操作" width="86" fixed="right" align="center">
          <template #default="{ row }">
talent-card-admin/src/views/HotelVerificationView.vue
@@ -26,6 +26,11 @@
  total: 0,
})
// 后端返回的是 ISO 时间(2026-09-01T11:29:35),列表统一显示成 "2026-09-01 11:29:35"。
function formatDateTime(value?: string) {
  return typeof value === 'string' && value ? value.replace('T', ' ').slice(0, 19) : ''
}
async function loadData() {
  loading.value = true
  try {
@@ -75,7 +80,7 @@
    { title: '持卡人', value: row => row.talentName }, { title: '手机号', value: row => row.talentMobile },
    { title: '人才等级', value: row => row.talentLevel }, { title: '酒店', value: row => row.merchantName },
    { title: '权益', value: row => row.benefitName }, { title: '核销金额(元)', value: row => Number(row.verifyAmount || 0) / 100 },
    { title: '核销时间', value: row => row.verifyTime }, { title: '状态', value: row => row.status },
    { title: '核销时间', value: row => formatDateTime(row.verifyTime) }, { title: '状态', value: row => row.status },
  ], result.records)
  ElMessage.success(`已导出 ${result.records.length} 条核销记录`)
}
@@ -142,7 +147,9 @@
        <el-table-column prop="talentMobile" label="持卡人电话" min-width="140" align="center" />
        <el-table-column prop="talentLevel" label="人才等级" min-width="120" align="center" />
        <el-table-column prop="merchantName" label="商家名称" min-width="130" align="center" />
        <el-table-column prop="verifyTime" label="核销时间" min-width="150" align="center" />
        <el-table-column prop="verifyTime" label="核销时间" min-width="150" align="center">
          <template #default="{ row }">{{ formatDateTime(row.verifyTime) }}</template>
        </el-table-column>
        <el-table-column prop="status" label="状态" min-width="120" align="center" />
      </el-table>
talent-card-admin/src/views/MedicalApplicationView.vue
@@ -58,6 +58,11 @@
  loadData()
}
// 后端返回 ISO 时间(2026-09-01T11:29:35),列表统一显示成 "2026-09-01 11:29:35"。
function formatDateTime(value?: string) {
  return typeof value === 'string' && value ? value.replace('T', ' ').slice(0, 19) : ''
}
function resetFilters() {
  filters.talentKeyword = ''
  filters.hospitalName = ''
@@ -141,8 +146,12 @@
        <el-table-column prop="hospitalName" label="医院名称" min-width="96" align="center" />
        <el-table-column prop="applicantName" label="就医人姓名" min-width="96" align="center" />
        <el-table-column prop="applicantMobile" label="手机号" min-width="112" align="center" />
        <el-table-column prop="expectedTime" label="就医期望时间" min-width="118" align="center" />
        <el-table-column prop="submittedAt" label="提交时间" min-width="126" align="center" />
        <el-table-column prop="expectedTime" label="就医期望时间" min-width="118" align="center">
          <template #default="{ row }">{{ formatDateTime(row.expectedTime) }}</template>
        </el-table-column>
        <el-table-column prop="submittedAt" label="提交时间" min-width="126" align="center">
          <template #default="{ row }">{{ formatDateTime(row.submittedAt) }}</template>
        </el-table-column>
        <el-table-column prop="status" label="状态" min-width="82" align="center" />
        <el-table-column label="操作" width="86" fixed="right" align="center">
          <template #default="{ row }">
talent-card-admin/src/views/MedicalReservationView.vue
@@ -146,7 +146,9 @@
        <el-table-column prop="packageName" label="套餐名称" min-width="92" align="center" />
        <el-table-column prop="applicantName" label="体检人姓名" min-width="96" align="center" />
        <el-table-column prop="applicantMobile" label="手机号" min-width="112" align="center" />
        <el-table-column prop="expectedTime" label="体检期望时间" min-width="118" align="center" />
        <el-table-column prop="expectedTime" label="体检期望时间" min-width="118" align="center">
          <template #default="{ row }">{{ formatDateTime(row.expectedTime) }}</template>
        </el-table-column>
        <el-table-column label="提交时间" min-width="126" align="center">
          <template #default="{ row }">{{ formatDateTime(row.submittedAt) }}</template>
        </el-table-column>
talent-card-admin/src/views/MerchantCreateView.vue
@@ -1811,6 +1811,12 @@
  return Number(amount).toFixed(2).replace(/\.00$/, '')
}
// 销量是计数:0 也要显示成 0,不能因为 0 变空白让人以为没统计出来。
function salesCountText(row: Product) {
  const count = row.salesCount
  return count === undefined || count === null ? 0 : count
}
function parseJsonValue<T>(value: unknown, fallback: T): T {
  if (!value) return fallback
  if (typeof value !== 'string') return value as T
@@ -3103,7 +3109,7 @@
              <template #default="{ row }">{{ productAmountText(row) }}</template>
            </el-table-column>
            <el-table-column v-if="!isHotelMerchant && !isHealthCheckMerchant" prop="salesCount" label="销量" min-width="90">
              <template #default="{ row }">{{ row.salesCount || '' }}</template>
              <template #default="{ row }">{{ salesCountText(row) }}</template>
            </el-table-column>
            <el-table-column label="修改时间" min-width="90">
              <template #default="{ row }">{{ formatDisplayTime(row.submitTime || row.createdAt) }}</template>
@@ -3561,7 +3567,9 @@
                <el-table-column label="商品金额" min-width="98">
                  <template #default="{ row }">{{ productAmountText(row) }}</template>
                </el-table-column>
                <el-table-column prop="salesCount" label="销量" min-width="98" />
                <el-table-column prop="salesCount" label="销量" min-width="98">
                  <template #default="{ row }">{{ salesCountText(row) }}</template>
                </el-table-column>
                <el-table-column label="修改时间" min-width="98">
                  <template #default="{ row }">{{ formatDisplayTime(row.submitTime || row.createdAt) }}</template>
                </el-table-column>
talent-card-admin/src/views/MovieTicketRecordView.vue
@@ -24,6 +24,11 @@
  total: 0,
})
// 后端返回 ISO 时间(2026-09-01T11:29:35),列表统一显示成 "2026-09-01 11:29:35"。
function formatDateTime(value?: string) {
  return typeof value === 'string' && value ? value.replace('T', ' ').slice(0, 19) : ''
}
async function loadData() {
  loading.value = true
  try {
@@ -118,7 +123,9 @@
        <el-table-column prop="talentName" label="持卡人名称" min-width="130" align="center" />
        <el-table-column prop="talentMobile" label="持卡人电话" min-width="130" align="center" />
        <el-table-column prop="talentLevel" label="人才等级" min-width="120" align="center" />
        <el-table-column prop="receivedAt" label="领取时间" min-width="150" align="center" />
        <el-table-column prop="receivedAt" label="领取时间" min-width="150" align="center">
          <template #default="{ row }">{{ formatDateTime(row.receivedAt) }}</template>
        </el-table-column>
      </el-table>
      <div class="pagination-row">
talent-card-admin/src/views/MovieTicketView.vue
@@ -11,7 +11,6 @@
  getTicketPackageCodes,
  getTicketPackages,
  importTicketPackageCodes,
  supplementTicketPackageClaim,
  updateTicketPackage,
  updateTicketPackageStatus,
  uploadAdminImage,
@@ -29,7 +28,6 @@
const merchants = ref<Record<string, any>[]>([])
const codeDialogVisible = ref(false)
const generateDialogVisible = ref(false)
const supplementVisible = ref(false)
const merchantDialogVisible = ref(false)
const editingId = ref<number | string | null>(null)
const activePackage = ref<Record<string, any> | null>(null)
@@ -90,7 +88,6 @@
const codeForm = reactive({ batchNo: '', codesText: '', remark: '' })
const generateForm = reactive({ batchNo: '', codePrefix: 'EX', codeCount: 100, remark: '' })
const supplementForm = reactive({ packageId: '', codeId: '', exchangeCode: '', talentId: '', cardId: '', remark: '' })
const title = computed(() => {
  if (mode.value === 'claims') return '票包领取记录'
@@ -388,25 +385,6 @@
  await loadData()
}
function openSupplement() {
  Object.assign(supplementForm, { packageId: rowId(selectedPackage.value || {}) || '', codeId: '', exchangeCode: '', talentId: '', cardId: '', remark: '' })
  supplementVisible.value = true
}
async function supplement() {
  await supplementTicketPackageClaim({
    packageId: Number(supplementForm.packageId),
    codeId: supplementForm.codeId ? Number(supplementForm.codeId) : undefined,
    exchangeCode: supplementForm.exchangeCode || undefined,
    talentId: Number(supplementForm.talentId),
    cardId: supplementForm.cardId ? Number(supplementForm.cardId) : undefined,
    remark: supplementForm.remark,
  })
  ElMessage.success('补录成功')
  supplementVisible.value = false
  await loadData()
}
async function download() {
  if (mode.value === 'claims') {
    const result = await getTicketPackageClaims({ ...claimParams(), page: 1, size: 1000 })
@@ -517,7 +495,6 @@
        </el-form>
        <div class="movie-ticket-record-tools">
          <el-button type="primary" @click="openSupplement">补录发放</el-button>
          <el-button v-permission="'admin.movie-tickets.export'" :icon="Download" @click="download">下载</el-button>
        </div>
@@ -665,16 +642,5 @@
      </template>
    </el-dialog>
    <el-dialog v-model="supplementVisible" title="补录发放记录" width="560px">
      <el-form :model="supplementForm" label-width="100px">
        <el-form-item label="票包ID"><el-input v-model="supplementForm.packageId" disabled /></el-form-item>
        <el-form-item label="兑换码ID"><el-input v-model="supplementForm.codeId" /></el-form-item>
        <el-form-item label="兑换码值"><el-input v-model="supplementForm.exchangeCode" /></el-form-item>
        <el-form-item label="持卡人ID"><el-input v-model="supplementForm.talentId" /></el-form-item>
        <el-form-item label="人才卡ID"><el-input v-model="supplementForm.cardId" /></el-form-item>
        <el-form-item label="备注"><el-input v-model="supplementForm.remark" /></el-form-item>
      </el-form>
      <template #footer><el-button @click="supplementVisible = false">取消</el-button><el-button type="primary" @click="supplement">补录</el-button></template>
    </el-dialog>
  </div>
</template>
talent-card-admin/src/views/ProductView.vue
@@ -62,6 +62,12 @@
  return Number(amount).toFixed(2).replace(/\.00$/, '')
}
// 销量是计数:0 也要显示成 0,不能因为 0 变空白让人以为没统计出来。
function salesText(row: Product) {
  const count = row.salesCount
  return count === undefined || count === null ? 0 : count
}
function formatDateTime(value?: string) {
  return value ? value.replace('T', ' ') : ''
}
@@ -200,7 +206,7 @@
          <template #default="{ row }">{{ amountText(row) }}</template>
        </el-table-column>
        <el-table-column prop="salesCount" label="销量" width="110" align="center">
          <template #default="{ row }">{{ row.salesCount || '' }}</template>
          <template #default="{ row }">{{ salesText(row) }}</template>
        </el-table-column>
        <el-table-column label="提交修改时间" width="145" align="center">
          <template #default="{ row }">{{ formatDateTime(row.submitTime || row.createdAt) }}</template>
talent-card-admin/src/views/SpouseEmploymentView.vue
@@ -142,7 +142,9 @@
        <el-table-column prop="talentLevel" label="人才等级" min-width="96" align="center" />
        <el-table-column prop="spouseName" label="配偶姓名" min-width="96" align="center" />
        <el-table-column prop="spouseMobile" label="配偶手机号" min-width="116" align="center" />
        <el-table-column prop="submittedAt" label="提交时间" min-width="126" align="center" />
        <el-table-column prop="submittedAt" label="提交时间" min-width="126" align="center">
          <template #default="{ row }">{{ formatDateTime(row.submittedAt) }}</template>
        </el-table-column>
        <el-table-column prop="status" label="状态" min-width="120" align="center" />
        <el-table-column label="操作" width="86" fixed="right" align="center">
          <template #default="{ row }">
talent-card-admin/src/views/StaffManagementView.vue
@@ -105,6 +105,18 @@
);
const roleName = (code: string) =>
  roles.value.find((r) => r.roleCode === code)?.roleName || code;
// 停用角色不能分配给员工,但员工列表仍要能显示出它的名称;
// 之前只保留启用角色,导致停用角色的员工显示成 role_1789541215146 这种代码。
const enabledRoles = computed(() => roles.value.filter((r) => r.status === 1));
const formRoleOptions = computed(() => {
  const options = enabledRoles.value.slice();
  const current = form.roleCode;
  if (current && !options.some((r) => r.roleCode === current)) {
    const matched = roles.value.find((r) => r.roleCode === current);
    if (matched) options.push(matched); // 编辑时保留原角色,避免下拉框显示成代码
  }
  return options;
});
const formatDateTime = (value?: string) =>
  value ? value.replace("T", " ").slice(0, 19) : "";
const isDefaultAdmin = (row: AdminStaff) => row.account === "admin";
@@ -130,7 +142,7 @@
  try {
    const result = await Promise.all([getAdminStaff(), getAdminRoles()]);
    rows.value = result[0];
    roles.value = result[1].filter((r) => r.status === 1);
    roles.value = result[1];
  } finally {
    loading.value = false;
  }
@@ -148,7 +160,7 @@
    account: "",
    password: "",
    confirmPassword: "",
    roleCode: roles.value[0]?.roleCode || "",
    roleCode: enabledRoles.value[0]?.roleCode || "",
    positionName: "",
    customerWechat: "",
    status: 1,
@@ -367,7 +379,7 @@
        ><el-form-item label="角色" prop="roleCode" required
          ><el-select v-model="form.roleCode" class="full"
            ><el-option
              v-for="role in roles"
              v-for="role in formRoleOptions"
              :key="role.id"
              :label="role.roleName"
              :value="role.roleCode" /></el-select></el-form-item
talent-card-admin/src/views/TalentListView.vue
@@ -290,7 +290,7 @@
      <div class="talent-import-dialog">
        <div class="talent-import-guide">
          <strong>导入说明</strong>
          <span>模板中的 11 项均为必填;服务专员须填写已启用的服务专员姓名。单次最多导入 1000 条,仅支持 .xlsx 文件。</span>
          <span>模板中的 11 项均为必填;服务专员须填写「系统管理 - 员工管理」里已启用员工的姓名。单次最多导入 1000 条,仅支持 .xlsx 文件。</span>
          <el-button link type="primary" :icon="Download" @click="downloadImportTemplate">下载导入模板</el-button>
        </div>
talent-card-admin/src/views/UpgradeApplicationDetailView.vue
@@ -42,7 +42,12 @@
async function approve() {
  if (!detail.value) return
  await ElMessageBox.confirm('确认通过该升级申请?通过后将更新持卡人等级。', '审核通过', { type: 'warning' })
  const target = detail.value.targetLevel ? `「${detail.value.targetLevel}」` : '申请等级'
  await ElMessageBox.confirm(
    `确认通过该升级申请?系统不会自动修改持卡人等级,通过后请到「持卡人管理」手动把该持卡人等级改为${target}。`,
    '审核通过',
    { type: 'warning' },
  )
  await process('已通过')
}
@@ -57,7 +62,11 @@
  processing.value = true
  try {
    detail.value = await auditUpgradeApplication(detail.value.id, status, remark)
    ElMessage.success(status === '已通过' ? '升级申请已通过' : '升级申请已驳回')
    ElMessage.success(
      status === '已通过'
        ? '升级申请已通过,请到「持卡人管理」手动修改该持卡人的等级'
        : '升级申请已驳回',
    )
  } finally {
    processing.value = false
  }
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java
@@ -180,8 +180,8 @@
        if (status != null) { sql.append(" and status = ?"); countSql.append(" and status = ?"); args.add(status); }
        if (publishStart != null && !publishStart.isBlank()) { sql.append(" and date(publish_time) >= ?"); countSql.append(" and date(publish_time) >= ?"); args.add(publishStart); }
        if (publishEnd != null && !publishEnd.isBlank()) { sql.append(" and date(publish_time) <= ?"); countSql.append(" and date(publish_time) <= ?"); args.add(publishEnd); }
        // 置顶优先,置顶组内按置顶时间倒序(越后置顶越靠前);非置顶按排序值倒序。
        sql.append(" order by is_top desc, case when is_top = 1 then top_time end desc, sort_no desc, publish_time desc, id desc");
        // 置顶优先;置顶组内按“修改时间”倒序(最新修改的排前面,和人才端人才资讯一致),非置顶按排序值倒序。
        sql.append(" order by is_top desc, case when is_top = 1 then updated_at end desc, sort_no desc, publish_time desc, id desc");
        return page(sql.toString(), countSql.toString(), page, size, args.toArray());
    }
@@ -858,13 +858,13 @@
                select p.id, p.id as product_id, p.merchant_id, m.merchant_name, m.merchant_type, m.merchant_type_code, m.contact_mobile,
                       p.product_no, p.product_type, p.product_name, p.subtitle, p.cover_url,
                       p.original_price, p.sale_price, p.subsidy_amount, p.is_government_subsidy,
                       p.stock, p.sales_count, p.detail_text, p.benefit_desc, p.purchase_notice,
                       p.stock, %s, p.detail_text, p.benefit_desc, p.purchase_notice,
                       p.valid_start_time, p.valid_end_time, p.product_images, p.rights_config,
                       p.audit_status, p.audit_remark, p.status, p.sort_no, p.submit_time, p.created_at, p.updated_at
                from merchant_product p
                left join merchant m on m.id = p.merchant_id
                where p.deleted = 0 and p.merchant_id = ?
                """);
                """.formatted(salesCountSelect("p.id", "p")));
        StringBuilder countSql = new StringBuilder("""
                select count(*)
                from merchant_product p
@@ -913,7 +913,7 @@
                select r.id, r.product_id, r.merchant_id, m.merchant_name, m.merchant_type, m.merchant_type_code, m.contact_mobile,
                       r.product_no, r.product_type, r.product_name,
                       r.subtitle, r.cover_url, r.original_price, r.sale_price, r.subsidy_amount,
                       r.is_government_subsidy, r.stock, r.sales_count, r.detail_text, r.benefit_desc,
                       r.is_government_subsidy, r.stock, %s, r.detail_text, r.benefit_desc,
                       r.purchase_notice, r.valid_start_time, r.valid_end_time, r.product_images, r.rights_config,
                       r.audit_status, r.audit_remark, r.audit_by, r.audit_time, r.submit_time,
                       r.source_created_at as created_at, p.status
@@ -921,7 +921,7 @@
                left join merchant_product p on p.id = r.product_id
                left join merchant m on m.id = r.merchant_id
                where r.deleted = 0
                """);
                """.formatted(salesCountSelect("r.product_id", "p")));
        StringBuilder countSql = new StringBuilder("""
                select count(*)
                from merchant_product_audit_record r
@@ -980,13 +980,13 @@
                select p.id, p.merchant_id, m.merchant_name, m.merchant_type, m.merchant_type_code, m.contact_mobile,
                       p.product_no, p.product_type, p.product_name,
                       p.subtitle, p.cover_url, p.original_price, p.sale_price, p.subsidy_amount,
                       p.is_government_subsidy, p.stock, p.sales_count, p.detail_text, p.benefit_desc,
                       p.is_government_subsidy, p.stock, %s, p.detail_text, p.benefit_desc,
                       p.purchase_notice, p.valid_start_time, p.valid_end_time, p.product_images, p.rights_config,
                       p.audit_status, p.audit_remark, p.status, p.sort_no, p.submit_time, p.created_at, p.updated_at
                from merchant_product p
                left join merchant m on m.id = p.merchant_id
                where p.deleted = 0 and p.id = ?
                """, id);
                """.formatted(salesCountSelect("p.id", "p")), id);
    }
    @Transactional(readOnly = true)
@@ -995,7 +995,7 @@
                select r.id, r.product_id, r.merchant_id, m.merchant_name, m.merchant_type, m.merchant_type_code, m.contact_mobile,
                       r.product_no, r.product_type, r.product_name, r.subtitle, r.cover_url,
                       r.original_price, r.sale_price, r.subsidy_amount, r.is_government_subsidy,
                       r.stock, r.sales_count, r.detail_text, r.benefit_desc, r.purchase_notice,
                       r.stock, %s, r.detail_text, r.benefit_desc, r.purchase_notice,
                       r.valid_start_time, r.valid_end_time, r.product_images, r.rights_config,
                       r.audit_status, r.audit_remark, r.audit_by, r.audit_time, r.submit_time,
                       r.source_created_at as created_at, p.status
@@ -1003,7 +1003,7 @@
                left join merchant_product p on p.id = r.product_id
                left join merchant m on m.id = r.merchant_id
                where r.deleted = 0 and r.id = ?
                """, auditRecordId);
                """.formatted(salesCountSelect("r.product_id", "p")), auditRecordId);
    }
    @Transactional
@@ -1580,6 +1580,18 @@
        return Boolean.TRUE.equals(value == null ? defaultValue : value) ? (byte) 1 : (byte) 0;
    }
    /**
     * 团单销量 = 该商品的有效订单数(已支付,且未取消/未退款)。
     *
     * 商品表上的 sales_count 只在部分流程(如酒店房型预约)里累加,普通团单下单后一直是 0,
     * 后台列表就统计不出销量。这里直接按订单算,同时保留 sales_count 作为下限,兼容历史数据。
     */
    String salesCountSelect(String productIdExpression, String productAlias) {
        return "greatest(coalesce(" + productAlias + ".sales_count, 0), (select count(*) from benefit_order bo"
                + " where bo.deleted = 0 and bo.product_id = " + productIdExpression
                + " and bo.order_status = 1 and (bo.refund_status is null or bo.refund_status = 0))) as sales_count";
    }
    private List<Long> couponProductIds(Long couponId) {
        return jdbcTemplate.queryForList(
                "select product_id from merchant_coupon_product where coupon_id = ? order by id",
@@ -1589,13 +1601,13 @@
    private List<Map<String, Object>> couponProducts(Long couponId) {
        return DatabaseRows.normalize(jdbcTemplate.queryForList("""
                select p.id, p.product_name, p.cover_url, p.sale_price, p.sales_count, p.submit_time, p.created_at,
                select p.id, p.product_name, p.cover_url, p.sale_price, %s, p.submit_time, p.created_at,
                       p.audit_status, p.status
                from merchant_coupon_product cp
                join merchant_product p on p.id = cp.product_id
                where cp.coupon_id = ? and p.deleted = 0
                order by cp.id
                """, couponId));
                """.formatted(salesCountSelect("p.id", "p")), couponId));
    }
    private void syncCouponProducts(Long couponId, List<Long> productIds, String productScope) {
talent-card-server/src/main/java/com/talentcard/server/business/card/CardAdminService.java
@@ -206,16 +206,8 @@
        if (!"待审核".equals(application.get("status"))) {
            throw new BusinessException(ErrorCode.CONFLICT, "该申请已审核");
        }
        if ("已通过".equals(status) && application.get("talentId") != null) {
            jdbcTemplate.update("update talent_user set talent_level = ? where deleted = 0 and id = ?",
                    application.get("targetLevel"),
                    application.get("talentId"));
            jdbcTemplate.update("""
                    update talent_card
                    set card_level = ?
                    where deleted = 0 and talent_id = ? and card_type = 'PERSONAL'
                    """, application.get("targetLevel"), application.get("talentId"));
        }
        // 通过升级申请只改申请单状态:持卡人等级由运营在「持卡人管理」里手动修改
        //(申请等级只作参考,实际等级以后台操作为准),所以这里不再自动改 talent_level / card_level。
        jdbcTemplate.update("""
                update upgrade_application
                set status = ?, audit_time = now(), auditor_name = ?, audit_remark = ?
talent-card-server/src/main/java/com/talentcard/server/business/portal/ChildrenEntrustmentPortalService.java
@@ -36,7 +36,9 @@
        result.put("heroImage", merchantHeroImage(serviceId,
                text(merchant.get("coverUrl"), text(benefit.get("heroImage"), ""))));
        result.put("heroImages", merchantHeroImages(serviceId));
        result.put("rightsText", text(matchedRight.get("detail"), text(benefit.get("rightsText"), "暂无使用说明")));
        // 使用说明只认商家权益里配置的内容:商家没配(或该人才等级没配)就返回空,
        // 小程序显示"暂无使用说明",不再回落到服务权益里的历史默认文案。
        result.put("rightsText", text(matchedRight.get("detail"), ""));
        result.put("available", available);
        result.put("unavailableReason", available ? "" : text(benefit.get("unavailableReason"), "该权益暂未开放"));
        return result;
@@ -45,25 +47,9 @@
    @Transactional(readOnly = true)
    public List<Map<String, Object>> schools(Long serviceId) {
        Map<String, Object> merchant = approvalService(serviceId);
        List<Map<String, Object>> configuredSchools = configuredSchools(merchant.get("usageInstructions"));
        if (!configuredSchools.isEmpty()) return configuredSchools;
        Map<String, Object> benefit = benefit();
        return query("""
                select id, school_name, address, latitude, longitude, contact_mobile, detail_available
                from children_entrustment_school
                where benefit_id = ? and enabled = 1 and deleted = 0
                order by sort_no asc, id asc
                """, benefit.get("id")).stream().map(row -> {
            Map<String, Object> school = new LinkedHashMap<>();
            school.put("id", row.get("id"));
            school.put("name", row.get("schoolName"));
            school.put("address", text(row.get("address"), "暂未配置学校地址"));
            school.put("latitude", row.get("latitude"));
            school.put("longitude", row.get("longitude"));
            school.put("phone", text(row.get("contactMobile"), ""));
            school.put("detailAvailable", number(row.get("detailAvailable")) == 1);
            return school;
                }).toList();
        // 只返回商家自己配置的适用学校;没配就是空列表(小程序显示"暂无适用学校"),
        // 不再回落到历史全局学校表,避免新添加的商家显示别人的学校。
        return configuredSchools(merchant.get("usageInstructions"));
    }
    private List<String> merchantHeroImages(Long merchantId) {
@@ -140,11 +126,8 @@
            result.put("content", matched.get("content"));
            return result;
        }
        try {
            return school(Long.parseLong(schoolId));
        } catch (NumberFormatException exception) {
        // 商家没配学校时不去查历史全局学校表,直接按"学校不存在"返回。
            throw new BusinessException(ErrorCode.NOT_FOUND, "学校不存在或暂未开放");
        }
    }
    @Transactional(readOnly = true)
talent-card-server/src/main/java/com/talentcard/server/business/portal/ParkingMerchantPortalService.java
@@ -65,8 +65,11 @@
        String parkingType = text(right == null ? null : right.get("parkingType"), "paid");
        // 付费权益到店出示人才码,免费权益先登记车牌。
        result.put("actionMode", "paid".equalsIgnoreCase(parkingType) ? "talentCode" : "plate");
        result.put("plateFormMode", text(benefit.get("plateFormMode"), "simple"));
        result.put("benefit", benefitView(benefit));
        // 车牌表单模式以商家权益里配的「停车场选择范围」为准:
        // 全部可选(all)只要填车牌;只选其一(selected)才需要挑停车场。
        String plateFormMode = effectivePlateFormMode(right, benefit);
        result.put("plateFormMode", plateFormMode);
        result.put("benefit", benefitView(benefit, plateFormMode));
        result.put("applicableStores", parkingStores(merchant.get("usageInstructions")));
        result.put("merchantRightsConfig", rights);
        result.put("merchantRight", right == null ? Map.of() : right);
@@ -127,7 +130,7 @@
            throw new BusinessException(ErrorCode.CONFLICT, "付费停车权益无需登记车牌");
        }
        Map<String, Object> selectedParkingLot = null;
        if ("withParking".equals(benefit.get("plateFormMode"))) {
        if (requiresParkingLotSelection(right, benefit)) {
            if (request.parkingLotId() == null) throw new BusinessException(ErrorCode.BAD_REQUEST, "请选择停车场");
            selectedParkingLot = parkingStores(merchant.get("usageInstructions")).stream()
                    .filter(item -> String.valueOf(request.parkingLotId()).equals(String.valueOf(item.get("id"))))
@@ -254,15 +257,34 @@
                text(parkingLot.get("storeName"), text(parkingLot.get("name"), "")));
    }
    private Map<String, Object> benefitView(Map<String, Object> benefit) {
    private Map<String, Object> benefitView(Map<String, Object> benefit, String plateFormMode) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("benefitId", benefit.get("id"));
        result.put("actionMode", text(benefit.get("actionMode"), "plate"));
        result.put("plateFormMode", text(benefit.get("plateFormMode"), "simple"));
        result.put("plateFormMode", plateFormMode);
        result.put("validEnd", benefit.get("validEnd"));
        return result;
    }
    /**
     * 是否需要小程序里挑一个停车场。
     * 以商家权益(merchant_rights_config)里的 parkingScope 为准:selected/ONE=只选其一,all=全部可选;
     * 权益里没配范围的旧数据,继续沿用停车权益表的 plate_form_mode。
     */
    private boolean requiresParkingLotSelection(Map<String, Object> right, Map<String, Object> benefit) {
        if (right != null) {
            String scope = text(right.get("parkingScope"), "").trim().toUpperCase(Locale.ROOT);
            if (!scope.isBlank()) {
                return "SELECTED".equals(scope) || "ONE".equals(scope) || scope.contains("只选") || scope.contains("其一");
            }
        }
        return "withParking".equalsIgnoreCase(text(benefit.get("plateFormMode"), ""));
    }
    private String effectivePlateFormMode(Map<String, Object> right, Map<String, Object> benefit) {
        return requiresParkingLotSelection(right, benefit) ? "withParking" : "simple";
    }
    private Map<String, Object> registrationView(Map<String, Object> row) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("registrationId", row.get("id"));
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java
@@ -118,7 +118,12 @@
    @Transactional
    public Map<String, Object> newsDetail(Long id) {
        int updated = jdbcTemplate.update("update news_article set view_count = view_count + 1 where id = ? and deleted = 0 and status = 1", id);
        // 浏览计数不能顺手刷新 updated_at:置顶组是按“修改时间”倒序排的,
        // 否则哪篇被点得多,哪篇就被顶到置顶组最前面,看起来像没按修改时间排。
        int updated = jdbcTemplate.update("""
                update news_article set view_count = view_count + 1, updated_at = updated_at
                where id = ? and deleted = 0 and status = 1
                """, id);
        if (updated == 0) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "资讯不存在");
        }
@@ -148,7 +153,11 @@
    @Transactional
    public Map<String, Object> policy(Long id) {
        int updated = jdbcTemplate.update("update policy_article set view_count = view_count + 1 where id = ? and deleted = 0 and status = 1", id);
        // 同上:浏览政策详情不应该改“修改时间”(后台详情页会展示修改人/修改时间)。
        int updated = jdbcTemplate.update("""
                update policy_article set view_count = view_count + 1, updated_at = updated_at
                where id = ? and deleted = 0 and status = 1
                """, id);
        if (updated == 0) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "政策已下架或不存在");
        }
@@ -902,7 +911,7 @@
        String serviceWhere = " where m.deleted = 0 and m.merchant_type_code = ?";
        PageResult<Map<String, Object>> result = page("""
                select m.id, m.merchant_name, m.merchant_type_code as merchant_type, m.contact_mobile, m.cover_url, m.address,
                       m.service_label, m.status, m.sort_no, m.longitude, m.latitude,
                       m.service_label, m.merchant_rights_config, m.status, m.sort_no, m.longitude, m.latitude,
                       mapping.sort_no as topic_sort_no
                from merchant m
                left join premium_topic_merchant mapping on mapping.merchant_id = m.id and mapping.deleted = 0 and mapping.category_key = ?
@@ -914,16 +923,31 @@
            item.put("businessType", merchantType.premiumBusinessType());
            item.put("targetAction", merchantType.premiumBusinessType());
            boolean tagVisible = !TAGLESS_MERCHANT_TYPES.contains(merchantType.code());
            String benefitText = tagVisible ? textOrDefault(row.get("serviceLabel"), "人才专享") : "";
            // 标签只用后台配的:先取商家权益里第一个启用等级的标签,再退回商家服务标签;
            // 都没配就留空(后台标签是必填项,不再兜底"人才专享")。
            String benefitText = tagVisible ? configuredMerchantTag(row.get("merchantRightsConfig"), row.get("serviceLabel")) : "";
            // 没有标签文本时把 visible 置 false,小程序就不会渲染空标签块。
            boolean tagPresent = tagVisible && !benefitText.isBlank();
            item.put("benefitText", benefitText);
            item.put("discountText", benefitText);
            item.put("benefitTags", tagVisible ? List.of(benefitText) : List.of());
            item.put("benefitTagVisible", tagVisible);
            item.put("benefitTags", tagPresent ? List.of(benefitText) : List.of());
            item.put("benefitTagVisible", tagPresent);
            item.remove("merchantRightsConfig");
            return item;
        }).toList();
        return new PageResult<>(records, result.page(), result.size(), result.total());
    }
    /** 列表标签只用后台配置:商家权益里第一个启用等级的标签 → 商家服务标签 → 空(不兜底默认文案)。 */
    String configuredMerchantTag(Object rightsConfig, Object serviceLabel) {
        for (Map<String, Object> right : merchantRightsConfigs(rightsConfig)) {
            if (!booleanValue(right.get("enabled"))) continue;
            String tag = textOrDefault(right.get("tag"), "");
            if (!tag.isBlank()) return tag;
        }
        return textOrDefault(serviceLabel, "");
    }
    private Map<String, Object> enrichMerchantSearchRow(Map<String, Object> row) {
        Map<String, Object> result = new LinkedHashMap<>(row);
        String merchantType = String.valueOf(result.getOrDefault("merchantType", ""));
talent-card-server/src/main/java/com/talentcard/server/business/talent/TalentService.java
@@ -53,6 +53,15 @@
        if (cardCount != null && cardCount > 0) {
            throw new BusinessException(ErrorCode.CONFLICT, "人才卡号已存在");
        }
        String mobile = blankToNull(request.mobile());
        // 一个手机号只能存在一个账号:新建持卡人前先查重(编辑手机号走 updateMobile,同样有查重)。
        if (mobile != null) {
            Integer mobileCount = jdbcTemplate.queryForObject(
                    "select count(*) from talent_user where deleted = 0 and mobile = ?", Integer.class, mobile);
            if (mobileCount != null && mobileCount > 0) {
                throw new BusinessException(ErrorCode.CONFLICT, "该手机号已存在账号,一个手机号只能存在一个账号");
            }
        }
        jdbcTemplate.update("""
                insert into talent_user(openid, name, mobile, id_card_no, gender, work_unit, talent_level,
                                        source_area_type, work_address, contact_address)
@@ -60,7 +69,7 @@
                """,
                blankToNull(request.openid()),
                request.name(),
                blankToNull(request.mobile()),
                mobile,
                blankToNull(request.idCardNo()),
                request.gender(),
                blankToNull(request.workUnit()),
@@ -386,7 +395,7 @@
    public Map<String, Object> importExcel(InputStream inputStream) {
        List<Map<String, Object>> failures = new ArrayList<>();
        List<TalentCreateRequest> validRows = new ArrayList<>();
        List<Long> advisorIds = new ArrayList<>();
        List<Long> staffIds = new ArrayList<>();
        Set<String> fileMobiles = new HashSet<>();
        Set<String> fileIdCards = new HashSet<>();
        Set<String> fileCardNos = new HashSet<>();
@@ -424,15 +433,16 @@
                    failures.add(importFailure(rowIndex + 1, name, error));
                    continue;
                }
                Long advisorId = advisorIdByName(advisorName);
                if (advisorId == null) {
                    failures.add(importFailure(rowIndex + 1, name, advisorName.isBlank() ? "服务专员不能为空" : "服务专员不存在、已停用或名称不唯一"));
                Long staffId = serviceStaffIdByName(advisorName);
                if (staffId == null) {
                    failures.add(importFailure(rowIndex + 1, name,
                            advisorName.isBlank() ? "服务专员不能为空" : "服务专员不存在、已停用或在员工管理中姓名不唯一"));
                    continue;
                }
                fileMobiles.add(mobile);
                fileIdCards.add(idCardNo);
                fileCardNos.add(talentCardNo);
                advisorIds.add(advisorId);
                staffIds.add(staffId);
                validRows.add(new TalentCreateRequest(null, name, mobile, idCardNo, genderValue(gender), workUnit, level, talentCardNo,
                        "外地".equals(area) ? "OUTSIDE" : "LOCAL", workAddress, contactAddress));
            }
@@ -447,7 +457,8 @@
        for (int index = 0; index < validRows.size(); index++) {
            Map<String, Object> created = create(validRows.get(index));
            updateAdvisor(((Number) created.get("id")).longValue(), advisorIds.get(index));
            // 服务专员写进专属服务人员字段(和「持卡人管理」里选服务人员是同一个字段)。
            updateServiceStaff(((Number) created.get("id")).longValue(), staffIds.get(index));
        }
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("successCount", validRows.size());
@@ -557,11 +568,18 @@
        return null;
    }
    private Long advisorIdByName(String advisorName) {
        if (advisorName == null || advisorName.isBlank()) return null;
        List<Long> ids = jdbcTemplate.queryForList(
                "select id from talent_card_advisor where status = 1 and advisor_name = ?",
                Long.class, advisorName.trim());
    /**
     * 导入时"服务专员"填的是员工姓名,对应「系统管理 → 员工管理」里的员工(admin_operator)。
     *
     * 以前查的是老的 talent_card_advisor 顾问表,员工管理里新建的人根本查不到,
     * 于是导入一直报"服务专员不存在"。这里改成按员工姓名查(未删除、已启用、且姓名唯一)。
     */
    Long serviceStaffIdByName(String staffName) {
        if (staffName == null || staffName.isBlank()) return null;
        List<Long> ids = jdbcTemplate.queryForList("""
                select id from admin_operator
                where deleted = 0 and status = 1 and display_name = ?
                """, Long.class, staffName.trim());
        return ids.size() == 1 ? ids.get(0) : null;
    }
talent-card-server/src/test/java/com/talentcard/server/business/admin/AdminBusinessServiceTests.java
@@ -42,4 +42,18 @@
    void supportsMerchantAppLoginForHotelMerchants() {
        assertThat(service.supportsMerchantAppLogin("酒店服务")).isTrue();
    }
    @Test
    void productSalesCountComesFromValidOrders() {
        // 商品表上的 sales_count 只有部分流程会累加,销量必须按有效订单算,否则后台列表一直是空的。
        String salesSelect = service.salesCountSelect("r.product_id", "p");
        assertThat(salesSelect)
                .contains("greatest")
                .contains("from benefit_order bo")
                .contains("bo.product_id = r.product_id")
                .contains("bo.order_status = 1")
                .contains("bo.refund_status is null or bo.refund_status = 0")
                .endsWith("as sales_count");
    }
}
talent-card-server/src/test/java/com/talentcard/server/business/portal/ChildrenEntrustmentPortalServiceTests.java
@@ -39,4 +39,16 @@
                .containsEntry("address", "学校地址")
                .containsEntry("content", "<p>后台配置的学校详情</p>");
    }
    @Test
    void unconfiguredMerchantHasNoSchools() {
        Long serviceId = 91L;
        // 新添加、还没配学校的商家:不能回落到历史全局学校表(否则会显示别人的学校)。
        when(jdbc.queryForList(anyString(), eq(serviceId))).thenReturn(List.of(Map.of(
                "id", serviceId,
                "merchant_type_code", "children",
                "usage_instructions", "")));
        assertThat(service.schools(serviceId)).isEmpty();
    }
}
talent-card-server/src/test/java/com/talentcard/server/business/portal/ParkingMerchantPortalServiceTests.java
@@ -43,4 +43,27 @@
        assertThat(freeTags).containsExactly("免费");
        assertThat(paidTags).containsExactly("8.5折");
    }
    @Test
    void parkingScopeDecidesWhetherThePlatePageNeedsAParkingLot() throws Exception {
        Method method = ParkingMerchantPortalService.class.getDeclaredMethod(
                "requiresParkingLotSelection", Map.class, Map.class);
        method.setAccessible(true);
        // 后台选“停车场全部可选”时只需要填车牌,不再让小程序挑停车场。
        assertThat(method.invoke(service, Map.of("parkingScope", "all"), Map.of("plateFormMode", "withParking")))
                .isEqualTo(false);
        assertThat(method.invoke(service, Map.of("parkingScope", "ALL"), Map.of()))
                .isEqualTo(false);
        // 后台选“停车场只选其一”时保留停车场搜索。
        assertThat(method.invoke(service, Map.of("parkingScope", "selected"), Map.of("plateFormMode", "simple")))
                .isEqualTo(true);
        assertThat(method.invoke(service, Map.of("parkingScope", "ONE"), Map.of()))
                .isEqualTo(true);
        // 老数据没配范围时沿用停车权益表的 plate_form_mode。
        assertThat(method.invoke(service, Map.of(), Map.of("plateFormMode", "withParking")))
                .isEqualTo(true);
        assertThat(method.invoke(service, Map.of(), Map.of("plateFormMode", "simple")))
                .isEqualTo(false);
    }
}
talent-card-server/src/test/java/com/talentcard/server/business/portal/PortalServiceTests.java
@@ -4,6 +4,7 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.lang.reflect.Method;
@@ -14,6 +15,7 @@
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.ArgumentCaptor;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -191,4 +193,31 @@
        assertThat(method.invoke(service, plain, "卓越人才卡", "LOCAL", true)).isNotNull();
        assertThat(method.invoke(service, plain, "卓越人才卡", "OUTSIDE", true)).isNotNull();
    }
    @Test
    void newsListSortsPinnedArticlesByModifiedTime() {
        when(jdbc.queryForObject(anyString(), eq(Long.class), any(Object[].class))).thenReturn(0L);
        when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(List.of());
        service.news(1, 10);
        ArgumentCaptor<String> sql = ArgumentCaptor.forClass(String.class);
        verify(jdbc).queryForList(sql.capture(), any(Object[].class));
        assertThat(sql.getValue())
                .contains("from news_article")
                .contains("is_top desc")
                .contains("case when is_top = 1 then updated_at end desc");
    }
    @Test
    void premiumListTagComesFromMerchantConfigurationOnly() {
        // 商家权益里配了标签 → 用它;没配(新商家)→ 留空,不再兜底"人才专享"。
        assertThat(service.configuredMerchantTag(
                "[{\"level\":\"卓越人才卡\",\"enabled\":true,\"tag\":\"卓越人才卡\"}]", ""))
                .isEqualTo("卓越人才卡");
        assertThat(service.configuredMerchantTag("", "")).isEmpty();
        assertThat(service.configuredMerchantTag("[]", "")).isEmpty();
        // 权益里没写标签时退回商家服务标签。
        assertThat(service.configuredMerchantTag("[{\"level\":\"卓越人才卡\",\"enabled\":true}]", "专属服务")).isEqualTo("专属服务");
    }
}
talent-card-server/src/test/java/com/talentcard/server/business/talent/TalentServiceTests.java
New file
@@ -0,0 +1,55 @@
package com.talentcard.server.business.talent;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import com.talentcard.server.common.BusinessException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List;
@ExtendWith(MockitoExtension.class)
class TalentServiceTests {
    @Mock
    private JdbcTemplate jdbcTemplate;
    @InjectMocks
    private TalentService service;
    private TalentCreateRequest request(String mobile) {
        return new TalentCreateRequest(null, "测试持卡人", mobile, "51010119900101001X", (byte) 1, "测试单位",
                "卓越人才卡", "TC-TEST-0001", "LOCAL", "工作地址", "联系地址");
    }
    @Test
    void createRejectsMobileThatAlreadyHasAnAccount() {
        // 第一次查人才卡号(不存在),第二次查手机号(已存在 1 条)——一个手机号只能存在一个账号。
        when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), any(Object[].class))).thenReturn(0, 1);
        assertThatThrownBy(() -> service.create(request("18782105413")))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("一个手机号只能存在一个账号");
    }
    @Test
    void importLooksUpServiceStaffInStaffManagement() {
        // 导入 Excel 里的"服务专员"是「系统管理-员工管理」里的员工姓名(admin_operator),
        // 不是老的顾问表 talent_card_advisor。
        when(jdbcTemplate.queryForList(anyString(), eq(Long.class), eq("7"))).thenReturn(List.of(11L));
        when(jdbcTemplate.queryForList(anyString(), eq(Long.class), eq("查不到的人"))).thenReturn(List.of());
        assertThat(service.serviceStaffIdByName("7")).isEqualTo(11L);
        assertThat(service.serviceStaffIdByName("查不到的人")).isNull();
        assertThat(service.serviceStaffIdByName("  ")).isNull();
    }
}