From 38e39c50dcca12534c4efe2b49355a97c396b507 Mon Sep 17 00:00:00 2001
From: shinnosuke <tantao@1hoper.com>
Date: 星期六, 10 十月 2026 11:44:05 +0800
Subject: [PATCH] 更新....
---
rencaika/docs/pages/63-观影券详情.md | 39 ++++
talent-card-admin/src/views/TalentDetailView.vue | 19 ++
talent-card-server/src/main/java/com/talentcard/server/business/talent/TalentController.java | 11 +
rencaika/pages/index/index.uvue | 41 ++++
talent-card-admin/src/api/admin.ts | 8 +
rencaika_merchant/docs/pages/24-观影券核销详情.md | 53 ++++++
talent-card-server/src/main/java/com/talentcard/server/business/talent/TalentService.java | 45 +++++
rencaika/pages/ticket-package-detail/ticket-package-detail.uvue | 93 +++++++++++
rencaika_merchant/pages/ticket-package-detail/ticket-package-detail.uvue | 103 ++++++++++++
talent-card-admin/src/router/index.ts | 6
rencaika/static/common/image-placeholder.png | 0
talent-card-server/src/main/java/com/talentcard/server/business/talent/TalentUpgradeRequest.java | 41 +++++
12 files changed, 450 insertions(+), 9 deletions(-)
diff --git "a/rencaika/docs/pages/63-\350\247\202\345\275\261\345\210\270\350\257\246\346\203\205.md" "b/rencaika/docs/pages/63-\350\247\202\345\275\261\345\210\270\350\257\246\346\203\205.md"
new file mode 100644
index 0000000..ed99600
--- /dev/null
+++ "b/rencaika/docs/pages/63-\350\247\202\345\275\261\345\210\270\350\257\246\346\203\205.md"
@@ -0,0 +1,39 @@
+# 观影券详情(使用记录)
+
+## 基本信息
+
+| 项目 | 内容 |
+| --- | --- |
+| 路由 | `/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` 是领券成功页(展示可领取额度、兑换码与二维码);
+本页是使用记录里的观影券详情,按「观影券」原型展示二维码、适用门店与领取/核销时间。
diff --git a/rencaika/pages/index/index.uvue b/rencaika/pages/index/index.uvue
index d55b75e..0190815 100644
--- a/rencaika/pages/index/index.uvue
+++ b/rencaika/pages/index/index.uvue
@@ -8,15 +8,18 @@
</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>
@@ -176,16 +179,42 @@
.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; }
diff --git a/rencaika/pages/ticket-package-detail/ticket-package-detail.uvue b/rencaika/pages/ticket-package-detail/ticket-package-detail.uvue
new file mode 100644
index 0000000..4b430cd
--- /dev/null
+++ b/rencaika/pages/ticket-package-detail/ticket-package-detail.uvue
@@ -0,0 +1,93 @@
+<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>
diff --git a/rencaika/static/common/image-placeholder.png b/rencaika/static/common/image-placeholder.png
new file mode 100644
index 0000000..ff41d5b
--- /dev/null
+++ b/rencaika/static/common/image-placeholder.png
Binary files differ
diff --git "a/rencaika_merchant/docs/pages/24-\350\247\202\345\275\261\345\210\270\346\240\270\351\224\200\350\257\246\346\203\205.md" "b/rencaika_merchant/docs/pages/24-\350\247\202\345\275\261\345\210\270\346\240\270\351\224\200\350\257\246\346\203\205.md"
new file mode 100644
index 0000000..3aaff0e
--- /dev/null
+++ "b/rencaika_merchant/docs/pages/24-\350\247\202\345\275\261\345\210\270\346\240\270\351\224\200\350\257\246\346\203\205.md"
@@ -0,0 +1,53 @@
+# 观影券核销详情(待核销 / 已核销)
+
+## 页面信息
+
+| 项目 | 内容 |
+| --- | --- |
+| 页面名称 | 核销确认(观影券) |
+| 路由 | `/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` | 页面提示「观影券参数缺失」。 |
+
+## 后台可见性
+
+管理后台「电影票管理 → 核销记录」可按票包、持卡人、卡片等级、领取时间、核销时间和核销商家查询到同一条记录。
diff --git a/rencaika_merchant/pages/ticket-package-detail/ticket-package-detail.uvue b/rencaika_merchant/pages/ticket-package-detail/ticket-package-detail.uvue
new file mode 100644
index 0000000..875e10b
--- /dev/null
+++ b/rencaika_merchant/pages/ticket-package-detail/ticket-package-detail.uvue
@@ -0,0 +1,103 @@
+<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>
diff --git a/talent-card-admin/src/api/admin.ts b/talent-card-admin/src/api/admin.ts
index a00a050..7ef20a4 100644
--- a/talent-card-admin/src/api/admin.ts
+++ b/talent-card-admin/src/api/admin.ts
@@ -185,6 +185,14 @@
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')
}
diff --git a/talent-card-admin/src/router/index.ts b/talent-card-admin/src/router/index.ts
index d8e7448..34769c6 100644
--- a/talent-card-admin/src/router/index.ts
+++ b/talent-card-admin/src/router/index.ts
@@ -46,6 +46,12 @@
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,
diff --git a/talent-card-admin/src/views/TalentDetailView.vue b/talent-card-admin/src/views/TalentDetailView.vue
index 1699298..8cad00d 100644
--- a/talent-card-admin/src/views/TalentDetailView.vue
+++ b/talent-card-admin/src/views/TalentDetailView.vue
@@ -1,6 +1,6 @@
<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 {
@@ -15,6 +15,7 @@
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[]>([])
@@ -45,7 +46,18 @@
}
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 '女'
@@ -246,7 +258,8 @@
</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>
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/talent/TalentController.java b/talent-card-server/src/main/java/com/talentcard/server/business/talent/TalentController.java
index 042cf93..f381c79 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/talent/TalentController.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/talent/TalentController.java
@@ -126,6 +126,17 @@
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,
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/talent/TalentService.java b/talent-card-server/src/main/java/com/talentcard/server/business/talent/TalentService.java
index ba96b94..7f3b096 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/talent/TalentService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/talent/TalentService.java
@@ -231,6 +231,51 @@
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("""
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/talent/TalentUpgradeRequest.java b/talent-card-server/src/main/java/com/talentcard/server/business/talent/TalentUpgradeRequest.java
new file mode 100644
index 0000000..b6f8316
--- /dev/null
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/talent/TalentUpgradeRequest.java
@@ -0,0 +1,41 @@
+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
+) {
+}
--
Gitblit v1.7.1