| | |
| | | <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 |
| | |
| | | console.log('App Launch') |
| | | // 先把服务端地址解析好:开发环境探测本机局域网 IP,换网后不必改代码重新编译。 |
| | | initializeDevelopmentApiBaseUrl() |
| | | loadCalligraphyFont() |
| | | if (developmentLoginBypassEnabled) { |
| | | loginWithDevelopmentSession(() => {}, (error) => { |
| | | console.warn('Development session initialization failed: ' + error.code) |
| | |
| | | background-color: transparent !important; |
| | | } |
| | | |
| | | /* |
| | | * 专题页的大标题/菜名用行楷:优先用 App.uvue 动态加载的服务器字体(FoodCalligraphyFull), |
| | | * 没加载出来或缺少字符时回退到包内子集(FoodCalligraphy)与系统字体。 |
| | | */ |
| | | .dish-name, |
| | | .scene-name { |
| | | font-family: 'FoodCalligraphyFull', 'FoodCalligraphy' !important; |
| | | } |
| | | |
| | | .nav-row .back-button-active { |
| | | opacity: .55; |
| | | } |
| | |
| | | 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 |
| | | |
| | |
| | | |
| | | ## 资源与包体说明 |
| | | |
| | | - 体验版仅保留 `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/子集。 |
| | |
| | | |
| | | | 功能 | 当前实现 | 后续接口目标 | |
| | | | --- | --- | --- | |
| | | | 顶部导航 | 按微信胶囊动态适配;标题实际使用 `/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` 切换。 | 登录态失效时统一进入登录页。 | |
| | | |
| | |
| | | | 14~28 | 个人中心、人才卡升级、使用记录与订单状态、家庭附属卡、设置与协议 | |
| | | | 29~46 | 游、住、酒店预约与免费权益、娱、行、购、享及相关商户/结果页面 | |
| | | | 47~58 | 配偶推荐就业说明、体检、行政审批、金融服务、学生托付、子女信息、学校详情、人才码与体检医院详情 | |
| | | | 60~62 | 配偶信息填写、配偶信息提交成功、外部服务网页 | |
| | | | 60~63 | 配偶信息填写、配偶信息提交成功、外部服务网页、观影券详情 | |
| | | |
| | | - 文档编号必须唯一;当前编号 `00~62` 中保留历史业务顺序,不要求与 `pages.json` 顺序一致。 |
| | | - 一份文档可覆盖同一路由的一种明确业务模式,但必须写明入口参数、状态差异和后台字段。 |
| | |
| | | - 人才权益原始顶图约 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` 猜测文案高度,后台文案变更为多行时,服务列表必须自动向下顺延。 |
| | | - **全量发版要求**:正式全量发布时,需按发布包策略注入完整的“古典艺术行楷简”字体包,并复核微信小程序分包/总体积限制、字体授权及真机渲染效果;开发预览阶段继续使用标题图片,避免超过预览包限制。 |
| | |
| | | } |
| | | }, |
| | | { |
| | | "path": "pages/ticket-package-detail/ticket-package-detail", |
| | | "style": { |
| | | "navigationStyle": "custom", |
| | | "backgroundColor": "#FFFFFF", |
| | | "disableScroll": true |
| | | } |
| | | }, |
| | | { |
| | | "path": "pages/ticket-package/ticket-package", |
| | | "style": { |
| | | "navigationStyle": "custom", |
| | |
| | | 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 |
| | |
| | | ] |
| | | |
| | | 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) |
| | |
| | | </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; } |
| | |
| | | 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[]>([]) |
| | |
| | | <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> |
| | |
| | | <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> |
| | | |
| | |
| | | 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) |
| | |
| | | <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; } |
| | |
| | | .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; } |
| | |
| | | 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[]>([]) |
| | |
| | | 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') |
| | |
| | | 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) |
| | |
| | | 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' }) |
| | |
| | | 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折') |
| | |
| | | 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) => { |
| | |
| | | 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 |
| | | |
| | |
| | | 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 = () => { |
| | |
| | | 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') |
| | |
| | | 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) => { |
| | |
| | | |
| | | 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 |
| | |
| | | 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; |
| | |
| | | |
| | | // 切换环境时只修改这里;页面和业务服务不得自行拼接后台域名。 |
| | | // 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. |
| | |
| | | | F-006 | 详情路由 | 非免费入住订单携带订单 ID、订单号进入已有详情页。 | 已接入路由 | |
| | | | F-007 | 开发演示 | 开发环境标题右侧提供“演示数据”开关,可在本地分页数据与真实接口之间切换。 | 已完成 | |
| | | | F-008 | 底部导航 | 与首页、商户中心同级,使用 `uni.reLaunch` 切换。 | 已完成 | |
| | | | F-009 | 观影券样式 | 「团购」里混排观影券(票包领取记录):标题固定「观影券1张」,与人才卡等级同一行,无金额行;未核销显示「领取时间」、已核销显示「核销时间」,状态标签为「待核销 / 已核销」。 | 已接入 | |
| | | | F-010 | 观影券详情 | 点开观影券进入 `/pages/ticket-package-detail/ticket-package-detail?claimId={领取记录ID}`。 | 已接入路由 | |
| | | |
| | | ## 操作说明 |
| | | |
| | |
| | | 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` | 核销方式、备注 | 核销结果 | 服务端已有,扫码核销为主流程 | |
| | | |
| | |
| | | |
| | | - 分类只接受 `group`、`store`、`stay`;状态只接受约定枚举,后端不依赖中文文案。 |
| | | - 日期采用 `yyyy-MM-dd`,结束日期按次日零点前包含整日;开始日期晚于结束日期时返回明确错误。 |
| | | - `category=group` 时列表把普通团购订单与观影券混排,`sourceType=TICKET_PACKAGE` 表示这一条是观影券;分页 `total` 是两类记录之和。 |
| | | - 后端按当前商户、门店权限过滤,并只向列表返回脱敏姓名和手机号。 |
| | | - 列表响应使用 `records`、`page`、`size`、`total` 判断分页,不以本地长度猜测是否到底。 |
| | | |
| | |
| | | |
| | | ## 后台可见性 |
| | | |
| | | - 管理后台「电影票管理 → 领取记录」展示领取状态与核销时间(`ticket_package_claim.status/used_at`),核销后即可看到;无需单独页面。 |
| | | - 管理后台「电影票管理 → 领取记录」展示领取状态与核销时间(`ticket_package_claim.status/used_at`)。 |
| | | - 管理后台「电影票管理 → 核销记录」(`GET /api/admin/ticket-packages/verifications`)只列已核销记录,可按票包、持卡人、卡片等级、领取时间、核销时间和核销商家筛选与导出。 |
| | | - 商家端在「订单管理 → 团购」里能看到这条观影券,点开的核销详情见 `24-观影券核销详情.md`。 |
| | |
| | | | 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}` | 团购列表观影券的待核销/已核销详情已接入 | |
| | | |
| | | - 文档编号必须唯一,页面目录、路由和文档标题应保持一致。 |
| | | - 页面有多种业务状态时,可在同一说明中用“状态差异”说明,或增加明确命名的补充文档。 |
| | |
| | | "backgroundColor": "#F7F8FA", |
| | | "disableScroll": true |
| | | } |
| | | }, |
| | | { |
| | | "path": "pages/ticket-package-detail/ticket-package-detail", |
| | | "style": { |
| | | "navigationStyle": "custom", |
| | | "backgroundColor": "#FFFFFF", |
| | | "disableScroll": true |
| | | } |
| | | } |
| | | ], |
| | | "globalStyle": { |
| | |
| | | <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> |
| | | |
| | |
| | | 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 |
| | |
| | | id: string |
| | | orderNo: string |
| | | category: string |
| | | /** 订单来源:普通订单为空,观影券(票包领取记录)为 TICKET_PACKAGE。 */ |
| | | sourceType?: string |
| | | /** 观影券的领取记录 ID(只有 sourceType = TICKET_PACKAGE 时才有值)。 */ |
| | | claimId?: string |
| | | status: string |
| | | statusText: string |
| | | title: string |
| | |
| | | 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 = { |
| | |
| | | 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', '订单'), |
| | |
| | | }, (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 是扫码尝试编号;从订单列表直接核销时不传。 |
| | |
| | | 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) |
| | | } |
| | |
| | | getTicketPackageCodes, |
| | | getTicketPackageMerchantOptions, |
| | | getTicketPackages, |
| | | getTicketPackageVerifications, |
| | | importTicketPackageCodes, |
| | | updateTicketPackage, |
| | | updateTicketPackageStatus, |
| | |
| | | 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) |
| | |
| | | claimedRange: [] as string[], |
| | | }) |
| | | |
| | | // 核销记录是「领取记录」的已核销子集,筛选口径和原型一致:持卡人、卡片等级、领取时间、核销时间、核销商家。 |
| | | const verificationFilters = reactive({ |
| | | talentKeyword: '', |
| | | cardLevel: '', |
| | | claimedRange: [] as string[], |
| | | verifiedRange: [] as string[], |
| | | merchantKeyword: '', |
| | | }) |
| | | |
| | | const codeFilters = reactive({ |
| | | keyword: '', |
| | | packageKeyword: '', |
| | |
| | | |
| | | const title = computed(() => { |
| | | if (mode.value === 'claims') return '票包领取记录' |
| | | if (mode.value === 'verifications') return '票包核销记录' |
| | | if (mode.value === 'form') return '票包信息' |
| | | if (mode.value === 'codes') return '兑换码码池' |
| | | return '查询表格' |
| | |
| | | } |
| | | } |
| | | |
| | | 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 || [] |
| | |
| | | |
| | | function resetClaimFilters() { |
| | | Object.assign(claimFilters, { talentKeyword: '', cardLevel: '', claimedRange: [] }) |
| | | search() |
| | | } |
| | | |
| | | function resetVerificationFilters() { |
| | | Object.assign(verificationFilters, { talentKeyword: '', cardLevel: '', claimedRange: [], verifiedRange: [], merchantKeyword: '' }) |
| | | search() |
| | | } |
| | | |
| | |
| | | ], 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) }, |
| | |
| | | <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> |
| | |
| | | </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"> |
| | |
| | | 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( |
| | |
| | | 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, |
| | |
| | | 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); |
| | | } |
| | | // 退款到不到最终结果只有通道知道,退款通知又会遇到和支付通知一样的问题(本地/内网环境收不到)。 |
| | | // 读列表之前先把这家商户在途的退款收敛一次,免得商户看到的退款状态比人才端晚几分钟。这一步必须 |
| | | // 排在本事务的第一条读语句之前,否则列表读到的是收敛前的旧快照。免费入住没有退款,走上面的分支。 |
| | |
| | | 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 |
| | |
| | | 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)); |
| | |
| | | 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)); |
| | |
| | | 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 "待核销"; |
| | |
| | | @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); |
| | | } |
| | |
| | | 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, "观影权益已过期"); |
| | |
| | | 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) { |
| | |
| | | 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)); |