yeg
2026-09-28 85bfb9351803d97a33329d507f30a2f78bf1fc75
Merge remote-tracking branch 'origin/main'
6个文件已添加
40个文件已修改
1126 ■■■■■ 已修改文件
.scratch/admin-permission-mapping/issues/01-staff-account-module-500.md 90 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/family-card-unbind-back-button/issues/01-back-button-without-handler.md 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/docs/pages/05-暖心食享.md 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/docs/pages/25-解除家属卡绑定.md 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/docs/pages/29-悦览花城.md 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/docs/pages/30-舒心安居.md 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/docs/pages/34-怡乐生活.md 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/docs/pages/36-电影券领券成功.md 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/docs/pages/37-畅行无忧.md 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/docs/pages/44-惠享好物.md 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/docs/pages/46-臻享礼遇.md 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/coupon-received/coupon-received.uvue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/entertainment-benefits/entertainment-benefits.uvue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/family-card-unbind/family-card-unbind.uvue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/food-benefits/food-benefits.uvue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/premium-benefits/premium-benefits.uvue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/shopping-benefits/shopping-benefits.uvue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/stay-benefits/stay-benefits.uvue 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/transport-benefits/transport-benefits.uvue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/travel-benefits/travel-benefits.uvue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/services/portal.uts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/config/api.uts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/docs/pages/00-登录页.md 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/docs/pages/05-商品详情.md 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/docs/pages/12-相机扫码.md 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/docs/pages/23-观影权益核销.md 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages.json 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/free-stay-confirm/free-stay-confirm.uvue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/login/login.uvue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/product-detail/product-detail.uvue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/scan/scan.uvue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/ticket-package-confirm/ticket-package-confirm.uvue 120 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/services/http.uts 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/services/verification.uts 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/OrderDetailView.vue 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/auth/AdminAuthInterceptor.java 162 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/auth/AdminAuthService.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/ManualConsumptionService.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantVerificationService.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/BenefitVerificationCodeController.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/CarWashMerchantPortalService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/service/BenefitVerificationCodeService.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/service/CarWashJumpConfigAdminService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/admin/auth/AdminAuthInterceptorMappingTests.java 151 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/FreeStayExceedDiscountTextTests.java 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/admin-permission-mapping/issues/01-staff-account-module-500.md
New file
@@ -0,0 +1,90 @@
# 员工账号进对应权限模块报「服务器内部错误」、解绑家属卡报「家属附属卡申请处理失败」
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`),
  需要重启后端生效后复验。探针用的两个临时会话已删除。
.scratch/family-card-unbind-back-button/issues/01-back-button-without-handler.md
New file
@@ -0,0 +1,45 @@
# 人才端-家属卡解绑:左上角返回按钮点了没反应
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` 的事件绑定
  再扫一遍,已无「模板挂了处理函数但脚本没定义」的情况。
rencaika/docs/pages/05-暖心食享.md
@@ -12,7 +12,7 @@
| 功能 | 当前实现 | 后续接口目标 |
| --- | --- | --- |
| 顶部主图 | 默认展示当前选中图册项的大图。 | 使用后台返回的 HTTPS 图片 URL,并提供加载失败占位图。 |
| 顶部主图 | 展示当前选中图册项的大图,**主图自身支持左右滑动**切换图册项,与下方缩略图双向联动(点缩略图主图滑过去、滑主图缩略图跟着走)。 | 使用后台返回的 HTTPS 图片 URL,并提供加载失败占位图。 |
| 横向图册 | 体验版使用 6 项重复演示数据验证横向滑动,仅由 2 张图片交替复用;点击或滑动结束后,中心项自动选中、放大并带动画吸附到容器中央。 | 后台返回图片、名称、排序和启用状态,正式版图片走后台/CDN。 |
| 动态菜名 | 主图上显示当前菜品名称,使用古典艺术行楷字体子集。 | 正式环境通过完整字体包或 CDN 字体覆盖后台任意名称字符。 |
| 专题描述 | 橙色渐变卡片显示后台返回的专题固定描述,不随图册选择变化。 | 后台返回专题级纯文本描述,并限制长度及换行数量。 |
@@ -23,7 +23,7 @@
## 操作说明
1. 在人才权益页点击“暖心食享”进入本页。
2. 左右滑动顶部缩略图册浏览菜品图片。
2. 左右滑动顶部主图(或滑动下方缩略图带)浏览菜品图片。
3. 点击缩略图后,顶部主图、菜名和橙色描述卡同步切换。
4. 点击下方餐饮商户进入对应权益详情。
5. 点击左上角返回图标回到人才权益页。
rencaika/docs/pages/25-解除家属卡绑定.md
@@ -11,6 +11,7 @@
- 展示当前可解绑家属,支持勾选后发起解除绑定。
- 家属列表已补局部加载、成功空数据、加载失败和点击重试;失败不会被当成“暂无可解绑家属”。
- 空数据时隐藏“解除绑定”按钮,避免提交不存在的家属 ID。
- 左上角返回按钮调用 `safeBack('/pages/family-card/family-card')`:页面栈足够时原路返回,栈底时回管理页。
- 提交前校验至少选择一位家属,随后二次确认风险提示。
- 原型提交成功后返回管理页展示空状态;正式版本必须以解绑接口结果刷新列表。
- 后台应返回不可解绑原因,例如审核中、权益未结束、退款处理中等;失败时保留原列表与选择状态。
rencaika/docs/pages/29-悦览花城.md
@@ -10,6 +10,7 @@
## 功能点
- 顶部大图、可横向滑动并点击切换的景区图册。
- 顶部大图本身也支持左右滑动切换图册项,与下方缩略图双向联动(点缩略图主图滑过去、滑主图缩略图跟着走)。
- 人才文旅权益简介、景区服务分页列表、首屏/下一页失败重试、加载中、到底与空状态。
- 点击景区服务进入商家详情演示页。
- 体验版仅保留 2 张唯一文旅图片,图册和景区列表通过重复项交替复用;正式版图片由后台/CDN配置。
rencaika/docs/pages/30-舒心安居.md
@@ -10,6 +10,7 @@
## 功能点
- 顶部酒店大图与横向图册,支持点击切换、滑动吸附、选中放大并居中。
- 顶部大图本身也支持左右滑动切换图册项,与横向图册双向联动。
- 固定住宿权益介绍。
- “酒店服务 / 公寓租赁服务”分类切换。
- 服务列表支持分页加载、首屏/下一页失败重试、空状态、加载中与到底状态;切换酒店/公寓分类时清理上一分类错误。
rencaika/docs/pages/34-怡乐生活.md
@@ -14,6 +14,7 @@
| --- | --- | --- |
| 沉浸式头图 | 返回栏固定,上滑后渐显实体橙色背景;主图随后台图册选择同步切换。 | 后台可扩展主题配置。 |
| 横向图册 | 从后台读取图片 URL、排序和名称,支持点击、横向滑动、选中放大和自动居中。 | 正式版图片由后台/CDN维护。 |
| 主图滑动 | 顶部主图为轮播,可直接左右滑动切换图册项,与横向图册、名称同步。 | 图册数据同专题接口。 |
| 专题介绍 | 展示后台返回的审核后纯文本介绍。 | 无。 |
| 观影服务 | 从后台分页加载影院、剧院服务,支持首屏/下一页失败重试、空状态和到底状态。 | 可扩展库存和领券状态。 |
| 服务跳转 | 影院服务进入影城详情;剧院服务明确提示详情暂未开放;已下架服务不可进入。 | 按服务类型接入领券、购票或预约流程。 |
rencaika/docs/pages/36-电影券领券成功.md
@@ -16,6 +16,7 @@
| 领券结果 | 顶部标题展示“领券成功”,卡片显示接口返回的电影兑换券名称;业务图标使用高对比度橙色矢量图。 | `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。 |
@@ -23,7 +24,7 @@
1. 用户确认领取电影券后进入本页。
2. 查看年度权益使用进度及剩余数量。
3. 查看兑换码与使用说明。
3. 到店时向店员出示二维码(店员用商家端「扫码核销」扫它),也可查看/复制兑换码与使用说明。
4. 点击“复制兑换码”,系统写入剪贴板并反馈复制结果。
5. 用户也可从使用记录“已领取”进入本页查看同一兑换凭证。
6. 点击左上角返回上一页;页面栈缺失时返回使用记录“已领取”。
rencaika/docs/pages/37-畅行无忧.md
@@ -14,6 +14,7 @@
| --- | --- | --- |
| 沉浸式顶部 | 头图、标题、返回按钮固定在滚动容器外;上滑超过 64px 渐显实体橙色导航背景。 | 标题、图册和介绍由专题接口返回;导航仍由前端统一适配胶囊。 |
| 横向图册 | 按后台启用状态和排序展示;点击或横向滑动后自动选中、放大并居中。 | `GET /api/app/topics/transport` 返回图册图片与名称。 |
| 主图滑动 | 顶部主图为轮播,可直接左右滑动切换图册项,与横向图册、名称同步。 | 图册数据同专题接口。 |
| 介绍卡 | 橙色渐变卡以 `-66rpx` 负外边距悬浮在头图下方,文本按内容自然撑高。 | 返回专题纯文本介绍。 |
| 服务分类 | 提供加油服务、停车服务、自助洗车服务三个分类标签,切换后重置独立分页状态。 | `type=fuel|parking|carWash` 查询对应服务。 |
| 服务列表 | 展示后台服务图片、名称、折扣/权益标签、渐变地址条;支持首屏/下一页失败重试、上滑加载、到底和空状态。 | `GET /api/app/topics/transport/merchants?type={type}&page={page}&size={size}`。 |
rencaika/docs/pages/44-惠享好物.md
@@ -14,6 +14,7 @@
| --- | --- | --- |
| 沉浸式头图 | 顶图全屏展示,标题栏初始透明;上滑超过阈值后渐显实体橙色背景。 | 后台返回专题头图,图片由 CDN 加载。 |
| 横向图册 | 支持横向滑动和点击切换,选中图片放大并自动居中,名称使用项目内置展示字体。 | 后台返回图片、展示名称、排序和启用状态。 |
| 主图滑动 | 顶部主图为轮播,可直接左右滑动切换图册项,与横向图册、名称同步。 | 图册数据同专题接口。 |
| 专题介绍 | 橙色介绍卡悬浮在头图与列表之间,卡片高度由纯文本内容自然撑开。 | 后台返回安全纯文本介绍,客户端不按固定高度截断。 |
| 服务分类 | 支持购房优惠、购车服务、家电服务、数码服务、图书服务五类横向切换。 | 后台返回分类 ID、名称、排序和启用状态。 |
| 商家列表 | 展示商家图片、名称、优惠标签和地址,点击进入购物服务专用详情。 | 后台分页返回商家/项目 ID、分类、封面、权益摘要、地址及可用状态。 |
rencaika/docs/pages/46-臻享礼遇.md
@@ -14,6 +14,7 @@
| --- | --- | --- |
| 沉浸式头图 | 顶图全屏展示,导航初始透明;页面上滑后渐显实体橙色背景。 | 后台返回专题头图 URL。 |
| 横向图册 | 支持滑动和点击切换,选中图册放大并自动居中,名称使用项目内置展示字体。 | 后台返回图片 URL、展示名称、排序和启用状态。 |
| 主图滑动 | 顶部主图为轮播,可直接左右滑动切换图册项,与横向图册、名称同步。 | 图册数据同专题接口。 |
| 专题介绍 | 橙色介绍卡悬浮在头图与列表间,纯文本内容自然撑高。 | 后台返回安全纯文本介绍。 |
| 服务分类 | 支持免费体检、配偶推荐就业、金融服务、行政审批、学生托付五类横向切换。 | 后台返回分类 ID、名称、排序和启用状态。 |
| 服务列表 | 展示机构图片、名称、权益标签和地址。 | 后台分页返回服务 ID、分类 ID、机构信息、权益摘要、地址和状态。 |
rencaika/pages/coupon-received/coupon-received.uvue
@@ -6,7 +6,7 @@
        <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>
@@ -14,7 +14,7 @@
<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.
@@ -30,6 +30,15 @@
    })
    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' }) }) }
@@ -42,4 +51,5 @@
    .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>
rencaika/pages/entertainment-benefits/entertainment-benefits.uvue
@@ -12,7 +12,12 @@
        <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>
@@ -126,6 +131,12 @@
        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 }
@@ -184,4 +195,6 @@
    .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>
rencaika/pages/family-card-unbind/family-card-unbind.uvue
@@ -43,6 +43,9 @@
    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()
rencaika/pages/food-benefits/food-benefits.uvue
@@ -12,7 +12,13 @@
        <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>
@@ -174,6 +180,12 @@
        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
@@ -265,6 +277,7 @@
    .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%); }
rencaika/pages/premium-benefits/premium-benefits.uvue
@@ -2,7 +2,7 @@
    <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>
@@ -33,7 +33,12 @@
    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 }
@@ -63,6 +68,7 @@
        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>
@@ -73,4 +79,6 @@
    /* 标签与折扣并排显示:标签是后台配的文字(如「卓越人才卡」),折扣是另一个字段(如「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>
rencaika/pages/shopping-benefits/shopping-benefits.uvue
@@ -2,7 +2,7 @@
    <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">
@@ -45,6 +45,8 @@
    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 }
@@ -114,5 +116,7 @@
    .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>
rencaika/pages/stay-benefits/stay-benefits.uvue
@@ -12,7 +12,12 @@
        </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"
@@ -131,6 +136,8 @@
    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) => {
@@ -296,6 +303,14 @@
        width: 100%;
        height: 590rpx;
    }
    /* 主图轮播:与下方缩略图带同高同位,盖在原来的静态主图上。 */
    .hero-swiper {
        position: absolute;
        left: 0;
        top: 0;
        width: 100%;
        height: 590rpx;
    }
    .hero-shade {
        position: absolute;
        left: 0;
rencaika/pages/transport-benefits/transport-benefits.uvue
@@ -12,7 +12,12 @@
        <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>
@@ -95,6 +100,8 @@
    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) => {
@@ -131,6 +138,8 @@
    .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); }
rencaika/pages/travel-benefits/travel-benefits.uvue
@@ -2,7 +2,7 @@
    <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>
@@ -104,6 +104,12 @@
        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 }
@@ -178,4 +184,6 @@
    .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>
rencaika/services/portal.uts
@@ -148,6 +148,8 @@
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' }
rencaika_merchant/config/api.uts
@@ -55,6 +55,7 @@
    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'
rencaika_merchant/docs/pages/00-登录页.md
@@ -42,7 +42,8 @@
| 场景 | 用户可见提示 | 恢复方式 |
| --- | --- | --- |
| 账号未录入 | “账号未录入,请联系管理员” | 核对账号或联系管理员。 |
| 密码缺失/错误 | “登录密码错误” | 重新输入;正式环境需支持找回或重置密码。 |
| 密码未录入 | “请输入登录密码” | 补充密码后重新提交。 |
| 账号或密码错误 | “账号或密码错误”(展示登录接口返回的提示) | 重新输入;正式环境需支持找回或重置密码。后端不区分账号不存在与密码错误,避免账号枚举。 |
| 未同意协议 | “请先阅读并同意相关协议” | 勾选协议后重新提交。 |
| 登录接口失败 | 待接口联调后明确 | 保留输入内容,允许用户重试。 |
| 登录失效 | 待接口联调后明确 | 清理会话并返回登录页。 |
rencaika_merchant/docs/pages/05-商品详情.md
@@ -18,6 +18,7 @@
| PD-003 | 购买须知 | 有效期、可用时间、使用方式及其他规则均已绑定表单模型。 | 前端已完成 |
| PD-004 | 人才权益 | 展示各人才卡可享价格、折扣与免费次数。 | 静态演示 |
| PD-005 | 保存与下架 | 保存会提交审核并自动转为下架待审核;下架使用独立接口,失败时保留原状态;修改失败保留本地草稿。 | 已接入 |
| PD-006 | 体检医院专属表单 | 商户类型为体检医院时不显示商品金额,改为必填的「可预约时间」(开始/结束日期,原生日期选择器)与检查项目表(名称、项目意义、适用性别);可预约时间按「开始日期 至 结束日期」写入购买须知的 `validPeriod`。 | 已接入 |
## 接口与数据
@@ -35,3 +36,4 @@
- [x] 已实现按商品 ID 初始化、表单绑定、金额/必填校验、本地草稿和未保存返回确认。
- [x] 已实现封面与详情图片本地选择、压缩、大小和数量校验。
- [x] 详情数据、图片上传、保存、下架及表单校验完成接口接入。
- [x] 体检医院的可预约时间两个日期选择器可选择并把结果回填到表单(提交前校验必填、结束日期不早于开始日期)。
rencaika_merchant/docs/pages/12-相机扫码.md
@@ -19,6 +19,7 @@
| SC-004 | 二次确认核销 | 解析成功后显示脱敏持卡人信息并要求确认;确认请求携带幂等键。 | 已接入 |
| SC-005 | 状态恢复 | 支持核验中、确认中、核销成功、普通失败重试、非当前商户独立失败页和相机拒绝。 | 已完成 |
| SC-006 | 返回 | 返回时取消当前请求并回到首页。 | 已完成 |
| SC-007 | 免费入住券的「超过天数」文案 | 扫到酒店免费入住券时,核销页显示「免费入住N天」并附上门店配置的超出规则:到店消费配的是折扣显示「门市价X折」,配的是立减显示「立减X元」,都没配则不显示这一行(`discountText`)。 | 已接入 |
## 操作说明
rencaika_merchant/docs/pages/23-观影权益核销.md
New file
@@ -0,0 +1,32 @@
# 观影权益核销(票包兑换码)
## 页面信息
- 页面名称:核销输入(观影权益)
- 路由:`/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`),核销后即可看到;无需单独页面。
rencaika_merchant/pages.json
@@ -195,6 +195,14 @@
                "backgroundColor": "#F7F8FA",
                "disableScroll": true
            }
        },
        {
            "path": "pages/ticket-package-confirm/ticket-package-confirm",
            "style": {
                "navigationStyle": "custom",
                "backgroundColor": "#F7F8FA",
                "disableScroll": true
            }
        }
    ],
    "globalStyle": {
rencaika_merchant/pages/free-stay-confirm/free-stay-confirm.uvue
@@ -9,7 +9,7 @@
                <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>
@@ -103,7 +103,7 @@
    .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; }
rencaika_merchant/pages/login/login.uvue
@@ -30,6 +30,8 @@
                <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">
@@ -55,6 +57,7 @@
    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;'
@@ -74,16 +77,19 @@
    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
@@ -92,7 +98,7 @@
            hasError = true
        }
        if (password.value.length == 0) {
            passwordError.value = '登录密码错误'
            passwordError.value = '请输入登录密码'
            hasError = true
        }
        if (!agreed.value) {
@@ -113,7 +119,7 @@
            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' })
@@ -152,6 +158,8 @@
    .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; }
rencaika_merchant/pages/product-detail/product-detail.uvue
@@ -324,6 +324,18 @@
    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({
rencaika_merchant/pages/scan/scan.uvue
@@ -128,6 +128,20 @@
                })
                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)
rencaika_merchant/pages/ticket-package-confirm/ticket-package-confirm.uvue
New file
@@ -0,0 +1,120 @@
<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>
rencaika_merchant/services/http.uts
@@ -38,6 +38,15 @@
    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
@@ -121,7 +130,7 @@
                    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
rencaika_merchant/services/verification.uts
@@ -19,6 +19,11 @@
    /** 免费入住:超过免费天数后的折扣文案(如「门市价9折」,没配折扣时为空)。 */
    discountText: string
    benefitText: string
    /** 观影权益(票包兑换码):核销时回传的兑换码、票包名与有效期。 */
    verifyCode: string
    packageName: string
    merchantName: string
    validEnd: string
}
export type VerificationConfirmResult = {
@@ -87,12 +92,34 @@
            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,
talent-card-admin/src/views/OrderDetailView.vue
@@ -76,6 +76,12 @@
  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
@@ -96,14 +102,41 @@
}
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('退款申请已提交')
}
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java
@@ -1056,16 +1056,17 @@
        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(),
@@ -1084,45 +1085,48 @@
                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");
        }
@@ -1130,9 +1134,9 @@
                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
@@ -1406,6 +1410,35 @@
        }
    }
    /**
     * 团单在后台有两个 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 = ?",
talent-card-server/src/main/java/com/talentcard/server/business/admin/auth/AdminAuthInterceptor.java
@@ -1,58 +1,169 @@
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) {
@@ -87,4 +198,9 @@
        else return "";
        return "admin." + page + "." + action;
    }
    /** 空权限码表示「这个请求不校验页面权限」,不能当成一个空字符串去比权限。 */
    private List<String> only(String permission) {
        return permission == null || permission.isBlank() ? List.of() : List.of(permission);
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/admin/auth/AdminAuthService.java
@@ -111,6 +111,16 @@
    @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
@@ -122,7 +132,8 @@
        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;
        }
talent-card-server/src/main/java/com/talentcard/server/business/merchant/ManualConsumptionService.java
@@ -164,19 +164,32 @@
    }
    /**
     * 免费入住券要显示「超过免费天数按门市价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();
    }
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantVerificationService.java
@@ -24,6 +24,8 @@
    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 位数字核销码:顾客屏幕上的数字码,相机不好用或屏幕反光时手输兜底。
@@ -70,6 +72,10 @@
        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, "二维码已失效、不可核销或不属于当前商户");
    }
@@ -191,6 +197,47 @@
        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("""
talent-card-server/src/main/java/com/talentcard/server/business/portal/BenefitVerificationCodeController.java
@@ -40,6 +40,15 @@
                .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(
talent-card-server/src/main/java/com/talentcard/server/business/portal/CarWashMerchantPortalService.java
@@ -159,7 +159,7 @@
            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) {
talent-card-server/src/main/java/com/talentcard/server/service/BenefitVerificationCodeService.java
@@ -27,12 +27,14 @@
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;
@@ -50,12 +52,14 @@
    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,
@@ -65,6 +69,7 @@
        this.quotaService = quotaService;
        this.merchantMapper = merchantMapper;
        this.codeMapper = codeMapper;
        this.ticketPackageClaimMapper = ticketPackageClaimMapper;
        this.ticketPackageService = ticketPackageService;
        this.couponMapper = couponMapper;
        this.claimMapper = claimMapper;
@@ -78,14 +83,39 @@
        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);
        }
    }
talent-card-server/src/main/java/com/talentcard/server/service/CarWashJumpConfigAdminService.java
@@ -63,7 +63,7 @@
            current.setMerchantId(id);
            current.setTalentLevels("卓越人才卡、杰出人才卡、菁英人才卡");
            current.setServicePointText("请咨询商户");
            current.setTipText("到店后请按现场指引操作");
            current.setTipText("到店后按现场指引操作,可直接享受洗车权益。");
            current.setDeleted(0);
            apply(current, config, request.status());
            serviceMapper.insert(current);
talent-card-server/src/test/java/com/talentcard/server/business/admin/auth/AdminAuthInterceptorMappingTests.java
New file
@@ -0,0 +1,151 @@
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;
    }
}
talent-card-server/src/test/java/com/talentcard/server/business/merchant/FreeStayExceedDiscountTextTests.java
New file
@@ -0,0 +1,54 @@
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;
    }
}