Merge remote-tracking branch 'origin/main'
| | |
| | | <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> |
| | |
| | | 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 |
| | |
| | | .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; } |
| | |
| | | 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[] = [] |
| | |
| | | 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) |
| | | }) |
| | | } |
| | |
| | | .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> |
| | |
| | | // #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++) { |
| | |
| | | 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 : '人才政策' |
| | | }) |
| | | } |
| | |
| | | .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; } |
| | |
| | | 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) => { |
| | |
| | | |
| | | .movie-ticket-record-tools { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | /* 只剩"下载"一个按钮,靠右对齐(原来左边是"补录发放",该功能已下线)。 */ |
| | | justify-content: flex-end; |
| | | padding: 18px 0 16px; |
| | | } |
| | | |
| | |
| | | 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) |
| | |
| | | <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> |
| | |
| | | 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() } |
| | |
| | | <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> |
| | |
| | | <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"> |
| | |
| | | <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 }"> |
| | |
| | | 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 { |
| | |
| | | { 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} 条核销记录`) |
| | | } |
| | |
| | | <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> |
| | | |
| | |
| | | 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 = '' |
| | |
| | | <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 }"> |
| | |
| | | <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> |
| | |
| | | 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 |
| | |
| | | <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> |
| | |
| | | <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> |
| | |
| | | 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 { |
| | |
| | | <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"> |
| | |
| | | getTicketPackageCodes, |
| | | getTicketPackages, |
| | | importTicketPackageCodes, |
| | | supplementTicketPackageClaim, |
| | | updateTicketPackage, |
| | | updateTicketPackageStatus, |
| | | uploadAdminImage, |
| | |
| | | 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) |
| | |
| | | |
| | | 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 '票包领取记录' |
| | |
| | | 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 }) |
| | |
| | | </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> |
| | | |
| | |
| | | </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> |
| | |
| | | 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', ' ') : '' |
| | | } |
| | |
| | | <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> |
| | |
| | | <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 }"> |
| | |
| | | ); |
| | | 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"; |
| | |
| | | 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; |
| | | } |
| | |
| | | account: "", |
| | | password: "", |
| | | confirmPassword: "", |
| | | roleCode: roles.value[0]?.roleCode || "", |
| | | roleCode: enabledRoles.value[0]?.roleCode || "", |
| | | positionName: "", |
| | | customerWechat: "", |
| | | status: 1, |
| | |
| | | ><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 |
| | |
| | | <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> |
| | | |
| | |
| | | |
| | | 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('已通过') |
| | | } |
| | | |
| | |
| | | processing.value = true |
| | | try { |
| | | detail.value = await auditUpgradeApplication(detail.value.id, status, remark) |
| | | ElMessage.success(status === '已通过' ? '升级申请已通过' : '升级申请已驳回') |
| | | ElMessage.success( |
| | | status === '已通过' |
| | | ? '升级申请已通过,请到「持卡人管理」手动修改该持卡人的等级' |
| | | : '升级申请已驳回', |
| | | ) |
| | | } finally { |
| | | processing.value = false |
| | | } |
| | |
| | | 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()); |
| | | } |
| | | |
| | |
| | | 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 |
| | |
| | | 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 |
| | |
| | | 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 |
| | |
| | | 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) |
| | |
| | | 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 |
| | |
| | | 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 |
| | |
| | | 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", |
| | |
| | | |
| | | 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) { |
| | |
| | | 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 = ? |
| | |
| | | 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; |
| | |
| | | @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) { |
| | |
| | | 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) |
| | |
| | | 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); |
| | |
| | | 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")))) |
| | |
| | | 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")); |
| | |
| | | |
| | | @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, "资讯不存在"); |
| | | } |
| | |
| | | |
| | | @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, "政策已下架或不存在"); |
| | | } |
| | |
| | | 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 = ? |
| | |
| | | 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", "")); |
| | |
| | | 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) |
| | |
| | | """, |
| | | blankToNull(request.openid()), |
| | | request.name(), |
| | | blankToNull(request.mobile()), |
| | | mobile, |
| | | blankToNull(request.idCardNo()), |
| | | request.gender(), |
| | | blankToNull(request.workUnit()), |
| | |
| | | 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<>(); |
| | |
| | | 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)); |
| | | } |
| | |
| | | |
| | | 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()); |
| | |
| | | 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; |
| | | } |
| | | |
| | |
| | | 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"); |
| | | } |
| | | } |
| | |
| | | .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(); |
| | | } |
| | | } |
| | |
| | | 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); |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | 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; |
| | | |
| | |
| | | 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("专属服务"); |
| | | } |
| | | } |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |