Merge remote-tracking branch 'origin/main'
| New file |
| | |
| | | # 员工账号进对应权限模块报「服务器内部错误」、解绑家属卡报「家属附属卡申请处理失败」 |
| | | |
| | | Status: resolved |
| | | 领域: 其余业务(管理后台权限) |
| | | 来源: 用户工单「【系统管理】员工管理:登录员工账号,查看对应的权限模块,报服务器错误,且员工操作解绑家属卡提示申请失败」 |
| | | 日期: 2026-09-26 |
| | | |
| | | ## 现象 |
| | | |
| | | 账号 `15533213321`(员工,角色「运营管理员」`role_1789541215146`)登录后台: |
| | | |
| | | 1. 打开「预约管理 → 体检预约管理」:列表空、右上角弹「服务器内部错误」。 |
| | | 2. 打开「持卡管理 → 家属卡管理 → 家属卡详情」,点「解除绑定」:弹「家属附属卡申请处理失败」。 |
| | | |
| | | 同一个账号进「人才资讯管理」「停车车牌管理」是正常的,说明登录本身没问题。 |
| | | |
| | | ## 根因(两层,缺一不可) |
| | | |
| | | **一、接口要求的权限码,角色管理界面里根本勾不到** |
| | | |
| | | `AdminAuthInterceptor` 按 URI 前缀推导权限码,但有两类接口是「一个接口服务多个页面」,推导出来的 |
| | | 页面名与角色管理(`RoleFormView.vue`)提供的页面名对不上: |
| | | |
| | | | 接口 | 拦截器原来要求的权限 | 角色管理里能给的权限 | 结果 | |
| | | | --- | --- | --- | --- | |
| | | | `/api/admin/service-appointment-applications`(体检预约/加油/酒店预约/配偶就业/医疗服务共用,靠 `applicationType` 区分) | `admin.appointment-management.*` | 角色管理没有「服务预约管理」这一项 | 除超管外**任何账号都进不去**这几个模块 | |
| | | | `PATCH /api/admin/dependent-card-applies/{id}/unbind` | `admin.family-card-applications.edit` | 家属卡申请只有 查看/审核/下载 | 解绑**永远**拿不到权限 | |
| | | |
| | | **二、权限 403 被控制器的兜底 advice 改写成 500** |
| | | |
| | | `talent-card-server/src/main/java/com/talentcard/server/config/` 下有一批按控制器限定的 |
| | | `@RestControllerAdvice`(`assignableTypes = XxxController.class`,`@Order(HIGHEST_PRECEDENCE)`), |
| | | 每个都用 `@ExceptionHandler(Exception.class)` → `ResponseStatusException(403)` 也被它们接走, |
| | | 统一按 HTTP 500 返回。所以前端看到的是「服务器内部错误」「家属附属卡申请处理失败」, |
| | | 而不是「当前账号没有该操作权限」,也没有任何地方提示是权限问题。 |
| | | |
| | | ## 证据 |
| | | |
| | | 用员工账号(operator 12)临时造会话打真实接口(2026-09-26,`localhost:8080`): |
| | | |
| | | ```text |
| | | GET /api/admin/service-appointment-applications?applicationType=MEDICAL_RESERVATION |
| | | -> 500 {"code":500,"message":"服务器内部错误"} |
| | | 同一接口用超管账号 -> 200 {"code":0,...,"records":[...]} |
| | | |
| | | PATCH /api/admin/dependent-card-applies/999999/unbind(员工)-> 500 "家属附属卡申请处理失败" |
| | | PATCH /api/admin/dependent-card-applies/999999/unbind(超管)-> 500 "家属卡不存在"(本该 404) |
| | | |
| | | GET /api/admin/management/roles(员工,没有 roles.view)-> 403 "当前账号没有该操作权限" |
| | | ← 说明 403 本身是好的,问题只出在这批 advice 覆盖到的控制器上 |
| | | ``` |
| | | |
| | | 数据库侧:`admin_role` id=12 的 `permission_codes` 里同时有 |
| | | `admin.medical-reservations.view/audit/export`、`admin.family-cards.view/disable`、 |
| | | `admin.family-card-applications.view/audit`——权限是配了的,只是拦截器要的码对不上。 |
| | | |
| | | ## 改法(本次已做) |
| | | |
| | | 1. `AdminAuthInterceptor`: |
| | | - 鉴权失败(401/403)**直接写响应**,不再抛异常,绕开那批把异常改写成 500 的 advice |
| | | (顺带修掉「这些模块上会话过期不跳登录页」的问题)。 |
| | | - `/api/admin/service-appointment-applications` 改为按 `applicationType` 归属页面: |
| | | `MEDICAL_RESERVATION→medical-reservations`、`MEDICAL_APPLICATION→medical-applications`、 |
| | | `FUEL_SERVICE→fuel-services`、`HOTEL_RESERVATION→hotel-reservations`、 |
| | | `SPOUSE_EMPLOYMENT→spouse-employment`、`PARKING_PLATE→parking-plates`; |
| | | 列表/详情要「查看」、导出要「下载」、处理驳回要「审核」。只带 id 的请求(详情、操作日志) |
| | | 回 `service_application` 查一次类型;归属不出来时退回 `appointment-management`(保持原状)。 |
| | | - `/api/admin/dependent-card-applies` 的查看类请求接受 |
| | | `admin.family-card-applications.view` 或 `admin.family-cards.view`;解绑接受 |
| | | `admin.family-card-applications.audit` 或 `admin.family-cards.disable`(接口挂在两个页面下, |
| | | 两边对应权限都认)。 |
| | | 2. `AdminAuthService` 增加 `hasAnyPermission(operatorId, permissions)`,`hasPermission` 收敛为它的 |
| | | 单值调用。 |
| | | |
| | | ## 遗留(同一类问题,未在本工单修,等指派) |
| | | |
| | | - `/api/admin/business/coupons` 要求 `admin.benefit-coupons.*`、`/api/admin/display-services` |
| | | 要求 `admin.display-services.*`:角色管理里都没有对应页面,等于除超管外无法使用。 |
| | | - 以下前缀在拦截器里没有任何规则,**完全不校验权限**(任何登录账号都能调): |
| | | `/api/admin/cards/upgrade-applications`、`/api/admin/operation/**`(电影票规则/领取记录)、 |
| | | `/api/admin/benefit-service-config/**`、`/api/admin/display-merchant-policies`、 |
| | | `/api/admin/management/settings`、`/api/admin/management/payment-reconciliation/run`、 |
| | | `/api/admin/workbench/statistics`。 |
| | | - 那批 `*ExceptionHandler` 仍然把落在自己控制器上的 404/409/500 一律改写成 500, |
| | | 语义和状态码都不对(本次只是让 401/403 不再被它们接走)。 |
| | | |
| | | ## Comments |
| | | |
| | | - 2026-09-26:由「其余业务」侧排查并修复(改动 `AdminAuthInterceptor`、`AdminAuthService`), |
| | | 需要重启后端生效后复验。探针用的两个临时会话已删除。 |
| New file |
| | |
| | | # 人才端-家属卡解绑:左上角返回按钮点了没反应 |
| | | |
| | | Status: resolved |
| | | 领域: 其余业务(家属卡) |
| | | 来源: 排查工单「【商家小程序】体检医院:商品管理,选择可预约时间没有生效」时,扫描两个小程序全部事件绑定顺带发现 |
| | | 日期: 2026-09-26 |
| | | |
| | | ## 现象 |
| | | |
| | | 人才端「家属卡解绑」页左上角的返回箭头点了没有任何反应,只能靠系统手势/物理返回键退出。 |
| | | |
| | | ## 根因 |
| | | |
| | | 模板上给返回按钮挂了 `@tap="goBack"`,但该页面的 `<script setup>` 里**没有定义 `goBack`**, |
| | | 点下去等于调用一个不存在的函数,页面不会有任何动作。属于「事件绑定漏写处理函数」这一类问题 |
| | | (同一批里商家端体检医院商品页的 `onReserveStartChange` / `onReserveEndChange` 也是这个毛病,已在 |
| | | `rencaika_merchant/pages/product-detail/product-detail.uvue` 修掉)。 |
| | | |
| | | ## 证据 |
| | | |
| | | `rencaika/pages/family-card-unbind/family-card-unbind.uvue`: |
| | | |
| | | ```text |
| | | 第 6 行:<view class="back-button" hover-class="back-button-active" @tap="goBack"> |
| | | <image src="/static/news-assets/arrow-left.svg" mode="aspectFit"></image></view> |
| | | ``` |
| | | |
| | | 对全部 `.uvue` 做「模板里出现的事件处理函数名 vs 脚本里的 `const/function/let` 定义」比对, |
| | | 只有这一处(以及上面已修的体检医院日期选择器)对不上。 |
| | | |
| | | ## 建议改法 |
| | | |
| | | 在该页面脚本里补一个处理函数,与其它页面保持一致: |
| | | |
| | | ```ts |
| | | const goBack = () => { uni.navigateBack() } |
| | | ``` |
| | | |
| | | ## Comments |
| | | |
| | | - 2026-09-26:由「其余业务」侧扫描事件绑定时发现,尚未改动该页面(不属于当前工单范围),等指派。 |
| | | - 2026-09-26:用户确认一起修,已在该页面补 `const goBack = () => safeBack('/pages/family-card/family-card')` |
| | | (与其它页面同一套返回约定:页面栈足够时原路返回,栈底时回家属卡管理页); |
| | | `rencaika/docs/pages/25-解除家属卡绑定.md` 同步补了一行说明。两个小程序全部 `.uvue` 的事件绑定 |
| | | 再扫一遍,已无「模板挂了处理函数但脚本没定义」的情况。 |
| | |
| | | |
| | | | 功能 | 当前实现 | 后续接口目标 | |
| | | | --- | --- | --- | |
| | | | 顶部主图 | 默认展示当前选中图册项的大图。 | 使用后台返回的 HTTPS 图片 URL,并提供加载失败占位图。 | |
| | | | 顶部主图 | 展示当前选中图册项的大图,**主图自身支持左右滑动**切换图册项,与下方缩略图双向联动(点缩略图主图滑过去、滑主图缩略图跟着走)。 | 使用后台返回的 HTTPS 图片 URL,并提供加载失败占位图。 | |
| | | | 横向图册 | 体验版使用 6 项重复演示数据验证横向滑动,仅由 2 张图片交替复用;点击或滑动结束后,中心项自动选中、放大并带动画吸附到容器中央。 | 后台返回图片、名称、排序和启用状态,正式版图片走后台/CDN。 | |
| | | | 动态菜名 | 主图上显示当前菜品名称,使用古典艺术行楷字体子集。 | 正式环境通过完整字体包或 CDN 字体覆盖后台任意名称字符。 | |
| | | | 专题描述 | 橙色渐变卡片显示后台返回的专题固定描述,不随图册选择变化。 | 后台返回专题级纯文本描述,并限制长度及换行数量。 | |
| | |
| | | ## 操作说明 |
| | | |
| | | 1. 在人才权益页点击“暖心食享”进入本页。 |
| | | 2. 左右滑动顶部缩略图册浏览菜品图片。 |
| | | 2. 左右滑动顶部主图(或滑动下方缩略图带)浏览菜品图片。 |
| | | 3. 点击缩略图后,顶部主图、菜名和橙色描述卡同步切换。 |
| | | 4. 点击下方餐饮商户进入对应权益详情。 |
| | | 5. 点击左上角返回图标回到人才权益页。 |
| | |
| | | - 展示当前可解绑家属,支持勾选后发起解除绑定。 |
| | | - 家属列表已补局部加载、成功空数据、加载失败和点击重试;失败不会被当成“暂无可解绑家属”。 |
| | | - 空数据时隐藏“解除绑定”按钮,避免提交不存在的家属 ID。 |
| | | - 左上角返回按钮调用 `safeBack('/pages/family-card/family-card')`:页面栈足够时原路返回,栈底时回管理页。 |
| | | - 提交前校验至少选择一位家属,随后二次确认风险提示。 |
| | | - 原型提交成功后返回管理页展示空状态;正式版本必须以解绑接口结果刷新列表。 |
| | | - 后台应返回不可解绑原因,例如审核中、权益未结束、退款处理中等;失败时保留原列表与选择状态。 |
| | |
| | | ## 功能点 |
| | | |
| | | - 顶部大图、可横向滑动并点击切换的景区图册。 |
| | | - 顶部大图本身也支持左右滑动切换图册项,与下方缩略图双向联动(点缩略图主图滑过去、滑主图缩略图跟着走)。 |
| | | - 人才文旅权益简介、景区服务分页列表、首屏/下一页失败重试、加载中、到底与空状态。 |
| | | - 点击景区服务进入商家详情演示页。 |
| | | - 体验版仅保留 2 张唯一文旅图片,图册和景区列表通过重复项交替复用;正式版图片由后台/CDN配置。 |
| | |
| | | ## 功能点 |
| | | |
| | | - 顶部酒店大图与横向图册,支持点击切换、滑动吸附、选中放大并居中。 |
| | | - 顶部大图本身也支持左右滑动切换图册项,与横向图册双向联动。 |
| | | - 固定住宿权益介绍。 |
| | | - “酒店服务 / 公寓租赁服务”分类切换。 |
| | | - 服务列表支持分页加载、首屏/下一页失败重试、空状态、加载中与到底状态;切换酒店/公寓分类时清理上一分类错误。 |
| | |
| | | | --- | --- | --- | |
| | | | 沉浸式头图 | 返回栏固定,上滑后渐显实体橙色背景;主图随后台图册选择同步切换。 | 后台可扩展主题配置。 | |
| | | | 横向图册 | 从后台读取图片 URL、排序和名称,支持点击、横向滑动、选中放大和自动居中。 | 正式版图片由后台/CDN维护。 | |
| | | | 主图滑动 | 顶部主图为轮播,可直接左右滑动切换图册项,与横向图册、名称同步。 | 图册数据同专题接口。 | |
| | | | 专题介绍 | 展示后台返回的审核后纯文本介绍。 | 无。 | |
| | | | 观影服务 | 从后台分页加载影院、剧院服务,支持首屏/下一页失败重试、空状态和到底状态。 | 可扩展库存和领券状态。 | |
| | | | 服务跳转 | 影院服务进入影城详情;剧院服务明确提示详情暂未开放;已下架服务不可进入。 | 按服务类型接入领券、购票或预约流程。 | |
| | |
| | | | 领券结果 | 顶部标题展示“领券成功”,卡片显示接口返回的电影兑换券名称;业务图标使用高对比度橙色矢量图。 | `GET /api/app/cinema-coupon-claims/{claimId}?talentId={talentId}` 返回用户券名称和状态。 | |
| | | | 年度权益 | 依据年度总额度和已领数量显示进度条与剩余数量,不展示重复明细行。 | 返回 `annualTotal`、`annualClaimed`、`annualRemaining`。 | |
| | | | 兑换凭证 | 展示有效兑换码、有效期及影城使用说明。 | 返回兑换码、状态、有效期、使用渠道、核销规则及失效原因。 | |
| | | | 到店二维码 | 兑换码上方展示二维码,影城店员用商家端小程序扫码核销;票包(观影权益)与电影券的领取记录不在同一张表,各走各的二维码接口。 | `GET /api/app/benefit-verification-codes/{claimId}/image`(电影券)、`GET /api/app/benefit-verification-codes/ticket-package-claims/{claimId}/image`(票包/观影权益)。 | |
| | | | 复制兑换码 | 仅在兑换码状态为 `ACTIVE` 时允许复制,成功后提示“兑换码已复制”。 | 复制后台返回的当前有效兑换码。 | |
| | | | 使用记录联动 | 领取成功页和“已领取”记录均携带 `claimId`,复用同一接口加载详情。 | 使用记录的 `targetDetailId` 为领取记录 ID。 | |
| | | |
| | |
| | | |
| | | 1. 用户确认领取电影券后进入本页。 |
| | | 2. 查看年度权益使用进度及剩余数量。 |
| | | 3. 查看兑换码与使用说明。 |
| | | 3. 到店时向店员出示二维码(店员用商家端「扫码核销」扫它),也可查看/复制兑换码与使用说明。 |
| | | 4. 点击“复制兑换码”,系统写入剪贴板并反馈复制结果。 |
| | | 5. 用户也可从使用记录“已领取”进入本页查看同一兑换凭证。 |
| | | 6. 点击左上角返回上一页;页面栈缺失时返回使用记录“已领取”。 |
| | |
| | | | --- | --- | --- | |
| | | | 沉浸式顶部 | 头图、标题、返回按钮固定在滚动容器外;上滑超过 64px 渐显实体橙色导航背景。 | 标题、图册和介绍由专题接口返回;导航仍由前端统一适配胶囊。 | |
| | | | 横向图册 | 按后台启用状态和排序展示;点击或横向滑动后自动选中、放大并居中。 | `GET /api/app/topics/transport` 返回图册图片与名称。 | |
| | | | 主图滑动 | 顶部主图为轮播,可直接左右滑动切换图册项,与横向图册、名称同步。 | 图册数据同专题接口。 | |
| | | | 介绍卡 | 橙色渐变卡以 `-66rpx` 负外边距悬浮在头图下方,文本按内容自然撑高。 | 返回专题纯文本介绍。 | |
| | | | 服务分类 | 提供加油服务、停车服务、自助洗车服务三个分类标签,切换后重置独立分页状态。 | `type=fuel|parking|carWash` 查询对应服务。 | |
| | | | 服务列表 | 展示后台服务图片、名称、折扣/权益标签、渐变地址条;支持首屏/下一页失败重试、上滑加载、到底和空状态。 | `GET /api/app/topics/transport/merchants?type={type}&page={page}&size={size}`。 | |
| | |
| | | | --- | --- | --- | |
| | | | 沉浸式头图 | 顶图全屏展示,标题栏初始透明;上滑超过阈值后渐显实体橙色背景。 | 后台返回专题头图,图片由 CDN 加载。 | |
| | | | 横向图册 | 支持横向滑动和点击切换,选中图片放大并自动居中,名称使用项目内置展示字体。 | 后台返回图片、展示名称、排序和启用状态。 | |
| | | | 主图滑动 | 顶部主图为轮播,可直接左右滑动切换图册项,与横向图册、名称同步。 | 图册数据同专题接口。 | |
| | | | 专题介绍 | 橙色介绍卡悬浮在头图与列表之间,卡片高度由纯文本内容自然撑开。 | 后台返回安全纯文本介绍,客户端不按固定高度截断。 | |
| | | | 服务分类 | 支持购房优惠、购车服务、家电服务、数码服务、图书服务五类横向切换。 | 后台返回分类 ID、名称、排序和启用状态。 | |
| | | | 商家列表 | 展示商家图片、名称、优惠标签和地址,点击进入购物服务专用详情。 | 后台分页返回商家/项目 ID、分类、封面、权益摘要、地址及可用状态。 | |
| | |
| | | | --- | --- | --- | |
| | | | 沉浸式头图 | 顶图全屏展示,导航初始透明;页面上滑后渐显实体橙色背景。 | 后台返回专题头图 URL。 | |
| | | | 横向图册 | 支持滑动和点击切换,选中图册放大并自动居中,名称使用项目内置展示字体。 | 后台返回图片 URL、展示名称、排序和启用状态。 | |
| | | | 主图滑动 | 顶部主图为轮播,可直接左右滑动切换图册项,与横向图册、名称同步。 | 图册数据同专题接口。 | |
| | | | 专题介绍 | 橙色介绍卡悬浮在头图与列表间,纯文本内容自然撑高。 | 后台返回安全纯文本介绍。 | |
| | | | 服务分类 | 支持免费体检、配偶推荐就业、金融服务、行政审批、学生托付五类横向切换。 | 后台返回分类 ID、名称、排序和启用状态。 | |
| | | | 服务列表 | 展示机构图片、名称、权益标签和地址。 | 后台分页返回服务 ID、分类 ID、机构信息、权益摘要、地址和状态。 | |
| | |
| | | <view v-else class="result-card"> |
| | | <view class="coupon-heading"><view class="heading-icon"><image src="/static/merchant-assets/coupon-orange.svg" mode="aspectFit"></image></view><text>{{couponName}}</text></view><view class="heading-divider"></view> |
| | | <view v-if="hasAnnual" class="annual-panel"><view class="annual-title-row"><view class="annual-title"><image src="/static/merchant-assets/coupon-orange.svg" mode="aspectFit"></image><text>可领取额度</text></view><view class="remaining-pill"><text>✓</text><text>剩余 {{annualRemaining}} 张</text></view></view><view class="progress-track"><view class="progress-value" :style="'width:' + progressPercent + '%'"></view></view><view class="annual-stats"><text>◉ 已领 {{annualClaimed}} 张</text><text>▣ 剩余 {{annualRemaining}} 张</text><text>总计 {{annualTotal}} 张</text></view><text class="annual-caption">可领取额度{{annualTotal}}张 / 已领{{annualClaimed}}张 / 剩余{{annualRemaining}}张</text></view> |
| | | <view class="voucher-section"><view class="section-title"><image src="/static/merchant-assets/coupon-orange.svg" mode="aspectFit"></image><text>兑换凭证</text></view><view v-if="canCopy" class="voucher-code"><text selectable="true">{{exchangeCode}}</text></view><view v-else class="voucher-unavailable"><text>{{codeStatusText}}</text></view><view class="voucher-tip"><view class="mini-copy-icon"><view></view><view></view></view><text>{{voucherTip}}</text></view></view> |
| | | <view class="voucher-section"><view class="section-title"><image src="/static/merchant-assets/coupon-orange.svg" mode="aspectFit"></image><text>兑换凭证</text></view><image v-if="canCopy && qrUrl.length > 0" class="voucher-qr" :src="qrUrl" mode="aspectFit"></image><view v-if="canCopy" class="voucher-code"><text selectable="true">{{exchangeCode}}</text></view><view v-else class="voucher-unavailable"><text>{{codeStatusText}}</text></view><view class="voucher-tip"><view class="mini-copy-icon"><view></view><view></view></view><text>{{voucherTipText}}</text></view></view> |
| | | <view v-if="canCopy" class="copy-button" hover-class="copy-button-active" @tap="copyExchangeCode"><view class="copy-icon"><view></view><view></view></view><text>复制兑换码</text></view> |
| | | </view> |
| | | </view> |
| | |
| | | |
| | | <script setup lang="uts"> |
| | | import { apiEnvironment } from '../../config/api.uts' |
| | | import { getCinemaCouponClaim, getTicketPackageClaim } from '../../services/portal.uts' |
| | | import { getCinemaCouponClaim, getTicketPackageClaim, getCinemaCodeImageUrl, getTicketPackageCodeImageUrl } from '../../services/portal.uts' |
| | | import { getSession } from '../../services/session.uts' |
| | | import { safeBack } from '../../utils/navigation.uts' |
| | | // Keep the first render below the status bar even before the MP capsule metrics arrive. |
| | |
| | | }) |
| | | const progressPercent = computed(() => annualTotal.value > 0 ? Math.min(100, Math.round(annualClaimed.value * 100 / annualTotal.value)) : 0) |
| | | const canCopy = computed(() => exchangeCodeStatus.value == 'ACTIVE' && exchangeCode.value != '') |
| | | /** 到店出示的二维码:电影券与观影权益(票包)的领取记录不在同一张表,走各自的二维码接口。 */ |
| | | const qrUrl = computed((): string => { |
| | | if (!canCopy.value || claimId.value == '') return '' |
| | | return claimType.value == 'cinemaCoupon' ? getCinemaCodeImageUrl(claimId.value) : getTicketPackageCodeImageUrl(claimId.value) |
| | | }) |
| | | /** 观影权益是「到店出示二维码,店员扫码核销」,和电影券「复制兑换码去影城 APP」的提示不一样。 */ |
| | | const voucherTipText = computed((): string => claimType.value == 'ticketPackage' && canCopy.value |
| | | ? '到店出示二维码,由店员扫码核销;也可复制兑换码备用' |
| | | : voucherTip.value) |
| | | const loadClaim = () => { if (claimId.value == '' || talentId.value == '') { loading.value = false; loadError.value = true; return }; loading.value = true; loadError.value = false; const onSuccess = (data: any) => { loading.value = false; couponName.value = data.couponName != null ? String(data.couponName) : couponName.value; merchantName.value = data.merchantName != null ? String(data.merchantName) : ''; const totalValue = data.packageQuotaTotal != null ? data.packageQuotaTotal : (data.quotaTotal != null ? data.quotaTotal : data.annualTotal); const usedValue = data.packageQuotaUsed != null ? data.packageQuotaUsed : (data.quotaUsed != null ? data.quotaUsed : data.annualClaimed); const remainingValue = data.packageQuotaRemaining != null ? data.packageQuotaRemaining : (data.quotaRemaining != null ? data.quotaRemaining : data.annualRemaining); if (totalValue != null && usedValue != null && remainingValue != null) { annualTotal.value = Number(totalValue); annualClaimed.value = Number(usedValue); annualRemaining.value = Number(remainingValue); hasAnnual.value = annualTotal.value > 0 }; exchangeCode.value = data.exchangeCode != null ? String(data.exchangeCode) : ''; exchangeCodeStatus.value = data.exchangeCodeStatus != null ? String(data.exchangeCodeStatus) : ''; if (exchangeCodeStatus.value == 'USED') codeStatusText.value = '兑换码已使用'; else if (exchangeCodeStatus.value == 'EXPIRED') codeStatusText.value = '兑换码已失效'; else if (exchangeCodeStatus.value == 'LEGACY' || exchangeCodeStatus.value == 'UNAVAILABLE') codeStatusText.value = '历史电影券暂无兑换码'; else if (exchangeCode.value == '') codeStatusText.value = '兑换码生成中,请稍后重试'; else codeStatusText.value = ''; const channel = data.appName != null && String(data.appName) != '' ? String(data.appName) + 'APP' : (merchantName.value == '' ? '影城' : merchantName.value) + '小程序'; voucherTip.value = exchangeCode.value == '' ? '该电影券为历史领取记录,当前无需操作' : '复制后前往' + channel + '使用,核销时出示兑换码' }; const onError = () => { loading.value = false; loadError.value = true }; if (claimType.value == 'cinemaCoupon') getCinemaCouponClaim(claimId.value, talentId.value, onSuccess, onError); else getTicketPackageClaim(claimId.value, talentId.value, onSuccess, onError) } |
| | | const goBack = () => safeBack('/pages/cinema-merchant/cinema-merchant?merchantId=' + encodeURIComponent(merchantId.value)) |
| | | const copyExchangeCode = () => { if (!canCopy.value) return; uni.setClipboardData({ data: exchangeCode.value, success: () => uni.showToast({ title: '兑换码已复制', icon: 'success' }), fail: () => uni.showToast({ title: '复制失败,请重试', icon: 'none' }) }) } |
| | |
| | | .annual-stats { display: flex; margin-top: 24rpx; color: #66717F; font-size: 21rpx; flex-direction: row; justify-content: space-between; }.annual-caption { display: block; margin-top: 16rpx; color: #A0A8B3; font-size: 20rpx; text-align: center; }.annual-panel { padding: 24rpx 27rpx 29rpx; border-radius: 28rpx; background-color: #F7F9FC; }.annual-title-row { display: flex; height: 48rpx; flex-direction: row; align-items: center; justify-content: space-between; }.annual-title { display: flex; flex-direction: row; align-items: center; }.annual-title image { width: 27rpx; height: 27rpx; margin-right: 12rpx; }.annual-title text { color: #4F5662; font-size: 27rpx; font-weight: 700; }.remaining-pill { display: flex; height: 43rpx; padding: 0 17rpx; border-radius: 22rpx; background-color: #FFF1E4; color: #FF7C35; font-size: 21rpx; flex-direction: row; align-items: center; }.remaining-pill text:first-child { margin-right: 8rpx; }.progress-track { height: 15rpx; margin-top: 28rpx; overflow: hidden; border-radius: 8rpx; background-color: #E4E8ED; }.progress-value { height: 15rpx; border-radius: 8rpx; background-image: linear-gradient(90deg, #FF4634 0%, #FF8D23 100%); } |
| | | .voucher-section { margin-top: 62rpx; padding: 0 22rpx; }.section-title { display: flex; height: 45rpx; flex-direction: row; align-items: center; }.section-title image { width: 27rpx; height: 27rpx; margin-right: 11rpx; }.section-title text { color: #505762; font-size: 27rpx; font-weight: 700; }.voucher-code, .voucher-unavailable { display: flex; min-height: 82rpx; margin-top: 10rpx; box-sizing: border-box; padding: 16rpx 18rpx; border-radius: 24rpx; background-color: #F6F8FB; justify-content: center; align-items: center; }.voucher-code text { width: 100%; color: #1D2531; font-size: 30rpx; line-height: 42rpx; font-weight: 700; letter-spacing: 2rpx; text-align: center; word-break: break-all; overflow-wrap: anywhere; }.voucher-unavailable text { color: #8C95A1; font-size: 24rpx; text-align: center; }.voucher-tip { display: flex; margin-top: 17rpx; color: #7F8998; font-size: 20rpx; line-height: 29rpx; flex-direction: row; align-items: flex-start; }.voucher-tip text { min-width: 0; flex: 1; }.mini-copy-icon { width: 23rpx; height: 25rpx; margin: 2rpx 12rpx 0 3rpx; position: relative; flex-shrink: 0; }.mini-copy-icon view, .copy-icon view { position: absolute; box-sizing: border-box; border: 2rpx solid currentColor; border-radius: 3rpx; }.mini-copy-icon view:first-child { left: 6rpx; top: 0; width: 14rpx; height: 17rpx; }.mini-copy-icon view:last-child { left: 1rpx; top: 6rpx; width: 14rpx; height: 17rpx; } |
| | | .copy-button { display: flex; width: 100%; height: 88rpx; margin-top: auto; margin-bottom: 150rpx; flex-shrink: 0; border-radius: 46rpx; color: #FFFFFF; background-image: linear-gradient(90deg, #FF4434 0%, #FF8D21 100%); box-shadow: 0 15rpx 28rpx rgba(255,103,39,.21); flex-direction: row; justify-content: center; align-items: center; }.copy-button-active { opacity: .78; }.copy-button text { font-size: 30rpx; font-weight: 600; }.copy-icon { width: 31rpx; height: 31rpx; margin-right: 16rpx; position: relative; }.copy-icon view:first-child { left: 9rpx; top: 1rpx; width: 18rpx; height: 21rpx; }.copy-icon view:last-child { left: 2rpx; top: 8rpx; width: 18rpx; height: 21rpx; } |
| | | .voucher-qr { width: 300rpx; height: 300rpx; margin: 24rpx auto 6rpx; display: block; border-radius: 20rpx; background-color: #FFFFFF; } |
| | | </style> |
| | |
| | | |
| | | <scroll-view class="page-scroll" scroll-y lower-threshold="80" @scroll="handleScroll" @scrolltolower="loadMore"> |
| | | <view class="hero"> |
| | | <image class="hero-image" :src="currentScene.image" mode="aspectFill" @error="replaceHeroImage"></image> |
| | | <swiper v-if="scenes.length > 0" class="hero-swiper" :current="activeIndex < 0 ? 0 : activeIndex" :circular="scenes.length > 1" :duration="240" @change="handleHeroChange"> |
| | | <swiper-item v-for="(item, index) in scenes" :key="item.id"> |
| | | <image class="hero-image" :src="item.image" mode="aspectFill" @error="replaceSceneImage(index)"></image> |
| | | </swiper-item> |
| | | </swiper> |
| | | <image v-else class="hero-image" :src="currentScene.image" mode="aspectFill" @error="replaceHeroImage"></image> |
| | | <view class="hero-shade"></view> |
| | | <text v-if="currentScene.name != ''" class="scene-name">{{currentScene.name}}</text> |
| | | <text v-if="scenes.length == 0" class="no-gallery">暂无图册内容</text> |
| | |
| | | if (index >= scenes.value.length) index = scenes.value.length - 1 |
| | | selectScene(index) |
| | | } |
| | | /** 主图左右滑动:滑动落点同步到缩略图、名称与描述。 */ |
| | | const handleHeroChange = (event: any) => { |
| | | const current = event != null && event.detail != null && event.detail.current != null ? Number(event.detail.current) : -1 |
| | | if (current < 0) return |
| | | selectScene(current) |
| | | } |
| | | const handleScroll = (event: any) => { isSolidNav.value = event.detail.scrollTop > 64 } |
| | | const loadServices = (append: boolean) => { |
| | | if (!isApiConfigured()) { initialLoading.value = false; loadingMore.value = false; initialError.value = true; return } |
| | |
| | | .topic-summary { position: relative; z-index: 4; margin-top: -66rpx; }.intro-card { margin: 0 31rpx; box-sizing: border-box; padding: 25rpx 31rpx; border-radius: 26rpx; background-image: linear-gradient(90deg, #FF5320 0%, #FF8A22 100%); box-shadow: 0 10rpx 22rpx rgba(255,104,28,.24); }.intro-card text { color: #FFFFFF; font-size: 23rpx; line-height: 34rpx; } |
| | | .service-section { padding: 32rpx 31rpx 0; }.section-heading { display: flex; width: 132rpx; height: 72rpx; flex-direction: column; align-items: center; }.section-heading text { display: block; color: #363636; font-size: 32rpx; font-weight: 700; }.section-heading view { width: 61rpx; height: 7rpx; margin: 12rpx 0 18rpx; border-radius: 4rpx; background-image: linear-gradient(90deg, #FF4D36 0%, #FFBF3A 100%); }.service-item { display: flex; min-height: 205rpx; margin-bottom: 20rpx; flex-direction: row; }.service-item.is-unavailable { opacity: .56; }.service-image { width: 184rpx; height: 184rpx; border-radius: 17rpx; flex-shrink: 0; background-color: #EEE; }.service-info { display: flex; min-width: 0; min-height: 184rpx; flex: 1; padding: 4rpx 0 4rpx 24rpx; flex-direction: column; justify-content: space-between; }.service-name { color: #292929; font-size: 29rpx; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }.discount-tag { display: block; width: fit-content; padding: 6rpx 9rpx; border-radius: 6rpx; background-color: #FFF0EC; color: #FF5D37; font-size: 21rpx; }.address { display: flex; min-width: 0; box-sizing: border-box; padding: 7rpx 10rpx; border-radius: 7rpx; background-image: linear-gradient(90deg, rgba(239,240,242,.98) 0%, rgba(242,243,245,.76) 58%, rgba(255,255,255,0) 100%); flex-direction: row; align-items: flex-start; }.address image { width: 27rpx; height: 27rpx; margin-right: 8rpx; flex-shrink: 0; }.address text { min-width: 0; flex: 1; color: #7E8490; font-size: 21rpx; line-height: 31rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } |
| | | .list-state, .no-more { display: flex; height: 150rpx; color: #989DA5; font-size: 23rpx; justify-content: center; align-items: center; }.list-state.compact { height: 90rpx; }.error-state { flex-direction: column; }.retry-text, .load-error text { margin-top: 18rpx; color: #F26A38; }.loading-spinner { width: 26rpx; height: 26rpx; margin-right: 12rpx; box-sizing: border-box; border: 3rpx solid #FFD6C4; border-top-color: #FF6C2B; border-radius: 50%; animation: entertainment-loading 720ms linear infinite; }@keyframes entertainment-loading { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }.bottom-space { height: calc(30rpx + env(safe-area-inset-bottom)); } |
| | | /* 主图轮播:与下方缩略图带同高同位,盖在原来的静态主图上。 */ |
| | | .hero-swiper { position: absolute; left: 0; top: 0; width: 100%; height: 730rpx; } |
| | | </style> |
| | |
| | | const talentId = ref('') |
| | | const talentCardId = ref('') |
| | | |
| | | // 模板上的返回按钮一直挂着 @tap="goBack" 却没定义处理函数,点了没有任何反应。 |
| | | const goBack = () => safeBack('/pages/family-card/family-card') |
| | | |
| | | onLoad((options: any) => { |
| | | // #ifdef MP-WEIXIN |
| | | const capsule = uni.getMenuButtonBoundingClientRect() |
| | |
| | | |
| | | <scroll-view class="page-scroll" scroll-y lower-threshold="80" @scroll="handlePageScroll" @scrolltolower="loadMoreMerchants"> |
| | | <view class="hero"> |
| | | <image class="hero-image" :src="currentImage" mode="aspectFill" @error="replaceTopicImage"></image> |
| | | <!-- 主图跟着图册左右滑:滑动的主图与下方缩略图互相联动(点缩略图 = 主图滑过去)。 --> |
| | | <swiper v-if="gallery.length > 0" class="hero-swiper" :current="activeIndex < 0 ? 0 : activeIndex" :circular="gallery.length > 1" :duration="240" @change="handleHeroChange"> |
| | | <swiper-item v-for="(item, index) in gallery" :key="item.id"> |
| | | <image class="hero-image" :src="item.image" mode="aspectFill" @error="replaceGalleryImage(index)"></image> |
| | | </swiper-item> |
| | | </swiper> |
| | | <image v-else class="hero-image" :src="currentImage" mode="aspectFill" @error="replaceTopicImage"></image> |
| | | <view class="hero-top-shade"></view> |
| | | <view class="hero-bottom-shade"></view> |
| | | |
| | |
| | | if (index >= gallery.value.length) index = gallery.value.length - 1 |
| | | selectGallery(index) |
| | | } |
| | | /** 主图左右滑动:滑动落点同步到缩略图、菜名和描述卡。 */ |
| | | const handleHeroChange = (event: any) => { |
| | | const current = event != null && event.detail != null && event.detail.current != null ? Number(event.detail.current) : -1 |
| | | if (current < 0) return |
| | | selectGallery(current) |
| | | } |
| | | const handlePageScroll = (event: any) => { |
| | | const shouldShow = event.detail.scrollTop > 64 |
| | | if (isSolidNav.value != shouldShow) isSolidNav.value = shouldShow |
| | |
| | | .page { width: 100%; height: 100vh; background-color: #FFFFFF; } |
| | | .page-scroll { width: 100%; height: 100vh; background-color: #FFFFFF; } |
| | | .hero { height: 730rpx; position: relative; z-index: 2; overflow: visible; background-color: #332F29; } |
| | | .hero-swiper { position: absolute; left: 0; top: 0; width: 100%; height: 730rpx; } |
| | | .hero-image { position: absolute; left: 0; top: 0; width: 100%; height: 730rpx; } |
| | | .hero-top-shade { position: absolute; left: 0; right: 0; top: 0; height: 230rpx; background-image: linear-gradient(180deg, rgba(0,0,0,.62) 0%, rgba(0,0,0,.18) 65%, rgba(0,0,0,0) 100%); } |
| | | .hero-bottom-shade { position: absolute; left: 0; right: 0; bottom: 0; height: 230rpx; background-image: linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,.45) 100%); } |
| | |
| | | <view class="page"> |
| | | <view class="custom-nav" :class="isSolidNav ? 'is-solid' : ''"><view class="nav-bg"></view><view class="safe-space" :style="'height:' + navTop + 'px'"></view><view class="nav-row" :style="'height:' + navHeight + 'px;padding-right:' + navRight + 'px'"><view class="back-button" @tap="goBack"><image src="/static/food-assets/arrow-left-white.svg" mode="aspectFit"></image></view><text class="nav-title">享—{{topicTitle}}</text><view class="capsule-space" :style="'width:' + navWidth + 'px'"></view></view></view> |
| | | <scroll-view class="page-scroll" scroll-y lower-threshold="80" @scroll="handleScroll" @scrolltolower="loadMore"> |
| | | <view class="hero"><image class="hero-image" :src="activeScene.image" mode="aspectFill" @error="replaceImage(activeScene)"></image><view class="hero-top-shade"></view><view class="hero-bottom-shade"></view><text class="scene-name">{{activeScene.name}}</text><scroll-view class="scene-scroll" scroll-x :show-scrollbar="false" scroll-with-animation :scroll-left="sceneScrollLeft" @scrollend="handleSceneScrollEnd"><view class="scene-row" :style="'width:' + sceneTrackWidth + 'rpx'"><view class="scene-pad"></view><view v-for="(item, index) in scenes" :key="item.id" class="scene-item" :class="index == activeIndex ? 'active' : ''" @tap="selectScene(index)"><image :src="item.image" mode="aspectFill" @error="replaceImage(item)"></image></view><view class="scene-pad"></view></view></scroll-view></view> |
| | | <view class="hero"><swiper v-if="scenes.length > 0" class="hero-swiper" :current="activeIndex < 0 ? 0 : activeIndex" :circular="scenes.length > 1" :duration="240" @change="handleHeroChange"><swiper-item v-for="(item, index) in scenes" :key="item.id"><image class="hero-image" :src="item.image" mode="aspectFill" @error="replaceImage(item)"></image></swiper-item></swiper><image v-else class="hero-image" :src="activeScene.image" mode="aspectFill" @error="replaceImage(activeScene)"></image><view class="hero-top-shade"></view><view class="hero-bottom-shade"></view><text class="scene-name">{{activeScene.name}}</text><scroll-view class="scene-scroll" scroll-x :show-scrollbar="false" scroll-with-animation :scroll-left="sceneScrollLeft" @scroll="handleSceneScroll"><view class="scene-row" :style="'width:' + sceneTrackWidth + 'rpx'"><view class="scene-pad"></view><view v-for="(item, index) in scenes" :key="item.id" class="scene-item" :class="index == activeIndex ? 'active' : ''" @tap="selectScene(index)"><image :src="item.image" mode="aspectFill" @error="replaceImage(item)"></image></view><view class="scene-pad"></view></view></scroll-view></view> |
| | | <view v-if="topicError" class="topic-error"><text>专题配置加载失败</text><text @tap="loadPremiumTopic">重新加载</text></view> |
| | | <view v-if="topicDescription != ''" class="topic-summary"><view class="intro-card"><text>{{topicDescription}}</text></view></view> |
| | | <view class="service-section"><scroll-view class="tab-scroll" scroll-x :show-scrollbar="false"><view class="service-tabs" :style="'width:' + Math.max(750, serviceTabs.length * 234) + 'rpx'"><view v-for="tab in serviceTabs" :key="tab.id" class="service-tab" :class="tab.id == activeTab ? 'active' : ''" @tap="selectTab(tab.id)"><text>{{tab.name}}</text><view class="tab-line"></view></view></view></scroll-view> |
| | |
| | | const replaceImage = (item: any) => { if (item.image != fallbackImage) item.image = fallbackImage } |
| | | const centerActiveScene = (index: number) => { sceneScrollLeft.value = index * 137 * windowWidth.value / 750 } |
| | | const selectScene = (index: number) => { if (index < 0 || index >= scenes.value.length) return; activeIndex.value = index; activeScene.value = scenes.value[index]; centerActiveScene(index) } |
| | | const handleSceneScrollEnd = (event: any) => { let index = Math.round(event.detail.scrollLeft * 750 / windowWidth.value / 137); if (index < 0) index = 0; if (index >= scenes.value.length) index = scenes.value.length - 1; selectScene(index) } |
| | | // scroll-view 的 scrollend 只在 Skyline 渲染下触发,WebView 下改用 scroll 停止滚动后判定居中图片 |
| | | const sceneIndexFromScroll = (scrollLeft: number): number => { let index = Math.round(scrollLeft * 750 / windowWidth.value / 137); if (index < 0) index = 0; if (index >= scenes.value.length) index = scenes.value.length - 1; return index } |
| | | let sceneSettleTimer = 0 |
| | | const handleSceneScroll = (event: any) => { const index = sceneIndexFromScroll(event.detail.scrollLeft as number); if (sceneSettleTimer != 0) clearTimeout(sceneSettleTimer); sceneSettleTimer = setTimeout(() => { sceneSettleTimer = 0; selectScene(index) }, 200) } |
| | | /** 主图左右滑动:滑动落点同步到缩略图、名称与描述。 */ |
| | | const handleHeroChange = (event: any) => { const current = event != null && event.detail != null && event.detail.current != null ? Number(event.detail.current) : -1; if (current < 0) return; selectScene(current) } |
| | | const handleScroll = (event: any) => { isSolidNav.value = event.detail.scrollTop > 64 } |
| | | const loadPremiumTopic = () => { |
| | | if (!isApiConfigured()) { topicError.value = true; initialLoading.value = false; initialError.value = true; return } |
| | |
| | | if (item.businessType == 'CHILD_ENTRUSTMENT') { uni.navigateTo({ url: '/pages/children-entrustment/children-entrustment?serviceId=' + encodeURIComponent(item.id) }); return } |
| | | uni.navigateTo({ url: '/pages/merchant/merchant?merchantType=' + encodeURIComponent(item.merchantType) + '&merchantId=' + encodeURIComponent(item.id) + '&name=' + encodeURIComponent(item.name) + '&image=' + encodeURIComponent(item.image) + '&address=' + encodeURIComponent(item.address) + '&discount=' + encodeURIComponent(item.benefit) }) |
| | | } |
| | | onUnload(() => { if (sceneSettleTimer != 0) { clearTimeout(sceneSettleTimer); sceneSettleTimer = 0 } }) |
| | | </script> |
| | | |
| | | <style> |
| | |
| | | /* 标签与折扣并排显示:标签是后台配的文字(如「卓越人才卡」),折扣是另一个字段(如「8折」)。 */ |
| | | .service-benefit-row { display:flex; flex-direction:row; align-items:center; } |
| | | .service-benefit-row .service-benefit + .service-benefit { margin-left:10rpx; } |
| | | /* 主图轮播:与下方缩略图带同高同位,盖在原来的静态主图上。 */ |
| | | .hero-swiper { position:absolute; left:0; top:0; width:100%; height:730rpx; } |
| | | </style> |
| | |
| | | <view class="page"> |
| | | <view class="custom-nav" :class="isSolidNav ? 'is-solid' : ''"><view class="nav-bg"></view><view class="safe-space" :style="'height:' + navTop + 'px'"></view><view class="nav-row" :style="'height:' + navHeight + 'px;padding-right:' + navRight + 'px'"><view class="back-button" @tap="goBack"><image src="/static/food-assets/arrow-left-white.svg" mode="aspectFit"></image></view><text class="nav-title">购—{{topicTitle}}</text><view class="capsule-space" :style="'width:' + navWidth + 'px'"></view></view></view> |
| | | <scroll-view class="page-scroll" scroll-y lower-threshold="80" @scroll="handleScroll" @scrolltolower="loadMore"> |
| | | <view class="hero"><image class="hero-image" :src="activeScene.image" mode="aspectFill" @error="replaceImage(activeScene)"></image><view class="hero-top-shade"></view><view class="hero-bottom-shade"></view><text class="scene-name">{{activeScene.name}}</text><scroll-view class="scene-scroll" scroll-x :show-scrollbar="false" scroll-with-animation :scroll-left="sceneScrollLeft" @scrollend="handleSceneScrollEnd"><view class="scene-row" :style="'width:' + sceneTrackWidth + 'rpx'"><view class="scene-pad"></view><view v-for="(item, index) in scenes" :key="item.id" class="scene-item" :class="index == activeIndex ? 'active' : ''" @tap="selectScene(index)"><image :src="item.image" mode="aspectFill" @error="replaceImage(item)"></image></view><view class="scene-pad"></view></view></scroll-view></view> |
| | | <view class="hero"><swiper v-if="scenes.length > 0" class="hero-swiper" :current="activeIndex < 0 ? 0 : activeIndex" :circular="scenes.length > 1" :duration="240" @change="handleHeroChange"><swiper-item v-for="(item, index) in scenes" :key="item.id"><image class="hero-image" :src="item.image" mode="aspectFill" @error="replaceImage(item)"></image></swiper-item></swiper><image v-else class="hero-image" :src="activeScene.image" mode="aspectFill" @error="replaceImage(activeScene)"></image><view class="hero-top-shade"></view><view class="hero-bottom-shade"></view><text class="scene-name">{{activeScene.name}}</text><scroll-view class="scene-scroll" scroll-x :show-scrollbar="false" scroll-with-animation :scroll-left="sceneScrollLeft" @scrollend="handleSceneScrollEnd"><view class="scene-row" :style="'width:' + sceneTrackWidth + 'rpx'"><view class="scene-pad"></view><view v-for="(item, index) in scenes" :key="item.id" class="scene-item" :class="index == activeIndex ? 'active' : ''" @tap="selectScene(index)"><image :src="item.image" mode="aspectFill" @error="replaceImage(item)"></image></view><view class="scene-pad"></view></view></scroll-view></view> |
| | | <view v-if="topicError" class="topic-error"><text>专题配置加载失败</text><text @tap="loadShoppingTopic">重新加载</text></view> |
| | | <view v-if="topicDescription != ''" class="topic-summary"><view class="intro-card"><text>{{topicDescription}}</text></view></view> |
| | | <view class="service-section"> |
| | |
| | | const centerActiveScene = (index: number) => { sceneScrollLeft.value = index * 137 * windowWidth.value / 750 } |
| | | const selectScene = (index: number) => { if (index < 0 || index >= scenes.value.length) return; activeIndex.value = index; activeScene.value = scenes.value[index]; centerActiveScene(index) } |
| | | const handleSceneScrollEnd = (event: any) => { let index = Math.round(event.detail.scrollLeft * 750 / windowWidth.value / 137); if (index < 0) index = 0; if (index >= scenes.value.length) index = scenes.value.length - 1; selectScene(index) } |
| | | /** 主图左右滑动:滑动落点同步到缩略图、名称与描述。 */ |
| | | const handleHeroChange = (event: any) => { const current = event != null && event.detail != null && event.detail.current != null ? Number(event.detail.current) : -1; if (current < 0) return; selectScene(current) } |
| | | const handleScroll = (event: any) => { isSolidNav.value = event.detail.scrollTop > 64 } |
| | | const loadShoppingTopic = () => { |
| | | if (!isApiConfigured()) { topicError.value = true; initialLoading.value = false; initialError.value = true; return } |
| | |
| | | .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,#EE542B); transition:opacity 280ms ease-out; }.custom-nav.is-solid .nav-bg { opacity:1; }.safe-space,.nav-row { position:relative; z-index:1; }.safe-space { height:28px; }.nav-row { display:flex; box-sizing:border-box; padding-left:24rpx; flex-direction:row; align-items:center; justify-content:space-between; }.back-button { display:flex; width:54rpx; height:54rpx; position:relative; z-index:2; border-radius:50%; background-color:rgba(45,45,45,.52); justify-content:center; align-items:center; }.custom-nav.is-solid .back-button { background-color:transparent; }.back-button image { width:38rpx; height:38rpx; }.nav-title { position:absolute; z-index:1; left:0; right:0; color:#FFFFFF; font-size:31rpx; font-weight:500; text-align:center; text-shadow:0 2rpx 8rpx rgba(0,0,0,.35); }.capsule-space { height:100%; flex-shrink:0; } |
| | | .hero { height:730rpx; position:relative; overflow:visible; background-color:#1C315E; }.hero-image { position:absolute; left:0; top:0; width:100%; height:730rpx; }.hero-top-shade { position:absolute; left:0; right:0; top:0; height:245rpx; background-image:linear-gradient(180deg,rgba(0,0,0,.62),rgba(0,0,0,0)); }.hero-bottom-shade { position:absolute; left:0; right:0; bottom:0; height:230rpx; background-image:linear-gradient(180deg,rgba(0,0,0,0),rgba(0,0,0,.42)); }.scene-name { position:absolute; z-index:3; left:38rpx; top:318rpx; color:#FFFFFF; font-family:'FoodCalligraphy'; font-size:48rpx; line-height:66rpx; text-shadow:0 3rpx 10rpx rgba(0,0,0,.55); }.scene-scroll { position:absolute; z-index:4; left:0; right:0; top:469rpx; width:100%; height:150rpx; white-space:nowrap; }.scene-row { display:flex; height:150rpx; flex-direction:row; align-items:center; }.scene-pad { width:322rpx; height:1rpx; flex-shrink:0; }.scene-item { display:flex; width:106rpx; height:106rpx; margin-right:31rpx; box-sizing:border-box; overflow:hidden; position:relative; z-index:1; border:3rpx solid rgba(255,255,255,.9); border-radius:8rpx; flex-shrink:0; transition:transform 180ms ease-out; }.scene-item image { width:100%; height:100%; }.scene-item.active { z-index:3; border-width:4rpx; border-color:#FFD329; transform:scale(1.28); box-shadow:0 8rpx 18rpx rgba(0,0,0,.28); } |
| | | .topic-error { display:flex; height:76rpx; padding:0 31rpx; background-color:#FFF8F4; flex-direction:row; justify-content:center; align-items:center; }.topic-error text { color:#A48A7A; font-size:22rpx; }.topic-error text + text { margin-left:22rpx; color:#F26A38; }.topic-summary { position:relative; z-index:4; margin-top:-66rpx; }.intro-card { margin:0 31rpx; box-sizing:border-box; padding:25rpx 31rpx; border-radius:26rpx; background-image:linear-gradient(90deg,#FF5320,#FF8A22); box-shadow:0 10rpx 22rpx rgba(255,104,28,.24); }.intro-card text { color:#FFFFFF; font-size:23rpx; line-height:34rpx; }.service-section { padding:32rpx 31rpx 0; }.tab-scroll { width:100%; height:78rpx; white-space:nowrap; }.service-tabs { display:flex; height:72rpx; flex-direction:row; align-items:flex-start; }.service-tab { display:flex; width:180rpx; margin-right:30rpx; flex-direction:column; align-items:center; flex-shrink:0; }.service-tab text { color:#8A8D92; font-size:29rpx; line-height:40rpx; font-weight:600; white-space:nowrap; }.service-tab.active text { color:#363636; font-weight:700; }.tab-line { width:0; height:7rpx; margin-top:12rpx; border-radius:4rpx; background-image:linear-gradient(90deg,#FF4D36,#FFBF3A); transition:width 180ms ease-out; }.service-tab.active .tab-line { width:61rpx; } |
| | | /* 主图轮播:与下方缩略图带同高同位,盖在原来的静态主图上。 */ |
| | | .hero-swiper { position:absolute; left:0; top:0; width:100%; height:730rpx; } |
| | | .service-list { padding-top:10rpx; }.service-item { display:flex; min-height:215rpx; margin-bottom:22rpx; flex-direction:row; align-items:flex-start; }.service-item.is-unavailable { opacity:.55; }.service-image { width:184rpx; height:184rpx; border-radius:17rpx; flex-shrink:0; background-color:#EEEEEE; }.service-info { display:flex; min-width:0; min-height:184rpx; flex:1; box-sizing:border-box; padding:4rpx 0 4rpx 24rpx; flex-direction:column; justify-content:space-between; }.service-name { color:#292929; font-size:29rpx; line-height:40rpx; font-weight:700; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.service-discount { align-self:flex-start; max-width:400rpx; padding:6rpx 9rpx; border-radius:6rpx; background-color:#FFF5E7; color:#FF7C2B; font-size:21rpx; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.service-tags { display:flex; min-height:38rpx; flex-direction:row; align-items:center; }.service-tags text { margin-right:9rpx; padding:5rpx 9rpx; border-radius:5rpx; font-size:21rpx; line-height:28rpx; white-space:nowrap; }.coupon-tag { background-color:#FFF0EC; color:#FF5D37; }.service-tag { background-color:#FFF5E7; color:#FF7C2B; }.address-row { display:flex; min-width:0; box-sizing:border-box; padding:7rpx 10rpx; border-radius:7rpx; background-image:linear-gradient(90deg,rgba(239,240,242,.98),rgba(242,243,245,.76) 58%,rgba(255,255,255,0)); flex-direction:row; align-items:flex-start; }.address-row image { width:27rpx; height:27rpx; margin-right:8rpx; flex-shrink:0; }.address-row text { min-width:0; flex:1; color:#7E8490; font-size:21rpx; line-height:31rpx; max-height:62rpx; overflow:hidden; }.service-empty { display:flex; height:310rpx; justify-content:center; align-items:center; }.service-empty text { color:#A5A8AD; font-size:25rpx; }.error-state { flex-direction:column; }.error-state .retry-text { margin-top:22rpx; color:#F26A38; }.loading-row,.no-more { display:flex; height:90rpx; flex-direction:row; justify-content:center; align-items:center; }.loading-spinner { width:28rpx; height:28rpx; margin-right:13rpx; box-sizing:border-box; border:3rpx solid #FFD6C4; border-top-color:#FF6C2B; border-radius:50%; animation:shopping-loading 720ms linear infinite; }.loading-row text { color:#989DA5; font-size:22rpx; }.load-error text { color:#F26A38; }.no-more view { width:70rpx; height:1rpx; background-color:#E7E7E7; }.no-more text { margin:0 18rpx; color:#B1B3B7; font-size:21rpx; }.bottom-space { height:calc(30rpx + env(safe-area-inset-bottom)); }@keyframes shopping-loading { from { transform:rotate(0deg); } to { transform:rotate(360deg); } } |
| | | </style> |
| | |
| | | </view> |
| | | <scroll-view class="page-scroll" scroll-y lower-threshold="80" @scroll="handleScroll" @scrolltolower="loadMore"> |
| | | <view class="hero"> |
| | | <image class="hero-image" :src="currentScene.image" mode="aspectFill" @error="replaceHeroImage"></image> |
| | | <swiper v-if="scenes.length > 0" class="hero-swiper" :current="activeIndex < 0 ? 0 : activeIndex" :circular="scenes.length > 1" :duration="240" @change="handleHeroChange"> |
| | | <swiper-item v-for="(item, index) in scenes" :key="item.id"> |
| | | <image class="hero-image" :src="item.image" mode="aspectFill" @error="replaceSceneImage(index)"></image> |
| | | </swiper-item> |
| | | </swiper> |
| | | <image v-else class="hero-image" :src="currentScene.image" mode="aspectFill" @error="replaceHeroImage"></image> |
| | | <view class="hero-shade"></view><text v-if="currentScene.name != ''" |
| | | class="scene-name">{{currentScene.name}}</text><text v-if="scenes.length == 0" |
| | | class="no-gallery">暂无图册内容</text><scroll-view v-else class="scene-scroll" scroll-x :show-scrollbar="false" |
| | |
| | | const retryInitialLoad = () => loadInitialServices(); const retryLoadMore = () => loadMore() |
| | | const selectScene = (index : number) => { if (index < 0 || index >= scenes.value.length) return; activeIndex.value = index; currentScene.value = scenes.value[index]; sceneScrollLeft.value = index * 137 * windowWidth.value / 750 } |
| | | const handleSceneScrollEnd = (event : any) => { let index = Math.round(event.detail.scrollLeft * 750 / windowWidth.value / 137); if (index < 0) index = 0; if (index >= scenes.value.length) index = scenes.value.length - 1; selectScene(index) } |
| | | /** 主图左右滑动:滑动落点同步到缩略图、名称与描述。 */ |
| | | const handleHeroChange = (event : any) => { const current = event != null && event.detail != null && event.detail.current != null ? Number(event.detail.current) : -1; if (current < 0) return; selectScene(current) } |
| | | const handleScroll = (event : any) => { isSolidNav.value = event.detail.scrollTop > 64 } |
| | | const switchTab = (tab : string) => { if (activeTab.value == tab && hasLoaded.value) return; activeTab.value = tab; loadInitialServices() } |
| | | const loadStayServices = (append : boolean) => { |
| | |
| | | width: 100%; |
| | | height: 590rpx; |
| | | } |
| | | /* 主图轮播:与下方缩略图带同高同位,盖在原来的静态主图上。 */ |
| | | .hero-swiper { |
| | | position: absolute; |
| | | left: 0; |
| | | top: 0; |
| | | width: 100%; |
| | | height: 590rpx; |
| | | } |
| | | .hero-shade { |
| | | position: absolute; |
| | | left: 0; |
| | |
| | | |
| | | <scroll-view class="page-scroll" scroll-y lower-threshold="80" @scroll="handleScroll" @scrolltolower="loadMoreServices"> |
| | | <view class="hero"> |
| | | <image class="hero-image" :src="currentScene.image" mode="aspectFill" @error="replaceHeroImage"></image> |
| | | <swiper v-if="scenes.length > 0" class="hero-swiper" :current="activeIndex < 0 ? 0 : activeIndex" :circular="scenes.length > 1" :duration="240" @change="handleHeroChange"> |
| | | <swiper-item v-for="(item, index) in scenes" :key="item.id"> |
| | | <image class="hero-image" :src="item.image" mode="aspectFill" @error="replaceSceneImage(index)"></image> |
| | | </swiper-item> |
| | | </swiper> |
| | | <image v-else class="hero-image" :src="currentScene.image" mode="aspectFill" @error="replaceHeroImage"></image> |
| | | <view class="hero-top-shade"></view> |
| | | <view class="hero-bottom-shade"></view> |
| | | <text v-if="currentScene.name != ''" class="scene-name">{{currentScene.name}}</text> |
| | |
| | | const retryInitialLoad = () => loadInitialServices(); const retryLoadMore = () => loadMoreServices() |
| | | const selectScene = (index: number) => { if (index < 0 || index >= scenes.value.length) return; activeIndex.value = index; currentScene.value = scenes.value[index]; sceneScrollLeft.value = index * 137 * windowWidth.value / 750 } |
| | | const handleSceneScrollEnd = (event: any) => { let index = Math.round(event.detail.scrollLeft * 750 / windowWidth.value / 137); if (index < 0) index = 0; if (index >= scenes.value.length) index = scenes.value.length - 1; selectScene(index) } |
| | | /** 主图左右滑动:滑动落点同步到缩略图、名称与描述。 */ |
| | | const handleHeroChange = (event: any) => { const current = event != null && event.detail != null && event.detail.current != null ? Number(event.detail.current) : -1; if (current < 0) return; selectScene(current) } |
| | | const handleScroll = (event: any) => { isSolidNav.value = event.detail.scrollTop > 64 } |
| | | const selectTab = (tab: string) => { if (activeTab.value == tab && hasLoaded.value) return; activeTab.value = tab; loadInitialServices() } |
| | | const loadServices = (append: boolean) => { |
| | |
| | | .capsule-space { height: 100%; flex-shrink: 0; } |
| | | .hero { height: 730rpx; position: relative; z-index: 2; overflow: visible; background-color: #6A563E; } |
| | | .hero-image { position: absolute; left: 0; top: 0; width: 100%; height: 730rpx; } |
| | | /* 主图轮播:与下方缩略图带同高同位,盖在原来的静态主图上。 */ |
| | | .hero-swiper { position: absolute; left: 0; top: 0; width: 100%; height: 730rpx; } |
| | | .hero-top-shade { position: absolute; left: 0; right: 0; top: 0; height: 230rpx; background-image: linear-gradient(180deg, rgba(0,0,0,.60) 0%, rgba(0,0,0,.18) 68%, rgba(0,0,0,0) 100%); } |
| | | .hero-bottom-shade { position: absolute; left: 0; right: 0; bottom: 0; height: 230rpx; background-image: linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,.46) 100%); } |
| | | .scene-name { position: absolute; z-index: 3; left: 38rpx; top: 322rpx; color: #FFFFFF; font-family: 'FoodCalligraphy'; font-size: 48rpx; line-height: 66rpx; text-shadow: 0 3rpx 10rpx rgba(0,0,0,.55); } |
| | |
| | | <view class="page"> |
| | | <view class="custom-nav" :class="isSolidNav ? 'is-solid' : ''"><view class="nav-bg"></view><view class="safe-space" :style="'height:' + navTop + 'px'"></view><view class="nav-row" :style="'height:' + navHeight + 'px;padding-right:' + navRight + 'px'"><view class="back-button" hover-class="back-button-active" @tap.stop="goBack"><image src="/static/food-assets/arrow-left-white.svg" mode="aspectFit"></image></view><text class="nav-title">游--悦览花城</text><view class="capsule-space" :style="'width:' + navWidth + 'px'"></view></view></view> |
| | | <scroll-view class="page-scroll" scroll-y lower-threshold="80" @scroll="handleScroll" @scrolltolower="loadMore"> |
| | | <view class="hero"><image class="hero-image" :src="currentScene.image" mode="aspectFill" @error="replaceHeroImage"></image><view class="hero-shade"></view><text v-if="currentScene.name != ''" class="scene-name">{{currentScene.name}}</text><text v-if="scenes.length == 0" class="no-gallery">暂无图册内容</text><scroll-view v-else class="scene-scroll" scroll-x scroll-with-animation :scroll-left="sceneScrollLeft" @scrollend="handleSceneScrollEnd"><view class="scene-row" :style="'width:' + sceneTrackWidth + 'rpx'"><view class="scene-pad"></view><view v-for="(item, index) in scenes" :key="item.id" class="scene-item" :class="index == activeIndex ? 'active' : ''" @tap="selectScene(index)"><image :src="item.image" mode="aspectFill" @error="replaceSceneImage(index)"></image></view><view class="scene-pad"></view></view></scroll-view></view> |
| | | <view class="hero"><swiper v-if="scenes.length > 0" class="hero-swiper" :current="activeIndex < 0 ? 0 : activeIndex" :circular="scenes.length > 1" :duration="240" @change="handleHeroChange"><swiper-item v-for="(item, index) in scenes" :key="item.id"><image class="hero-image" :src="item.image" mode="aspectFill" @error="replaceSceneImage(index)"></image></swiper-item></swiper><image v-else class="hero-image" :src="currentScene.image" mode="aspectFill" @error="replaceHeroImage"></image><view class="hero-shade"></view><text v-if="currentScene.name != ''" class="scene-name">{{currentScene.name}}</text><text v-if="scenes.length == 0" class="no-gallery">暂无图册内容</text><scroll-view v-else class="scene-scroll" scroll-x scroll-with-animation :scroll-left="sceneScrollLeft" @scrollend="handleSceneScrollEnd"><view class="scene-row" :style="'width:' + sceneTrackWidth + 'rpx'"><view class="scene-pad"></view><view v-for="(item, index) in scenes" :key="item.id" class="scene-item" :class="index == activeIndex ? 'active' : ''" @tap="selectScene(index)"><image :src="item.image" mode="aspectFill" @error="replaceSceneImage(index)"></image></view><view class="scene-pad"></view></view></scroll-view></view> |
| | | <view v-if="topicDescription != ''" class="topic-summary"><view class="intro-card"><text>{{topicDescription}}</text></view></view> |
| | | <view class="service-section"><view class="section-heading"><text>景区服务</text><view></view></view><view v-if="initialLoading" class="list-state"><view class="loading-spinner"></view><text>正在加载景区服务</text></view><view v-else-if="initialError" class="list-state error-state"><text>景区服务加载失败</text><text class="retry-text" @tap="retryInitialLoad">重新加载</text></view><view v-else-if="hasLoaded && visibleServices.length == 0" class="list-state"><text>暂无相关景区服务</text></view><view v-else-if="hasLoaded"><view class="service-item" :class="item.available ? '' : 'is-unavailable'" v-for="item in visibleServices" :key="item.id" @tap="openService(item)"><image class="service-image" :src="item.image" mode="aspectFill" @error="replaceServiceImage(item)"></image><view class="service-info"><text class="service-name">{{item.name}}</text><view v-if="!item.available" class="service-tags"><text class="service-discount">已下架</text></view><view v-else-if="item.tags.length > 0" class="service-tags"><text v-for="tag in item.tags" :key="tag" :class="tag == '券' ? 'coupon-tag' : 'service-discount'">{{tag}}</text></view><view class="address"><image src="/static/benefits-assets/map-pin.svg"></image><text>{{item.address}}</text></view></view></view></view><view v-if="loadingMore" class="list-state compact"><view class="loading-spinner"></view><text>正在加载</text></view><view v-else-if="loadMoreError" class="list-state compact load-error" @tap="retryLoadMore"><text>加载失败,点击重试</text></view><view v-else-if="hasLoaded && !hasMore && visibleServices.length > 0" class="no-more"><text>已经到底了</text></view></view><view class="bottom-space"></view> |
| | | </scroll-view> |
| | |
| | | if (index >= scenes.value.length) index = scenes.value.length - 1 |
| | | selectScene(index) |
| | | } |
| | | /** 主图左右滑动:滑动落点同步到缩略图、名称与描述。 */ |
| | | const handleHeroChange = (event: any) => { |
| | | const current = event != null && event.detail != null && event.detail.current != null ? Number(event.detail.current) : -1 |
| | | if (current < 0) return |
| | | selectScene(current) |
| | | } |
| | | const handleScroll = (event: any) => { isSolidNav.value = event.detail.scrollTop > 64 } |
| | | const loadTravelServices = (append: boolean) => { |
| | | if (!isApiConfigured()) { initialLoading.value = false; loadingMore.value = false; initialError.value = true; return } |
| | |
| | | .address { flex-direction: row; align-items: center; } |
| | | .address image { flex-shrink: 0; } |
| | | .address text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } |
| | | /* 主图轮播:与下方缩略图带同高同位,盖在原来的静态主图上。 */ |
| | | .hero-swiper { position: absolute; left: 0; top: 0; width: 100%; height: 730rpx; } |
| | | </style> |
| | |
| | | export const getCinemaCouponClaim = (claimId: string, talentId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appCinemaCouponClaims') + encodeURIComponent(claimId), method: 'GET', skipAuth: true, data: { talentId: talentId } }, onSuccess, onError) |
| | | export const getTicketPackageClaim = (claimId: string, talentId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appTicketPackageClaims') + encodeURIComponent(claimId), method: 'GET', skipAuth: true, data: { talentId: Number(talentId) } }, onSuccess, onError) |
| | | export const getCinemaCodeImageUrl = (claimId: string): string => resolveApiUrl(getApiEndpoint('appBenefitVerificationCodes') + '/' + encodeURIComponent(claimId) + '/image') |
| | | /** 观影权益(票包兑换码)的二维码:内容是后台批量生成、绑影城的那串兑换码。 */ |
| | | export const getTicketPackageCodeImageUrl = (claimId: string): string => resolveApiUrl(getApiEndpoint('appBenefitVerificationCodes') + '/ticket-package-claims/' + encodeURIComponent(claimId) + '/image') |
| | | |
| | | export const searchMerchants = (keyword: string, categoryId: string, mode: string, talentId: string, page: number, size: number, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => { |
| | | const data: any = { keyword: keyword, categoryId: categoryId, mode: mode, page: page, size: size, sortField: 'sort', sortOrder: 'asc' } |
| | |
| | | if (name == 'verificationNumericConfirm') return '/api/merchant/verification/numeric/confirm' |
| | | if (name == 'verificationConfirm') return '/api/merchant/verification/free-stay/confirm' |
| | | if (name == 'verificationFreeStayConfirm') return '/api/merchant/verification/free-stay/confirm' |
| | | if (name == 'benefitVerificationVerify') return '/api/merchant/benefit-verifications/verify' |
| | | if (name == 'manualConsumption') return '/api/merchant/manual-consumptions' |
| | | if (name == 'manualConsumptionQuote') return '/api/merchant/manual-consumptions/quote' |
| | | if (name == 'orderRevokeVerification') return '/api/merchant/orders/revoke-verification' |
| | |
| | | | 场景 | 用户可见提示 | 恢复方式 | |
| | | | --- | --- | --- | |
| | | | 账号未录入 | “账号未录入,请联系管理员” | 核对账号或联系管理员。 | |
| | | | 密码缺失/错误 | “登录密码错误” | 重新输入;正式环境需支持找回或重置密码。 | |
| | | | 密码未录入 | “请输入登录密码” | 补充密码后重新提交。 | |
| | | | 账号或密码错误 | “账号或密码错误”(展示登录接口返回的提示) | 重新输入;正式环境需支持找回或重置密码。后端不区分账号不存在与密码错误,避免账号枚举。 | |
| | | | 未同意协议 | “请先阅读并同意相关协议” | 勾选协议后重新提交。 | |
| | | | 登录接口失败 | 待接口联调后明确 | 保留输入内容,允许用户重试。 | |
| | | | 登录失效 | 待接口联调后明确 | 清理会话并返回登录页。 | |
| | |
| | | | PD-003 | 购买须知 | 有效期、可用时间、使用方式及其他规则均已绑定表单模型。 | 前端已完成 | |
| | | | PD-004 | 人才权益 | 展示各人才卡可享价格、折扣与免费次数。 | 静态演示 | |
| | | | PD-005 | 保存与下架 | 保存会提交审核并自动转为下架待审核;下架使用独立接口,失败时保留原状态;修改失败保留本地草稿。 | 已接入 | |
| | | | PD-006 | 体检医院专属表单 | 商户类型为体检医院时不显示商品金额,改为必填的「可预约时间」(开始/结束日期,原生日期选择器)与检查项目表(名称、项目意义、适用性别);可预约时间按「开始日期 至 结束日期」写入购买须知的 `validPeriod`。 | 已接入 | |
| | | |
| | | ## 接口与数据 |
| | | |
| | |
| | | - [x] 已实现按商品 ID 初始化、表单绑定、金额/必填校验、本地草稿和未保存返回确认。 |
| | | - [x] 已实现封面与详情图片本地选择、压缩、大小和数量校验。 |
| | | - [x] 详情数据、图片上传、保存、下架及表单校验完成接口接入。 |
| | | - [x] 体检医院的可预约时间两个日期选择器可选择并把结果回填到表单(提交前校验必填、结束日期不早于开始日期)。 |
| | |
| | | | SC-004 | 二次确认核销 | 解析成功后显示脱敏持卡人信息并要求确认;确认请求携带幂等键。 | 已接入 | |
| | | | SC-005 | 状态恢复 | 支持核验中、确认中、核销成功、普通失败重试、非当前商户独立失败页和相机拒绝。 | 已完成 | |
| | | | SC-006 | 返回 | 返回时取消当前请求并回到首页。 | 已完成 | |
| | | | SC-007 | 免费入住券的「超过天数」文案 | 扫到酒店免费入住券时,核销页显示「免费入住N天」并附上门店配置的超出规则:到店消费配的是折扣显示「门市价X折」,配的是立减显示「立减X元」,都没配则不显示这一行(`discountText`)。 | 已接入 | |
| | | |
| | | ## 操作说明 |
| | | |
| New file |
| | |
| | | # 观影权益核销(票包兑换码) |
| | | |
| | | ## 页面信息 |
| | | |
| | | - 页面名称:核销输入(观影权益) |
| | | - 路由:`/pages/ticket-package-confirm/ticket-package-confirm` |
| | | - 源码:`pages/ticket-package-confirm/ticket-package-confirm.uvue` |
| | | - 入口:商家端「扫码核销」扫到用户出示的观影权益二维码(`credentialType=TICKET_PACKAGE_CODE`)。 |
| | | - 业务链路:后台批量生成兑换码并绑到影城 → 发到票包 → 人才端领取(出示二维码)→ 影城店员扫码 → 确认核销 → 后台实时可见核销记录。 |
| | | |
| | | ## 功能点 |
| | | |
| | | | 功能 | 说明 | 状态 | |
| | | | --- | --- | --- | |
| | | | 扫码识别 | `POST /api/merchant/verification/scan` 识别票包兑换码,服务端校验:码存在、属于当前商户、状态 `ISSUED`、未过期;不满足时直接给出原因(已核销/已过期/不属于当前商户)。 | 已接入 | |
| | | | 核销确认页 | 展示脱敏持卡人、人才卡等级、票包名称与有效期,底部「确认核销」。 | 已接入 | |
| | | | 核销 | `POST /api/merchant/benefit-verifications/verify`(`benefitType=MOVIE_TICKET` + 兑换码),服务端把票包码池(`ticket_package_code`)与领取记录(`ticket_package_claim`)一起置为 `USED`,并写入核销记录 `benefit_verify_record`。 | 已接入 | |
| | | | 核销成功 | 跳转核销成功页(`credentialType=TICKET_PACKAGE_CODE`)。 | 已接入 | |
| | | |
| | | ## 异常处理 |
| | | |
| | | | 场景 | 表现 | |
| | | | --- | --- | |
| | | | 码已核销 | 服务端返回「该观影权益已核销或已失效」,扫码页提示失败。 | |
| | | | 码已过期 | 服务端返回「该观影权益已过期」。 | |
| | | | 码不属于当前商户 | 扫码统一返回「二维码已失效、不可核销或不属于当前商户」。 | |
| | | | 兑换码缺失(参数异常) | 确认页提示「核销凭证无效,请返回重新扫码」,按钮不可用。 | |
| | | | 核销接口失败 | toast 展示服务端原因(如「电影票核销码已使用或已过期」),可重新扫码。 | |
| | | |
| | | ## 后台可见性 |
| | | |
| | | - 管理后台「电影票管理 → 领取记录」展示领取状态与核销时间(`ticket_package_claim.status/used_at`),核销后即可看到;无需单独页面。 |
| | |
| | | "backgroundColor": "#F7F8FA", |
| | | "disableScroll": true |
| | | } |
| | | }, |
| | | { |
| | | "path": "pages/ticket-package-confirm/ticket-package-confirm", |
| | | "style": { |
| | | "navigationStyle": "custom", |
| | | "backgroundColor": "#F7F8FA", |
| | | "disableScroll": true |
| | | } |
| | | } |
| | | ], |
| | | "globalStyle": { |
| | |
| | | <view class="card-decoration"></view> |
| | | <view class="talent-head"><text class="talent-name">{{customerName}}</text><text class="talent-level">{{cardLevel}}</text></view> |
| | | <text class="talent-mobile">{{displayMobile(customerMobile)}}</text> |
| | | <view class="current-benefit"><text class="benefit-label">当前权益</text><text class="benefit-text">免费入住{{totalCount}}天</text><text v-if="discountText.length > 0" class="benefit-text benefit-extra">超过天数按{{discountText}}</text></view> |
| | | <view class="current-benefit"><text class="benefit-label">当前权益</text><text class="benefit-text">免费入住{{totalCount}}天</text><text v-if="discountText.length > 0" class="benefit-text benefit-extra">超过天数{{discountText}}</text></view> |
| | | </view> |
| | | <view class="method-card"> |
| | | <text class="section-label">优惠方式</text> |
| | |
| | | .current-benefit { display: flex; margin-top: 22rpx; flex-direction: row; align-items: center; position: relative; } |
| | | .benefit-label { color: rgba(255, 255, 255, 0.78); font-size: 23rpx; line-height: 33rpx; } |
| | | .benefit-text { margin-left: 18rpx; color: #FFFFFF; font-size: 23rpx; line-height: 33rpx; font-weight: 600; } |
| | | /* 「超过天数按门市价X折」跟在免费额度后面,间隔比标签再大一点。 */ |
| | | /* 「超过天数门市价X折 / 立减X元」跟在免费额度后面,间隔比标签再大一点。 */ |
| | | .benefit-extra { margin-left: 14rpx; font-weight: 500; } |
| | | .method-card { padding: 31rpx 34rpx; margin-top: 22rpx; border-radius: 30rpx; background-color: #FFFFFF; box-shadow: 0 14rpx 30rpx rgba(57, 67, 80, 0.06); } |
| | | .section-label { display: block; color: #8D99AA; font-size: 27rpx; line-height: 38rpx; } |
| | |
| | | <input class="input-field" type="text" :password="true" maxlength="64" v-model="password" placeholder="请输入登录密码" :placeholder-style="placeholderStyle" @input="clearPasswordError" /> |
| | | </view> |
| | | |
| | | <text v-if="loginError.length > 0" class="login-error">{{loginError}}</text> |
| | | |
| | | <text v-if="agreementError.length > 0" class="agreement-error">{{agreementError}}</text> |
| | | |
| | | <view class="agreement" @tap="toggleAgree"> |
| | |
| | | const agreed = ref(false) |
| | | const accountError = ref('') |
| | | const passwordError = ref('') |
| | | const loginError = ref('') |
| | | const agreementError = ref('') |
| | | const submitting = ref(false) |
| | | const placeholderStyle = 'color:#C0C0C0;font-size:28rpx;' |
| | |
| | | |
| | | const clearAccountError = () => { |
| | | accountError.value = '' |
| | | loginError.value = '' |
| | | } |
| | | |
| | | const clearPasswordError = () => { |
| | | passwordError.value = '' |
| | | loginError.value = '' |
| | | } |
| | | |
| | | const handleLogin = () => { |
| | | if (submitting.value) return |
| | | accountError.value = '' |
| | | passwordError.value = '' |
| | | loginError.value = '' |
| | | agreementError.value = '' |
| | | account.value = account.value.trim() |
| | | let hasError = false |
| | |
| | | hasError = true |
| | | } |
| | | if (password.value.length == 0) { |
| | | passwordError.value = '登录密码错误' |
| | | passwordError.value = '请输入登录密码' |
| | | hasError = true |
| | | } |
| | | if (!agreed.value) { |
| | |
| | | uni.hideLoading() |
| | | submitting.value = false |
| | | if (error.code == 'UNAUTHORIZED') { |
| | | passwordError.value = '登录密码错误' |
| | | loginError.value = error.message != null && error.message.length > 0 ? error.message : '账号或密码错误' |
| | | return |
| | | } |
| | | uni.showToast({ title: error.message != null ? error.message : '登录失败,请稍后重试', icon: 'none' }) |
| | |
| | | .input-icon { width: 34rpx; height: 34rpx; margin-right: 20rpx; flex-shrink: 0; } |
| | | .input-field { flex: 1; height: 104rpx; font-size: 28rpx; color: #313744; } |
| | | |
| | | .login-error { color: #F06A66; font-size: 24rpx; line-height: 34rpx; margin-top: -18rpx; margin-bottom: 20rpx; } |
| | | |
| | | .agreement-error { color: #F06A66; font-size: 24rpx; line-height: 34rpx; margin-top: -18rpx; margin-bottom: 12rpx; } |
| | | |
| | | .agreement { display: flex; flex-direction: row; align-items: center; margin-top: 2rpx; margin-bottom: 32rpx; min-height: 32rpx; } |
| | |
| | | const clearNameError = () => { nameError.value = '' } |
| | | const clearAmountError = () => { amountError.value = '' } |
| | | |
| | | // 体检医院「可预约时间」的两个日期选择器:模板上挂的 @change 一直没有对应处理函数, |
| | | // 选完日期不写回表单,表现就是「选了没生效」。 |
| | | const onReserveStartChange = (event: any) => { |
| | | reserveStartTerm.value = event.detail.value as string |
| | | reserveError.value = '' |
| | | } |
| | | |
| | | const onReserveEndChange = (event: any) => { |
| | | reserveEndTerm.value = event.detail.value as string |
| | | reserveError.value = '' |
| | | } |
| | | |
| | | const changeCover = () => { |
| | | coverError.value = '' |
| | | uni.chooseImage({ |
| | |
| | | }) |
| | | return |
| | | } |
| | | // 观影权益(票包兑换码):后台批量生成、绑影城,用户在小程序领取后出示二维码。 |
| | | if (result.credentialType == 'TICKET_PACKAGE_CODE') { |
| | | openedConfirmation = true |
| | | uni.navigateTo({ |
| | | url: '/pages/ticket-package-confirm/ticket-package-confirm?verificationId=' + encodeURIComponent(result.verificationId) |
| | | + '&verifyCode=' + encodeURIComponent(result.verifyCode) |
| | | + '&customerName=' + encodeURIComponent(result.customerName) |
| | | + '&customerMobile=' + encodeURIComponent(result.customerMobile) |
| | | + '&cardLevel=' + encodeURIComponent(result.cardLevel) |
| | | + '&packageName=' + encodeURIComponent(result.packageName) |
| | | + '&validEnd=' + encodeURIComponent(result.validEnd) |
| | | }) |
| | | return |
| | | } |
| | | openedConfirmation = true |
| | | uni.navigateTo({ |
| | | url: '/pages/verification-confirm/verification-confirm?verificationId=' + encodeURIComponent(result.verificationId) |
| New file |
| | |
| | | <template> |
| | | <view class="page"> |
| | | <view class="navigation-bar"> |
| | | <view class="back-button" @tap="goBack"><image class="back-icon" src="/static/icons/lucide/back-left.svg" mode="aspectFit"></image></view> |
| | | <text class="navigation-title">核销输入</text> |
| | | </view> |
| | | <scroll-view class="content" scroll-y="true" :show-scrollbar="false"> |
| | | <view class="talent-card"> |
| | | <view class="card-decoration"></view> |
| | | <view class="talent-head"><text class="talent-name">{{customerName}}</text><text class="talent-level">{{cardLevel}}</text></view> |
| | | <text class="talent-mobile">{{displayMobile(customerMobile)}}</text> |
| | | <view class="current-benefit"><text class="benefit-label">当前权益</text><text class="benefit-text">{{displayPackageName}}</text></view> |
| | | </view> |
| | | <view class="method-card"> |
| | | <text class="section-label">优惠方式</text> |
| | | <view class="tag-list"><text class="benefit-tag">{{displayPackageName}}</text><text v-if="validEnd.length > 0" class="benefit-tag">有效期至{{validEnd}}</text></view> |
| | | </view> |
| | | <view class="movie-ticket"> |
| | | <view class="ticket-left"><text class="ticket-vertical">观<br/>影<br/>券</text></view> |
| | | <view class="ticket-divider"></view> |
| | | <view class="ticket-right"><text class="ticket-count">一张</text><text class="ticket-free">观影权益</text></view> |
| | | <view class="ticket-notch ticket-notch-left"></view><view class="ticket-notch ticket-notch-right"></view> |
| | | </view> |
| | | <text v-if="validEnd.length > 0" class="remaining-hint">有效期至 {{validEnd}},请在有效期内到店使用</text> |
| | | <view class="content-bottom-space"></view> |
| | | </scroll-view> |
| | | <view class="bottom-bar"><view class="confirm-button" :class="submitting || loadError.length > 0 ? 'button-disabled' : ''" @tap="confirm"><text>{{submitting ? '核销中...' : '确认核销'}}</text></view></view> |
| | | </view> |
| | | </template> |
| | | |
| | | <script setup lang="uts"> |
| | | import { verifyBenefitCode } from '../../services/verification.uts' |
| | | |
| | | const verificationId = ref('') |
| | | const verifyCode = ref('') |
| | | const customerName = ref('持卡人') |
| | | const customerMobile = ref('') |
| | | const cardLevel = ref('人才卡') |
| | | const packageName = ref('') |
| | | const validEnd = ref('') |
| | | const loadError = ref('') |
| | | const submitting = ref(false) |
| | | |
| | | const textOption = (options: any, name: string, fallback: string): string => options != null && options[name] != null && ('' + options[name]).length > 0 ? decodeURIComponent('' + options[name]) : fallback |
| | | const displayMobile = (mobile: string): string => mobile.length < 7 ? mobile : mobile.substring(0, 3) + '****' + mobile.substring(mobile.length - 4) |
| | | const goBack = () => uni.navigateBack() |
| | | const displayPackageName = computed((): string => packageName.value.length > 0 ? packageName.value : '观影权益') |
| | | |
| | | const confirm = () => { |
| | | if (submitting.value || loadError.value.length > 0) return |
| | | uni.showModal({ |
| | | title: '确认核销', |
| | | content: '确认核销本次观影权益吗?核销后不可重复使用。', |
| | | confirmText: '确认核销', |
| | | confirmColor: '#FF6B2C', |
| | | success: (modal: any) => { |
| | | if (!modal.confirm) return |
| | | submitting.value = true |
| | | verifyBenefitCode(verifyCode.value, 'merchant-ticket-package-' + verificationId.value + '-' + Date.now(), (result: any, _traceId: string) => { |
| | | submitting.value = false |
| | | const verifyNo = result != null && result.verifyNo != null ? '' + result.verifyNo : '' |
| | | uni.redirectTo({ url: '/pages/verification-success/verification-success?orderNo=' + encodeURIComponent(verifyNo) + '&credentialType=TICKET_PACKAGE_CODE' }) |
| | | }, (error: any) => { |
| | | submitting.value = false |
| | | uni.showToast({ title: error != null && error.message != null ? '' + error.message : '核销失败,请重试', icon: 'none' }) |
| | | }) |
| | | } |
| | | }) |
| | | } |
| | | |
| | | onLoad((options: any) => { |
| | | verificationId.value = textOption(options, 'verificationId', '') |
| | | verifyCode.value = textOption(options, 'verifyCode', '') |
| | | customerName.value = textOption(options, 'customerName', '持卡人') |
| | | customerMobile.value = textOption(options, 'customerMobile', '') |
| | | cardLevel.value = textOption(options, 'cardLevel', '人才卡') |
| | | packageName.value = textOption(options, 'packageName', '') |
| | | validEnd.value = textOption(options, 'validEnd', '') |
| | | if (verifyCode.value.length == 0) loadError.value = '核销凭证无效,请返回重新扫码' |
| | | }) |
| | | </script> |
| | | |
| | | <style> |
| | | page { background-color: #F7F8FA; } |
| | | .page { display: flex; width: 100%; height: 100vh; flex-direction: column; overflow: hidden; background-color: #F7F8FA; } |
| | | .navigation-bar { display: flex; height: 174rpx; flex: none; justify-content: center; align-items: flex-end; padding-bottom: 25rpx; box-sizing: border-box; position: relative; } |
| | | /* 标题绝对定位居中:bottom 取值让标题中线与返回按钮/胶囊中线对齐(与核销输入页保持一致)。 */ |
| | | .navigation-title { position: absolute; right: 0; bottom: 30rpx; left: 0; z-index: 1; color: #1F2937; font-size: 35rpx; line-height: 49rpx; font-weight: 700; text-align: center; } |
| | | .back-button { display: flex; width: 72rpx; height: 72rpx; justify-content: center; align-items: center; position: absolute; top: 82rpx; left: 28rpx; } |
| | | .back-icon { width: 40rpx; height: 40rpx; } |
| | | .content { height: 0; flex: 1; padding: 20rpx 34rpx 0; box-sizing: border-box; } |
| | | .talent-card { min-height: 238rpx; padding: 34rpx 34rpx 28rpx; border-radius: 38rpx; background-image: linear-gradient(135deg, #C99D32 0%, #F7E69D 55%, #E5BD4E 100%); box-shadow: 0 18rpx 38rpx rgba(188, 139, 37, 0.2); box-sizing: border-box; position: relative; overflow: hidden; } |
| | | .card-decoration { width: 220rpx; height: 220rpx; border: 1rpx solid rgba(255, 255, 255, 0.35); border-radius: 110rpx; position: absolute; top: -72rpx; right: -12rpx; } |
| | | .talent-head { display: flex; flex-direction: row; justify-content: space-between; align-items: center; position: relative; } |
| | | .talent-name { color: #FFFFFF; font-size: 39rpx; line-height: 55rpx; font-weight: 700; } |
| | | .talent-level { padding: 9rpx 20rpx; border-radius: 24rpx; color: #FFFFFF; font-size: 22rpx; line-height: 31rpx; font-weight: 600; background-color: rgba(149, 104, 22, 0.46); } |
| | | .talent-mobile { display: block; margin-top: 15rpx; color: #FFFFFF; font-size: 29rpx; line-height: 41rpx; font-weight: 600; position: relative; } |
| | | .current-benefit { display: flex; margin-top: 22rpx; flex-direction: row; align-items: center; position: relative; } |
| | | .benefit-label { color: rgba(255, 255, 255, 0.78); font-size: 23rpx; line-height: 33rpx; } |
| | | .benefit-text { margin-left: 18rpx; color: #FFFFFF; font-size: 23rpx; line-height: 33rpx; font-weight: 600; } |
| | | .method-card { padding: 31rpx 34rpx; margin-top: 22rpx; border-radius: 30rpx; background-color: #FFFFFF; box-shadow: 0 14rpx 30rpx rgba(57, 67, 80, 0.06); } |
| | | .section-label { display: block; color: #8D99AA; font-size: 27rpx; line-height: 38rpx; } |
| | | .tag-list { display: flex; margin-top: 20rpx; flex-direction: row; flex-wrap: wrap; } |
| | | .benefit-tag { display: flex; min-height: 48rpx; padding: 7rpx 18rpx; margin-right: 12rpx; margin-bottom: 4rpx; justify-content: center; align-items: center; border-radius: 24rpx; color: #FF772C; font-size: 23rpx; line-height: 33rpx; background-color: #FFF0E5; box-sizing: border-box; } |
| | | .movie-ticket { display: flex; height: 210rpx; margin: 40rpx 42rpx 0; flex-direction: row; align-items: center; border-radius: 32rpx; background-image: linear-gradient(100deg, #FF5D25 0%, #FF8A27 100%); box-shadow: 0 18rpx 34rpx rgba(255, 98, 35, 0.23); position: relative; overflow: hidden; } |
| | | .ticket-left { display: flex; width: 134rpx; justify-content: center; align-items: center; } |
| | | .ticket-vertical { color: #FFFFFF; font-size: 28rpx; line-height: 37rpx; font-weight: 700; text-align: center; } |
| | | .ticket-divider { width: 2rpx; height: 144rpx; background-color: rgba(255, 255, 255, 0.3); } |
| | | .ticket-right { display: flex; flex: 1; flex-direction: column; justify-content: center; align-items: center; } |
| | | .ticket-count { color: #FFFFFF; font-size: 43rpx; line-height: 60rpx; font-weight: 700; } |
| | | .ticket-free { color: #FFFFFF; font-size: 26rpx; line-height: 40rpx; font-weight: 600; } |
| | | .ticket-notch { width: 34rpx; height: 34rpx; border-radius: 20rpx; background-color: #F7F8FA; position: absolute; top: 88rpx; } |
| | | .ticket-notch-left { left: -17rpx; } |
| | | .ticket-notch-right { right: -17rpx; } |
| | | .remaining-hint { margin-top: 20rpx; color: #98A2B1; font-size: 23rpx; line-height: 33rpx; text-align: center; } |
| | | .content-bottom-space { height: 180rpx; } |
| | | .bottom-bar { display: flex; height: 154rpx; padding: 20rpx 96rpx 34rpx; background-color: #FFFFFF; box-sizing: border-box; position: fixed; right: 0; bottom: 0; left: 0; z-index: 10; } |
| | | .confirm-button { display: flex; height: 94rpx; flex: 1; justify-content: center; align-items: center; border-radius: 48rpx; color: #FFFFFF; font-size: 29rpx; line-height: 41rpx; font-weight: 700; background-image: linear-gradient(90deg, #FF7625 0%, #FF962F 100%); box-shadow: 0 12rpx 24rpx rgba(255, 107, 36, 0.22); } |
| | | .button-disabled { opacity: 0.6; box-shadow: none; } |
| | | </style> |
| | |
| | | traceId: '' |
| | | }) |
| | | |
| | | // 登录请求(skipAuth)的 401 是账号密码校验失败,要展示后端给的失败原因; |
| | | // 其余请求的 401 才是会话失效。 |
| | | const unauthorizedMessage = (skipAuth: boolean | null, payload: any): string => { |
| | | if (skipAuth == true && payload != null && payload.message != null && ('' + payload.message).length > 0) { |
| | | return '' + payload.message |
| | | } |
| | | return '登录已失效,请重新登录' |
| | | } |
| | | |
| | | const redirectToLogin = () => { |
| | | if (unauthorizedRedirecting) return |
| | | unauthorizedRedirecting = true |
| | |
| | | return |
| | | } |
| | | clearSession() |
| | | const error: HttpError = { code: 'UNAUTHORIZED', message: '登录已失效,请重新登录', statusCode: 401, traceId: traceId } |
| | | const error: HttpError = { code: 'UNAUTHORIZED', message: unauthorizedMessage(options.skipAuth, payload), statusCode: 401, traceId: traceId } |
| | | onError(error) |
| | | if (!options.skipUnauthorizedRedirect) redirectToLogin() |
| | | return |
| | |
| | | /** 免费入住:超过免费天数后的折扣文案(如「门市价9折」,没配折扣时为空)。 */ |
| | | discountText: string |
| | | benefitText: string |
| | | /** 观影权益(票包兑换码):核销时回传的兑换码、票包名与有效期。 */ |
| | | verifyCode: string |
| | | packageName: string |
| | | merchantName: string |
| | | validEnd: string |
| | | } |
| | | |
| | | export type VerificationConfirmResult = { |
| | |
| | | freeStayTotalCount: data.freeStayTotalCount != null ? Number(data.freeStayTotalCount) : 0, |
| | | freeStayRemainingCount: data.freeStayRemainingCount != null ? Number(data.freeStayRemainingCount) : 0, |
| | | discountText: data.discountText != null ? '' + data.discountText : '', |
| | | benefitText: data.benefitText != null ? '' + data.benefitText : '' |
| | | benefitText: data.benefitText != null ? '' + data.benefitText : '', |
| | | verifyCode: data.verifyCode != null ? '' + data.verifyCode : '', |
| | | packageName: data.packageName != null ? '' + data.packageName : '', |
| | | merchantName: data.merchantName != null ? '' + data.merchantName : '', |
| | | validEnd: data.validEnd != null ? '' + data.validEnd : '' |
| | | } |
| | | onSuccess(result, traceId) |
| | | }, onError) |
| | | } |
| | | |
| | | /** |
| | | * 观影权益核销:票包兑换码(后台批量生成、绑影城)在现场扫码核销。 |
| | | * |
| | | * 走的是后台「权益核销」接口:它会校验码属于当前商户、未核销、未过期,并把票包码池与领取记录 |
| | | * 一起置为已核销,同时写下核销记录(后台「电影票管理-领取记录」能实时看到)。 |
| | | */ |
| | | export const verifyBenefitCode = ( |
| | | verifyCode: string, |
| | | idempotencyKey: string, |
| | | onSuccess: (result: VerificationConfirmResult, traceId: string) => void, |
| | | onError: (error: HttpError) => void |
| | | ): (() => void) => request<VerificationConfirmResult>({ |
| | | path: getApiEndpoint('benefitVerificationVerify'), |
| | | method: 'POST', |
| | | idempotencyKey: idempotencyKey, |
| | | data: { benefitType: 'MOVIE_TICKET', verifyCode: verifyCode } |
| | | }, onSuccess, onError) |
| | | |
| | | /** 免费入住券核销:不涉及支付,只扣券次数。订单核销走 confirmMerchantOrderVerification。 */ |
| | | export const confirmVerificationCode = ( |
| | | verificationId: string, |
| | |
| | | return detail.value?.orderStatus === 1 && detail.value?.verifyStatus !== 1 |
| | | } |
| | | |
| | | // 已成功 + 退款中的金额都算「已经退掉的」,剩下的才是可退金额(与后端 prepare 的口径一致)。 |
| | | const refundedAmount = computed(() => refunds.value |
| | | .filter((item: Detail) => Number(item.refundStatus) === 1 || Number(item.refundStatus) === 2) |
| | | .reduce((sum: number, item: Detail) => sum + Number(item.refundAmount || 0), 0)) |
| | | const refundableAmount = computed(() => Math.max(0, Number(detail.value?.payAmount || 0) - refundedAmount.value)) |
| | | |
| | | async function load() { |
| | | const orderNo = String(route.params.orderNo || '') |
| | | if (!orderNo) return |
| | |
| | | } |
| | | |
| | | async function refund() { |
| | | if (!detail.value?.orderNo) return |
| | | const defaultAmount = (Number(detail.value.payAmount || 0) / 100).toFixed(2) |
| | | const { value } = await ElMessageBox.prompt('请输入退款金额(元)', '订单退款', { |
| | | inputValue: defaultAmount, |
| | | inputPattern: /^(0|[1-9]\d*)(\.\d{1,2})?$/, |
| | | inputErrorMessage: '请输入正确的金额', |
| | | }) |
| | | await refundOrder(detail.value.orderNo, Number(value)) |
| | | const orderNo = detail.value?.orderNo |
| | | if (!orderNo) return |
| | | // 实付 0 元的订单(整单被权益免费额度覆盖)在通道里没有钱可退:要还回去的是这一单占用的免费额度, |
| | | // 金额只能是 0。弹一个让人填金额的框,填出任何大于 0 的数都会被后端按「退款金额非法」拒掉。 |
| | | if (Number(detail.value?.payAmount || 0) <= 0) { |
| | | try { |
| | | await ElMessageBox.confirm('该订单实付 0 元(由人才权益免费额度覆盖),确认退款将退回本单占用的免费额度。', '订单退款', { type: 'warning' }) |
| | | } catch { |
| | | return |
| | | } |
| | | await refundOrder(orderNo, 0) |
| | | await load() |
| | | ElMessage.success('退款成功,已退回占用的免费额度') |
| | | return |
| | | } |
| | | const refundable = refundableAmount.value |
| | | if (refundable <= 0) { |
| | | ElMessage.warning('该订单已无可退金额') |
| | | return |
| | | } |
| | | let value = '' |
| | | try { |
| | | const result = await ElMessageBox.prompt(`本单可退 ${yuan(refundable)},请输入退款金额(元)`, '订单退款', { |
| | | inputValue: (refundable / 100).toFixed(2), |
| | | inputPattern: /^(0|[1-9]\d*)(\.\d{1,2})?$/, |
| | | inputErrorMessage: '请输入正确的金额', |
| | | inputValidator: (input: string) => Number(input) > 0 && Number(input) <= refundable / 100 |
| | | ? true |
| | | : `退款金额需大于 0 且不超过 ${yuan(refundable)}`, |
| | | }) |
| | | value = String(result.value ?? '') |
| | | } catch { |
| | | return |
| | | } |
| | | await refundOrder(orderNo, Number(value)) |
| | | await load() |
| | | ElMessage.success('退款申请已提交') |
| | | } |
| | |
| | | ensureMerchantExists(request.merchantId()); |
| | | validateProductConfiguration(request.merchantId(), request.salePrice(), request.productImages()); |
| | | validateProductRights(request.merchantId(), request.rightsConfig()); |
| | | Long productId = resolveProductId(id); |
| | | BigDecimal subsidyAmount = valueOrZero(request.subsidyAmount()); |
| | | int updated = jdbcTemplate.update(""" |
| | | update merchant_product |
| | | set merchant_id = ?, product_type = ?, product_name = ?, subtitle = ?, cover_url = ?, |
| | | original_price = ?, sale_price = ?, subsidy_amount = ?, is_government_subsidy = ?, |
| | | stock = ?, detail_text = ?, benefit_desc = ?, purchase_notice = ?, valid_start_time = ?, |
| | | valid_end_time = ?, product_images = ?, rights_config = ?, status = 0, sort_no = ?, |
| | | audit_status = 1, audit_remark = null, audit_time = null, submit_time = now() |
| | | where deleted = 0 and id = ? |
| | | """, |
| | | int updated = jdbcTemplate.update(""" |
| | | update merchant_product |
| | | set merchant_id = ?, product_type = ?, product_name = ?, subtitle = ?, cover_url = ?, |
| | | original_price = ?, sale_price = ?, subsidy_amount = ?, is_government_subsidy = ?, |
| | | stock = ?, detail_text = ?, benefit_desc = ?, purchase_notice = ?, valid_start_time = ?, |
| | | valid_end_time = ?, product_images = ?, rights_config = ?, status = 0, sort_no = ?, |
| | | audit_status = 1, audit_remark = null, audit_time = null, submit_time = now() |
| | | where deleted = 0 and id = ? |
| | | """, |
| | | request.merchantId(), |
| | | request.productType(), |
| | | request.productName(), |
| | |
| | | toJson(request.productImages()), |
| | | toJson(request.rightsConfig()), |
| | | request.sortNo() == null ? 0 : request.sortNo(), |
| | | id); |
| | | productId); |
| | | ensureUpdated(updated); |
| | | appendProductAuditRecord(id); |
| | | return product(id); |
| | | appendProductAuditRecord(productId); |
| | | return product(productId); |
| | | } |
| | | |
| | | @Transactional |
| | | public Map<String, Object> updateProductStatus(Long id, Byte status) { |
| | | Number auditStatus = (Number) product(id).get("auditStatus"); |
| | | Long productId = resolveProductId(id); |
| | | Number auditStatus = (Number) product(productId).get("auditStatus"); |
| | | if (auditStatus == null || auditStatus.intValue() != 2) { |
| | | throw new BusinessException(ErrorCode.BAD_REQUEST, "Only approved products can be listed or unlisted"); |
| | | throw new BusinessException(ErrorCode.BAD_REQUEST, "只有审核通过的团单才能上架或下架"); |
| | | } |
| | | int updated = jdbcTemplate.update("update merchant_product set status = ? where deleted = 0 and id = ?", status, id); |
| | | int updated = jdbcTemplate.update("update merchant_product set status = ? where deleted = 0 and id = ?", status, productId); |
| | | ensureUpdated(updated); |
| | | return product(id); |
| | | return product(productId); |
| | | } |
| | | |
| | | @Transactional |
| | | public Map<String, Object> deleteProduct(Long id) { |
| | | Map<String, Object> existing = product(id); |
| | | int updated = jdbcTemplate.update("update merchant_product set deleted = 1 where deleted = 0 and id = ?", id); |
| | | Long productId = resolveProductId(id); |
| | | Map<String, Object> existing = product(productId); |
| | | int updated = jdbcTemplate.update("update merchant_product set deleted = 1 where deleted = 0 and id = ?", productId); |
| | | ensureUpdated(updated); |
| | | return existing; |
| | | } |
| | | |
| | | @Transactional |
| | | public Map<String, Object> auditProduct(Long id, ProductAuditRequest request) { |
| | | Long productId = resolveProductId(id); |
| | | List<Map<String, Object>> pendingRecords = DatabaseRows.normalize(jdbcTemplate.queryForList(""" |
| | | select id from merchant_product_audit_record |
| | | where product_id = ? and deleted = 0 and audit_status = 1 |
| | | order by id desc limit 1 |
| | | """, id)); |
| | | """, productId)); |
| | | if (!pendingRecords.isEmpty()) { |
| | | return auditProductAuditRecord(Long.valueOf(String.valueOf(pendingRecords.get(0).get("id"))), request); |
| | | } |
| | | if (request.auditStatus() != 2 && request.auditStatus() != 3) { |
| | | throw new BusinessException(ErrorCode.BAD_REQUEST, "Invalid audit status"); |
| | | } |
| | | Number currentAuditStatus = (Number) product(id).get("auditStatus"); |
| | | Number currentAuditStatus = (Number) product(productId).get("auditStatus"); |
| | | if (currentAuditStatus == null || currentAuditStatus.intValue() != 1) { |
| | | throw new BusinessException(ErrorCode.BAD_REQUEST, "Product is not pending audit"); |
| | | } |
| | |
| | | update merchant_product |
| | | set audit_status = ?, audit_remark = ?, audit_time = now(), status = ? |
| | | where deleted = 0 and id = ? |
| | | """, request.auditStatus(), request.auditRemark(), request.auditStatus() == 2 ? 1 : 0, id); |
| | | """, request.auditStatus(), request.auditRemark(), request.auditStatus() == 2 ? 1 : 0, productId); |
| | | ensureUpdated(updated); |
| | | return product(id); |
| | | return product(productId); |
| | | } |
| | | |
| | | @Transactional |
| | |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 团单在后台有两个 id 口径:商家编辑页的团单列表给的是商品 id,团单审核列表给的是审核记录 id。 |
| | | * 两个都认——否则拿另一口径的 id 去改团单,就只会在 update 影响 0 行之后抛出英文的 |
| | | * {@code Data not found},现场完全看不出是"id 口径不一致"。 |
| | | */ |
| | | private Long resolveProductId(Long id) { |
| | | if (id == null) { |
| | | throw new BusinessException(ErrorCode.BAD_REQUEST, "团单参数不能为空"); |
| | | } |
| | | List<Long> direct = jdbcTemplate.queryForList( |
| | | "select id from merchant_product where deleted = 0 and id = ?", |
| | | Long.class, |
| | | id); |
| | | if (!direct.isEmpty()) { |
| | | return direct.get(0); |
| | | } |
| | | List<Long> byAuditRecord = jdbcTemplate.queryForList(""" |
| | | select p.id |
| | | from merchant_product_audit_record r |
| | | join merchant_product p on p.id = r.product_id and p.deleted = 0 |
| | | where r.deleted = 0 and r.id = ? |
| | | limit 1 |
| | | """, Long.class, id); |
| | | if (!byAuditRecord.isEmpty()) { |
| | | return byAuditRecord.get(0); |
| | | } |
| | | throw new BusinessException(ErrorCode.NOT_FOUND, "团单不存在或已被删除,请刷新列表后重试"); |
| | | } |
| | | |
| | | private void ensureMerchantExists(Long merchantId) { |
| | | Long count = jdbcTemplate.queryForObject( |
| | | "select count(*) from merchant where deleted = 0 and id = ?", |
| | |
| | | package com.talentcard.server.business.admin.auth; |
| | | |
| | | import java.io.IOException; |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.util.List; |
| | | import java.util.Locale; |
| | | import java.util.Map; |
| | | import java.util.regex.Matcher; |
| | | import java.util.regex.Pattern; |
| | | |
| | | import com.talentcard.server.common.ApiResponse; |
| | | import com.talentcard.server.common.BusinessException; |
| | | import jakarta.servlet.http.HttpServletRequest; |
| | | import jakarta.servlet.http.HttpServletResponse; |
| | | import org.springframework.http.HttpHeaders; |
| | | import org.springframework.http.HttpStatus; |
| | | import org.springframework.http.MediaType; |
| | | import org.springframework.jdbc.core.JdbcTemplate; |
| | | import org.springframework.stereotype.Component; |
| | | import org.springframework.web.servlet.HandlerInterceptor; |
| | | import org.springframework.web.server.ResponseStatusException; |
| | | import org.springframework.http.HttpStatus; |
| | | import tools.jackson.databind.ObjectMapper; |
| | | |
| | | /** |
| | | * 管理后台的登录与页面权限拦截。 |
| | | * |
| | | * <p>鉴权失败时直接写响应,不再抛异常:仓库里有一批按控制器限定的 {@code @RestControllerAdvice} |
| | | * ({@code com.talentcard.server.config} 下的 {@code *ExceptionHandler}),会把落在自己控制器上的 |
| | | * 任何异常统一改写成 500「服务器内部错误 / 某某处理失败」。权限不足这类本该是 403 的响应被它们吞掉 |
| | | * 之后,前端既看不出原因,也不会跳回登录页。直接写响应可以绕开这些兜底,保证状态码和提示语如实送达。 |
| | | * |
| | | * <p>权限码按「这请求属于哪个页面」推导,并且只使用角色管理里真正能勾选的权限码——否则员工账号 |
| | | * 配好了权限也进不去对应模块(例如体检预约的接口曾要求一个角色界面根本不提供的权限码)。 |
| | | */ |
| | | @Component |
| | | public class AdminAuthInterceptor implements HandlerInterceptor { |
| | | public static final String OPERATOR_ID_ATTRIBUTE = "authenticatedAdminOperatorId"; |
| | | public static final String OPERATOR_NAME_ATTRIBUTE = "authenticatedAdminOperatorName"; |
| | | |
| | | private final AdminAuthService service; |
| | | private static final String FORBIDDEN_MESSAGE = "当前账号没有该操作权限"; |
| | | |
| | | public AdminAuthInterceptor(AdminAuthService service) { |
| | | /** 服务预约申请是「一个接口服务多个页面」(按 applicationType 区分),各归到自己的页面权限。 */ |
| | | private static final Map<String, String> SERVICE_APPLICATION_PAGES = Map.of( |
| | | "MEDICAL_RESERVATION", "medical-reservations", |
| | | "MEDICAL_APPLICATION", "medical-applications", |
| | | "FUEL_SERVICE", "fuel-services", |
| | | "HOTEL_RESERVATION", "hotel-reservations", |
| | | "SPOUSE_EMPLOYMENT", "spouse-employment", |
| | | "PARKING_PLATE", "parking-plates" |
| | | ); |
| | | private static final Pattern SERVICE_APPLICATION_ID = |
| | | Pattern.compile("^/api/admin/service-appointment-applications/(\\d+)(/.*)?$"); |
| | | |
| | | /** 家属卡接口同时挂在「家属卡申请」和「家属卡管理」两个页面下,两边对应的权限都认。 */ |
| | | private static final List<String> FAMILY_CARD_VIEW_PERMISSIONS = |
| | | List.of("admin.family-card-applications.view", "admin.family-cards.view"); |
| | | private static final List<String> FAMILY_CARD_UNBIND_PERMISSIONS = |
| | | List.of("admin.family-card-applications.audit", "admin.family-cards.disable"); |
| | | |
| | | private final AdminAuthService service; |
| | | private final JdbcTemplate jdbc; |
| | | private final ObjectMapper objectMapper; |
| | | |
| | | public AdminAuthInterceptor(AdminAuthService service, JdbcTemplate jdbc, ObjectMapper objectMapper) { |
| | | this.service = service; |
| | | this.jdbc = jdbc; |
| | | this.objectMapper = objectMapper; |
| | | } |
| | | |
| | | @Override |
| | | public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { |
| | | Long operatorId = service.authenticate(request.getHeader(HttpHeaders.AUTHORIZATION)); |
| | | public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException { |
| | | Long operatorId; |
| | | String operatorName; |
| | | try { |
| | | operatorId = service.authenticate(request.getHeader(HttpHeaders.AUTHORIZATION)); |
| | | operatorName = service.operatorName(operatorId); |
| | | } catch (BusinessException exception) { |
| | | write(response, exception.errorCode().code(), exception.getMessage()); |
| | | return false; |
| | | } |
| | | request.setAttribute(OPERATOR_ID_ATTRIBUTE, operatorId); |
| | | request.setAttribute(OPERATOR_NAME_ATTRIBUTE, service.operatorName(operatorId)); |
| | | String permission = permission(request.getMethod(), request.getRequestURI()); |
| | | if (!permission.isBlank() && !service.hasPermission(operatorId, permission)) { |
| | | if (request.getRequestURI().startsWith("/api/admin/management/merchant-settlement/onboardings")) { |
| | | throw new com.talentcard.server.common.BusinessException(com.talentcard.server.common.ErrorCode.FORBIDDEN, "当前账号没有该操作权限"); |
| | | } |
| | | throw new ResponseStatusException(HttpStatus.FORBIDDEN, "当前账号没有该操作权限"); |
| | | request.setAttribute(OPERATOR_NAME_ATTRIBUTE, operatorName); |
| | | List<String> permissions = permissions(request); |
| | | if (!permissions.isEmpty() && !service.hasAnyPermission(operatorId, permissions)) { |
| | | write(response, HttpStatus.FORBIDDEN.value(), FORBIDDEN_MESSAGE); |
| | | return false; |
| | | } |
| | | return true; |
| | | } |
| | | |
| | | private String permission(String method, String uri) { |
| | | private void write(HttpServletResponse response, int code, String message) throws IOException { |
| | | response.setStatus(code); |
| | | response.setCharacterEncoding(StandardCharsets.UTF_8.name()); |
| | | response.setContentType(MediaType.APPLICATION_JSON_VALUE); |
| | | objectMapper.writeValue(response.getWriter(), |
| | | ApiResponse.failure(code, message == null || message.isBlank() ? FORBIDDEN_MESSAGE : message)); |
| | | } |
| | | |
| | | /** 本次请求允许的权限码;返回空表示这个接口不校验页面权限。 */ |
| | | private List<String> permissions(HttpServletRequest request) { |
| | | String method = request.getMethod(); |
| | | String uri = request.getRequestURI(); |
| | | if (uri.startsWith("/api/admin/management/merchant-settlement/onboardings")) { |
| | | return "admin.merchant-settlement." + ("GET".equalsIgnoreCase(method) ? "view" : "edit"); |
| | | return List.of("admin.merchant-settlement." + ("GET".equalsIgnoreCase(method) ? "view" : "edit")); |
| | | } |
| | | if (uri.startsWith("/api/admin/management/roles")) { |
| | | return managementPermission(method, uri, "roles"); |
| | | return only(managementPermission(method, uri, "roles")); |
| | | } |
| | | if (uri.startsWith("/api/admin/management/staff")) { |
| | | return managementPermission(method, uri, "staff"); |
| | | return only(managementPermission(method, uri, "staff")); |
| | | } |
| | | if (uri.startsWith("/api/admin/service-appointment-applications")) { |
| | | return serviceApplicationPermissions(request); |
| | | } |
| | | if (uri.startsWith("/api/admin/dependent-card-applies")) { |
| | | return dependentCardPermissions(method, uri); |
| | | } |
| | | String page = page(uri); |
| | | if (page.isBlank()) return ""; |
| | | String action = "GET".equalsIgnoreCase(method) ? "view" : "edit"; |
| | | if ("POST".equalsIgnoreCase(method)) action = "create"; |
| | | if ("DELETE".equalsIgnoreCase(method)) action = "delete"; |
| | | return page.isBlank() ? List.of() : only(pagePermission(method, uri, page)); |
| | | } |
| | | |
| | | /** 服务预约申请:列表/详情看模块的「查看」,导出看「下载」,处理/驳回看「审核」。 */ |
| | | private List<String> serviceApplicationPermissions(HttpServletRequest request) { |
| | | String method = request.getMethod(); |
| | | String uri = request.getRequestURI(); |
| | | String action = uri.endsWith("/export") ? "export" |
| | | : "PATCH".equalsIgnoreCase(method) ? "audit" : "view"; |
| | | String page = serviceApplicationPage(request); |
| | | // 归属不出来的(历史数据、通用页)沿用原来的 appointment-management 权限,避免放开成不校验。 |
| | | if (page.isBlank()) page = "appointment-management"; |
| | | return List.of("admin." + page + "." + action); |
| | | } |
| | | |
| | | private String serviceApplicationPage(HttpServletRequest request) { |
| | | String type = request.getParameter("applicationType"); |
| | | if (type == null || type.isBlank()) return applicationTypeById(request.getRequestURI()); |
| | | return SERVICE_APPLICATION_PAGES.getOrDefault(type.trim().toUpperCase(Locale.ROOT), ""); |
| | | } |
| | | |
| | | /** 详情、处理这类只带 id 的请求拿不到 applicationType,回表查一次才知道属于哪个模块。 */ |
| | | private String applicationTypeById(String uri) { |
| | | Matcher matcher = SERVICE_APPLICATION_ID.matcher(uri); |
| | | if (!matcher.matches()) return ""; |
| | | List<String> types = jdbc.queryForList( |
| | | "select application_type from service_application where id = ? and deleted = 0 limit 1", |
| | | String.class, Long.valueOf(matcher.group(1))); |
| | | if (types.isEmpty()) return ""; |
| | | String type = types.get(0); |
| | | return type == null ? "" : SERVICE_APPLICATION_PAGES.getOrDefault(type.trim().toUpperCase(Locale.ROOT), ""); |
| | | } |
| | | |
| | | private List<String> dependentCardPermissions(String method, String uri) { |
| | | if (uri.endsWith("/unbind")) return FAMILY_CARD_UNBIND_PERMISSIONS; |
| | | if (uri.endsWith("/audit")) return List.of("admin.family-card-applications.audit"); |
| | | if ("GET".equalsIgnoreCase(method)) return FAMILY_CARD_VIEW_PERMISSIONS; |
| | | return List.of("admin.family-card-applications.edit"); |
| | | } |
| | | |
| | | private String pagePermission(String method, String uri, String page) { |
| | | if ("GET".equalsIgnoreCase(method)) return "admin." + page + ".view"; |
| | | if ("POST".equalsIgnoreCase(method)) return "admin." + page + ".create"; |
| | | if ("DELETE".equalsIgnoreCase(method)) return "admin." + page + ".delete"; |
| | | if ("PATCH".equalsIgnoreCase(method)) { |
| | | action = uri.endsWith("/audit") || uri.endsWith("/status") ? (uri.endsWith("/audit") ? "audit" : "disable") : "edit"; |
| | | String action = uri.endsWith("/audit") ? "audit" : uri.endsWith("/status") ? "disable" : "edit"; |
| | | return "admin." + page + "." + action; |
| | | } |
| | | return "admin." + page + "." + action; |
| | | return "admin." + page + ".edit"; |
| | | } |
| | | |
| | | private String page(String uri) { |
| | |
| | | else return ""; |
| | | return "admin." + page + "." + action; |
| | | } |
| | | |
| | | /** 空权限码表示「这个请求不校验页面权限」,不能当成一个空字符串去比权限。 */ |
| | | private List<String> only(String permission) { |
| | | return permission == null || permission.isBlank() ? List.of() : List.of(permission); |
| | | } |
| | | } |
| | |
| | | |
| | | @Transactional(readOnly = true) |
| | | public boolean hasPermission(Long operatorId, String permission) { |
| | | return hasAnyPermission(operatorId, List.of(permission)); |
| | | } |
| | | |
| | | /** |
| | | * 一个接口可能同时服务多个页面(例如家属卡接口既挂在「家属卡申请」也挂在「家属卡管理」下), |
| | | * 这时只要命中其中任意一个页面权限就放行。 |
| | | */ |
| | | @Transactional(readOnly = true) |
| | | public boolean hasAnyPermission(Long operatorId, List<String> permissions) { |
| | | if (permissions.isEmpty()) return true; |
| | | List<Map<String, Object>> result = rows(""" |
| | | select o.role_code, r.permission_codes |
| | | from admin_operator o left join admin_role r on r.role_code = o.role_code |
| | |
| | | if ("platform_admin".equals(String.valueOf(operator.get("roleCode")))) return true; |
| | | String raw = String.valueOf(operator.getOrDefault("permissionCodes", "")); |
| | | try { |
| | | return JSON.parseArray(raw, String.class).contains(permission); |
| | | List<String> granted = JSON.parseArray(raw, String.class); |
| | | return permissions.stream().anyMatch(granted::contains); |
| | | } catch (Exception ignored) { |
| | | return false; |
| | | } |
| | |
| | | } |
| | | |
| | | /** |
| | | * 免费入住券要显示「超过免费天数按门市价X折」:X 就是这家店给该人才卡配的到店消费折扣 |
| | | * (走和到店消费页同一套解析,结构化配置和旧的门店规则表都能取到)。 |
| | | * 免费入住券要显示「超过免费天数按门店配置的到店消费规则计算」:门店给该人才卡配了折扣就是 |
| | | * 「门市价X折」,配的是立减就是「立减X元」(走和到店消费页同一套解析,结构化配置和旧的门店 |
| | | * 规则表都能取到)。 |
| | | * |
| | | * <p>没配折扣、或配的是立减时返回空串,调用方据此不显示这段说明。 |
| | | * <p>以前只认折扣,酒店把到店消费配成「优惠(立减)」时整段是空的——而酒店最常见的配法恰恰 |
| | | * 是立减(见商家权益里的到店消费二选一),页面上就什么都不显示,看起来像功能没做。 |
| | | * |
| | | * <p>都没有配时返回空串,调用方据此不显示这段说明。 |
| | | */ |
| | | String freeStayExceedDiscountText(Long merchantId, String cardLevel, String sourceAreaType) { |
| | | for (Map<String, Object> rule : rules(merchantId, cardLevel, sourceAreaType)) { |
| | | BigDecimal rate = decimal(rule.get("discountRate")); |
| | | if (rate.signum() > 0) return "门市价" + rate.movePointRight(1).stripTrailingZeros().toPlainString() + "折"; |
| | | String text = exceedDiscountText(rule); |
| | | if (!text.isBlank()) return text; |
| | | } |
| | | return ""; |
| | | } |
| | | |
| | | /** 单条规则的「超过免费天数」文案:折扣优先,没配折扣(门店选的是立减)时用立减。 */ |
| | | String exceedDiscountText(Map<String, Object> rule) { |
| | | BigDecimal rate = rule.get("discountRate") == null ? BigDecimal.ZERO : decimal(rule.get("discountRate")); |
| | | if (rate.signum() > 0) return "门市价" + rate.movePointRight(1).stripTrailingZeros().toPlainString() + "折"; |
| | | BigDecimal reduction = rule.get("fixedReduction") == null ? BigDecimal.ZERO : decimal(rule.get("fixedReduction")); |
| | | if (reduction.signum() > 0) return "立减" + reduction.stripTrailingZeros().toPlainString() + "元"; |
| | | return ""; |
| | | } |
| | | |
| | | boolean hasApplicableRule(Long merchantId, String cardLevel, String sourceAreaType) { |
| | | return !rules(merchantId, cardLevel, sourceAreaType).isEmpty(); |
| | | } |
| | |
| | | private static final String TALENT_CODE = "TALENT_CODE"; |
| | | private static final String ORDER_CODE = "ORDER_CODE"; |
| | | private static final String FREE_STAY_CODE = "FREE_STAY_CODE"; |
| | | /** 观影权益(票包兑换码):后台批量生成、绑影城、用户领取后出示二维码,店员扫码核销。 */ |
| | | private static final String TICKET_PACKAGE_CODE = "TICKET_PACKAGE_CODE"; |
| | | |
| | | /** |
| | | * 6 位数字核销码:顾客屏幕上的数字码,相机不好用或屏幕反光时手输兜底。 |
| | |
| | | Map<String, Object> freeStayCode = findFreeStayCode(token, principal); |
| | | if (freeStayCode != null) { |
| | | return createFreeStayAttempt(principal, freeStayCode); |
| | | } |
| | | Map<String, Object> ticketPackageCode = findTicketPackageCode(token, principal); |
| | | if (ticketPackageCode != null) { |
| | | return createTicketPackageAttempt(token, ticketPackageCode); |
| | | } |
| | | throw new BusinessException(ErrorCode.CONFLICT, "二维码已失效、不可核销或不属于当前商户"); |
| | | } |
| | |
| | | return result; |
| | | } |
| | | |
| | | /** |
| | | * 观影权益兑换码:票包后台批量生成、绑到影城、用户在小程序领取后出示二维码。 |
| | | * |
| | | * <p>只认「属于当前商户 + 已发放未核销 + 未过期」的码;核销本身走 |
| | | * {@code POST /api/merchant/benefit-verifications/verify}(与后台同一套记录), |
| | | * 这里只做扫码后的资格确认,让店员先看清是谁的券再确认。 |
| | | */ |
| | | private Map<String, Object> findTicketPackageCode(String token, MerchantPrincipal principal) { |
| | | List<Map<String, Object>> claims = rows(""" |
| | | select cl.id claim_id, cl.exchange_code, cl.talent_name, cl.talent_mobile, cl.card_level, |
| | | cl.valid_end, cl.status claim_status, p.package_name, m.merchant_name |
| | | 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.exchange_code = ? and cl.deleted = 0 and cl.merchant_id = ? |
| | | limit 1 |
| | | """, token, principal.merchantId()); |
| | | if (claims.isEmpty()) return null; |
| | | Map<String, Object> claim = claims.get(0); |
| | | if (!"ISSUED".equalsIgnoreCase(value(claim.get("claimStatus"), "").trim())) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "该观影权益已核销或已失效"); |
| | | } |
| | | if (expiredDate(claim.get("validEnd"))) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "该观影权益已过期"); |
| | | } |
| | | return claim; |
| | | } |
| | | |
| | | private Map<String, Object> createTicketPackageAttempt(String exchangeCode, Map<String, Object> claim) { |
| | | // 这类码没有 attempt 表可用(它只服务动态码/订单码/免费入住券),核销记录由 |
| | | // benefit_verify_record 落库,因此这里用「TP+领取记录ID」当核销流水号。 |
| | | Map<String, Object> result = scanResult("TP" + number(claim.get("claimId")), TICKET_PACKAGE_CODE, |
| | | "观影权益有效,请确认后核销", |
| | | claim.get("talentName"), claim.get("talentMobile"), claim.get("cardLevel"), null, null, 0L); |
| | | result.put("verifyCode", exchangeCode); |
| | | result.put("packageName", value(claim.get("packageName"), "")); |
| | | result.put("merchantName", value(claim.get("merchantName"), "")); |
| | | result.put("validEnd", claim.get("validEnd") == null ? "" : String.valueOf(claim.get("validEnd"))); |
| | | return result; |
| | | } |
| | | |
| | | private String freeStayBenefitText(Map<String, Object> code, MerchantPrincipal principal) { |
| | | try { |
| | | List<Map<String, Object>> benefits = rows(""" |
| | |
| | | .body(service.movieCodeImage(claimId)); |
| | | } |
| | | |
| | | @Operation(summary = "获取观影权益兑换码二维码", description = "票包兑换码二维码,出示给影城扫码核销;已核销或过期后失效") |
| | | @GetMapping(value = "/ticket-package-claims/{claimId}/image", produces = MediaType.IMAGE_PNG_VALUE) |
| | | public ResponseEntity<byte[]> ticketPackageImage( |
| | | @Parameter(description = "票包领取记录ID", required = true) @PathVariable Long claimId) { |
| | | return ResponseEntity.ok() |
| | | .cacheControl(CacheControl.noStore()) |
| | | .body(service.ticketPackageCodeImage(claimId)); |
| | | } |
| | | |
| | | @Operation(summary = "查询权益可用次数", description = "按主卡或亲属附属卡、人才卡等级和商户配置查询当前可用权益次数,不生成核销码") |
| | | @GetMapping("/quota") |
| | | public ApiResponse<Map<String, Object>> quota( |
| | |
| | | String consumeDesc = text(right.get("consumeDesc"), ""); |
| | | if (!consumeDesc.isBlank()) return consumeDesc; |
| | | } |
| | | return text(service.get("tipText"), "请按现场指引操作"); |
| | | return text(service.get("tipText"), "到店后按现场指引操作,可直接享受洗车权益。"); |
| | | } |
| | | |
| | | private String rightsText(Map<String, Object> merchant, Map<String, Object> right, Map<String, Object> service) { |
| | |
| | | import com.talentcard.server.entity.BenefitFreeStayVoucher; |
| | | import com.talentcard.server.entity.BenefitMerchant; |
| | | import com.talentcard.server.entity.TicketPackageCode; |
| | | import com.talentcard.server.entity.TicketPackageClaim; |
| | | import com.talentcard.server.mapper.BenefitCinemaCouponClaimMapper; |
| | | import com.talentcard.server.mapper.BenefitCinemaCouponMapper; |
| | | import com.talentcard.server.mapper.BenefitFreeStayVerificationCodeMapper; |
| | | import com.talentcard.server.mapper.BenefitFreeStayVoucherMapper; |
| | | import com.talentcard.server.mapper.BenefitMerchantMapper; |
| | | import com.talentcard.server.mapper.TicketPackageCodeMapper; |
| | | import com.talentcard.server.mapper.TicketPackageClaimMapper; |
| | | import org.springframework.stereotype.Service; |
| | | import org.springframework.transaction.annotation.Transactional; |
| | | |
| | |
| | | private final BenefitFreeStayVoucherMapper voucherMapper; |
| | | private final BenefitFreeStayVerificationCodeMapper freeStayCodeMapper; |
| | | private final TicketPackageCodeMapper codeMapper; |
| | | private final TicketPackageClaimMapper ticketPackageClaimMapper; |
| | | private final TicketPackagePortalService ticketPackageService; |
| | | |
| | | public BenefitVerificationCodeService( |
| | | BenefitQuotaService quotaService, |
| | | BenefitMerchantMapper merchantMapper, |
| | | TicketPackageCodeMapper codeMapper, |
| | | TicketPackageClaimMapper ticketPackageClaimMapper, |
| | | TicketPackagePortalService ticketPackageService, |
| | | BenefitCinemaCouponMapper couponMapper, |
| | | BenefitCinemaCouponClaimMapper claimMapper, |
| | |
| | | this.quotaService = quotaService; |
| | | this.merchantMapper = merchantMapper; |
| | | this.codeMapper = codeMapper; |
| | | this.ticketPackageClaimMapper = ticketPackageClaimMapper; |
| | | this.ticketPackageService = ticketPackageService; |
| | | this.couponMapper = couponMapper; |
| | | this.claimMapper = claimMapper; |
| | |
| | | if (!activeMovieCode(claim)) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "电影票兑换码不可用"); |
| | | } |
| | | return qrPng(claim.getExchangeCode(), "电影票二维码生成失败"); |
| | | } |
| | | |
| | | /** |
| | | * 观影权益(票包兑换码)的二维码:内容是后台批量生成、绑定影城的那串兑换码。 |
| | | * |
| | | * <p>与电影券的二维码分成两个入口,因为两者的领取记录不在同一张表——同一个 id 可能两边都存在, |
| | | * 合并成一个接口会张冠李戴。 |
| | | */ |
| | | @Transactional(readOnly = true) |
| | | public byte[] ticketPackageCodeImage(Long claimId) { |
| | | TicketPackageClaim claim = ticketPackageClaimMapper.selectById(claimId); |
| | | 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 (expiredDate(claim.getValidEnd())) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "观影权益已过期"); |
| | | } |
| | | return qrPng(claim.getExchangeCode(), "观影权益二维码生成失败"); |
| | | } |
| | | |
| | | private byte[] qrPng(String content, String failureMessage) { |
| | | try { |
| | | var matrix = new QRCodeWriter().encode(claim.getExchangeCode(), BarcodeFormat.QR_CODE, 480, 480); |
| | | var matrix = new QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, 480, 480); |
| | | BufferedImage image = MatrixToImageWriter.toBufferedImage(matrix); |
| | | ByteArrayOutputStream output = new ByteArrayOutputStream(); |
| | | ImageIO.write(image, "PNG", output); |
| | | return output.toByteArray(); |
| | | } catch (Exception ex) { |
| | | throw new BusinessException(ErrorCode.INTERNAL_ERROR, "电影票二维码生成失败"); |
| | | throw new BusinessException(ErrorCode.INTERNAL_ERROR, failureMessage); |
| | | } |
| | | } |
| | | |
| | |
| | | current.setMerchantId(id); |
| | | current.setTalentLevels("卓越人才卡、杰出人才卡、菁英人才卡"); |
| | | current.setServicePointText("请咨询商户"); |
| | | current.setTipText("到店后请按现场指引操作"); |
| | | current.setTipText("到店后按现场指引操作,可直接享受洗车权益。"); |
| | | current.setDeleted(0); |
| | | apply(current, config, request.status()); |
| | | serviceMapper.insert(current); |
| New file |
| | |
| | | package com.talentcard.server.business.admin.auth; |
| | | |
| | | import static org.assertj.core.api.Assertions.assertThat; |
| | | import static org.mockito.ArgumentMatchers.any; |
| | | import static org.mockito.ArgumentMatchers.anyLong; |
| | | import static org.mockito.ArgumentMatchers.anyString; |
| | | import static org.mockito.ArgumentMatchers.eq; |
| | | import static org.mockito.Mockito.mock; |
| | | import static org.mockito.Mockito.when; |
| | | |
| | | import java.util.ArrayList; |
| | | import java.util.List; |
| | | |
| | | import com.talentcard.server.common.BusinessException; |
| | | import com.talentcard.server.common.ErrorCode; |
| | | import org.junit.jupiter.api.BeforeEach; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.springframework.jdbc.core.JdbcTemplate; |
| | | import org.springframework.mock.web.MockHttpServletRequest; |
| | | import org.springframework.mock.web.MockHttpServletResponse; |
| | | import tools.jackson.databind.json.JsonMapper; |
| | | |
| | | /** |
| | | * 拦截器的「接口 → 权限码」映射。 |
| | | * |
| | | * <p>这层映射出过两次事故:一个接口服务多个页面时,推导出来的权限码在角色管理里根本勾不到, |
| | | * 于是配好权限的员工账号也进不去模块;而权限 403 又被各控制器自带的兜底 advice 改写成 500, |
| | | * 现场只看到「服务器内部错误」。这里不启动 Spring 上下文,直接盯住这两件事。 |
| | | */ |
| | | class AdminAuthInterceptorMappingTests { |
| | | |
| | | private final AdminAuthService service = mock(AdminAuthService.class); |
| | | private final JdbcTemplate jdbc = mock(JdbcTemplate.class); |
| | | private final List<List<String>> requested = new ArrayList<>(); |
| | | private AdminAuthInterceptor interceptor; |
| | | |
| | | @BeforeEach |
| | | void setUp() { |
| | | // 运行期注入的是 Jackson 3 的 ObjectMapper(tools.jackson),这里用同一套,别再用 Jackson 2 的。 |
| | | interceptor = new AdminAuthInterceptor(service, jdbc, new JsonMapper()); |
| | | when(service.authenticate(anyString())).thenReturn(1L); |
| | | when(service.operatorName(anyLong())).thenReturn("测试员工"); |
| | | when(service.hasAnyPermission(anyLong(), any())).thenAnswer(invocation -> { |
| | | requested.add(invocation.getArgument(1)); |
| | | return true; |
| | | }); |
| | | } |
| | | |
| | | @Test |
| | | void serviceApplicationRequestsAreJudgedByTheirModulePage() { |
| | | assertThat(pass(get("/api/admin/service-appointment-applications", "applicationType", "MEDICAL_RESERVATION"))) |
| | | .containsExactly("admin.medical-reservations.view"); |
| | | assertThat(pass(get("/api/admin/service-appointment-applications", "applicationType", "FUEL_SERVICE"))) |
| | | .containsExactly("admin.fuel-services.view"); |
| | | assertThat(pass(get("/api/admin/service-appointment-applications/export", "applicationType", "SPOUSE_EMPLOYMENT"))) |
| | | .containsExactly("admin.spouse-employment.export"); |
| | | assertThat(pass(patch("/api/admin/service-appointment-applications", "applicationType", "HOTEL_RESERVATION"))) |
| | | .containsExactly("admin.hotel-reservations.audit"); |
| | | } |
| | | |
| | | /** 详情页只带 id,得回表查类型,否则又会掉回没人能勾选的 appointment-management。 */ |
| | | @Test |
| | | void serviceApplicationDetailLooksUpItsTypeFromTheDatabase() { |
| | | when(jdbc.queryForList(anyString(), eq(String.class), any())).thenReturn(List.of("MEDICAL_APPLICATION")); |
| | | |
| | | assertThat(pass(get("/api/admin/service-appointment-applications/42"))) |
| | | .containsExactly("admin.medical-applications.view"); |
| | | } |
| | | |
| | | @Test |
| | | void unknownServiceApplicationTypeKeepsTheOldPermission() { |
| | | assertThat(pass(get("/api/admin/service-appointment-applications"))) |
| | | .containsExactly("admin.appointment-management.view"); |
| | | } |
| | | |
| | | @Test |
| | | void familyCardRequestsAcceptEitherOfTheTwoPagesThatUseThem() { |
| | | assertThat(pass(get("/api/admin/dependent-card-applies", "management", "true"))) |
| | | .containsExactly("admin.family-card-applications.view", "admin.family-cards.view"); |
| | | assertThat(pass(patch("/api/admin/dependent-card-applies/15/unbind"))) |
| | | .containsExactly("admin.family-card-applications.audit", "admin.family-cards.disable"); |
| | | assertThat(pass(patch("/api/admin/dependent-card-applies/15/audit"))) |
| | | .containsExactly("admin.family-card-applications.audit"); |
| | | } |
| | | |
| | | /** 权限不足必须如实回 403,而不是被控制器兜底改写成 500「服务器内部错误」。 */ |
| | | @Test |
| | | void deniedPermissionIsWrittenAsFourZeroThree() throws Exception { |
| | | when(service.hasAnyPermission(anyLong(), any())).thenReturn(false); |
| | | MockHttpServletRequest request = get("/api/admin/service-appointment-applications", "applicationType", "MEDICAL_RESERVATION"); |
| | | MockHttpServletResponse response = new MockHttpServletResponse(); |
| | | |
| | | assertThat(interceptor.preHandle(request, response, new Object())).isFalse(); |
| | | assertThat(response.getStatus()).isEqualTo(403); |
| | | assertThat(response.getContentAsString()).contains("\"code\":403").contains("当前账号没有该操作权限"); |
| | | } |
| | | |
| | | /** 会话失效要回 401,前端才会清会话并跳登录页;以前这批接口会把它写成 500。 */ |
| | | @Test |
| | | void expiredSessionIsWrittenAsFourZeroOne() throws Exception { |
| | | when(service.authenticate(anyString())) |
| | | .thenThrow(new BusinessException(ErrorCode.UNAUTHORIZED, "登录已失效,请重新登录")); |
| | | MockHttpServletResponse response = new MockHttpServletResponse(); |
| | | |
| | | assertThat(interceptor.preHandle(get("/api/admin/dependent-card-applies/15"), response, new Object())).isFalse(); |
| | | assertThat(response.getStatus()).isEqualTo(401); |
| | | assertThat(response.getContentAsString()).contains("\"code\":401").contains("登录已失效"); |
| | | } |
| | | |
| | | /** 不在这两个特殊前缀里的接口沿用原来的页面映射。 */ |
| | | @Test |
| | | void otherAdminRequestsKeepTheirPageMapping() { |
| | | assertThat(pass(get("/api/admin/business/news"))).containsExactly("admin.news.view"); |
| | | assertThat(pass(patch("/api/admin/parking-plate-submissions/9/audit"))) |
| | | .containsExactly("admin.parking-plates.audit"); |
| | | assertThat(pass(get("/api/admin/management/roles"))).containsExactly("admin.roles.view"); |
| | | } |
| | | |
| | | private List<String> pass(MockHttpServletRequest request) { |
| | | requested.clear(); |
| | | try { |
| | | interceptor.preHandle(request, new MockHttpServletResponse(), new Object()); |
| | | } catch (Exception exception) { |
| | | throw new AssertionError(exception); |
| | | } |
| | | assertThat(requested).as("拦截器应该只校验一次权限").hasSize(1); |
| | | return requested.get(0); |
| | | } |
| | | |
| | | private MockHttpServletRequest get(String uri) { |
| | | return get(uri, new String[0]); |
| | | } |
| | | |
| | | private MockHttpServletRequest get(String uri, String... params) { |
| | | MockHttpServletRequest request = new MockHttpServletRequest("GET", uri); |
| | | request.addHeader("Authorization", "Bearer test-token"); |
| | | for (int index = 0; index + 1 < params.length; index += 2) { |
| | | request.setParameter(params[index], params[index + 1]); |
| | | } |
| | | return request; |
| | | } |
| | | |
| | | private MockHttpServletRequest patch(String uri, String... params) { |
| | | MockHttpServletRequest request = new MockHttpServletRequest("PATCH", uri); |
| | | request.addHeader("Authorization", "Bearer test-token"); |
| | | for (int index = 0; index + 1 < params.length; index += 2) { |
| | | request.setParameter(params[index], params[index + 1]); |
| | | } |
| | | return request; |
| | | } |
| | | } |
| New file |
| | |
| | | package com.talentcard.server.business.merchant; |
| | | |
| | | import static org.assertj.core.api.Assertions.assertThat; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.Map; |
| | | |
| | | import org.junit.jupiter.api.Test; |
| | | |
| | | /** |
| | | * 免费入住确认页上「超过天数按…」那句话。 |
| | | * |
| | | * <p>酒店在商家权益里把到店消费配成「折扣」或「优惠(立减)」二选一;以前这里只认折扣, |
| | | * 配成优惠的酒店页面上整行空白(工单「酒店服务-到店消费:需要显示超过免费天数后,按照 |
| | | * 门店的多少折计算」)。这个类不启动 Spring 上下文,只盯文案拼装。 |
| | | */ |
| | | class FreeStayExceedDiscountTextTests { |
| | | |
| | | private final ManualConsumptionService service = new ManualConsumptionService(null, null); |
| | | |
| | | @Test |
| | | void discountRuleTurnsIntoTheMarketPriceRate() { |
| | | assertThat(service.exceedDiscountText(rule("0.68", "0"))) |
| | | .as("门店配的是折扣:显示门市价几折") |
| | | .isEqualTo("门市价6.8折"); |
| | | assertThat(service.exceedDiscountText(rule("0.9", "0"))).isEqualTo("门市价9折"); |
| | | } |
| | | |
| | | @Test |
| | | void reductionRuleTurnsIntoTheDiscountAmount() { |
| | | assertThat(service.exceedDiscountText(rule("0", "100"))) |
| | | .as("门店配的是优惠(立减):也该有文案,不能整段空着") |
| | | .isEqualTo("立减100元"); |
| | | } |
| | | |
| | | @Test |
| | | void discountWinsWhenBothAreConfigured() { |
| | | assertThat(service.exceedDiscountText(rule("0.7", "100"))).isEqualTo("门市价7折"); |
| | | } |
| | | |
| | | @Test |
| | | void nothingConfiguredMeansNoExtraLine() { |
| | | assertThat(service.exceedDiscountText(rule("0", "0"))).isEmpty(); |
| | | assertThat(service.exceedDiscountText(new LinkedHashMap<>())).isEmpty(); |
| | | } |
| | | |
| | | private Map<String, Object> rule(String discountRate, String fixedReduction) { |
| | | Map<String, Object> rule = new LinkedHashMap<>(); |
| | | rule.put("discountRate", new BigDecimal(discountRate)); |
| | | rule.put("fixedReduction", new BigDecimal(fixedReduction)); |
| | | return rule; |
| | | } |
| | | } |