| New file |
| | |
| | | # 观影券详情(使用记录) |
| | | |
| | | ## 基本信息 |
| | | |
| | | | 项目 | 内容 | |
| | | | --- | --- | |
| | | | 路由 | `/pages/ticket-package-detail/ticket-package-detail?claimId={领取记录ID}&talentId={持卡人ID}&merchantId={商家ID}` | |
| | | | 页面目标 | 在「使用记录」里点开已领取的观影券,出示二维码给影城店员扫码核销,并查看领取/核销时间。 | |
| | | | 进入方式 | 个人中心 → 使用记录 → 观影券记录(`targetDetailType=TICKET_PACKAGE_DETAIL`)。 | |
| | | | 负责人/更新时间 | 待指定 / 2026-10-09 | |
| | | |
| | | ## 功能点 |
| | | |
| | | | 编号 | 功能 | 说明 | 当前状态 | |
| | | | --- | --- | --- | --- | |
| | | | F-001 | 核销二维码 | 展示票包兑换码的二维码(`GET /api/app/benefit-verification-codes/ticket-package-claims/{claimId}/image`),店员扫码后走观影权益核销。 | 已接入 | |
| | | | F-002 | 适用门店 | 展示票包配置的适用商家(多个用「、」连接)。 | 已接入 | |
| | | | F-003 | 领取时间 | 领取记录创建时间。 | 已接入 | |
| | | | F-004 | 核销时间 | 已核销后才显示。 | 已接入 | |
| | | | F-005 | 复制兑换码 | 未核销时可复制兑换码;已核销时按钮置灰显示「已使用」。 | 已接入 | |
| | | |
| | | ## 状态差异 |
| | | |
| | | | 状态 | 表现 | |
| | | | --- | --- | |
| | | | 未核销(`ACTIVE`) | 显示二维码、适用门店、领取时间;底部蓝色「复制兑换码」按钮可用。 | |
| | | | 已核销(`USED`) | 二维码保留(核销时服务端会拒绝重复核销),额外显示核销时间;底部按钮置灰显示「已使用」。 | |
| | | |
| | | ## 接口与数据 |
| | | |
| | | | 数据项/动作 | 接口标识 | 参数 | 返回字段 | 状态 | |
| | | | --- | --- | --- | --- | --- | |
| | | | 领取详情 | `GET /api/app/ticket-packages/claims/{claimId}` | `claimId`、`talentId` | `merchantNames`、`exchangeCode`、`exchangeCodeStatus`、`createdAt`、`usedAt` | 已接入 | |
| | | | 核销二维码 | `GET /api/app/benefit-verification-codes/ticket-package-claims/{claimId}/image` | `claimId` | PNG 二维码 | 已接入 | |
| | | |
| | | ## 与「电影券领券成功」的区别 |
| | | |
| | | `/pages/coupon-received/coupon-received` 是领券成功页(展示可领取额度、兑换码与二维码); |
| | | 本页是使用记录里的观影券详情,按「观影券」原型展示二维码、适用门店与领取/核销时间。 |
| | |
| | | </view> |
| | | |
| | | <view class="city-panel"> |
| | | <image class="city-stand" src="/static/home-assets/mascot-stand.png" mode="aspectFit"></image> |
| | | <view class="city-heading"><text>才聚东区,共富未来</text><view class="city-label" @tap="openIntro"><text>幸福东区</text><image src="/static/home-assets/arrow-right.svg"></image></view></view> |
| | | <view class="city-grid"> |
| | | <view class="city-card city-intro" @tap="openIntro"><image class="city-bg" :src="homeImage('city-intro.jpg')" mode="aspectFill"></image><text class="intro-name">东区简介</text><view class="intro-button"><text>查看更多</text><image src="/static/home-assets/arrow-right.svg"></image></view></view> |
| | | <view class="city-side"><view class="city-card city-potential"><image class="city-bg" :src="homeImage('city-potential.jpg')" mode="aspectFill"></image><text>潜力城</text></view><view class="city-card city-golden"><image class="city-bg" :src="homeImage('city-golden.jpg')" mode="aspectFill"></image><text>黄金城</text></view></view> |
| | | <view class="city-side"><view class="city-card city-industry"><image class="city-bg" :src="homeImage('city-industry.jpg')" mode="aspectFill"></image><text>兴业城</text></view><view class="city-card city-livable"><image class="city-bg" :src="homeImage('city-livable.png')" mode="aspectFill"></image><text>宜居城</text></view></view> |
| | | </view> |
| | | <!-- 放在 .city-grid 之后:DOM 顺序保证它画在橙色城市区之上,不依赖 z-index 是否被小程序忽略。 --> |
| | | <image class="city-mascot" src="/static/home-assets/mascot-rest.png" mode="aspectFit"></image> |
| | | </view> |
| | | |
| | | <view class="section-head"><view class="section-title"><text>人才资讯</text></view><text class="more" @tap="goNews">查看更多</text></view> |
| | | <view class="section-head"><view class="section-title"><image class="section-icon" src="/static/home-assets/mascot-leap.png" mode="aspectFit"></image><text>人才资讯</text></view><text class="more" @tap="goNews">查看更多</text></view> |
| | | <view v-if="homeLoading" class="news-state"><text>资讯加载中...</text></view> |
| | | <view v-else-if="homeError" class="news-state" @tap="loadHome"><text>{{homeError}}</text><text class="news-retry">点击重试</text></view> |
| | | <view v-else> |
| | |
| | | .play-button { position: absolute; z-index: 2; bottom: 20rpx; left: 50%; margin-left: -78rpx; width: 156rpx; height: 56rpx; border-radius: 28rpx; background-image: linear-gradient(180deg, #FFB75A 0%, #FF6A24 100%); border-width: 2rpx; border-color: rgba(255,255,255,.82); box-shadow: 0 7rpx 14rpx rgba(234,92,26,.26); flex-direction: row; justify-content: center; align-items: center; } |
| | | .play-button text { color: #FFFFFF; font-size: 23rpx; } |
| | | |
| | | .city-panel { margin: -10rpx 16rpx 38rpx; padding: 22rpx 0 0; overflow: visible; background-color: #FFFFFF; border-radius: 26rpx; position: relative; box-shadow: 0 -8rpx 14rpx rgba(255, 103, 39, .27); } |
| | | .city-heading { height: 58rpx; margin: 0 22rpx; flex-direction: row; justify-content: space-between; align-items: center; } |
| | | /* |
| | | * 顶部图下方的衔接:原型里白色区是「通版」(左右不留边、上沿不圆角、没有橙色投影), |
| | | * 主图只把底部两个角做圆角,让白色自然接上来;橙色城市区自己再左右内缩 16rpx。 |
| | | * 所以这里把面板改成通版(margin 左右 0、去掉圆角和投影),内缩交给 .city-grid。 |
| | | */ |
| | | .city-panel { margin: 0 0 38rpx; padding: 22rpx 0 0; overflow: visible; background-color: #FFFFFF; position: relative; } |
| | | /* 面板改通版后,标题的绝对位置保持不变:原来 16(面板内缩)+22(自身边距)=38rpx,现在直接给 38rpx。 */ |
| | | .city-heading { height: 58rpx; margin: 0 38rpx; position: relative; flex-direction: row; justify-content: space-between; align-items: center; } |
| | | /* |
| | | * 站立吉祥物:贴在面板右上角,跨在白色面板上沿之上(身体压在城市主图上),和原型一致。 |
| | | * 原型量出来可见轮廓是 58×100rpx(右边缘正好贴面板右边缘、顶端高出面板上沿约 57rpx); |
| | | * 素材 PNG 画布 68×198、可见内容只有 68×118(上下各 40px 透明留白),所以盒子要按画布比例放大到 |
| | | * 58×168rpx,再把盒子上沿抬到 -91rpx,可见部分才落在原型的位置上。 |
| | | */ |
| | | .city-stand { position: absolute; right: 0rpx; top: -91rpx; width: 58rpx; height: 168rpx; z-index: 4; } |
| | | /* |
| | | * 趴着的吉祥物:在「才聚东区,共富未来」和「幸福东区」之间,草坪底边压在橙色城市区上沿。 |
| | | * 原型量出来是 51×28px(素材无透明留白、比例 1.815 与原型一致)→ 128×70rpx; |
| | | * 右边缘距面板右边缘 64px→160rpx;顶端距面板上沿 21px→52rpx(它现在挂在 .city-panel 上, |
| | | * 所以 right/top 直接按面板算:right 160rpx、top 52rpx 减去画布上留白对应的 12rpx → 40rpx)。 |
| | | * 素材底部补了 8px 透明留白,所以盒子高度要按画布比例放大到 75rpx(可见部分仍是 128×70rpx)。 |
| | | * top 取 40rpx:草地底边落在橙色城市区上沿之内、但不压到下方卡片(原型里草地底边距卡片上沿还有 4px)。 |
| | | */ |
| | | .city-mascot { position: absolute; right: 160rpx; top: 40rpx; width: 128rpx; height: 75rpx; z-index: 3; } |
| | | .city-heading > text { color: #F16624; font-size: 35rpx; font-weight: 700; letter-spacing: 1rpx; background-image: linear-gradient(180deg, #F45A2A 0%, #FFB257 100%); background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent; } |
| | | .city-label { margin-right: 48rpx; padding: 10rpx 13rpx; border-radius: 18rpx; background-image: linear-gradient(180deg, #FFB85E 0%, #FF6726 100%); box-shadow: 0 5rpx 10rpx rgba(239,101,34,.22); flex-direction: row; align-items: center; } .city-label text { color: #FFFFFF; font-size: 20rpx; } .city-label image { width: 20rpx; height: 20rpx; margin-left: 7rpx; } |
| | | .city-grid { box-sizing: border-box; height: 292rpx; margin-top: 18rpx; padding: 22rpx; overflow: visible; background-image: linear-gradient(180deg, #FF5F2D 0%, #FFB36B 100%); border-bottom-left-radius: 26rpx; border-bottom-right-radius: 26rpx; flex-direction: row; } .city-card { overflow: hidden; border-radius: 17rpx; position: relative; justify-content: flex-end; background-image: linear-gradient(135deg, #E96C3D 0%, #F8AA56 100%); } |
| | | /* |
| | | * 「幸福东区」按钮:原型里按钮右边缘距面板右边缘 18px(面板 287px)→ 45rpx; |
| | | * .city-heading 自身左右各留 38rpx,所以这里 margin-right 取 7rpx(38+7=45rpx),和原型一致。 |
| | | */ |
| | | .city-label { margin-right: 7rpx; padding: 10rpx 13rpx; border-radius: 18rpx; background-image: linear-gradient(180deg, #FFB85E 0%, #FF6726 100%); box-shadow: 0 5rpx 10rpx rgba(239,101,34,.22); flex-direction: row; align-items: center; } .city-label text { color: #FFFFFF; font-size: 20rpx; } .city-label image { width: 20rpx; height: 20rpx; margin-left: 7rpx; } |
| | | .city-grid { box-sizing: border-box; height: 292rpx; margin: 18rpx 16rpx 0; padding: 22rpx; overflow: visible; background-image: linear-gradient(180deg, #FF5F2D 0%, #FFB36B 100%); border-bottom-left-radius: 26rpx; border-bottom-right-radius: 26rpx; flex-direction: row; } .city-card { overflow: hidden; border-radius: 17rpx; position: relative; justify-content: flex-end; background-image: linear-gradient(135deg, #E96C3D 0%, #F8AA56 100%); } |
| | | .city-bg { position: absolute; left: 0; top: 0; width: 100%; height: 100%; } |
| | | .city-intro { width: 242rpx; height: 308rpx; box-sizing: border-box; margin-right: 14rpx; padding: 22rpx; } .intro-name { position: absolute; z-index: 1; top: 50%; left: 0; right: 0; transform: translateY(-50%); color: #FFFFFF; font-size: 37rpx; font-weight: 700; text-align: center; } .intro-button { position: relative; z-index: 1; align-self: center; box-sizing: border-box; width: 146rpx; height: 42rpx; margin-top: 13rpx; padding: 0 12rpx; border-radius: 21rpx; background-image: linear-gradient(90deg, rgba(255,207,138,.92) 0%, rgba(255,101,36,.68) 100%); border-width: 1rpx; border-color: rgba(255,255,255,.42); flex-direction: row; align-items: center; justify-content: space-between; } .intro-button text { color: #FFFFFF; font-size: 19rpx; } .intro-button image { width: 19rpx; height: 19rpx; margin-left: 8rpx; } |
| | | .city-side { height: 308rpx; flex: 1; margin-right: 14rpx; justify-content: space-between; } .city-side:last-child { margin-right: 0; } .city-side .city-card { height: 147rpx; padding: 0; } .city-side .city-card > text { position: absolute; z-index: 1; left: 0; right: 0; bottom: 0; padding: 10rpx 12rpx; color: #FFFFFF; font-size: 22rpx; font-weight: 700; text-shadow: 0 2rpx 8rpx rgba(0,0,0,.5); } |
| | | |
| | | .section-head { height: 93rpx; padding: 0 31rpx; flex-direction: row; align-items: center; justify-content: space-between; } .section-title { flex-direction: row; align-items: center; } .section-title text { color: #353535; font-size: 31rpx; font-weight: 700; } .more { color: #5C5C5C; font-size: 22rpx; } |
| | | .section-head { height: 93rpx; padding: 0 31rpx; flex-direction: row; align-items: center; justify-content: space-between; } .section-title { flex-direction: row; align-items: center; } .section-icon { width: 44rpx; height: 54rpx; margin-right: 10rpx; } .section-title text { color: #353535; font-size: 31rpx; font-weight: 700; } .more { color: #5C5C5C; font-size: 22rpx; } |
| | | .news-state { min-height: 150rpx; margin: 0 31rpx; justify-content: center; align-items: center; color: #8B8B8B; font-size: 24rpx; } .news-retry { margin-top: 12rpx; color: #F16624; } |
| | | .news-featured { height: 205rpx; margin: 0 19rpx; border-radius: 18rpx; overflow: hidden; position: relative; background-image: linear-gradient(135deg, #F26A38 0%, #F9A551 100%); } .news-featured > image { width: 100%; height: 100%; } .news-shade { position: absolute; left: 0; right: 0; bottom: 0; height: 118rpx; background-color: rgba(0,0,0,.44); } .top-badge { position: absolute; left: 20rpx; top: 18rpx; padding: 7rpx 14rpx; border-radius: 12rpx; background-color: #FFF3D4; } .top-badge text { color: #F2722E; font-size: 20rpx; font-weight: 700; } .featured-copy { position: absolute; left: 20rpx; right: 20rpx; bottom: 17rpx; } .featured-copy text:first-child { display: block; color: #FFFFFF; font-size: 24rpx; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .featured-copy text:last-child { display: block; margin-top: 8rpx; color: #FFFFFF; opacity: .85; font-size: 19rpx; } |
| | | .news-list { margin: 0 31rpx; } .news-row { min-height: 116rpx; padding: 22rpx 0 16rpx; border-bottom-width: 1rpx; border-bottom-color: #EEEEEE; } .news-name { color: #363636; font-size: 25rpx; line-height: 36rpx; } .news-meta { margin-top: 12rpx; flex-direction: row; align-items: center; color: #98A2B3; font-size: 19rpx; } .new-badge { margin-right: 14rpx; padding: 4rpx 10rpx; border-radius: 12rpx; background-color: #FFF0D9; color: #FE7930; font-size: 18rpx; } |
| New file |
| | |
| | | <template> |
| | | <view class="page"> |
| | | <view class="page-header"> |
| | | <view class="safe-space" :style="'height:' + navTop + 'px'"></view> |
| | | <view class="nav-row" :style="'height:' + navHeight + 'px;padding-right:' + navRight + 'px'"> |
| | | <view class="back-button" hover-class="back-button-active" @tap="goBack"><image src="/static/news-assets/arrow-left.svg" mode="aspectFit"></image></view> |
| | | <text class="nav-title">观影券</text> |
| | | <view class="capsule-space" :style="'width:' + navWidth + 'px'"></view> |
| | | </view> |
| | | </view> |
| | | <view v-if="loading" class="page-state"><view class="loading-spinner"></view><text>正在加载观影券</text></view> |
| | | <view v-else-if="loadError" class="page-state error-state"><text>{{errorText}}</text><text class="retry-text" @tap="loadClaim">重新加载</text></view> |
| | | <view v-else class="content"> |
| | | <image v-if="qrUrl.length > 0" class="voucher-qr" :src="qrUrl" mode="aspectFit"></image> |
| | | <text class="merchant-text">适用门店:{{merchantNames.length > 0 ? merchantNames : '以门店公示为准'}}</text> |
| | | <text class="time-text">领取时间:{{formatDateTime(createdAt)}}</text> |
| | | <text v-if="used" class="time-text">核销时间:{{formatDateTime(usedAt)}}</text> |
| | | <view v-if="canCopy" class="copy-button" hover-class="copy-button-active" @tap="copyExchangeCode"><text>复制兑换码</text></view> |
| | | <view v-else class="copy-button disabled-button"><text>{{buttonText}}</text></view> |
| | | </view> |
| | | </view> |
| | | </template> |
| | | |
| | | <script setup lang="uts"> |
| | | import { getTicketPackageClaim, getTicketPackageCodeImageUrl } from '../../services/portal.uts' |
| | | import { getSession } from '../../services/session.uts' |
| | | import { safeBack } from '../../utils/navigation.uts' |
| | | |
| | | // Keep the first render below the status bar even before the MP capsule metrics arrive. |
| | | const navTop = ref(44); const navHeight = ref(32); const navWidth = ref(88); const navRight = ref(10) |
| | | const claimId = ref(''); const talentId = ref('') |
| | | const loading = ref(true); const loadError = ref(false); const errorText = ref('观影券详情加载失败') |
| | | const merchantNames = ref(''); const exchangeCode = ref(''); const claimStatus = ref('') |
| | | const createdAt = ref(''); const usedAt = ref('') |
| | | |
| | | onLoad((options: any) => { |
| | | // #ifdef MP-WEIXIN |
| | | const capsule = uni.getMenuButtonBoundingClientRect(); const systemInfo = uni.getSystemInfoSync(); navTop.value = capsule.top; navHeight.value = capsule.height; navWidth.value = capsule.width; navRight.value = systemInfo.windowWidth - capsule.right |
| | | // #endif |
| | | if (options != null && options['claimId'] != null) claimId.value = String(options['claimId']) |
| | | if (options != null && options['talentId'] != null) talentId.value = String(options['talentId']) |
| | | const session = getSession() |
| | | if (talentId.value == '' && session != null && session.talentId != null) talentId.value = String(session.talentId) |
| | | loadClaim() |
| | | }) |
| | | |
| | | const used = computed((): boolean => claimStatus.value == 'USED') |
| | | const canCopy = computed((): boolean => claimStatus.value == 'ISSUED' && exchangeCode.value != '') |
| | | const buttonText = computed((): string => used.value ? '已使用' : '已失效') |
| | | /** 二维码内容就是后台发的兑换码,店员扫码后走观影权益核销。 */ |
| | | const qrUrl = computed((): string => claimId.value == '' ? '' : getTicketPackageCodeImageUrl(claimId.value)) |
| | | const formatDateTime = (value: string): string => value.length > 0 ? value.replace('T', ' ') : '—' |
| | | const goBack = () => safeBack('/pages/order/order') |
| | | |
| | | const loadClaim = () => { |
| | | if (claimId.value == '' || talentId.value == '') { loading.value = false; loadError.value = true; errorText.value = '观影券参数缺失'; return } |
| | | loading.value = true; loadError.value = false |
| | | getTicketPackageClaim(claimId.value, talentId.value, (data: any) => { |
| | | loading.value = false |
| | | merchantNames.value = data.merchantNames != null ? String(data.merchantNames) : (data.merchantName != null ? String(data.merchantName) : '') |
| | | exchangeCode.value = data.exchangeCode != null ? String(data.exchangeCode) : '' |
| | | claimStatus.value = data.claimStatus != null ? String(data.claimStatus) : (data.exchangeCodeStatus != null ? String(data.exchangeCodeStatus) : '') |
| | | createdAt.value = data.createdAt != null ? String(data.createdAt) : '' |
| | | usedAt.value = data.usedAt != null ? String(data.usedAt) : '' |
| | | }, (error: any) => { loading.value = false; loadError.value = true; errorText.value = error.message != null ? error.message : '观影券详情加载失败' }) |
| | | } |
| | | |
| | | const copyExchangeCode = () => { |
| | | if (!canCopy.value) return |
| | | uni.setClipboardData({ data: exchangeCode.value, success: () => uni.showToast({ title: '兑换码已复制', icon: 'success' }), fail: () => uni.showToast({ title: '复制失败,请重试', icon: 'none' }) }) |
| | | } |
| | | </script> |
| | | |
| | | <style> |
| | | .page { display: flex; width: 100%; height: 100vh; box-sizing: border-box; overflow: hidden; background-color: #FFFFFF; flex-direction: column; } |
| | | .page-header { position: relative; z-index: 2; flex-shrink: 0; }.safe-space { height: 28px; } |
| | | .nav-row { display: flex; box-sizing: border-box; padding-left: 20rpx; position: relative; flex-direction: row; align-items: center; justify-content: space-between; } |
| | | .back-button { display: flex; width: 70rpx; height: 64rpx; margin-left: -4rpx; justify-content: center; align-items: center; } |
| | | .back-button image { width: 36rpx; height: 36rpx; }.back-button-active { opacity: .55; } |
| | | .nav-title { position: absolute; left: 50%; top: 50%; color: #171B23; font-size: 36rpx; line-height: 48rpx; font-weight: 700; transform: translate(-50%, -50%); white-space: nowrap; } |
| | | .capsule-space { height: 100%; flex-shrink: 0; } |
| | | .content { display: flex; flex: 1; padding: 0 66rpx; box-sizing: border-box; flex-direction: column; align-items: center; } |
| | | .voucher-qr { width: 320rpx; height: 320rpx; margin-top: 60rpx; display: block; background-color: #FFFFFF; } |
| | | .merchant-text { margin-top: 56rpx; color: #3A4353; font-size: 27rpx; line-height: 38rpx; text-align: center; } |
| | | .time-text { margin-top: 30rpx; color: #3A4353; font-size: 27rpx; line-height: 38rpx; text-align: center; } |
| | | .copy-button { display: flex; width: 100%; height: 88rpx; margin-top: 96rpx; flex-shrink: 0; border-radius: 12rpx; color: #FFFFFF; background-color: #2F7BF6; flex-direction: row; justify-content: center; align-items: center; } |
| | | .copy-button-active { opacity: .78; }.copy-button text { font-size: 30rpx; font-weight: 600; } |
| | | .disabled-button { background-color: #C9CED8; } |
| | | .page-state { display: flex; min-height: 560rpx; color: #6F7885; font-size: 27rpx; flex-direction: column; align-items: center; justify-content: center; } |
| | | .loading-spinner { width: 42rpx; height: 42rpx; margin-bottom: 22rpx; box-sizing: border-box; border: 5rpx solid #DCE3EE; border-top-color: #2F7BF6; border-radius: 50%; animation: ticket-spin 750ms linear infinite; } |
| | | .error-state { color: #7D8490; }.retry-text { margin-top: 20rpx; color: #2F7BF6; } |
| | | @keyframes ticket-spin { to { transform: rotate(360deg); } } |
| | | </style> |
| New file |
| | |
| | | # 观影券核销详情(待核销 / 已核销) |
| | | |
| | | ## 页面信息 |
| | | |
| | | | 项目 | 内容 | |
| | | | --- | --- | |
| | | | 页面名称 | 核销确认(观影券) | |
| | | | 路由 | `/pages/ticket-package-detail/ticket-package-detail?claimId={领取记录ID}` | |
| | | | 源码 | `pages/ticket-package-detail/ticket-package-detail.uvue` | |
| | | | 入口 | 商家端「订单管理 → 团购」列表中观影券那一条 | |
| | | | 负责人/更新时间 | 待指定 / 2026-10-09 | |
| | | |
| | | ## 页面目标 |
| | | |
| | | 观影券没有金额、也不是 `benefit_order`,商户在团购列表里只能看到「观影券1张」。 |
| | | 本页补上这条记录的完整信息:券是否已核销、什么时候领取、什么时候核销、是谁的券、当前权益是什么。 |
| | | |
| | | ## 功能点 |
| | | |
| | | | 功能 | 说明 | 状态 | |
| | | | --- | --- | --- | |
| | | | 状态展示 | 顶部大字显示「待核销 / 已核销」,来自领取记录 `status`(`ISSUED` / `USED`)。 | 已接入 | |
| | | | 券信息 | 固定展示「观影券1张」(票包一次核销一张)。 | 已接入 | |
| | | | 订单信息 | 领取时间 = `ticket_package_claim.created_at`;核销时间 = `used_at`,未核销显示「—」。 | 已接入 | |
| | | | 权益信息 | 卡等级用户 + 脱敏姓名/手机号 + 「当前权益」文案。 | 已接入 | |
| | | | 当前权益口径 | 先取商家人才权益里该等级的配置描述,没配再回落到该商户到店消费权益文案,与到店消费保持一致。 | 已接入 | |
| | | |
| | | ## 状态差异 |
| | | |
| | | | 状态 | 表现 | |
| | | | --- | --- | |
| | | | 待核销(`ISSUED`) | 状态红色系大字;核销时间显示「—」;权益信息正常展示。 | |
| | | | 已核销(`USED`) | 状态灰色大字;核销时间展示真实时间。 | |
| | | |
| | | 本页只做展示。核销动作仍走「用户出示二维码 → 店员扫码」:扫码后进入 |
| | | `/pages/ticket-package-confirm/ticket-package-confirm`,确认核销后本页状态变为「已核销」。 |
| | | |
| | | ## 接口与数据 |
| | | |
| | | | 数据项/动作 | 接口标识 | 参数 | 返回字段 | 状态 | |
| | | | --- | --- | --- | --- | --- | |
| | | | 观影券领取记录详情 | `GET /api/merchant/ticket-package-claims/{claimId}` | 领取记录 ID;商户来自会话 | `claimId`、`claimNo`、`title`、`packageName`、`merchantName`、`customerName`、`customerMobile`、`cardLevel`、`status`、`statusText`、`createdAt`、`usedAt`、`benefitText` | 已接入 | |
| | | |
| | | ## 异常处理 |
| | | |
| | | | 场景 | 表现 | |
| | | | --- | --- | |
| | | | 领取记录不存在或不属于当前商户 | 服务端返回「观影券领取记录不存在或不属于当前商户」,页面显示错误态并可重试。 | |
| | | | 缺少 `claimId` | 页面提示「观影券参数缺失」。 | |
| | | |
| | | ## 后台可见性 |
| | | |
| | | 管理后台「电影票管理 → 核销记录」可按票包、持卡人、卡片等级、领取时间、核销时间和核销商家查询到同一条记录。 |
| New file |
| | |
| | | <template> |
| | | <view class="page"> |
| | | <view class="navigation-bar"> |
| | | <view class="back-button" @tap.stop="goBack"><image class="back-icon" src="/static/icons/lucide/back-left.svg" mode="aspectFit"></image></view> |
| | | <text class="navigation-title">核销确认</text> |
| | | </view> |
| | | <scroll-view class="detail-scroll" scroll-y="true" :show-scrollbar="false"> |
| | | <view v-if="loadState == 'loading'" class="page-state"><text>观影券详情加载中</text></view> |
| | | <view v-else-if="loadState == 'error'" class="page-state"><text>{{loadError}}</text><text class="retry-text" @tap="loadClaim">重新加载</text></view> |
| | | <view v-else class="detail-body"> |
| | | <text class="status-title" :class="verified ? 'verified-title' : ''">{{statusText}}</text> |
| | | <text class="ticket-title">{{title}}</text> |
| | | <view class="divider"></view> |
| | | <text class="section-title">订单信息</text> |
| | | <view class="info-card"> |
| | | <view class="info-row"><text class="info-label">领取时间</text><text class="info-value">{{formatDateTime(createdAt)}}</text></view> |
| | | <view class="info-row last-row"><text class="info-label">核销时间</text><text class="info-value" :class="verified ? '' : 'muted-value'">{{verified ? formatDateTime(usedAt) : '—'}}</text></view> |
| | | </view> |
| | | <text class="section-title benefit-section-title">权益信息</text> |
| | | <view class="benefit-card"> |
| | | <text class="benefit-level">{{cardLevel}}用户</text> |
| | | <view class="benefit-person"><text class="benefit-name">{{customerName}}</text><text class="benefit-mobile">{{customerMobile}}</text></view> |
| | | <text v-if="benefitText.length > 0" class="benefit-text">当前权益:{{benefitText}}</text> |
| | | <text v-else class="benefit-text muted-value">当前权益:该卡等级暂未配置权益说明</text> |
| | | </view> |
| | | <view class="bottom-space"></view> |
| | | </view> |
| | | </scroll-view> |
| | | </view> |
| | | </template> |
| | | |
| | | <script setup lang="uts"> |
| | | import { loadMerchantTicketPackageClaim, MerchantTicketPackageClaim } from '../../services/orders.uts' |
| | | |
| | | const claimId = ref('') |
| | | const title = ref('观影券1张') |
| | | const statusText = ref('待核销') |
| | | const verified = ref(false) |
| | | const customerName = ref('') |
| | | const customerMobile = ref('') |
| | | const cardLevel = ref('人才卡') |
| | | const createdAt = ref('') |
| | | const usedAt = ref('') |
| | | const benefitText = ref('') |
| | | const loadState = ref('loading') |
| | | const loadError = ref('观影券详情加载失败') |
| | | |
| | | const formatDateTime = (value: string): string => value.length > 0 ? value.replace('T', ' ') : '—' |
| | | const goBack = () => uni.navigateBack() |
| | | |
| | | const applyClaim = (claim: MerchantTicketPackageClaim) => { |
| | | if (claim.title.length > 0) title.value = claim.title |
| | | statusText.value = claim.statusText.length > 0 ? claim.statusText : '待核销' |
| | | verified.value = claim.status == 'completed' |
| | | customerName.value = claim.customerName |
| | | customerMobile.value = claim.customerMobile |
| | | cardLevel.value = claim.cardLevel.length > 0 ? claim.cardLevel : '人才卡' |
| | | createdAt.value = claim.createdAt |
| | | usedAt.value = claim.usedAt |
| | | benefitText.value = claim.benefitText |
| | | loadState.value = 'success' |
| | | } |
| | | |
| | | const loadClaim = () => { |
| | | loadState.value = 'loading' |
| | | if (claimId.value.length == 0) { loadError.value = '观影券参数缺失'; loadState.value = 'error'; return } |
| | | loadMerchantTicketPackageClaim(claimId.value, (claim: MerchantTicketPackageClaim, _traceId: string) => applyClaim(claim), (error: any) => { loadError.value = error.message != null ? error.message : '观影券详情加载失败'; loadState.value = 'error' }) |
| | | } |
| | | |
| | | onLoad((options: any) => { claimId.value = options != null && options.claimId != null ? '' + options.claimId : ''; loadClaim() }) |
| | | </script> |
| | | |
| | | <style> |
| | | page { background-color: #FFFFFF; } |
| | | .page { display: flex; flex: 1; flex-direction: column; width: 100%; height: 100vh; overflow: hidden; background-color: #FFFFFF; } |
| | | .navigation-bar { display: flex; height: 174rpx; flex: none; position: relative; box-sizing: border-box; justify-content: center; align-items: flex-end; } |
| | | .back-button { display: flex; width: 72rpx; height: 72rpx; justify-content: center; align-items: center; position: absolute; top: 82rpx; left: 28rpx; z-index: 2; } |
| | | .back-icon { width: 40rpx; height: 40rpx; } |
| | | .navigation-title { color: #171B23; font-size: 36rpx; line-height: 60rpx; font-weight: 700; } |
| | | .detail-scroll { flex: 1; height: 0; padding: 0 34rpx; box-sizing: border-box; } |
| | | .detail-body { display: flex; flex-direction: column; } |
| | | .status-title { display: block; margin-top: 34rpx; color: #1F2530; font-size: 42rpx; line-height: 58rpx; font-weight: 700; text-align: center; } |
| | | .verified-title { color: #7C8798; } |
| | | .ticket-title { display: block; margin-top: 62rpx; color: #1F2530; font-size: 34rpx; line-height: 48rpx; font-weight: 700; text-align: center; } |
| | | .divider { height: 1rpx; margin-top: 62rpx; background-color: #EDEFF3; } |
| | | .section-title { display: block; margin-top: 40rpx; color: #263043; font-size: 31rpx; line-height: 44rpx; font-weight: 700; } |
| | | .info-card { padding: 12rpx 30rpx; margin-top: 18rpx; border-radius: 28rpx; background-color: #FAFBFC; box-sizing: border-box; } |
| | | .info-row { display: flex; min-height: 71rpx; flex-direction: row; justify-content: space-between; align-items: center; } |
| | | .info-label { color: #8E97A5; font-size: 27rpx; line-height: 38rpx; } |
| | | .info-value { color: #39435A; font-size: 27rpx; line-height: 38rpx; font-weight: 600; text-align: right; } |
| | | .muted-value { color: #9AA4B3; font-weight: 500; } |
| | | .last-row { min-height: 74rpx; } |
| | | .benefit-section-title { margin-top: 46rpx; } |
| | | .benefit-card { padding: 32rpx 32rpx 34rpx; margin-top: 22rpx; border-width: 1rpx; border-style: solid; border-color: #EDEFF3; border-radius: 28rpx; background-color: #FFFFFF; box-shadow: 0 12rpx 30rpx rgba(69, 65, 57, 0.04); box-sizing: border-box; } |
| | | .benefit-level { display: block; color: #263043; font-size: 33rpx; line-height: 46rpx; font-weight: 700; } |
| | | .benefit-person { display: flex; margin-top: 22rpx; flex-direction: row; align-items: center; } |
| | | .benefit-name { color: #4A5464; font-size: 27rpx; line-height: 38rpx; } |
| | | .benefit-mobile { margin-left: 32rpx; color: #4A5464; font-size: 27rpx; line-height: 38rpx; } |
| | | .benefit-text { display: block; margin-top: 22rpx; color: #4A5464; font-size: 27rpx; line-height: 40rpx; } |
| | | .bottom-space { height: 120rpx; } |
| | | .page-state { display: flex; height: 700rpx; flex-direction: column; justify-content: center; align-items: center; color: #798494; font-size: 27rpx; line-height: 38rpx; } |
| | | .retry-text { margin-top: 22rpx; color: #FF6B2C; font-weight: 700; } |
| | | </style> |
| | |
| | | return http.patch<Talent, Talent>(`/api/talents/${id}/card-level`, { talentLevel }) |
| | | } |
| | | |
| | | /** 游客(无人才卡)补录信息并升级为持卡人:补认证信息 + 选等级 + 发卡。 */ |
| | | export function upgradeTalent( |
| | | id: number | string, |
| | | payload: Pick<TalentPayload, 'name' | 'idCardNo' | 'gender' | 'workUnit' | 'talentLevel' | 'talentCardNo' | 'sourceAreaType' | 'workAddress' | 'contactAddress'>, |
| | | ) { |
| | | return http.post<Talent, Talent>(`/api/talents/${id}/upgrade`, payload) |
| | | } |
| | | |
| | | export function getTalentAdvisors() { |
| | | return http.get<TalentAdvisor[], TalentAdvisor[]>('/api/talents/advisors') |
| | | } |
| | |
| | | meta: { title: '持卡人详情' }, |
| | | }, |
| | | { |
| | | path: 'talents/:id/upgrade', |
| | | name: 'talent-upgrade', |
| | | component: () => import('../views/TalentUpgradeView.vue'), |
| | | meta: { title: '升级人才' }, |
| | | }, |
| | | { |
| | | path: 'family-card-applications', |
| | | name: 'family-card-applications', |
| | | component: familyCardView, |
| | |
| | | <script setup lang="ts"> |
| | | import { computed, onMounted, ref } from 'vue' |
| | | import { useRoute } from 'vue-router' |
| | | import { useRoute, useRouter } from 'vue-router' |
| | | import { ElMessage } from 'element-plus' |
| | | import type { FormInstance, FormRules } from 'element-plus' |
| | | import { |
| | |
| | | import type { AdminStaff, Talent, TalentCard } from '../types/admin' |
| | | |
| | | const route = useRoute() |
| | | const router = useRouter() |
| | | const loading = ref(false) |
| | | const talent = ref<Talent>() |
| | | const cards = ref<TalentCard[]>([]) |
| | |
| | | } |
| | | const talentLevels = ['卓越人才卡', '杰出人才卡', '菁英人才卡', '普通游客'] |
| | | |
| | | const primaryCard = computed(() => cards.value[0]) |
| | | const personalCard = computed(() => cards.value.find((card) => String(card.cardType || '').toUpperCase() === 'PERSONAL')) |
| | | const primaryCard = computed(() => personalCard.value || cards.value[0]) |
| | | |
| | | /** |
| | | * 游客:还没有个人人才卡。后端改等级要求「先有有效主卡」,所以这类持卡人只能走 |
| | | * 「升级人才」:补认证信息 + 选等级 + 发卡;已经有卡的仍然走原来的「修改人才等级」。 |
| | | */ |
| | | const isVisitor = computed(() => !personalCard.value) |
| | | |
| | | function openUpgrade() { |
| | | router.push(`/talents/${String(route.params.id)}/upgrade`) |
| | | } |
| | | |
| | | function genderText(gender?: number) { |
| | | if (gender === 2) return '女' |
| | |
| | | </div> |
| | | <strong class="card-number">卡片编号 {{ cardNo() }}</strong> |
| | | </div> |
| | | <el-button type="primary" @click="openLevelDialog">修改人才等级</el-button> |
| | | <el-button v-if="isVisitor" type="primary" @click="openUpgrade">升级人才</el-button> |
| | | <el-button v-else type="primary" @click="openLevelDialog">修改人才等级</el-button> |
| | | </div> |
| | | </section> |
| | | |
| | |
| | | return ApiResponse.success(talentService.updateCardLevel(id, request.talentLevel())); |
| | | } |
| | | |
| | | /** |
| | | * 游客升级为持卡人:补录认证信息 + 选等级 + 发卡。详情页里「升级人才」按钮走这条。 |
| | | */ |
| | | @PostMapping("/{id}/upgrade") |
| | | public ApiResponse<Map<String, Object>> upgrade( |
| | | @PathVariable Long id, |
| | | @Valid @RequestBody TalentUpgradeRequest request |
| | | ) { |
| | | return ApiResponse.success(talentService.upgrade(id, request)); |
| | | } |
| | | |
| | | @PatchMapping("/{id}/advisor") |
| | | public ApiResponse<Map<String, Object>> updateAdvisor( |
| | | @PathVariable Long id, |
| | |
| | | return detail(id); |
| | | } |
| | | |
| | | /** |
| | | * 游客(还没有人才卡)补录认证信息并升级为持卡人:建一张 PERSONAL 卡,同时把等级、卡号写回 talent_user。 |
| | | * |
| | | * <p>已经有有效主卡的持卡人不能走这条路——改等级有单独的「修改人才等级」入口,避免这里重复发卡。 |
| | | */ |
| | | @Transactional |
| | | public Map<String, Object> upgrade(Long id, TalentUpgradeRequest request) { |
| | | Integer existingCards = jdbcTemplate.queryForObject(""" |
| | | select count(*) from talent_card |
| | | where deleted = 0 and talent_id = ? and card_type = 'PERSONAL' |
| | | """, Integer.class, id); |
| | | if (existingCards != null && existingCards > 0) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "该持卡人已有有效人才卡,请改用「修改人才等级」"); |
| | | } |
| | | String cardNo = request.talentCardNo().trim().toUpperCase(); |
| | | Integer cardCount = jdbcTemplate.queryForObject("select count(*) from talent_card where card_no = ?", Integer.class, cardNo); |
| | | if (cardCount != null && cardCount > 0) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "人才卡号已存在"); |
| | | } |
| | | int updated = jdbcTemplate.update(""" |
| | | update talent_user |
| | | set name = ?, id_card_no = ?, gender = ?, work_unit = ?, talent_level = ?, |
| | | source_area_type = ?, work_address = ?, contact_address = ?, talent_card_no = ? |
| | | where id = ? and deleted = 0 |
| | | """, |
| | | request.name(), |
| | | blankToNull(request.idCardNo()), |
| | | request.gender(), |
| | | blankToNull(request.workUnit()), |
| | | request.talentLevel(), |
| | | defaultValue(request.sourceAreaType(), "LOCAL"), |
| | | blankToNull(request.workAddress()), |
| | | blankToNull(request.contactAddress()), |
| | | cardNo, |
| | | id); |
| | | if (updated == 0) { |
| | | throw new BusinessException(ErrorCode.NOT_FOUND, "持卡人不存在"); |
| | | } |
| | | jdbcTemplate.update(""" |
| | | insert into talent_card(talent_id, card_no, card_type, card_level, valid_start, valid_end, issue_time, status) |
| | | values (?, ?, 'PERSONAL', ?, curdate(), date_add(curdate(), interval 1 year), now(), 1) |
| | | """, id, cardNo, request.talentLevel()); |
| | | return detail(id); |
| | | } |
| | | |
| | | @Transactional |
| | | public Map<String, Object> updateCardLevel(Long id, String talentLevel) { |
| | | Integer cardCount = jdbcTemplate.queryForObject(""" |
| New file |
| | |
| | | package com.talentcard.server.business.talent; |
| | | |
| | | import jakarta.validation.constraints.NotBlank; |
| | | import jakarta.validation.constraints.NotNull; |
| | | import jakarta.validation.constraints.Pattern; |
| | | import jakarta.validation.constraints.Size; |
| | | |
| | | /** |
| | | * 游客(还没有人才卡)补录信息并升级为持卡人。 |
| | | * |
| | | * <p>字段口径与「新增持卡人」一致:认证信息 + 人才等级 + 人才卡号 + 来源地。 |
| | | * 手机号是账号标识,这里不参与修改(改手机号走 {@code PATCH /api/talents/{id}/mobile},那里有查重)。 |
| | | */ |
| | | public record TalentUpgradeRequest( |
| | | @NotBlank(message = "请输入持卡人姓名") |
| | | @Size(max = 64) |
| | | String name, |
| | | @NotBlank(message = "请输入身份证号") |
| | | @Pattern(regexp = "^[0-9A-Z]{18}$", message = "身份证号须为18位数字或大写英文字母") |
| | | String idCardNo, |
| | | @NotNull(message = "请选择性别") |
| | | Byte gender, |
| | | @Size(max = 128) |
| | | String workUnit, |
| | | @NotBlank(message = "请选择人才等级") |
| | | @Pattern(regexp = "^(卓越人才卡|杰出人才卡|菁英人才卡)$", message = "人才等级无效") |
| | | String talentLevel, |
| | | @NotBlank(message = "请输入人才卡号") |
| | | @Pattern(regexp = "^[0-9A-Z-]{1,64}$", message = "人才卡号只能包含大写字母、数字和短横线") |
| | | String talentCardNo, |
| | | @NotBlank(message = "请选择本地或外地") |
| | | @Pattern(regexp = "^(LOCAL|OUTSIDE)$", message = "来源地无效") |
| | | String sourceAreaType, |
| | | @NotBlank(message = "请输入工作地址") |
| | | @Size(max = 255) |
| | | String workAddress, |
| | | @NotBlank(message = "请输入联系地址") |
| | | @Size(max = 255) |
| | | String contactAddress |
| | | ) { |
| | | } |