shinnosuke
5 天以前 e52934f6527027bd6008c2ade61c3083159efd4a
更新....
10个文件已删除
37个文件已修改
750 ■■■■■ 已修改文件
rencaika/App.uvue 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/config/api.uts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/docs/pages/05-暖心食享.md 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/docs/pages/59-首页.md 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/docs/pages/README.md 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/docs/开发进度与注意事项.md 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages.json 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/benefit-search/benefit-search.uvue 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/benefits/benefits.uvue 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/entertainment-benefits/entertainment-benefits.uvue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/food-benefits/food-benefits.uvue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/index/index.uvue 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/merchant/merchant.uvue 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/order-completed/order-completed.uvue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/order/order.uvue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/payment-confirm/payment-confirm.uvue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/pending-use/pending-use.uvue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/policy-detail/policy-detail.uvue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/product-detail/product-detail.uvue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/spouse-employment/spouse-employment.uvue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/stay-benefits/stay-benefits.uvue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/common/image-fallback.svg 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/entertainment-assets/entertainment-hero.webp 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/entertainment-assets/wanda-cinema.webp 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/food-assets/food-1.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/food-assets/food-2.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/home-assets/top.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/news-assets/meeting-thumb.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/policy-assets/policy-explanation.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/stay-assets/hotel-hampton.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/stay-assets/hotel-vienna.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/config/api.uts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/docs/pages/02-订单管理.md 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/docs/pages/23-观影权益核销.md 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/docs/pages/README.md 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages.json 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/orders/orders.uvue 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/services/orders.uts 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/api/admin.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MovieTicketView.vue 101 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/TicketPackageAdminController.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkController.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java 166 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/TicketPackageClaimMapper.java 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/service/BenefitVerificationCodeService.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/service/TicketPackageAdminService.java 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/service/TicketPackagePortalService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/App.uvue
@@ -1,7 +1,24 @@
<script setup lang="uts">
    import { developmentLoginBypassEnabled } from './config/api.uts'
    import { developmentLoginBypassEnabled, isApiConfigured } from './config/api.uts'
    import { initializeDevelopmentApiBaseUrl } from './config/development-server.uts'
    import { loginWithDevelopmentSession } from './services/auth.uts'
    import { resolveApiUrl } from './services/http.uts'
    /**
     * 行楷字体(古典艺术行楷简,5.1MB)放在服务器 `/uploads/fonts/gudian-xingkai.ttf`:
     * 包体主包上限 2MB,完整字体不能进包,所以启动时动态加载;加载失败时页面仍用包内子集/系统字体。
     * `global: true` 只在 app.uvue 里调用才全局生效(微信小程序基础库 2.10+)。
     */
    const loadCalligraphyFont = () => {
        if (!isApiConfigured()) return
        uni.loadFontFace({
            family: 'FoodCalligraphyFull',
            source: 'url("' + resolveApiUrl('/uploads/fonts/gudian-xingkai.ttf') + '")',
            global: true,
            success: () => { console.log('Calligraphy font loaded') },
            fail: (error: any) => { console.warn('Calligraphy font load failed: ' + JSON.stringify(error)) }
        })
    }
    // #ifdef APP-ANDROID || APP-HARMONY
    let firstBackTime = 0
@@ -11,6 +28,7 @@
        console.log('App Launch')
        // 先把服务端地址解析好:开发环境探测本机局域网 IP,换网后不必改代码重新编译。
        initializeDevelopmentApiBaseUrl()
        loadCalligraphyFont()
        if (developmentLoginBypassEnabled) {
            loginWithDevelopmentSession(() => {}, (error) => {
                console.warn('Development session initialization failed: ' + error.code)
@@ -98,6 +116,15 @@
        background-color: transparent !important;
    }
    /*
     * 专题页的大标题/菜名用行楷:优先用 App.uvue 动态加载的服务器字体(FoodCalligraphyFull),
     * 没加载出来或缺少字符时回退到包内子集(FoodCalligraphy)与系统字体。
     */
    .dish-name,
    .scene-name {
        font-family: 'FoodCalligraphyFull', 'FoodCalligraphy' !important;
    }
    .nav-row .back-button-active {
        opacity: .55;
    }
rencaika/config/api.uts
@@ -4,7 +4,8 @@
import { developmentApiBaseUrl } from './development-server.uts'
// 2026-09-29:体验版指向攀枝花测试环境。本机联调改回 'development';生产上线改 'production'。
export const apiEnvironment: ApiEnvironment = 'staging'
// 2026-10-09:本地联调电影票,临时切 'development'(开发者工具走 127.0.0.1:8080,见 config/development-server.uts)。测完改回 'staging'。
export const apiEnvironment: ApiEnvironment = 'development'
// Separate from demo-data controls; only development builds request a test session.
export const developmentLoginBypassEnabled: boolean = false 
rencaika/docs/pages/05-暖心食享.md
@@ -43,8 +43,8 @@
## 资源与包体说明
- 体验版仅保留 `static/food-assets/food-1.jpg`、`food-2.jpg` 两张唯一餐饮图,图册、商户、商品、支付及订单相关展示均循环复用,合计约 210KB。
- 餐饮图不再单独打一份兜底图:接口没配图、图片加载失败、以及本地演示数据统一用 `static/common/image-placeholder.png`(全站唯一兜底图,24KB),图册/商户/商品/支付/订单相关展示都用它。
- 完整餐饮素材继续保留在项目外 `icon/吃` 素材库,正式版由后台/CDN配置,不直接打入小程序代码包。
- 完整“古典艺术行楷简”字体约 5.35MB,开发包未直接引入。
- 完整“古典艺术行楷简”字体约 5.35MB,**不放包内**(主包上限 2MB),已放服务器 `/data/talent-card/uploads/fonts/gudian-xingkai.ttf`(`/uploads/fonts/gudian-xingkai.ttf`),由 `App.uvue` 启动时用 `uni.loadFontFace({ family:'FoodCalligraphyFull', global:true })` 动态加载;专题页的 `.scene-name/.dish-name` 全局优先用它,加载失败或缺字回退到包内子集与系统字体。
- 当前菜名使用约 12KB 的 `food-calligraphy-subset.ttf` 子集,只用于设计稿演示字符。
- 后台动态名称正式上线前,必须完成完整字体的分包、远程加载或正式 CDN 方案,并验证字体授权与失败回退。
- 后台动态名称的完整字体已走"服务器 + 动态加载"方案;上线前仍需验证字体授权、首次加载耗时(5.35MB)与失败回退,必要时换 WOFF/子集。
rencaika/docs/pages/59-首页.md
@@ -12,9 +12,9 @@
| 功能 | 当前实现 | 后续接口目标 |
| --- | --- | --- |
| 顶部导航 | 按微信胶囊动态适配;标题实际使用 `/static/home-assets/home-title-left-v3.png`;下滑超过阈值后橙色实体背景渐显,标题位置保持固定。 | 后台无需下发导航资源;发版时按字体与包体策略确认标题资源。 |
| 东区主图 | 顶图叠加橙色透明渐变,提供“向东看”入口。 | 后台返回宣传视频或内容链接及封面。 |
| 城市介绍 | 展示东区简介、潜力城、兴业城、黄金城、宜居城入口。 | 后台配置专题类型、标题、图片和详情内容。 |
| 顶部导航 | 按微信胶囊动态适配;标题是文本「攀枝花市东区人才服务平台」,用行楷(优先服务器字体 `FoodCalligraphyFull`,加载失败或缺字回退系统字体);下滑超过阈值后橙色实体背景渐显,标题位置保持固定。 | 后台无需下发导航资源。 |
| 东区主图 | 主图取服务器 `/uploads/home/home-top.png`(`homeImage()` 拼当前接口域名),上半部压橙色透明渐变、底部圆角,提供“向东看”入口。 | 后台返回宣传视频或内容链接及封面。 |
| 城市介绍 | 展示东区简介、潜力城、兴业城、黄金城、宜居城入口,配图取服务器 `/uploads/home/city-*.jpg\|png`;换图只换服务器文件,不用发版。 | 后台配置专题类型、标题、图片和详情内容。 |
| 人才资讯 | 配置服务地址后调用 `GET /api/app/home` 获取置顶及普通资讯摘要,按稳定 ID 进入富文本资讯详情;查看更多进入资讯列表。 | 后台发布管理、封面图和附件待补充。 |
| 底部导航 | 首页、人才权益、人才卡、人才政策、个人中心为平级页面,使用 `reLaunch` 切换。 | 登录态失效时统一进入登录页。 |
rencaika/docs/pages/README.md
@@ -17,7 +17,7 @@
| 14~28 | 个人中心、人才卡升级、使用记录与订单状态、家庭附属卡、设置与协议 |
| 29~46 | 游、住、酒店预约与免费权益、娱、行、购、享及相关商户/结果页面 |
| 47~58 | 配偶推荐就业说明、体检、行政审批、金融服务、学生托付、子女信息、学校详情、人才码与体检医院详情 |
| 60~62 | 配偶信息填写、配偶信息提交成功、外部服务网页 |
| 60~63 | 配偶信息填写、配偶信息提交成功、外部服务网页、观影券详情 |
- 文档编号必须唯一;当前编号 `00~62` 中保留历史业务顺序,不要求与 `pages.json` 顺序一致。
- 一份文档可覆盖同一路由的一种明确业务模式,但必须写明入口参数、状态差异和后台字段。
rencaika/docs/开发进度与注意事项.md
@@ -92,7 +92,7 @@
- 人才权益原始顶图约 9.1MB,项目内使用压缩后的 `static/benefits-assets/benefits-hero.jpg`(1080×607,约 140KB),禁止将原图直接加入小程序包。
- 小程序预览主包上限为 4MB。娱页面图片和首页城市小卡图已使用面向显示尺寸的 WebP 资源;新增或替换资源后必须检查编译目录总大小并预留至少 200KB 余量,原始大图保留在项目外的 `icon/` 素材库中,不得直接放入 `static/`。
- **体验版专题素材规则**:吃/游/住/娱/行/购/享每个专题最多保留 2 张唯一演示图片,图册可保留多个滑动项但必须循环复用这两张图;商户、商品、支付、预约和订单详情优先复用所属专题图片,不再单独打包重复封面。完整素材保留在项目外 `icon/`,正式全量版由后台/CDN加载;每次新增图片前必须先检查编译包体并保留安全余量。
- 暖心食享演示菜名使用 `static/fonts/food-calligraphy-subset.ttf` 字体子集,编译后以内联字体使用;该子集只覆盖当前演示菜名,后台配置任意名称时需加载完整字体包或正式 CDN 字体。
- 暖心食享演示菜名使用 `static/fonts/food-calligraphy-subset.ttf` 字体子集,编译后以内联字体使用;完整“古典艺术行楷简”(5.35MB)不进包,放服务器 `/uploads/fonts/gudian-xingkai.ttf`,由 `App.uvue` 启动时 `uni.loadFontFace` 动态加载(`family: 'FoodCalligraphyFull'`,`global: true`),专题页 `.scene-name/.dish-name` 优先用它、失败回退子集与系统字体。**覆盖范围**:7 个专题页顶部大图的那行名称(吃/游/住/娱/行/购/享)——吃用 `.dish-name`,其余用 `.scene-name`;住—舒心安居与娱—怡乐生活原先没有(或只有子集)声明,已按同一套字体链补上。
- 所有“顶部背景全图/沉浸式头图”页面统一使用固定自定义导航栏:导航栏必须放在纵向滚动容器外,返回按钮和标题不得随内容滚走;初始状态透明叠加在头图上,页面上滑超过 `64px` 后渐显橙色实体渐变背景,背景使用 `linear-gradient(180deg, #F26A38 0%, #EE542B 100%)` 和 `280ms ease-out` 透明度过渡;导航高度、底部留白和微信胶囊适配保持与首页一致。除非设计图明确要求不同,后续同类页面必须按此方式交付。当前已应用:首页、人才权益、暖心食享、商家详情、商品详情、人才政策。
- 吃/游/住/娱等“顶图 + 图册 + 介绍文案 + 列表导航”专题页,介绍卡必须置于正常文档流并由内容自然撑高,同时通过统一的 `-66rpx` 负外边距向上覆盖顶图,保留悬浮效果;列表导航区固定使用介绍卡底部后的 `48rpx` 间距。不得通过固定介绍卡高度或固定 `padding-top` 猜测文案高度,后台文案变更为多行时,服务列表必须自动向下顺延。
- **全量发版要求**:正式全量发布时,需按发布包策略注入完整的“古典艺术行楷简”字体包,并复核微信小程序分包/总体积限制、字体授权及真机渲染效果;开发预览阶段继续使用标题图片,避免超过预览包限制。
rencaika/pages.json
@@ -241,6 +241,14 @@
            }
        },
        {
            "path": "pages/ticket-package-detail/ticket-package-detail",
            "style": {
                "navigationStyle": "custom",
                "backgroundColor": "#FFFFFF",
                "disableScroll": true
            }
        },
        {
            "path": "pages/ticket-package/ticket-package",
            "style": {
                "navigationStyle": "custom",
rencaika/pages/benefit-search/benefit-search.uvue
@@ -92,20 +92,20 @@
    const histories = ref<string[]>([])
    // 演示数据已按 sort 从小到大排列;后端接入后由接口返回同序分页结果。
    const allMerchants = [
        { id: 101, name: '乡菜郎·攀枝花特色延边菜', category: '暖心食享', merchantType: 'food', discount: '6折', address: '西区格里坪镇清香坪东街78号', image: '/static/food-assets/food-1.jpg', sort: 10 },
        { id: 102, name: '攀枝花希尔顿欢朋酒店', category: '舒心安居', merchantType: 'hotel', discount: '6折', address: '东区机场路10号', image: '/static/stay-assets/hotel-hampton.jpg', sort: 20 },
        { id: 103, name: '攀枝花万达影城', category: '怡乐生活', merchantType: 'cinema', discount: '免费券', address: '东区攀枝花大道东段699号', image: '/static/entertainment-assets/wanda-cinema.webp', sort: 30 },
        { id: 104, name: '攀枝花中国三线建设博物馆', category: '悦览花城', merchantType: 'scenic', discount: '优惠票', address: '仁和区花城新区干坝塘村', image: '/static/common/image-fallback.svg', sort: 40 },
        { id: 105, name: '金府酒楼(西区店)', category: '暖心食享', merchantType: 'food', discount: '6折', address: '西区清香坪北街51号', image: '/static/food-assets/food-2.jpg', sort: 50 },
        { id: 106, name: '中国石油东区新福路加油站', category: '畅行无忧', merchantType: 'fuel', discount: '9.4折', address: '东区新福路20号附3-21号', image: '/static/home-assets/top.jpg', sort: 60 },
        { id: 107, name: '渡口公司管辖停车场', category: '畅行无忧', merchantType: 'parking', discount: '免费', address: '攀枝花市东区指定停车场', image: '/static/common/image-fallback.svg', sort: 70 },
        { id: 108, name: '攀商未来城', category: '惠享好物', merchantType: 'shopping', discount: '购房优惠3%', address: '东区榕树街55号', image: '/static/home-assets/top.jpg', sort: 80 },
        { id: 109, name: '攀枝花市中心医院', category: '臻享礼遇', merchantType: 'medical', discount: '免费体检', address: '东区攀枝花大道中段益康街34号', image: '/static/stay-assets/hotel-vienna.jpg', sort: 90 },
        { id: 110, name: '人才金融服务中心', category: '臻享礼遇', merchantType: 'finance', discount: '专属金融方案', address: '东区人民街中心广场', image: '/static/stay-assets/hotel-hampton.jpg', sort: 100 },
        { id: 111, name: '东区人才自助洗车服务点', category: '畅行无忧', merchantType: 'carWash', discount: '专属优惠', address: '东区机场路人才服务站', image: '/static/home-assets/top.jpg', sort: 110 },
        { id: 112, name: '攀枝花维也纳国际酒店', category: '舒心安居', merchantType: 'hotel', discount: '免费5天', address: '东区攀枝花大道中段', image: '/static/stay-assets/hotel-vienna.jpg', sort: 120 },
        { id: 113, name: '百杏厨房(中环天地店)', category: '暖心食享', merchantType: 'food', discount: '8.8折', address: '东区炳三区新福路20号', image: '/static/food-assets/food-1.jpg', sort: 130 },
        { id: 114, name: '攀枝花大剧院', category: '怡乐生活', merchantType: 'cinema', discount: '演出优惠', address: '东区机场路文化艺术中心', image: '/static/entertainment-assets/entertainment-hero.webp', sort: 140 }
        { id: 101, name: '乡菜郎·攀枝花特色延边菜', category: '暖心食享', merchantType: 'food', discount: '6折', address: '西区格里坪镇清香坪东街78号', image: '/static/common/image-placeholder.png', sort: 10 },
        { id: 102, name: '攀枝花希尔顿欢朋酒店', category: '舒心安居', merchantType: 'hotel', discount: '6折', address: '东区机场路10号', image: '/static/common/image-placeholder.png', sort: 20 },
        { id: 103, name: '攀枝花万达影城', category: '怡乐生活', merchantType: 'cinema', discount: '免费券', address: '东区攀枝花大道东段699号', image: '/static/common/image-placeholder.png', sort: 30 },
        { id: 104, name: '攀枝花中国三线建设博物馆', category: '悦览花城', merchantType: 'scenic', discount: '优惠票', address: '仁和区花城新区干坝塘村', image: '/static/common/image-placeholder.png', sort: 40 },
        { id: 105, name: '金府酒楼(西区店)', category: '暖心食享', merchantType: 'food', discount: '6折', address: '西区清香坪北街51号', image: '/static/common/image-placeholder.png', sort: 50 },
        { id: 106, name: '中国石油东区新福路加油站', category: '畅行无忧', merchantType: 'fuel', discount: '9.4折', address: '东区新福路20号附3-21号', image: '/static/common/image-placeholder.png', sort: 60 },
        { id: 107, name: '渡口公司管辖停车场', category: '畅行无忧', merchantType: 'parking', discount: '免费', address: '攀枝花市东区指定停车场', image: '/static/common/image-placeholder.png', sort: 70 },
        { id: 108, name: '攀商未来城', category: '惠享好物', merchantType: 'shopping', discount: '购房优惠3%', address: '东区榕树街55号', image: '/static/common/image-placeholder.png', sort: 80 },
        { id: 109, name: '攀枝花市中心医院', category: '臻享礼遇', merchantType: 'medical', discount: '免费体检', address: '东区攀枝花大道中段益康街34号', image: '/static/common/image-placeholder.png', sort: 90 },
        { id: 110, name: '人才金融服务中心', category: '臻享礼遇', merchantType: 'finance', discount: '专属金融方案', address: '东区人民街中心广场', image: '/static/common/image-placeholder.png', sort: 100 },
        { id: 111, name: '东区人才自助洗车服务点', category: '畅行无忧', merchantType: 'carWash', discount: '专属优惠', address: '东区机场路人才服务站', image: '/static/common/image-placeholder.png', sort: 110 },
        { id: 112, name: '攀枝花维也纳国际酒店', category: '舒心安居', merchantType: 'hotel', discount: '免费5天', address: '东区攀枝花大道中段', image: '/static/common/image-placeholder.png', sort: 120 },
        { id: 113, name: '百杏厨房(中环天地店)', category: '暖心食享', merchantType: 'food', discount: '8.8折', address: '东区炳三区新福路20号', image: '/static/common/image-placeholder.png', sort: 130 },
        { id: 114, name: '攀枝花大剧院', category: '怡乐生活', merchantType: 'cinema', discount: '演出优惠', address: '东区机场路文化艺术中心', image: '/static/common/image-placeholder.png', sort: 140 }
    ]
    const formatRemoteDiscount = (item: any): string => {
        const sale = item.salePrice != null ? Number(item.salePrice) : 0
rencaika/pages/benefits/benefits.uvue
@@ -110,18 +110,18 @@
    ]
    const allBenefits = [
        { id: 1, name: '乡菜郎·攀枝花特色延边菜', discount: '6折', address: '西区格里坪镇清香坪东街78号', image: '/static/food-assets/food-1.jpg' },
        { id: 2, name: '金府酒楼(西区店)', discount: '6折', address: '西区清香坪北街51号', image: '/static/food-assets/food-2.jpg' },
        { id: 3, name: '乡菜郎·攀枝花特色延边菜', discount: '6折', address: '西区格里坪镇清香坪东街78号', image: '/static/food-assets/food-1.jpg' },
        { id: 4, name: '金府酒楼(西区店)', discount: '6折', address: '西区清香坪北街51号', image: '/static/food-assets/food-2.jpg' },
        { id: 5, name: '百杏厨房(中环天地店)', discount: '8.8折', address: '东区炳三区新福路20号中环天地负一层', image: '/static/food-assets/food-1.jpg' },
        { id: 6, name: '香佰里鲜货火锅(万象城店)', discount: '8.8折', address: '东区攀枝花大道东段298号', image: '/static/food-assets/food-2.jpg' },
        { id: 7, name: '乡菜郎·攀枝花特色延边菜', discount: '6折', address: '西区格里坪镇清香坪东街78号', image: '/static/food-assets/food-1.jpg' },
        { id: 8, name: '金府酒楼(西区店)', discount: '6折', address: '西区清香坪北街51号', image: '/static/food-assets/food-2.jpg' },
        { id: 9, name: '百杏厨房(中环天地店)', discount: '8.8折', address: '东区炳三区新福路20号中环天地负一层', image: '/static/food-assets/food-1.jpg' },
        { id: 10, name: '香佰里鲜货火锅(万象城店)', discount: '8.8折', address: '东区攀枝花大道东段298号', image: '/static/food-assets/food-2.jpg' },
        { id: 11, name: '乡菜郎·攀枝花特色延边菜', discount: '6折', address: '西区格里坪镇清香坪东街78号', image: '/static/food-assets/food-1.jpg' },
        { id: 12, name: '金府酒楼(西区店)', discount: '6折', address: '西区清香坪北街51号', image: '/static/food-assets/food-2.jpg' }
        { id: 1, name: '乡菜郎·攀枝花特色延边菜', discount: '6折', address: '西区格里坪镇清香坪东街78号', image: '/static/common/image-placeholder.png' },
        { id: 2, name: '金府酒楼(西区店)', discount: '6折', address: '西区清香坪北街51号', image: '/static/common/image-placeholder.png' },
        { id: 3, name: '乡菜郎·攀枝花特色延边菜', discount: '6折', address: '西区格里坪镇清香坪东街78号', image: '/static/common/image-placeholder.png' },
        { id: 4, name: '金府酒楼(西区店)', discount: '6折', address: '西区清香坪北街51号', image: '/static/common/image-placeholder.png' },
        { id: 5, name: '百杏厨房(中环天地店)', discount: '8.8折', address: '东区炳三区新福路20号中环天地负一层', image: '/static/common/image-placeholder.png' },
        { id: 6, name: '香佰里鲜货火锅(万象城店)', discount: '8.8折', address: '东区攀枝花大道东段298号', image: '/static/common/image-placeholder.png' },
        { id: 7, name: '乡菜郎·攀枝花特色延边菜', discount: '6折', address: '西区格里坪镇清香坪东街78号', image: '/static/common/image-placeholder.png' },
        { id: 8, name: '金府酒楼(西区店)', discount: '6折', address: '西区清香坪北街51号', image: '/static/common/image-placeholder.png' },
        { id: 9, name: '百杏厨房(中环天地店)', discount: '8.8折', address: '东区炳三区新福路20号中环天地负一层', image: '/static/common/image-placeholder.png' },
        { id: 10, name: '香佰里鲜货火锅(万象城店)', discount: '8.8折', address: '东区攀枝花大道东段298号', image: '/static/common/image-placeholder.png' },
        { id: 11, name: '乡菜郎·攀枝花特色延边菜', discount: '6折', address: '西区格里坪镇清香坪东街78号', image: '/static/common/image-placeholder.png' },
        { id: 12, name: '金府酒楼(西区店)', discount: '6折', address: '西区清香坪北街51号', image: '/static/common/image-placeholder.png' }
    ]
    const visibleBenefits = ref<any[]>([])
    const loadedBenefitCount = ref(0)
rencaika/pages/entertainment-benefits/entertainment-benefits.uvue
@@ -185,6 +185,8 @@
</script>
<style>
    /* 顶部大图标题用行楷:优先 App.uvue 动态加载的服务器完整字体,回退包内子集与系统字体。 */
    .scene-name { font-family: 'FoodCalligraphyFull', 'FoodCalligraphy'; }
    .page, .page-scroll { width: 100%; height: 100vh; background-color: #FFFFFF; }
    .custom-nav { position: fixed; z-index: 20; left: 0; right: 0; top: 0; padding-bottom: 20rpx; }
    .nav-bg { position: absolute; left: 0; right: 0; top: 0; bottom: 0; opacity: 0; background-image: linear-gradient(180deg, #F26A38 0%, #EE542B 100%); transition: opacity 280ms ease-out; }
rencaika/pages/food-benefits/food-benefits.uvue
@@ -76,7 +76,7 @@
    const activeIndex = ref(-1)
    const windowWidth = ref(375)
    const thumbScrollLeft = ref(0)
    const fallbackFoodImage = '/static/food-assets/food-1.jpg'
    const fallbackFoodImage = '/static/common/image-placeholder.png'
    const topicDescription = ref('')
    const gallery = ref<any[]>([])
rencaika/pages/index/index.uvue
@@ -2,6 +2,7 @@
    <view class="page">
        <scroll-view class="scroll-page" scroll-y @scroll="handleScroll">
            <view class="hero">
                <image class="hero-image" :src="homeImage('home-top.png')" mode="aspectFill"></image>
                <view class="hero-gradient"></view>
                <view v-if="homeVideoUrl != ''" class="play-button" @tap="playVideo"><text>向东看</text></view>
            </view>
@@ -9,9 +10,9 @@
            <view class="city-panel">
                <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"><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"><text>潜力城</text></view><view class="city-card city-golden"><text>黄金城</text></view></view>
                    <view class="city-side"><view class="city-card city-industry"><text>兴业城</text></view><view class="city-card city-livable"><text>宜居城</text></view></view>
                    <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>
            </view>
@@ -46,6 +47,12 @@
    import { isApiConfigured } from '../../config/api.uts'
    import { getAppHome } from '../../services/portal.uts'
    import { resolveApiUrl } from '../../services/http.uts'
    /**
     * 首页图片放在服务器上(`/uploads/home/…`),代码里只存相对路径:
     * 换图不用改代码、也不用重新上传体验版,和后台域名一起走环境配置。
     */
    const homeImage = (name: string): string => resolveApiUrl('/uploads/home/' + name)
    const navTop = ref(28)
    const navHeight = ref(32)
@@ -155,15 +162,16 @@
<style>
    .page { height: 100%; background-color: #FFFFFF; }
    .scroll-page { height: calc(100% - 126rpx); height: calc(100% - 126rpx - env(safe-area-inset-bottom)); background-color: #FFFFFF; }
    .hero { height: 468rpx; background-color: #FFFFFF; position: relative; overflow: hidden; }
    .hero-gradient { position: absolute; z-index: 1; top: 0; left: 0; right: 0; bottom: 0; border-bottom-left-radius: 48rpx; border-bottom-right-radius: 48rpx; background-image: linear-gradient(145deg, #F45A2A 0%, #FF9E4B 55%, #FFE0AC 100%); }
    .hero { height: 468rpx; background-color: #FFFFFF; position: relative; overflow: hidden; border-bottom-left-radius: 48rpx; border-bottom-right-radius: 48rpx; }
    .hero-image { position: absolute; left: 0; top: 0; width: 100%; height: 468rpx; }
    .hero-gradient { position: absolute; z-index: 1; top: 0; left: 0; right: 0; height: 300rpx; background-image: linear-gradient(180deg, rgba(244,90,42,.88) 0%, rgba(255,130,60,.42) 62%, rgba(255,166,80,0) 100%); }
    .safe-space { height: 28px; position: relative; z-index: 2; }
    .nav-row { height: 32px; padding: 0 32rpx; position: relative; z-index: 2; flex-direction: row; align-items: center; justify-content: space-between; }
    .sticky-nav { position: absolute; z-index: 30; top: 0; left: 0; right: 0; padding-bottom: 20rpx; }
    .sticky-nav-bg { position: absolute; z-index: 0; top: 0; left: 0; right: 0; bottom: 0; opacity: 0; background-image: linear-gradient(180deg, #F26A38 0%, #EE542B 100%); transition: opacity 280ms ease-out; }
    .sticky-nav.is-solid .sticky-nav-bg { opacity: 1; }
    .nav-title { position: absolute; z-index: 1; left: 30rpx; top: 10rpx; height: 100%; flex-direction: row; align-items: center; justify-content: flex-start; }
    .nav-title text { color: #FFFFFF; font-size: 28rpx; font-weight: 700; opacity: .95; }
    .nav-title text { color: #FFFFFF; font-family: 'FoodCalligraphyFull', 'FoodCalligraphy'; font-size: 38rpx; font-weight: 700; opacity: .95; }
    .capsule-reserve { width: 88px; height: 100%; flex-shrink: 0; }
    .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; }
@@ -173,8 +181,9 @@
    .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%); }
    .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; }
    .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; }
    .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; }
rencaika/pages/merchant/merchant.uvue
@@ -227,30 +227,30 @@
    const applicableStores = ref<any[]>([])
    const packages = ref([
        { id: 'food-set-001', name: '【川味盛宴】川菜12人餐', discount: '6折', price: '138', originalPrice: '266', image: '/static/food-assets/food-1.jpg' },
        { id: 'food-set-002', name: '【川香四溢】川菜4人餐', discount: '6折', price: '118', originalPrice: '198', image: '/static/food-assets/food-2.jpg' },
        { id: 'food-set-003', name: '【欢聚时光】招牌菜6人餐', discount: '6.8折', price: '168', originalPrice: '248', image: '/static/food-assets/food-1.jpg' },
        { id: 'food-set-004', name: '【暖心小聚】特色菜双人餐', discount: '7折', price: '88', originalPrice: '126', image: '/static/food-assets/food-2.jpg' },
        { id: 'food-set-005', name: '【家庭聚餐】经典川菜8人餐', discount: '6.5折', price: '238', originalPrice: '366', image: '/static/food-assets/food-1.jpg' },
        { id: 'food-set-006', name: '【人气必点】招牌菜4人餐', discount: '6.8折', price: '128', originalPrice: '188', image: '/static/food-assets/food-2.jpg' },
        { id: 'food-set-007', name: '【周末欢聚】特色菜10人餐', discount: '6折', price: '298', originalPrice: '498', image: '/static/food-assets/food-1.jpg' },
        { id: 'food-set-008', name: '【一人食】暖心工作餐', discount: '8折', price: '39', originalPrice: '49', image: '/static/food-assets/food-2.jpg' }
        { id: 'food-set-001', name: '【川味盛宴】川菜12人餐', discount: '6折', price: '138', originalPrice: '266', image: '/static/common/image-placeholder.png' },
        { id: 'food-set-002', name: '【川香四溢】川菜4人餐', discount: '6折', price: '118', originalPrice: '198', image: '/static/common/image-placeholder.png' },
        { id: 'food-set-003', name: '【欢聚时光】招牌菜6人餐', discount: '6.8折', price: '168', originalPrice: '248', image: '/static/common/image-placeholder.png' },
        { id: 'food-set-004', name: '【暖心小聚】特色菜双人餐', discount: '7折', price: '88', originalPrice: '126', image: '/static/common/image-placeholder.png' },
        { id: 'food-set-005', name: '【家庭聚餐】经典川菜8人餐', discount: '6.5折', price: '238', originalPrice: '366', image: '/static/common/image-placeholder.png' },
        { id: 'food-set-006', name: '【人气必点】招牌菜4人餐', discount: '6.8折', price: '128', originalPrice: '188', image: '/static/common/image-placeholder.png' },
        { id: 'food-set-007', name: '【周末欢聚】特色菜10人餐', discount: '6折', price: '298', originalPrice: '498', image: '/static/common/image-placeholder.png' },
        { id: 'food-set-008', name: '【一人食】暖心工作餐', discount: '8折', price: '39', originalPrice: '49', image: '/static/common/image-placeholder.png' }
    ])
    const hotelPackages = [
        { id: 101, name: '都市视野大床房', discount: '6折', price: '588-800', originalPrice: '', image: '/static/stay-assets/hotel-hampton.jpg' },
        { id: 102, name: '山景云窗商务双床房', discount: '6折', price: '388-600', originalPrice: '', image: '/static/stay-assets/hotel-vienna.jpg' },
        { id: 103, name: '都市视野大床房', discount: '6折', price: '588-800', originalPrice: '', image: '/static/stay-assets/hotel-hampton.jpg' },
        { id: 104, name: '行政景观大床房', discount: '6折', price: '688-920', originalPrice: '', image: '/static/stay-assets/hotel-vienna.jpg' },
        { id: 105, name: '亲子主题家庭房', discount: '6.5折', price: '628-860', originalPrice: '', image: '/static/stay-assets/hotel-hampton.jpg' },
        { id: 106, name: '高层城景双床房', discount: '6折', price: '468-680', originalPrice: '', image: '/static/stay-assets/hotel-vienna.jpg' },
        { id: 107, name: '豪华行政套房', discount: '6.8折', price: '988-1280', originalPrice: '', image: '/static/stay-assets/hotel-hampton.jpg' },
        { id: 108, name: '舒适大床房', discount: '6折', price: '428-620', originalPrice: '', image: '/static/stay-assets/hotel-vienna.jpg' }
        { id: 101, name: '都市视野大床房', discount: '6折', price: '588-800', originalPrice: '', image: '/static/common/image-placeholder.png' },
        { id: 102, name: '山景云窗商务双床房', discount: '6折', price: '388-600', originalPrice: '', image: '/static/common/image-placeholder.png' },
        { id: 103, name: '都市视野大床房', discount: '6折', price: '588-800', originalPrice: '', image: '/static/common/image-placeholder.png' },
        { id: 104, name: '行政景观大床房', discount: '6折', price: '688-920', originalPrice: '', image: '/static/common/image-placeholder.png' },
        { id: 105, name: '亲子主题家庭房', discount: '6.5折', price: '628-860', originalPrice: '', image: '/static/common/image-placeholder.png' },
        { id: 106, name: '高层城景双床房', discount: '6折', price: '468-680', originalPrice: '', image: '/static/common/image-placeholder.png' },
        { id: 107, name: '豪华行政套房', discount: '6.8折', price: '988-1280', originalPrice: '', image: '/static/common/image-placeholder.png' },
        { id: 108, name: '舒适大床房', discount: '6折', price: '428-620', originalPrice: '', image: '/static/common/image-placeholder.png' }
    ]
    // 体检医院演示套餐:图片按体验版素材规则复用“享”专题两张演示图。
    // 体检医院演示套餐:本地演示数据,图片统一用兜底占位图(后台没配图时也是这张)。
    const medicalPackages = [
        { id: 1, name: 'A套餐(脑血管系统筛查)', openTime: '5-6月', image: '/static/stay-assets/hotel-hampton.jpg' },
        { id: 2, name: 'B套餐(心血管系统筛查)', openTime: '5-6月', image: '/static/stay-assets/hotel-vienna.jpg' },
        { id: 3, name: 'C套餐(消化系统筛查)', openTime: '5-6月', image: '/static/stay-assets/hotel-hampton.jpg' }
        { id: 1, name: 'A套餐(脑血管系统筛查)', openTime: '5-6月', image: '/static/common/image-placeholder.png' },
        { id: 2, name: 'B套餐(心血管系统筛查)', openTime: '5-6月', image: '/static/common/image-placeholder.png' },
        { id: 3, name: 'C套餐(消化系统筛查)', openTime: '5-6月', image: '/static/common/image-placeholder.png' }
    ]
    const visiblePackages = ref<any[]>([])
    const visibleMedicalPackages = ref<any[]>([])
rencaika/pages/order-completed/order-completed.uvue
@@ -58,7 +58,7 @@
    const talentId = ref('')
    const loading = ref(true)
    const loadError = ref(false)
    const productCoverUrl = ref('/static/home-assets/top.jpg')
    const productCoverUrl = ref('/static/common/image-placeholder.png')
    const productName = ref('')
    const originalAmount = ref('0.00')
    const payAmount = ref('0.00')
@@ -93,7 +93,7 @@
        loading.value = true
        loadError.value = false
        getCompletedOrder(orderId.value, talentId.value, (data: any) => {
            productCoverUrl.value = resolveApiUrl(data.productCoverUrlSnapshot || '/static/home-assets/top.jpg')
            productCoverUrl.value = resolveApiUrl(data.productCoverUrlSnapshot || '/static/common/image-placeholder.png')
            productName.value = data.productNameSnapshot || '人才权益服务'
            originalAmount.value = Number(data.originalAmount).toFixed(2)
            payAmount.value = Number(data.payAmount).toFixed(2)
rencaika/pages/order/order.uvue
@@ -374,8 +374,12 @@
        if (item.statusCode == 'received') {
            const detailName = item.detailName != null ? item.detailName as string : item.name as string
            const claimId = item.targetDetailId != null ? item.targetDetailId as string : ''
            const typeParam = item.targetDetailType == 'TICKET_PACKAGE_DETAIL' ? '&type=ticketPackage' : ''
            uni.navigateTo({ url: '/pages/coupon-received/coupon-received?recordId=' + encodeURIComponent(item.id) + '&claimId=' + encodeURIComponent(claimId) + '&name=' + encodeURIComponent(detailName) + typeParam + '&talentId=' + encodeURIComponent(talentId.value) + '&merchantId=' + encodeURIComponent(item.merchantId) })
            // 观影券(票包):详情页按「观影券」原型展示二维码、适用门店和领取/核销时间。
            if (item.targetDetailType == 'TICKET_PACKAGE_DETAIL') {
                uni.navigateTo({ url: '/pages/ticket-package-detail/ticket-package-detail?claimId=' + encodeURIComponent(claimId) + '&talentId=' + encodeURIComponent(talentId.value) + '&merchantId=' + encodeURIComponent(item.merchantId) })
                return
            }
            uni.navigateTo({ url: '/pages/coupon-received/coupon-received?recordId=' + encodeURIComponent(item.id) + '&claimId=' + encodeURIComponent(claimId) + '&name=' + encodeURIComponent(detailName) + '&talentId=' + encodeURIComponent(talentId.value) + '&merchantId=' + encodeURIComponent(item.merchantId) })
            return
        }
        uni.showToast({ title: '记录详情后续接入', icon: 'none' })
rencaika/pages/payment-confirm/payment-confirm.uvue
@@ -67,7 +67,7 @@
    const couponText = ref('无')
    const couponAmount = ref(0.0)
    const productName = ref('【川味盛宴】川菜12人餐')
    const productImage = ref('/static/food-assets/food-1.jpg')
    const productImage = ref('/static/common/image-placeholder.png')
    const salePrice = ref(138.0)
    const originalAmount = ref(266.0)
    const productDiscount = ref('6折')
@@ -80,13 +80,13 @@
    let quoteSequence = 0
    const applyProductById = (id: string) => {
        if (id == 'food-set-002') { productName.value = '【川香四溢】川菜4人餐'; productImage.value = '/static/food-assets/food-2.jpg'; salePrice.value = 118.0; originalAmount.value = 198.0; productDiscount.value = '6折'; return }
        if (id == 'food-set-002') { productName.value = '【川香四溢】川菜4人餐'; productImage.value = '/static/common/image-placeholder.png'; salePrice.value = 118.0; originalAmount.value = 198.0; productDiscount.value = '6折'; return }
        if (id == 'food-set-003') { productName.value = '【欢聚时光】招牌菜6人餐'; salePrice.value = 168.0; originalAmount.value = 248.0; productDiscount.value = '6.8折'; return }
        if (id == 'food-set-004') { productName.value = '【暖心小聚】特色菜双人餐'; productImage.value = '/static/food-assets/food-2.jpg'; salePrice.value = 88.0; originalAmount.value = 126.0; productDiscount.value = '7折'; return }
        if (id == 'food-set-004') { productName.value = '【暖心小聚】特色菜双人餐'; productImage.value = '/static/common/image-placeholder.png'; salePrice.value = 88.0; originalAmount.value = 126.0; productDiscount.value = '7折'; return }
        if (id == 'food-set-005') { productName.value = '【家庭聚餐】经典川菜8人餐'; salePrice.value = 238.0; originalAmount.value = 366.0; productDiscount.value = '6.5折'; return }
        if (id == 'food-set-006') { productName.value = '【人气必点】招牌菜4人餐'; productImage.value = '/static/food-assets/food-2.jpg'; salePrice.value = 128.0; originalAmount.value = 188.0; productDiscount.value = '6.8折'; return }
        if (id == 'food-set-006') { productName.value = '【人气必点】招牌菜4人餐'; productImage.value = '/static/common/image-placeholder.png'; salePrice.value = 128.0; originalAmount.value = 188.0; productDiscount.value = '6.8折'; return }
        if (id == 'food-set-007') { productName.value = '【周末欢聚】特色菜10人餐'; salePrice.value = 298.0; originalAmount.value = 498.0; productDiscount.value = '6折'; return }
        if (id == 'food-set-008') { productName.value = '【一人食】暖心工作餐'; productImage.value = '/static/food-assets/food-2.jpg'; salePrice.value = 39.0; originalAmount.value = 49.0; productDiscount.value = '8折'; return }
        if (id == 'food-set-008') { productName.value = '【一人食】暖心工作餐'; productImage.value = '/static/common/image-placeholder.png'; salePrice.value = 39.0; originalAmount.value = 49.0; productDiscount.value = '8折'; return }
    }
    onLoad((options: any) => {
rencaika/pages/pending-use/pending-use.uvue
@@ -55,7 +55,7 @@
    const navWidth = ref(88)
    const navRight = ref(10)
    const isSubmittingRefund = ref(false)
    const productCoverUrl = ref('/static/home-assets/top.jpg')
    const productCoverUrl = ref('/static/common/image-placeholder.png')
    const recordId = ref('')
    const orderId = ref(''); const talentId = ref(''); const codeImage = ref(''); const codeText = ref('正在生成动态核销码'); const codeError = ref(false); const productName = ref('人才权益服务'); const originalAmount = ref('0.00'); const payAmount = ref('0.00'); const discountAmount = ref('0.00'); const merchantName = ref(''); const merchantAddress = ref(''); const merchantMobile = ref(''); const merchantLatitude = ref(0.0); const merchantLongitude = ref(0.0); const createdAt = ref(''); const verifyTime = ref('-'); const canRefund = ref(false); let refreshTimer = 0
@@ -84,13 +84,13 @@
            uni.redirectTo({ url: '/pages/order-cancelled/order-cancelled?recordId=' + encodeURIComponent(recordId.value) + '&orderId=' + encodeURIComponent(orderId.value) + '&talentId=' + encodeURIComponent(talentId.value) })
            return
        }
        productName.value=data.productNameSnapshot||productName.value; productCoverUrl.value=resolveApiUrl(data.productCoverUrlSnapshot||'/static/home-assets/top.jpg'); originalAmount.value=Number(data.originalAmount).toFixed(2); payAmount.value=Number(data.payAmount).toFixed(2); discountAmount.value=Number(data.discountAmount).toFixed(2); merchantName.value=data.merchantNameSnapshot||''; merchantAddress.value=data.merchantAddressSnapshot||''; merchantMobile.value=data.merchantMobileSnapshot||''; merchantLatitude.value=Number(data.merchantLatitudeSnapshot); merchantLongitude.value=Number(data.merchantLongitudeSnapshot); createdAt.value=(''+data.createdAt).replace('T',' ').substring(0,19); verifyTime.value=data.verifyTime?(''+data.verifyTime).replace('T',' ').substring(0,19):'-'; canRefund.value=data.canRefund===true }, () => {})
        productName.value=data.productNameSnapshot||productName.value; productCoverUrl.value=resolveApiUrl(data.productCoverUrlSnapshot||'/static/common/image-placeholder.png'); originalAmount.value=Number(data.originalAmount).toFixed(2); payAmount.value=Number(data.payAmount).toFixed(2); discountAmount.value=Number(data.discountAmount).toFixed(2); merchantName.value=data.merchantNameSnapshot||''; merchantAddress.value=data.merchantAddressSnapshot||''; merchantMobile.value=data.merchantMobileSnapshot||''; merchantLatitude.value=Number(data.merchantLatitudeSnapshot); merchantLongitude.value=Number(data.merchantLongitudeSnapshot); createdAt.value=(''+data.createdAt).replace('T',' ').substring(0,19); verifyTime.value=data.verifyTime?(''+data.verifyTime).replace('T',' ').substring(0,19):'-'; canRefund.value=data.canRefund===true }, () => {})
    const stopRefresh=()=>{if(refreshTimer!=0){clearTimeout(refreshTimer);refreshTimer=0}}
    const refreshCode=()=>{stopRefresh(); if(orderId.value=='')return; codeError.value=false; issueOrderVerificationCode(orderId.value,talentId.value,(data:any)=>{codeImage.value=resolveApiUrl(data.imagePath as string);codeText.value='核销码 '+(data.displayCode as string)+',请向商家出示';refreshTimer=setTimeout(()=>refreshCode(),Math.max(1,Number(data.refreshAfterSeconds)-3)*1000)},()=>{codeImage.value='';codeError.value=true;codeText.value='点击重新生成核销码'})}
    // @error 必须收回图片地址:二维码取不回来时页面只会剩一片空白,用户分不清是没生成、没网还是码失效。
    // 这是 7.22 遗留问题 P1-006(远程图片失败会留白)的同一类缺陷,人才码和免费券码页都已按此处理。
    const handleCodeImageError=()=>{codeImage.value='';codeError.value=true;codeText.value='核销码加载失败,点击重新生成'}
    const handleCoverImageError=()=>{productCoverUrl.value='/static/home-assets/top.jpg'}
    const handleCoverImageError=()=>{productCoverUrl.value='/static/common/image-placeholder.png'}
    const openNavigation = () => { if(merchantLatitude.value==0||merchantLongitude.value==0){uni.showToast({title:'商户暂未配置导航地址',icon:'none'});return};uni.openLocation({latitude:merchantLatitude.value,longitude:merchantLongitude.value,name:merchantName.value,address:merchantAddress.value,scale:16}) }
    const callMerchant = () => { if(merchantMobile.value==''){uni.showToast({title:'商户暂未配置联系电话',icon:'none'});return};uni.makePhoneCall({phoneNumber:merchantMobile.value}) }
    const requestRefund = () => {
rencaika/pages/policy-detail/policy-detail.uvue
@@ -85,7 +85,7 @@
    const useHeroFallback = () => { policyCover.value = '' }
    const useContentImageFallback = (index: number) => {
        const next = policyContentImages.value.slice()
        next[index] = '/static/policy-assets/policy-explanation.jpg'
        next[index] = '/static/common/image-placeholder.png'
        policyContentImages.value = next
    }
    const goBack = () => safeBack('/pages/policy/policy')
rencaika/pages/product-detail/product-detail.uvue
@@ -84,13 +84,13 @@
    const purchaseNotices = ref<any[]>(defaultPurchaseNotices)
    const loadProduct = (id: string) => {
        if (id == 'food-set-002') { productName.value = '【川香四溢】川菜4人餐'; productImage.value = '/static/food-assets/food-2.jpg'; productPrice.value = '118'; productOriginalPrice.value = '198'; productDiscount.value = '6折'; return }
        if (id == 'food-set-003') { productName.value = '【欢聚时光】招牌菜6人餐'; productImage.value = '/static/food-assets/food-1.jpg'; productPrice.value = '168'; productOriginalPrice.value = '248'; productDiscount.value = '6.8折'; return }
        if (id == 'food-set-004') { productName.value = '【暖心小聚】特色菜双人餐'; productImage.value = '/static/food-assets/food-2.jpg'; productPrice.value = '88'; productOriginalPrice.value = '126'; productDiscount.value = '7折'; return }
        if (id == 'food-set-005') { productName.value = '【家庭聚餐】经典川菜8人餐'; productImage.value = '/static/food-assets/food-1.jpg'; productPrice.value = '238'; productOriginalPrice.value = '366'; productDiscount.value = '6.5折'; return }
        if (id == 'food-set-006') { productName.value = '【人气必点】招牌菜4人餐'; productImage.value = '/static/food-assets/food-2.jpg'; productPrice.value = '128'; productOriginalPrice.value = '188'; productDiscount.value = '6.8折'; return }
        if (id == 'food-set-007') { productName.value = '【周末欢聚】特色菜10人餐'; productImage.value = '/static/food-assets/food-1.jpg'; productPrice.value = '298'; productOriginalPrice.value = '498'; productDiscount.value = '6折'; return }
        if (id == 'food-set-008') { productName.value = '【一人食】暖心工作餐'; productImage.value = '/static/food-assets/food-2.jpg'; productPrice.value = '39'; productOriginalPrice.value = '49'; productDiscount.value = '8折'; return }
        if (id == 'food-set-002') { productName.value = '【川香四溢】川菜4人餐'; productImage.value = '/static/common/image-placeholder.png'; productPrice.value = '118'; productOriginalPrice.value = '198'; productDiscount.value = '6折'; return }
        if (id == 'food-set-003') { productName.value = '【欢聚时光】招牌菜6人餐'; productImage.value = '/static/common/image-placeholder.png'; productPrice.value = '168'; productOriginalPrice.value = '248'; productDiscount.value = '6.8折'; return }
        if (id == 'food-set-004') { productName.value = '【暖心小聚】特色菜双人餐'; productImage.value = '/static/common/image-placeholder.png'; productPrice.value = '88'; productOriginalPrice.value = '126'; productDiscount.value = '7折'; return }
        if (id == 'food-set-005') { productName.value = '【家庭聚餐】经典川菜8人餐'; productImage.value = '/static/common/image-placeholder.png'; productPrice.value = '238'; productOriginalPrice.value = '366'; productDiscount.value = '6.5折'; return }
        if (id == 'food-set-006') { productName.value = '【人气必点】招牌菜4人餐'; productImage.value = '/static/common/image-placeholder.png'; productPrice.value = '128'; productOriginalPrice.value = '188'; productDiscount.value = '6.8折'; return }
        if (id == 'food-set-007') { productName.value = '【周末欢聚】特色菜10人餐'; productImage.value = '/static/common/image-placeholder.png'; productPrice.value = '298'; productOriginalPrice.value = '498'; productDiscount.value = '6折'; return }
        if (id == 'food-set-008') { productName.value = '【一人食】暖心工作餐'; productImage.value = '/static/common/image-placeholder.png'; productPrice.value = '39'; productOriginalPrice.value = '49'; productDiscount.value = '8折'; return }
    }
    onLoad((options: any) => {
rencaika/pages/spouse-employment/spouse-employment.uvue
@@ -27,7 +27,7 @@
    const navTop = ref(28); const navHeight = ref(32); const navWidth = ref(88); const navRight = ref(10)
    const serviceId = ref(''); const talentId = ref(''); const loading = ref(true); const loadError = ref(false); const available = ref(false)
    const fallbackHeroImage = '/static/stay-assets/hotel-vienna.jpg'; const heroImage = ref(fallbackHeroImage); const title = ref('配偶推荐就业'); const applicableCards = ref(''); const rulesText = ref(''); const unavailableReason = ref('该权益暂未开放')
    const fallbackHeroImage = '/static/common/image-placeholder.png'; const heroImage = ref(fallbackHeroImage); const title = ref('配偶推荐就业'); const applicableCards = ref(''); const rulesText = ref(''); const unavailableReason = ref('该权益暂未开放')
    onLoad((options: any) => {
        // #ifdef MP-WEIXIN
        const capsule = uni.getMenuButtonBoundingClientRect(); const info = uni.getSystemInfoSync(); navTop.value = capsule.top; navHeight.value = capsule.height; navWidth.value = capsule.width; navRight.value = info.windowWidth - capsule.right
rencaika/pages/stay-benefits/stay-benefits.uvue
@@ -326,7 +326,7 @@
        top: 245rpx;
        max-width: 650rpx;
        color: #FFFFFF;
        font-family: 'StayCalligraphy', 'STXingkai', 'KaiTi', cursive;
        font-family: 'FoodCalligraphyFull', 'StayCalligraphy', 'STXingkai', 'KaiTi', cursive;
        font-size: 48rpx;
        font-weight: normal;
        font-style: normal;
rencaika/static/common/image-fallback.svg
File was deleted
rencaika/static/entertainment-assets/entertainment-hero.webp
Binary files differ
rencaika/static/entertainment-assets/wanda-cinema.webp
Binary files differ
rencaika/static/food-assets/food-1.jpg
Binary files differ
rencaika/static/food-assets/food-2.jpg
Binary files differ
rencaika/static/home-assets/top.jpg
Binary files differ
rencaika/static/news-assets/meeting-thumb.jpg
Binary files differ
rencaika/static/policy-assets/policy-explanation.jpg
Binary files differ
rencaika/static/stay-assets/hotel-hampton.jpg
Binary files differ
rencaika/static/stay-assets/hotel-vienna.jpg
Binary files differ
rencaika_merchant/config/api.uts
@@ -5,7 +5,8 @@
// 切换环境时只修改这里;页面和业务服务不得自行拼接后台域名。
// 2026-09-29:体验版指向攀枝花测试环境。本机联调改回 'development';生产上线改 'production'。
export const apiEnvironment: ApiEnvironment = 'staging'
// 2026-10-09:本地联调电影票,临时切 'development'(开发者工具走 127.0.0.1:8080,见 config/development-server.uts)。测完改回 'staging'。
export const apiEnvironment: ApiEnvironment = 'development'
// Keep the business pages accessible while merchant APIs are still being integrated.
// This flag is ignored outside the development environment.
rencaika_merchant/docs/pages/02-订单管理.md
@@ -21,6 +21,8 @@
| F-006 | 详情路由 | 非免费入住订单携带订单 ID、订单号进入已有详情页。 | 已接入路由 |
| F-007 | 开发演示 | 开发环境标题右侧提供“演示数据”开关,可在本地分页数据与真实接口之间切换。 | 已完成 |
| F-008 | 底部导航 | 与首页、商户中心同级,使用 `uni.reLaunch` 切换。 | 已完成 |
| F-009 | 观影券样式 | 「团购」里混排观影券(票包领取记录):标题固定「观影券1张」,与人才卡等级同一行,无金额行;未核销显示「领取时间」、已核销显示「核销时间」,状态标签为「待核销 / 已核销」。 | 已接入 |
| F-010 | 观影券详情 | 点开观影券进入 `/pages/ticket-package-detail/ticket-package-detail?claimId={领取记录ID}`。 | 已接入路由 |
## 操作说明
@@ -30,12 +32,14 @@
4. 上滑触底加载下一页;追加失败时保留已展示订单并可点击底部重试。
5. 无登录会话却关闭演示数据时,页面提示登录后加载真实订单,不将该场景误显示为空列表。
6. 点击团购待核销订单进入待核销详情;到店待支付和已完成订单进入对应详情;免费入住不提供详情入口。
7. 团购列表里的观影券来自票包领取记录(`ticket_package_claim`),和普通团购订单按时间倒序混排分页;点开进入观影券核销详情。
## 接口与数据
| 数据项/动作 | 接口标识 | 参数 | 返回字段 | 状态 |
| --- | --- | --- | --- | --- |
| 订单分页列表 | `GET /api/merchant/orders` | `page`、`size`、`category`、`status`、`startDate`、`endDate`;门店来自会话 | 订单 ID/号、分类、稳定状态、脱敏用户、金额、卡种、下单/核销时间、`total` | 已接入 |
| 观影券领取记录详情 | `GET /api/merchant/ticket-package-claims/{claimId}` | 领取记录 ID;商户来自会话 | 领取编号、券名、持卡人与卡等级、状态、领取/核销时间、当前权益 | 已接入 |
| 订单详情 | `GET /api/merchant/orders/{orderNo}` | 订单号;门店来自会话 | 商品/权益、用户摘要、金额、状态 | 服务端已有,详情页视觉后续继续深化 |
| 订单核销 | `POST /api/merchant/orders/{orderNo}/verify` | 核销方式、备注 | 核销结果 | 服务端已有,扫码核销为主流程 |
@@ -43,6 +47,7 @@
- 分类只接受 `group`、`store`、`stay`;状态只接受约定枚举,后端不依赖中文文案。
- 日期采用 `yyyy-MM-dd`,结束日期按次日零点前包含整日;开始日期晚于结束日期时返回明确错误。
- `category=group` 时列表把普通团购订单与观影券混排,`sourceType=TICKET_PACKAGE` 表示这一条是观影券;分页 `total` 是两类记录之和。
- 后端按当前商户、门店权限过滤,并只向列表返回脱敏姓名和手机号。
- 列表响应使用 `records`、`page`、`size`、`total` 判断分页,不以本地长度猜测是否到底。
rencaika_merchant/docs/pages/23-观影权益核销.md
@@ -29,4 +29,6 @@
## 后台可见性
- 管理后台「电影票管理 → 领取记录」展示领取状态与核销时间(`ticket_package_claim.status/used_at`),核销后即可看到;无需单独页面。
- 管理后台「电影票管理 → 领取记录」展示领取状态与核销时间(`ticket_package_claim.status/used_at`)。
- 管理后台「电影票管理 → 核销记录」(`GET /api/admin/ticket-packages/verifications`)只列已核销记录,可按票包、持卡人、卡片等级、领取时间、核销时间和核销商家筛选与导出。
- 商家端在「订单管理 → 团购」里能看到这条观影券,点开的核销详情见 `24-观影券核销详情.md`。
rencaika_merchant/docs/pages/README.md
@@ -31,6 +31,8 @@
| 19 | 进件待审核 | `/pages/merchant-onboarding-pending/merchant-onboarding-pending` | 待审核结果与返回流程首版已完成 |
| 21 | 结算管理 | `/pages/settlement-management/settlement-management` | 结算汇总、列表与导出已接入 |
| 22 | 核销失败 | `/pages/verification-failure/verification-failure` | 非当前商户二维码独立失败结果页已完成 |
| 23 | 观影权益核销 | `/pages/ticket-package-confirm/ticket-package-confirm` | 扫码核销观影权益(票包兑换码)已接入 |
| 24 | 观影券核销详情 | `/pages/ticket-package-detail/ticket-package-detail?claimId={claimId}` | 团购列表观影券的待核销/已核销详情已接入 |
- 文档编号必须唯一,页面目录、路由和文档标题应保持一致。
- 页面有多种业务状态时,可在同一说明中用“状态差异”说明,或增加明确命名的补充文档。
rencaika_merchant/pages.json
@@ -203,6 +203,14 @@
                "backgroundColor": "#F7F8FA",
                "disableScroll": true
            }
        },
        {
            "path": "pages/ticket-package-detail/ticket-package-detail",
            "style": {
                "navigationStyle": "custom",
                "backgroundColor": "#FFFFFF",
                "disableScroll": true
            }
        }
    ],
    "globalStyle": {
rencaika_merchant/pages/orders/orders.uvue
@@ -20,10 +20,10 @@
            <view v-if="listLoadState == 'success'" class="order-list">
                <view v-for="order in orders" :key="order.id" class="order-card" :class="order.category == 'stay' ? 'stay-order-card' : (order.category == 'store' ? 'store-order-card' : '')" @tap="openOrderItem(order)">
                    <view class="customer-row"><view class="customer-info"><image class="customer-icon" src="/static/icons/lucide/circle-user-round.svg" mode="aspectFit"></image><text class="customer-name">{{order.customerName}}</text><text class="customer-phone">{{order.customerMobile}}</text></view><text class="status" :class="order.status == 'completed' ? 'verified' : ((order.status == 'cancelled' || order.status == 'refunded') ? 'closed' : 'pending')">{{order.statusText}}</text></view>
                    <text v-if="order.category != 'stay'" class="order-name">{{order.title}}</text>
                    <view v-if="order.category == 'stay'" class="stay-title-row"><text class="stay-order-name">{{order.title}}</text><text class="card-tag">{{order.cardLevel}}</text></view>
                    <text v-if="order.category != 'stay' && !isTicketOrder(order)" class="order-name">{{order.title}}</text>
                    <view v-if="order.category == 'stay' || isTicketOrder(order)" class="stay-title-row"><text class="stay-order-name">{{order.title}}</text><text class="card-tag">{{order.cardLevel}}</text></view>
                    <view v-else class="price-row" :class="order.category == 'store' ? 'store-price-row' : ''"><view class="price-group"><text class="price">¥{{formatAmount(order.payAmount)}}</text><text v-if="order.orderAmount > order.payAmount" class="original-price">¥{{formatAmount(order.orderAmount)}}</text></view><text class="card-tag">{{order.cardLevel}}</text></view>
                    <text class="time-text">{{order.status == 'completed' && order.verifyTime.length > 0 ? '核销时间:' + formatDateTime(order.verifyTime) : '下单时间:' + formatDateTime(order.createdAt)}}</text>
                    <text class="time-text">{{orderTimeText(order)}}</text>
                </view>
            </view>
@@ -283,8 +283,21 @@
    const formatAmount = (amount: number): string => (amount / 100).toFixed(2)
    const formatDateTime = (value: string): string => value.length > 0 ? value.replace('T', ' ') : '—'
    /** 观影券没有金额,列表里按「标题 + 人才卡」一行展示,和免费入住同一套排版。 */
    const isTicketOrder = (order: MerchantOrder): boolean => order.sourceType == 'TICKET_PACKAGE'
    const orderTimeText = (order: MerchantOrder): string => {
        if (order.status == 'completed' && order.verifyTime.length > 0) return '核销时间:' + formatDateTime(order.verifyTime)
        return (isTicketOrder(order) ? '领取时间:' : '下单时间:') + formatDateTime(order.createdAt)
    }
    const openOrderItem = (order: MerchantOrder) => {
        const query = 'id=' + encodeURIComponent(order.id) + '&orderNo=' + encodeURIComponent(order.orderNo)
        if (isTicketOrder(order)) {
            const rawClaimId = order.claimId != null ? order.claimId as string : ''
            const claimId = rawClaimId.length > 0 ? rawClaimId : order.id.replace('TP', '')
            uni.navigateTo({ url: '/pages/ticket-package-detail/ticket-package-detail?claimId=' + encodeURIComponent(claimId) })
            return
        }
        if (order.status == 'pending_verification') {
            uni.navigateTo({ url: '/pages/verification-detail/verification-detail?' + query })
            return
rencaika_merchant/services/orders.uts
@@ -5,6 +5,10 @@
    id: string
    orderNo: string
    category: string
    /** 订单来源:普通订单为空,观影券(票包领取记录)为 TICKET_PACKAGE。 */
    sourceType?: string
    /** 观影券的领取记录 ID(只有 sourceType = TICKET_PACKAGE 时才有值)。 */
    claimId?: string
    status: string
    statusText: string
    title: string
@@ -19,6 +23,23 @@
    subsidyAmount: number
    createdAt: string
    verifyTime: string
}
/** 观影券在商户端没有任何金额,详情页只展示领取/核销时间、持卡人和当前权益。 */
export type MerchantTicketPackageClaim = {
    id: string
    claimNo: string
    title: string
    packageName: string
    merchantName: string
    customerName: string
    customerMobile: string
    cardLevel: string
    status: string
    statusText: string
    createdAt: string
    usedAt: string
    benefitText: string
}
export type MerchantOrderPage = {
@@ -117,6 +138,8 @@
    id: textValue(value, 'id', 'id'),
    orderNo: textValue(value, 'orderNo', 'order_no'),
    category: textValue(value, 'category', 'category', 'group'),
    sourceType: textValue(value, 'sourceType', 'source_type'),
    claimId: textValue(value, 'claimId', 'claim_id'),
    status: textValue(value, 'status', 'status', 'pending_verification'),
    statusText: textValue(value, 'statusText', 'status_text', '待核销'),
    title: textValue(value, 'title', 'title', '订单'),
@@ -180,6 +203,36 @@
}, (data: any, traceId: string) => onSuccess(toOrderDetail(data), traceId), onError)
/**
 * 观影券领取记录详情:团购列表里的观影券那一条点开后调用。
 * 观影券没有订单号,用领取记录 ID 定位。
 */
export const loadMerchantTicketPackageClaim = (
    claimId: string,
    onSuccess: (claim: MerchantTicketPackageClaim, traceId: string) => void,
    onError: (error: HttpError) => void
): (() => void) => request<any>({
    path: getApiEndpoint('merchantTicketPackageClaim') + encodeURIComponent(claimId),
    method: 'GET'
}, (data: any, traceId: string) => {
    const source = data != null ? data : {}
    onSuccess({
        id: textValue(source, 'claimId', 'claim_id'),
        claimNo: textValue(source, 'claimNo', 'claim_no'),
        title: textValue(source, 'title', 'title', '观影券1张'),
        packageName: textValue(source, 'packageName', 'package_name'),
        merchantName: textValue(source, 'merchantName', 'merchant_name'),
        customerName: textValue(source, 'customerName', 'customer_name'),
        customerMobile: textValue(source, 'customerMobile', 'customer_mobile'),
        cardLevel: textValue(source, 'cardLevel', 'card_level'),
        status: textValue(source, 'status', 'status', 'pending_verification'),
        statusText: textValue(source, 'statusText', 'status_text', '待核销'),
        createdAt: textValue(source, 'createdAt', 'created_at'),
        usedAt: textValue(source, 'usedAt', 'used_at'),
        benefitText: textValue(source, 'benefitText', 'benefit_text')
    }, traceId)
}, onError)
/**
 * 订单核销:核销成功会写核销记录并确认延迟入账的资金。
 *
 * verificationId 是扫码尝试编号;从订单列表直接核销时不传。
talent-card-admin/src/api/admin.ts
@@ -666,6 +666,10 @@
  return http.get<PageResult<AnyRecord>, PageResult<AnyRecord>>('/api/admin/ticket-packages/claims', { params })
}
export function getTicketPackageVerifications(params: AnyRecord = {}) {
  return http.get<PageResult<AnyRecord>, PageResult<AnyRecord>>('/api/admin/ticket-packages/verifications', { params })
}
export function supplementTicketPackageClaim(payload: AnyRecord) {
  return http.post<AnyRecord, AnyRecord>('/api/admin/ticket-packages/claims/supplement', payload)
}
talent-card-admin/src/views/MovieTicketView.vue
@@ -10,6 +10,7 @@
  getTicketPackageCodes,
  getTicketPackageMerchantOptions,
  getTicketPackages,
  getTicketPackageVerifications,
  importTicketPackageCodes,
  updateTicketPackage,
  updateTicketPackageStatus,
@@ -19,7 +20,7 @@
import { TALENT_CARD_LEVELS } from '../utils/cardLevels'
import { downloadExcel } from '../utils/excel'
type ViewMode = 'list' | 'claims' | 'form' | 'codes'
type ViewMode = 'list' | 'claims' | 'verifications' | 'form' | 'codes'
const loading = ref(false)
const saving = ref(false)
@@ -58,6 +59,15 @@
  claimedRange: [] as string[],
})
// 核销记录是「领取记录」的已核销子集,筛选口径和原型一致:持卡人、卡片等级、领取时间、核销时间、核销商家。
const verificationFilters = reactive({
  talentKeyword: '',
  cardLevel: '',
  claimedRange: [] as string[],
  verifiedRange: [] as string[],
  merchantKeyword: '',
})
const codeFilters = reactive({
  keyword: '',
  packageKeyword: '',
@@ -91,6 +101,7 @@
const title = computed(() => {
  if (mode.value === 'claims') return '票包领取记录'
  if (mode.value === 'verifications') return '票包核销记录'
  if (mode.value === 'form') return '票包信息'
  if (mode.value === 'codes') return '兑换码码池'
  return '查询表格'
@@ -163,12 +174,28 @@
  }
}
function verificationParams() {
  return {
    page: pagination.page,
    size: pagination.size,
    packageId: rowId(selectedPackage.value || {}) || undefined,
    talentKeyword: verificationFilters.talentKeyword || undefined,
    cardLevel: verificationFilters.cardLevel || undefined,
    claimedStart: verificationFilters.claimedRange?.[0],
    claimedEnd: verificationFilters.claimedRange?.[1],
    verifiedStart: verificationFilters.verifiedRange?.[0],
    verifiedEnd: verificationFilters.verifiedRange?.[1],
    merchantKeyword: verificationFilters.merchantKeyword || undefined,
  }
}
async function loadData() {
  if (mode.value === 'form') return
  loading.value = true
  try {
    let result
    if (mode.value === 'claims') result = await getTicketPackageClaims(claimParams())
    else if (mode.value === 'verifications') result = await getTicketPackageVerifications(verificationParams())
    else if (mode.value === 'codes') result = await getTicketPackageCodes(codeParams())
    else result = await getTicketPackages(packageParams())
    rows.value = result.records || []
@@ -199,6 +226,11 @@
function resetClaimFilters() {
  Object.assign(claimFilters, { talentKeyword: '', cardLevel: '', claimedRange: [] })
  search()
}
function resetVerificationFilters() {
  Object.assign(verificationFilters, { talentKeyword: '', cardLevel: '', claimedRange: [], verifiedRange: [], merchantKeyword: '' })
  search()
}
@@ -395,6 +427,21 @@
    ], result.records || [])
    return
  }
  if (mode.value === 'verifications') {
    const result = await getTicketPackageVerifications({ ...verificationParams(), page: 1, size: 1000 })
    downloadExcel('票包核销记录', [
      { title: '领取编号', value: row => text(row.claimNo || row.id) },
      { title: '票包名称', value: row => text(row.packageName) },
      { title: '适用商家', value: row => text(row.merchantNames || row.merchantName) },
      { title: '持卡人名称', value: row => text(row.talentName) },
      { title: '持卡人电话', value: row => text(row.talentMobile) },
      { title: '人才等级', value: row => text(row.cardLevel) },
      { title: '领取时间', value: row => formatTime(row.claimedAt || row.createdAt) },
      { title: '核销时间', value: row => formatTime(row.usedAt) },
      { title: '核销商家', value: row => text(row.verifyMerchantName) },
    ], result.records || [])
    return
  }
  const result = await getTicketPackages({ ...packageParams(), page: 1, size: 1000 })
  downloadExcel('电影票票包列表', [
    { title: '票包编号', value: row => text(row.packageNo || row.id) },
@@ -459,12 +506,13 @@
          <el-table-column label="状态" min-width="90" align="center">
            <template #default="{ row }">{{ statusText(row.status) }}</template>
          </el-table-column>
          <el-table-column label="操作" width="220" fixed="right" align="center">
          <el-table-column label="操作" width="280" fixed="right" align="center">
            <template #default="{ row }">
              <el-button link type="primary" @click="toggleStatus(row)">{{ Number(row.status) === 1 ? '禁用' : '启用' }}</el-button>
              <el-button link type="primary" @click="openEdit(row)">查看</el-button>
              <el-button link type="primary" @click="setMode('codes', row)">码池</el-button>
              <el-button link type="primary" @click="setMode('claims', row)">领取记录</el-button>
              <el-button link type="primary" @click="setMode('verifications', row)">核销记录</el-button>
            </template>
          </el-table-column>
        </el-table>
@@ -509,6 +557,55 @@
        </el-table>
      </template>
      <template v-else-if="mode === 'verifications'">
        <el-form class="movie-ticket-record-filter-form" :model="verificationFilters" label-width="72px">
          <el-form-item label="持卡人">
            <el-input v-model="verificationFilters.talentKeyword" placeholder="持卡人姓名、手机号、卡号" clearable />
          </el-form-item>
          <el-form-item label="卡片等级">
            <el-select v-model="verificationFilters.cardLevel" placeholder="全部" clearable>
              <el-option label="全部" value="" />
              <el-option v-for="level in ticketRuleLevels" :key="level" :label="level" :value="level" />
            </el-select>
          </el-form-item>
          <el-form-item label="领取时间">
            <el-date-picker v-model="verificationFilters.claimedRange" type="daterange" start-placeholder="开始日期" end-placeholder="结束日期" range-separator="至" value-format="YYYY-MM-DD" />
          </el-form-item>
          <el-form-item label="核销时间">
            <el-date-picker v-model="verificationFilters.verifiedRange" type="daterange" start-placeholder="开始日期" end-placeholder="结束日期" range-separator="至" value-format="YYYY-MM-DD" />
          </el-form-item>
          <el-form-item label="核销商家">
            <el-input v-model="verificationFilters.merchantKeyword" placeholder="商家名称" clearable />
          </el-form-item>
          <div class="movie-ticket-record-filter-actions">
            <el-button type="primary" :icon="Search" @click="search">查询</el-button>
            <el-button :icon="Refresh" @click="resetVerificationFilters">重置</el-button>
          </div>
        </el-form>
        <div class="movie-ticket-record-tools">
          <el-button v-permission="'admin.movie-tickets.export'" :icon="Download" @click="download">下载</el-button>
        </div>
        <el-table v-loading="loading" :data="rows" border class="movie-ticket-record-table" empty-text="暂无核销记录">
          <el-table-column prop="claimNo" label="领取编号" min-width="130" align="center" />
          <el-table-column prop="packageName" label="票包名称" min-width="150" align="center" />
          <el-table-column prop="merchantNames" label="适用商家" min-width="190" align="center" show-overflow-tooltip>
            <template #default="{ row }">{{ text(row.merchantNames || row.merchantName) }}</template>
          </el-table-column>
          <el-table-column prop="talentName" label="持卡人名称" min-width="120" align="center" />
          <el-table-column prop="talentMobile" label="持卡人电话" min-width="130" align="center" />
          <el-table-column prop="cardLevel" label="人才等级" min-width="120" align="center" />
          <el-table-column label="领取时间" min-width="160" align="center">
            <template #default="{ row }">{{ formatTime(row.claimedAt || row.createdAt) }}</template>
          </el-table-column>
          <el-table-column label="核销时间" min-width="160" align="center">
            <template #default="{ row }">{{ formatTime(row.usedAt) }}</template>
          </el-table-column>
          <el-table-column prop="verifyMerchantName" label="核销商家" min-width="160" align="center" show-overflow-tooltip />
        </el-table>
      </template>
      <template v-else-if="mode === 'codes'">
        <div class="movie-ticket-code-pool-filters">
          <el-form class="movie-ticket-package-filter-form movie-ticket-code-pool-filter-form" :model="codeFilters" label-width="72px">
talent-card-server/src/main/java/com/talentcard/server/business/admin/TicketPackageAdminController.java
@@ -168,6 +168,23 @@
        return ApiResponse.success(service.claims(packageId, talentId, status, keyword, packageKeyword, talentKeyword, cardLevel, claimedStart, claimedEnd, page, size));
    }
    @Operation(summary = "查询票包核销记录", description = "分页查询已核销的票包领取记录,支持按票包、持卡人、卡片等级、领取时间、核销时间和核销商家筛选")
    @GetMapping("/verifications")
    public ApiResponse<PageResult<Map<String, Object>>> verifications(
            @Parameter(description = "票包ID") @RequestParam(required = false) Long packageId,
            @Parameter(description = "持卡人姓名、手机号或卡号关键词") @RequestParam(required = false) String talentKeyword,
            @Parameter(description = "卡片等级") @RequestParam(required = false) String cardLevel,
            @Parameter(description = "领取开始日期") @RequestParam(required = false) LocalDate claimedStart,
            @Parameter(description = "领取结束日期") @RequestParam(required = false) LocalDate claimedEnd,
            @Parameter(description = "核销开始日期") @RequestParam(required = false) LocalDate verifiedStart,
            @Parameter(description = "核销结束日期") @RequestParam(required = false) LocalDate verifiedEnd,
            @Parameter(description = "核销商家名称关键词") @RequestParam(required = false) String merchantKeyword,
            @Parameter(description = "页码") @RequestParam(defaultValue = "1") int page,
            @Parameter(description = "每页条数") @RequestParam(defaultValue = "10") int size
    ) {
        return ApiResponse.success(service.verifications(packageId, talentKeyword, cardLevel, claimedStart, claimedEnd, verifiedStart, verifiedEnd, merchantKeyword, page, size));
    }
    @Operation(summary = "领取校验", description = "按兑换码码值校验兑换码是否可领取:是否存在、所属票包是否启用、是否在有效期内、是否适用于指定商户,以及当前状态(可用/锁定/已发放/已使用/已作废)")
    @GetMapping("/codes/validate")
    public ApiResponse<Map<String, Object>> validateCode(
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkController.java
@@ -234,6 +234,18 @@
        return ApiResponse.success(merchantWorkService.order(orderNo, principal.merchantId(), principal.storeId()));
    }
    /**
     * 观影券领取记录详情:团购列表里的观影券那一条点开后展示领取/核销时间和当前权益。
     * 观影券没有 benefit_order,用领取记录 ID 而不是订单号定位。
     */
    @GetMapping("/ticket-package-claims/{claimId}")
    public ApiResponse<Map<String, Object>> ticketPackageClaim(
            @PathVariable Long claimId,
            @RequestAttribute(MerchantAuthService.PRINCIPAL_ATTRIBUTE) MerchantPrincipal principal
    ) {
        return ApiResponse.success(merchantWorkService.ticketPackageClaim(claimId, principal.merchantId()));
    }
    @PostMapping("/orders/{orderNo}/verify")
    public ApiResponse<Map<String, Object>> verify(
            @PathVariable String orderNo,
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java
@@ -58,6 +58,10 @@
        if (query != null && "stay".equals(normalize(query.category()))) {
            return freeStayOrders(merchantId, storeId, page, size, query);
        }
        // 观影券(票包领取记录)在商户眼里也是「团购」的一份流水,和 benefit_order 混排分页。
        if (query != null && "group".equals(normalize(query.category()))) {
            return groupOrders(merchantId, storeId, page, size, query);
        }
        // 退款到不到最终结果只有通道知道,退款通知又会遇到和支付通知一样的问题(本地/内网环境收不到)。
        // 读列表之前先把这家商户在途的退款收敛一次,免得商户看到的退款状态比人才端晚几分钟。这一步必须
        // 排在本事务的第一条读语句之前,否则列表读到的是收敛前的旧快照。免费入住没有退款,走上面的分支。
@@ -176,6 +180,159 @@
        List<Map<String, Object>> records = query(select, recordArguments.toArray()).stream().map(this::toListItem).toList();
        Long total = jdbcTemplate.queryForObject("select count(*) from verify_record r" + condition, Long.class, arguments.toArray());
        return new PageResult<>(records, safePage, safeSize, total == null ? 0 : total);
    }
    /**
     * 团购列表:普通团购订单与观影券(票包领取记录)混排。
     *
     * <p>观影券不是 benefit_order:兑换码由后台票包码池批量发放,领取记录落在 ticket_package_claim,
     * 只有核销时才写 benefit_verify_record。但商户在「团购」里看到的应该是一份按时间倒序的流水,
     * 所以这里把两类记录并成一张表再统一分页,避免前端拿到两段各自分页的数据拼不齐。
     */
    @Transactional(readOnly = true)
    private PageResult<Map<String, Object>> groupOrders(Long merchantId, Long storeId, int page, int size, MerchantOrderQuery query) {
        refundService.confirmPendingRefundsOfMerchant(merchantId, HuifuRefundService.PENDING_CONFIRM_LIMIT);
        int safePage = Math.max(page, 1);
        int safeSize = Math.min(Math.max(size, 1), 100);
        OrderFilters filters = filters(query);
        // 观影券只有已发放/已使用两种有效状态,退款、待支付这些团购态在它身上不存在,按状态筛成空集。
        String claimStatusCondition = switch (normalize(query == null ? null : query.status())) {
            case "", "all" -> "";
            case "pending_verification" -> " and cl.status = 'ISSUED'";
            case "completed" -> " and cl.status = 'USED'";
            case "pending_payment" -> " and 1 = 0";
            case "closed" -> " and cl.status in ('EXPIRED', 'CANCELLED')";
            default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "订单状态无效");
        };
        LocalDate startDate = date(query == null ? null : query.startDate(), "开始日期");
        LocalDate endDate = date(query == null ? null : query.endDate(), "结束日期");
        StringBuilder claimConditions = new StringBuilder(claimStatusCondition);
        List<Object> claimArguments = new ArrayList<>();
        claimArguments.add(merchantId);
        // 时间筛选沿用「下单时间」口径:观影券的下单时间就是领取时间。
        if (startDate != null) {
            claimConditions.append(" and cl.created_at >= ?");
            claimArguments.add(Timestamp.valueOf(startDate.atStartOfDay()));
        }
        if (endDate != null) {
            claimConditions.append(" and cl.created_at < ?");
            claimArguments.add(Timestamp.valueOf(endDate.plusDays(1).atStartOfDay()));
        }
        String orderSource = """
                select cast(o.id as char) as id, o.order_no as order_no, 'GROUP_BUY' as order_type,
                       tu.name as talent_name, tu.mobile as talent_mobile, tu.gender,
                       coalesce(nullif(o.card_level_snapshot, ''), tc.card_level) as card_level,
                       o.product_name_snapshot, b.benefit_name, o.order_amount, o.subsidy_amount, o.pay_amount,
                       o.order_status, o.verify_status, o.refund_status, o.created_at,
                       verified.verify_time, '' as source_type, null as claim_id, o.created_at as sort_time, 0 as source_rank, o.id as sort_seq
                from benefit_order o
                left join talent_user tu on tu.id = o.talent_id
                left join talent_card tc on tc.id = o.card_id
                left join benefit b on b.id = o.benefit_id
                left join (select order_id, max(verify_time) verify_time from verify_record where status = 1 group by order_id) verified on verified.order_id = o.id
                where o.deleted = 0 and o.merchant_id = ? and (o.store_id is null or o.store_id = ?)
                """ + filters.conditions();
        String claimSource = """
                select concat('TP', cl.id) as id, cl.claim_no as order_no, 'GROUP_BUY' as order_type,
                       cl.talent_name as talent_name, cl.talent_mobile as talent_mobile, tu.gender,
                       cl.card_level as card_level,
                       '观影券1张' as product_name_snapshot, '观影券' as benefit_name,
                       0 as order_amount, 0 as subsidy_amount, 0 as pay_amount,
                       case when cl.status in ('ISSUED', 'USED') then 1 else 2 end as order_status,
                       case when cl.status = 'USED' then 1 else 0 end as verify_status,
                       0 as refund_status, cl.created_at,
                       case when cl.status = 'USED' then cl.used_at else null end as verify_time,
                       'TICKET_PACKAGE' as source_type, cl.id as claim_id, cl.created_at as sort_time, 1 as source_rank, cl.id as sort_seq
                from ticket_package_claim cl
                left join talent_user tu on tu.id = cl.talent_id
                where cl.deleted = 0 and cl.merchant_id = ?
                """ + claimConditions;
        String recordSql = """
                select t.id, t.order_no, t.order_type, t.talent_name, t.talent_mobile, t.gender, t.card_level,
                       t.product_name_snapshot, t.benefit_name, t.order_amount, t.subsidy_amount, t.pay_amount,
                       t.order_status, t.verify_status, t.refund_status, t.created_at, t.verify_time, t.source_type, t.claim_id
                from (
                """ + orderSource + "\n union all \n" + claimSource + """
                ) t
                order by t.sort_time desc, t.source_rank asc, t.sort_seq desc
                limit ? offset ?
                """;
        List<Object> recordArguments = new ArrayList<>();
        recordArguments.add(merchantId);
        recordArguments.add(storeId);
        recordArguments.addAll(filters.arguments());
        recordArguments.addAll(claimArguments);
        recordArguments.add(safeSize);
        recordArguments.add((safePage - 1) * safeSize);
        List<Map<String, Object>> records = query(recordSql, recordArguments.toArray()).stream().map(this::toListItem).toList();
        String countSql = "select (select count(*) from benefit_order o where o.deleted = 0 and o.merchant_id = ? and (o.store_id is null or o.store_id = ?)"
                + filters.conditions() + ") + (select count(*) from ticket_package_claim cl where cl.deleted = 0 and cl.merchant_id = ?"
                + claimConditions + ")";
        List<Object> countArguments = new ArrayList<>();
        countArguments.add(merchantId);
        countArguments.add(storeId);
        countArguments.addAll(filters.arguments());
        countArguments.addAll(claimArguments);
        Long total = jdbcTemplate.queryForObject(countSql, Long.class, countArguments.toArray());
        return new PageResult<>(records, safePage, safeSize, total == null ? 0 : total);
    }
    /**
     * 观影券领取记录详情:商户在「团购」里点开观影券那一条时看到的信息。
     *
     * <p>观影券没有 benefit_order,行项目来自 ticket_package_claim;核销时间取 used_at。
     */
    @Transactional(readOnly = true)
    public Map<String, Object> ticketPackageClaim(Long claimId, Long merchantId) {
        if (claimId == null || claimId <= 0) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "观影券领取记录ID不能为空");
        }
        List<Map<String, Object>> rows = query("""
                select cl.id, cl.claim_no, cl.package_id, p.package_name, cl.merchant_id, m.merchant_name,
                       m.merchant_rights_config,
                       cl.talent_name, cl.talent_mobile, tu.gender, cl.card_level, cl.card_no,
                       cl.status, cl.valid_end, cl.used_at, cl.created_at
                from ticket_package_claim cl
                join ticket_package p on p.id = cl.package_id and p.deleted = 0
                left join merchant m on m.id = cl.merchant_id and m.deleted = 0
                left join talent_user tu on tu.id = cl.talent_id
                where cl.id = ? and cl.deleted = 0 and cl.merchant_id = ?
                limit 1
                """, claimId, merchantId);
        if (rows.isEmpty()) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "观影券领取记录不存在或不属于当前商户");
        }
        Map<String, Object> row = rows.get(0);
        boolean used = "USED".equalsIgnoreCase(text(row.get("status")));
        String cardLevel = text(row.get("cardLevel"));
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("type", "TICKET_PACKAGE");
        result.put("claimId", row.get("id"));
        result.put("claimNo", text(row.get("claimNo")));
        result.put("title", "观影券1张");
        result.put("quantity", 1);
        result.put("packageName", text(row.get("packageName")));
        result.put("merchantName", text(row.get("merchantName")));
        result.put("customerName", displayName(row.get("talentName"), number(row.get("gender"))));
        result.put("customerMobile", maskMobile(String.valueOf(row.get("talentMobile"))));
        result.put("cardLevel", cardLevel);
        result.put("status", used ? "completed" : "pending_verification");
        result.put("statusText", used ? "已核销" : "待核销");
        result.put("createdAt", row.get("createdAt"));
        result.put("usedAt", row.get("usedAt"));
        result.put("benefitText", ticketPackageBenefitText(row.get("merchantRightsConfig"), merchantId, cardLevel));
        return result;
    }
    /**
     * 观影券详情的「当前权益」:与到店消费同一套口径——先取商家人才权益里该等级配置的描述,
     * 没配再回落到商户的到店消费权益文案,避免商户在观影券详情看到一句没有信息量的兜底话术。
     */
    private String ticketPackageBenefitText(Object rightsConfig, Long merchantId, String cardLevel) {
        if (cardLevel.isBlank()) return "";
        String configured = merchantBenefitDetail(rightsConfig, cardLevel);
        if (!configured.isBlank()) return configured;
        return manualConsumptionService.benefitTextForMerchant(merchantId, cardLevel);
    }
    @Transactional
@@ -362,7 +519,7 @@
        else status = verified ? "completed" : "pending_verification";
        result.put("category", category);
        result.put("status", status);
        result.put("statusText", statusText(status, refundStatus));
        result.put("statusText", statusText(status, refundStatus, category));
        result.put("customerName", displayName(order.get("talentName"), number(order.get("gender"))));
        result.put("customerMobile", maskMobile(String.valueOf(order.get("talentMobile"))));
        result.put("title", orderTitle(order, category));
@@ -449,7 +606,7 @@
        String status = status(number(order.get("orderStatus")), number(order.get("verifyStatus")), refundStatus);
        result.put("category", category);
        result.put("status", status);
        result.put("statusText", statusText(status, refundStatus));
        result.put("statusText", statusText(status, refundStatus, category));
        result.put("customerName", displayName(order.get("talentName"), number(order.get("gender"))));
        result.put("customerMobile", maskMobile(String.valueOf(order.get("talentMobile"))));
        result.put("title", orderTitle(order, category));
@@ -552,9 +709,10 @@
        return "pending_verification";
    }
    private String statusText(String status, Long refundStatus) {
    private String statusText(String status, Long refundStatus, String category) {
        if ("pending_payment".equals(status)) return "待支付";
        if ("completed".equals(status)) return "已完成";
        // 团购是到店核销的业务:已完成在商户眼里就是「已核销」,原型与列表口径都这么写。
        if ("completed".equals(status)) return "group".equals(category) ? "已核销" : "已完成";
        if ("cancelled".equals(status)) return "已取消";
        if ("refunded".equals(status)) return refundStatus != null && refundStatus == 1 ? "退款中" : "已退款";
        return "待核销";
talent-card-server/src/main/java/com/talentcard/server/mapper/TicketPackageClaimMapper.java
@@ -372,4 +372,83 @@
                        @Param("cardLevel") String cardLevel,
                        @Param("claimedStart") LocalDate claimedStart,
                        @Param("claimedEnd") LocalDate claimedEnd);
    /**
     * 后台「核销记录」:只列已核销(status = USED)的领取记录。
     *
     * <p>核销商家就是领取记录上的 merchant_id——核销接口要求核销方必须等于该商户,所以不需要再关联
     * benefit_verify_record。
     */
    @Select("""
            select cl.id,
                   cl.claim_no as claimNo,
                   cl.package_id as packageId,
                   p.package_name as packageName,
                   coalesce((
                       select group_concat(m1.merchant_name order by pm1.sort_no asc, pm1.id asc separator '、')
                       from ticket_package_merchant pm1
                       join merchant m1 on m1.id = pm1.merchant_id and m1.deleted = 0
                       where pm1.package_id = p.id and pm1.deleted = 0
                   ), m.merchant_name) as merchantNames,
                   m.merchant_name as verifyMerchantName,
                   cl.talent_id as talentId,
                   cl.card_id as cardId,
                   cl.card_no as cardNo,
                   cl.card_level as cardLevel,
                   cl.talent_name as talentName,
                   cl.talent_mobile as talentMobile,
                   cl.status,
                   cl.created_at as createdAt,
                   cl.used_at as usedAt
            from ticket_package_claim cl
            join ticket_package p on p.id = cl.package_id and p.deleted = 0
            left join merchant m on m.id = cl.merchant_id and m.deleted = 0
            where cl.deleted = 0
              and cl.status = 'USED'
              and (#{packageId} is null or cl.package_id = #{packageId})
              and (#{talentKeyword} is null or #{talentKeyword} = '' or cl.talent_name like concat('%', #{talentKeyword}, '%') or cl.talent_mobile like concat('%', #{talentKeyword}, '%') or cl.card_no like concat('%', #{talentKeyword}, '%'))
              and (#{cardLevel} is null or #{cardLevel} = '' or cl.card_level = #{cardLevel} or cl.card_level like concat('%', #{cardLevel}, '%'))
              and (#{claimedStart} is null or cl.created_at >= #{claimedStart})
              and (#{claimedEnd} is null or cl.created_at < date_add(cast(#{claimedEnd} as date), interval 1 day))
              and (#{verifiedStart} is null or cl.used_at >= #{verifiedStart})
              and (#{verifiedEnd} is null or cl.used_at < date_add(cast(#{verifiedEnd} as date), interval 1 day))
              and (#{merchantKeyword} is null or #{merchantKeyword} = '' or m.merchant_name like concat('%', #{merchantKeyword}, '%'))
            order by cl.used_at desc, cl.id desc
            limit #{size} offset #{offset}
            """)
    List<Map<String, Object>> selectAdminVerificationPage(@Param("packageId") Long packageId,
                                                          @Param("talentKeyword") String talentKeyword,
                                                          @Param("cardLevel") String cardLevel,
                                                          @Param("claimedStart") LocalDate claimedStart,
                                                          @Param("claimedEnd") LocalDate claimedEnd,
                                                          @Param("verifiedStart") LocalDate verifiedStart,
                                                          @Param("verifiedEnd") LocalDate verifiedEnd,
                                                          @Param("merchantKeyword") String merchantKeyword,
                                                          @Param("size") int size,
                                                          @Param("offset") int offset);
    @Select("""
            select count(*)
            from ticket_package_claim cl
            join ticket_package p on p.id = cl.package_id and p.deleted = 0
            left join merchant m on m.id = cl.merchant_id and m.deleted = 0
            where cl.deleted = 0
              and cl.status = 'USED'
              and (#{packageId} is null or cl.package_id = #{packageId})
              and (#{talentKeyword} is null or #{talentKeyword} = '' or cl.talent_name like concat('%', #{talentKeyword}, '%') or cl.talent_mobile like concat('%', #{talentKeyword}, '%') or cl.card_no like concat('%', #{talentKeyword}, '%'))
              and (#{cardLevel} is null or #{cardLevel} = '' or cl.card_level = #{cardLevel} or cl.card_level like concat('%', #{cardLevel}, '%'))
              and (#{claimedStart} is null or cl.created_at >= #{claimedStart})
              and (#{claimedEnd} is null or cl.created_at < date_add(cast(#{claimedEnd} as date), interval 1 day))
              and (#{verifiedStart} is null or cl.used_at >= #{verifiedStart})
              and (#{verifiedEnd} is null or cl.used_at < date_add(cast(#{verifiedEnd} as date), interval 1 day))
              and (#{merchantKeyword} is null or #{merchantKeyword} = '' or m.merchant_name like concat('%', #{merchantKeyword}, '%'))
            """)
    Long countAdminVerificationPage(@Param("packageId") Long packageId,
                                    @Param("talentKeyword") String talentKeyword,
                                    @Param("cardLevel") String cardLevel,
                                    @Param("claimedStart") LocalDate claimedStart,
                                    @Param("claimedEnd") LocalDate claimedEnd,
                                    @Param("verifiedStart") LocalDate verifiedStart,
                                    @Param("verifiedEnd") LocalDate verifiedEnd,
                                    @Param("merchantKeyword") String merchantKeyword);
}
talent-card-server/src/main/java/com/talentcard/server/service/BenefitVerificationCodeService.java
@@ -98,8 +98,10 @@
        if (claim == null || (claim.getDeleted() != null && claim.getDeleted() != 0)) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "观影权益领取记录不存在");
        }
        if (!"ISSUED".equals(upper(claim.getStatus()))) {
            throw new BusinessException(ErrorCode.CONFLICT, "观影权益已核销或已失效");
        // 已核销的观影券仍然返回二维码:使用记录详情要按原型继续展示这张码(核销时服务端会拒掉重复核销),
        // 只有过期/作废才不给。
        if (!"ISSUED".equals(upper(claim.getStatus())) && !"USED".equals(upper(claim.getStatus()))) {
            throw new BusinessException(ErrorCode.CONFLICT, "观影权益已失效");
        }
        if (expiredDate(claim.getValidEnd())) {
            throw new BusinessException(ErrorCode.CONFLICT, "观影权益已过期");
talent-card-server/src/main/java/com/talentcard/server/service/TicketPackageAdminService.java
@@ -341,6 +341,46 @@
        return new PageResult<>(records, safePage, safeSize, total == null ? 0 : total);
    }
    /**
     * 票包核销记录:只列已核销(status = USED)的领取记录,供后台「电影票管理 → 核销记录」查询与导出。
     *
     * <p>「核销商家」取领取记录上的 merchant_id。核销接口要求核销方必须等于该商户,所以这里不需要
     * 再关联 benefit_verify_record 才能知道是谁核销的。
     */
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> verifications(Long packageId, String talentKeyword, String cardLevel,
                                                         LocalDate claimedStart, LocalDate claimedEnd,
                                                         LocalDate verifiedStart, LocalDate verifiedEnd,
                                                         String merchantKeyword, int page, int size) {
        int safePage = Math.max(page, 1);
        int safeSize = Math.min(Math.max(size, 1), 100);
        Long total = claimMapper.countAdminVerificationPage(packageId, clean(talentKeyword), clean(cardLevel),
                claimedStart, claimedEnd, verifiedStart, verifiedEnd, clean(merchantKeyword));
        List<Map<String, Object>> records = claimMapper.selectAdminVerificationPage(packageId, clean(talentKeyword),
                        clean(cardLevel), claimedStart, claimedEnd, verifiedStart, verifiedEnd, clean(merchantKeyword),
                        safeSize, (safePage - 1) * safeSize)
                .stream()
                .map(this::verificationView)
                .toList();
        return new PageResult<>(records, safePage, safeSize, total == null ? 0 : total);
    }
    private Map<String, Object> verificationView(Map<String, Object> row) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("id", value(row, "id"));
        result.put("claimNo", text(row, "claimNo", "claim_no"));
        result.put("packageId", value(row, "packageId", "package_id"));
        result.put("packageName", text(row, "packageName", "package_name"));
        result.put("merchantNames", text(row, "merchantNames", "merchant_names"));
        result.put("talentName", text(row, "talentName", "talent_name"));
        result.put("talentMobile", text(row, "talentMobile", "talent_mobile"));
        result.put("cardLevel", text(row, "cardLevel", "card_level"));
        result.put("claimedAt", value(row, "createdAt", "created_at"));
        result.put("usedAt", value(row, "usedAt", "used_at"));
        result.put("verifyMerchantName", text(row, "verifyMerchantName", "verify_merchant_name"));
        return result;
    }
    @Transactional(readOnly = true)
    public Map<String, Object> codeDetail(Long codeId) {
talent-card-server/src/main/java/com/talentcard/server/service/TicketPackagePortalService.java
@@ -288,6 +288,8 @@
        result.put("exchangeCode", "ACTIVE".equals(codeStatus) ? text(row, "exchangeCode") : "");
        result.put("validEnd", value(row, "validEnd"));
        result.put("usedAt", value(row, "usedAt"));
        // 使用记录里的观影券详情要显示「领取时间」,也就是领取记录的创建时间。
        result.put("createdAt", value(row, "createdAt"));
        result.put("totalQuantity", totalQuantity);
        result.put("claimedCount", claimedCount);
        result.put("remainingCount", Math.max(totalQuantity - claimedCount, 0));