shinnosuke
2026-09-18 c2f0ec3b3891bc177bdc58c2a111ab9c4fea911d
更新....
4个文件已添加
29个文件已修改
737 ■■■■■ 已修改文件
.scratch/settlement-record-display/issues/01-settlement-status-missing-processing-code.md 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/settlement-record-display/issues/02-settlement-query-502-through-dev-proxy.md 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/zero-amount-order-refund/issues/01-zero-pay-order-refund-rejected.md 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages.json 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/consume-input/consume-input.uvue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/free-stay-confirm/free-stay-confirm.uvue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/order-detail/order-detail.uvue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/orders/orders.uvue 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/product-detail/product-detail.uvue 196 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/scan/scan.uvue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/verification-code-input/verification-code-input.uvue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/verification-confirm/verification-confirm.uvue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/verification-detail/verification-detail.uvue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/services/agreement.uts 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/services/merchant-management.uts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/services/orders.uts 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/services/verification.uts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/style.css 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/EquityImageView.vue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantCreateView.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MovieTicketView.vue 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/TalentNewsEditView.vue 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/TalentNewsPublishView.vue 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/TalentPolicyEditView.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/TalentPolicyPublishView.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/ManualConsumptionService.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantManagementService.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantProductUpdateRequest.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantVerificationService.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/MedicalPackagePortalService.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantManagementServiceTests.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/portal/MedicalPackagePortalServiceTests.java 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/settlement-record-display/issues/01-settlement-status-missing-processing-code.md
New file
@@ -0,0 +1,46 @@
# 财务管理-结算记录:状态列部分行显示成空的/裸字母标签
Status: ready-for-agent
领域: 支付链路(自动结算与结算记录)
来源: 用户工单「【财务管理】结算记录:状态没有显示出来」(截图:2026-09-18 两条流水状态列红框为空标签)
日期: 2026-09-18
## 现象
「财务管理 → 结算记录」列表里,状态列**大部分行正常显示「已结算」**,但**最新日期(2026-09-18)的两条**
(个人进件验证商户、景区服务-卡)只显示一个几乎空白的小标签——实际是标签里只有**一个裸字母 `I`**,
看起来像"状态没显示出来"。
## 根因
状态值直接来自斗拱结算流水的 `trans_stat`,前端只映射了三个值,其余原样输出:
- 后端:`MerchantSettlementAdminService.decode(...)` 第 116 行 `row.put("status", record.get("trans_stat"))`;
- 前端:[MerchantSettlementView.vue](D:/Code/talent-card/talent-card-admin/src/views/MerchantSettlementView.vue:54)
  `settleStatusLabel()` 只映射了 `S→已结算 / F→结算失败 / P→处理中`,其它值走 `|| String(value || '-')` 原样显示。
当天还没结算完成的流水,斗拱返回的是 **`trans_stat = "I"`(处理中/未终态)**,不在映射表里,于是标签里
只剩下一个裸的 `I`。更早的流水都是 `S`,所以显示正常——这也解释了为什么"只有最新的两行看起来是空的"。
## 证据
1. 截图:2026-09-18 两行状态列是空标签,其余 2026-09-17 / 09-15 / 08-29 / 08-26 都是「已结算」。
2. 代码里 `I` 是确实存在的交易状态:
   - `PendingPaymentConfirmationTests:105` `"trans_stat", "I", "resp_code", "00000000"`;
   - `MerchantVerificationServiceTests:228` `"trans_stat", "I", "resp_code", "00000000"`;
   - `PaymentReconciliationService:629` 把 `"I"` 和失败/未找到一起归为"未终态"。
3. 通道状态取值说明见 `talent-card-server/docs/huifu-payment-integration.md:103`
   (`trans_stat`:`S`/`SUCCESS`/`PAID`、`F`/`FAILED`、`P`/`PROCESSING`/`PENDING` 等)。
## 建议改法(支付链路负责人)
1. 前端 `settleStatusLabel()` / `settleStatusType()` 补上缺失取值,至少:
   `I → 处理中`(`warning`),并顺手兼容长写法 `SUCCESS/FAILED/PROCESSING/PENDING`(与后端文档口径一致)。
2. 更稳的做法是后端 `decode()` 里把 `trans_stat` **归一化**(`I`/`PROCESSING`/`PENDING` → `P`,
   `SUCCESS`/`PAID` → `S`,`FAILED` → `F`),前端只认三种值,避免以后每出现一个新码就再崩一次。
3. 另:结算记录页的「状态」筛选下拉目前是空的(`statusOptions` 对 `settlements` 返回 `[]`,
   `MerchantSettlementView.vue:36`),归一化后建议一并把 `S/F/P` 三个选项补上,方便按状态筛流水。
## Comments
- 2026-09-18:由「其余业务」侧的会话只读排查后归档(按仓库约定,支付链路代码未改动)。待转交支付链路负责人。
.scratch/settlement-record-display/issues/02-settlement-query-502-through-dev-proxy.md
New file
@@ -0,0 +1,66 @@
# 财务管理-结算记录:点「查询」偶发 502(Request failed with status code 502)
Status: needs-triage
领域: 支付链路(自动结算与结算记录)+ 管理后台公共 http 客户端
来源: 用户工单「【结算记录】查询报错了 这个是什么问题 现在无法重现」(截图:2026-09-18 页面顶部红色提示 Request failed with status code 502,表格里仍是上一次查询出来的数据)
日期: 2026-09-18
## 现象
「财务管理 → 结算记录」点「查询」时,页面顶部弹出红色提示 `Request failed with status code 502`。
列表本身已经渲染出数据(说明**上一次**查询是成功的),失败的是**这一次**请求。用户随后重试无法复现。
## 根因
这个 502 **不是结算记录接口自己返回的**,而是「前端 → 后端」之间代理层的报错:代理向上游发起请求时
连接出错(后端不在监听 / 连接被中途重置),Vite 的代理中间件就固定回一个 **502 + 空 body**。
三处证据:
1. **文案只可能来自这里**:`talent-card-admin/src/api/http.ts:36-38` 的提示是
   `error.response?.data?.message || error.message`。后端所有业务异常都会带 JSON `{code, message}`,
   只有响应体里没有 `message`(例如代理回的 502 空 body)时才会退化成 axios 兜底文案
   `Request failed with status code 502`。
2. **Vite 代理正好是 502 + 空 body**:`talent-card-admin/node_modules/vite/dist/node/chunks/node.js:18674-18681`
   —— `proxy.on('error', ...) { ... res.writeHead(502, { 'Content-Type': 'text/plain' }).end() }`;
   同时终端里会打一行 `http proxy error: /api/...` 加 `err.stack`。
3. **后端代码里没有 502**:`talent-card-server` 的 java 与 resources 全量搜索无 `502`/`Bad Gateway`,
   接口出错走的是 Bean Validation / `BusinessException`(JSON 响应),不会产生代理级状态码。
所以「无法重现」是预期的:触发条件是**那一瞬间的连接失败**,常见来源——
- 后端正好在重启(IDEA 里重新运行 / `mvn` 重新编译部署),端口有一段不可连接的空窗;
- 代理复用了 Tomcat 已经关掉的 keep-alive 长连接,Vite 侧收到 RST 后直接把这次请求判成 502;
- 后端进程被重启/挂起,请求在写响应前连接就断了。
## 关联的稳定性隐患(同一个接口,值得一并处理)
结算记录这条链路本身很"重",商户一多就更容易撞上上面任何一种超时/重试:
- `MerchantSettlementAdminService.settlements(...)`
  (`talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java:68-83`)
  是**逐商户串行**调用斗拱结算流水接口:`for (merchant : merchants) settlementRecordGateway.query(...)`,
  没有并发、没有超时上限、没有缓存。单次「查询」的成本 = 已进件商户数 × 一次跨公网 HTTPS 往返。
- 当前库里符合条件的商户(`status='approved'` 且 `provider_huifu_id` 非空的每个商户最新一条)是 4 家:
  `喜望商家 6666000239896857`、`存量商户验证专用 6666000226048687`、`个人进件验证商户 6666001804122`、
  `景区服务-卡 6666000242182522`。商户继续变多,整页耗时线性增长。
- 前端 axios 只等 **12 秒**(`talent-card-admin/src/api/http.ts:9` `timeout: 12000`),一旦超过就是
  `timeout of 12000ms exceeded`(另一个不同的报错文案),或用户以为"卡住了"而重复点击、放大后端压力。
- 后端**没有文件日志**(`application.yml:164-166` 只配了 `logging.level`,没有 file appender),
  所以事后查不到当时的 `Huifu settlement record query failed` / 请求耗时——这也是这次"查无实据"的原因之一。
## 建议改法(支付链路负责人 / 前端公共部分)
1. 先按现场日志定性:复现时看 **Vite 终端**有没有 `http proxy error: /api/admin/management/merchant-settlement/settlements`
   (有 = 连接层问题,看 `err.stack` 区分 ECONNREFUSED / ECONNRESET)、**IDEA 控制台**在那个时间点是否在重启。
2. 结算记录接口收敛耗时:商户维度并发查询(或限流并发)+ 单商户调用超时上限 + 结果短时缓存/手动刷新,
   让整页耗时不再随商户数线性增长。
3. 后端加文件日志(或至少把通道查询耗时记进日志),下次出现同类问题能直接判断是"慢"还是"断"。
4. 前端错误提示区分"代理/网关错误"与业务错误:对 `502/504/ECONNABORTED` 给出中文提示,
   而不是把 axios 的英文兜底文案直接弹给用户。
## Comments
- 2026-09-18:由「其余业务」侧的会话只读排查后归档(按仓库约定,支付链路代码未改动)。
  结论:本次 502 是**代理层**的连接失败(后端重启/长连接被重置),不是结算记录接口的业务缺陷;
  但该接口「逐商户串行查斗拱 + 前端 12 秒超时 + 无文件日志」让它天然容易出现这类偶发失败,建议一并收敛。
.scratch/zero-amount-order-refund/issues/01-zero-pay-order-refund-rejected.md
New file
@@ -0,0 +1,52 @@
# 人才端-待使用:实付 0 元的订单申请退款报 Invalid refund amount
Status: ready-for-agent
领域: 支付链路(退款)
来源: 用户工单「【人才小程序】使用记录:无金额的订单进行退款报错了」(截图:待使用详情,实付 ¥0.00,点“申请退款”弹 Invalid refund amount)
日期: 2026-09-18
## 现象
人才端「使用记录 -> 待使用」详情页,**实付金额为 0 元**的订单(商品 20.00 元、优惠金额 -20.00 元、实付 0.00 元,人才折扣直接抵完)点底部「申请退款」,弹出 `Invalid refund amount`(英文 toast),退款申请根本提交不了。库里这种“已支付但实付 0”的活跃订单有 **16 笔**,不是个例。
## 根因
**是后端自己拦的,还没走到斗拱。** `HuifuRefundService.prepare(...)`:
```java
long paidAmount = number(order.get("payAmount"));                                    // 实付 0
long refundableAmount = Math.max(paidAmount - successfulRefund - pendingRefund, 0);  // -> 0
long refundAmount = requestedAmount > 0 ? requestedAmount : refundableAmount;        // -> 0
if (refundAmount <= 0 || refundAmount > refundableAmount)
    throw new BusinessException(ErrorCode.BAD_REQUEST, "Invalid refund amount");      // 在这里抛
```
实付 0 的订单没有可退金额,`refundAmount <= 0` 被直接判成非法请求;而这个英文 message 原样透传到小程序(`pages/pending-use/pending-use.uvue:107` 用 `error.message` 弹 toast)。入口链路:`POST /api/app/pending-use-orders/{orderNo}/refund` -> `OrderService.refund` -> `HuifuRefundService.request`。
## 证据
1. 截图:订单 `P202609171720386015`,商品金额 20.00 / 优惠金额 -20.00 / 实付 0.00,报 `Invalid refund amount`。
2. 库(2026-09-18 只读查询):
```sql
select id, order_no, order_amount, coupon_amount, pay_amount, order_status, verify_status, refund_status
from benefit_order where order_no = 'P202609171720386015';
-- 541 | P202609171720386015 | 2000 | 0 | 0 | 1 | 0 | 0     <- 实付 0(优惠来自人才折扣,不是券)
select count(*) from benefit_order
where deleted = 0 and pay_amount = 0 and order_status = 1 and refund_status = 0;
-- 16                                                        <- 同类订单还有 16 笔
```
3. 代码位置:`talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuRefundService.java:257-263`。
## 建议改法(支付链路负责人)
1. **实付 0 的订单不要走通道退款**:在 `prepare(...)` 里显式分流——`paidAmount == 0` 时按“取消订单”处理(置 `order_status = 2`,释放占用的优惠券 / 权益次数 / 库存),返回“订单已取消”,不调用斗拱(汇付那边没有可退金额,调了也会被拒)。
2. 或者退一步:在 `prepare(...)` 里对 `paidAmount == 0` 抛一个**中文**可读异常(例如“该订单实付 0 元,无需退款”),至少不要给出 `Invalid refund amount` 这种英文技术文案。
3. 前端 `pages/pending-use/pending-use.uvue`:实付 0 的订单,底部按钮建议由「申请退款」改为「取消订单」(或隐藏),成功后刷新订单状态;现在这个按钮在 0 元订单上必然失败。
4. 注意保留现有 `refundAmount <= 0` 的护栏(真实的少退/超退仍要拦),本次只是把“0 元订单”这一类提前分流出去。
## Comments
- 2026-09-18:由「其余业务」侧的会话只读排查后归档(按仓库约定,支付链路代码未改动)。待转交支付链路负责人。
rencaika_merchant/pages.json
@@ -181,6 +181,14 @@
            }
        },
        {
            "path": "pages/order-submit-success/order-submit-success",
            "style": {
                "navigationStyle": "custom",
                "backgroundColor": "#F6F5F2",
                "disableScroll": true
            }
        },
        {
            "path": "pages/free-stay-confirm/free-stay-confirm",
            "style": {
                "navigationStyle": "custom",
rencaika_merchant/pages/consume-input/consume-input.uvue
@@ -148,7 +148,9 @@
        cancelCreateRequest = createManualConsumptionOrder(verificationId.value, selectedRuleId.value, amountValue(), selectedCouponId.value, 'manual-consumption-' + verificationId.value, (order, _traceId) => {
            cancelCreateRequest = null
            submitting.value = false
            uni.showModal({ title: '订单已发送', content: '已向人才发送待支付订单,订单号:' + order.orderNo, showCancel: false, confirmText: '知道了', success: () => uni.navigateBack({ delta: 2 }) })
            // 提交成功进结果页(待用户支付 + 订单号 + 查看订单),不再只弹一个弹窗。
            // redirectTo 是为了让结果页返回时回到扫码页,接着处理下一位顾客。
            uni.redirectTo({ url: '/pages/order-submit-success/order-submit-success?orderNo=' + encodeURIComponent(order.orderNo) })
        }, (error: HttpError) => {
            cancelCreateRequest = null
            submitting.value = false
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></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>
@@ -78,7 +78,9 @@
        customerMobile.value = textOption(options, 'customerMobile', '')
        cardLevel.value = textOption(options, 'cardLevel', '人才卡')
        totalCount.value = Math.max(Number(textOption(options, 'freeStayTotalCount', '5')), 1)
        discountText.value = parseDiscount(textOption(options, 'benefitText', ''))
        // 优先用服务端按门店到店消费折扣算出来的文案;老版本没带这个参数时,仍从权益文案里兜底解析。
        discountText.value = textOption(options, 'discountText', '')
        if (discountText.value.length == 0) discountText.value = parseDiscount(textOption(options, 'benefitText', ''))
        if (verificationId.value.length == 0) loadError.value = '核销凭证无效,请返回重新扫码'
    })
</script>
@@ -101,6 +103,8 @@
    .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折」跟在免费额度后面,间隔比标签再大一点。 */
    .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; }
    .tag-list { display: flex; margin-top: 20rpx; flex-direction: row; flex-wrap: wrap; }
rencaika_merchant/pages/order-detail/order-detail.uvue
@@ -74,7 +74,9 @@
        createdAt.value = detail.createdAt.replace('T', ' ')
        // 已取消/已退款:订单已经离开可核销范围,不能再说成「待核销」,也没有核销时间。
        const closed = detail.status == 'cancelled' || detail.status == 'refunded'
        verificationAt.value = detail.verifyTime.length > 0 ? detail.verifyTime.replace('T', ' ') : (closed ? '—' : '待核销')
        // 商家刚提交、等人才付款的到店消费订单同样还没核销。
        const pendingPayment = detail.status == 'pending_payment'
        verificationAt.value = detail.verifyTime.length > 0 ? detail.verifyTime.replace('T', ' ') : (closed || pendingPayment ? '—' : '待核销')
        customerName.value = detail.customerName
        customerPhone.value = detail.customerMobile
        cardLevel.value = detail.cardLevel.length > 0 ? detail.cardLevel : '人才卡'
@@ -88,6 +90,10 @@
            statusTitle.value = detail.statusText.length > 0 ? detail.statusText : (detail.status == 'cancelled' ? '已取消' : '已退款')
            statusDesc.value = detail.status == 'cancelled' ? '订单在支付前已取消,无需核销。' : '退款已受理或完成,款项退回人才原支付账户,订单不再核销。'
            statusIcon.value = '/static/icons/lucide/x.svg'
        } else if (pendingPayment) {
            statusTitle.value = detail.statusText.length > 0 ? detail.statusText : '待支付'
            statusDesc.value = '订单已发送给人才,等待其完成支付。'
            statusIcon.value = '/static/icons/lucide/clock-3.svg'
        } else {
            statusTitle.value = isCompleted.value ? '已完成' : '待核销'
            statusDesc.value = isCompleted.value ? '订单已完成核销,相关权益已结算。' : '订单已完成支付,请确认后即可核销。'
rencaika_merchant/pages/orders/orders.uvue
@@ -390,13 +390,10 @@
        activeDateField.value = field
        if (field == 'start') {
            setPickerDate(startDateValue.value > 0 ? startDateValue.value : defaultDateValue.value)
        } else {
            if (endDateValue.value == 0) {
                endDateValue.value = startDateValue.value
                endDate.value = startDate.value
            }
            setPickerDate(endDateValue.value)
            return
        }
        // 不再把起始日期抄进截止日期:现在支持开区间,抄一次会把"起始日期之后"悄悄变成"只有这一天"。
        setPickerDate(endDateValue.value > 0 ? endDateValue.value : (startDateValue.value > 0 ? startDateValue.value : defaultDateValue.value))
    }
    const getMaxDay = (year: number, month: number) => {
@@ -407,9 +404,12 @@
    }
    const setPickerDate = (dateValue: number) => {
        const year = Math.floor(dateValue / 10000)
        const month = Math.floor(dateValue / 100) % 100
        const day = dateValue % 100
        // dateValue 为 0 表示这一头还没选过(占位符状态)。以前直接按 0 算,滚轮会跳到"0 年 0 月 0 日"
        // 这种不存在的列上,表现为"点进来滚轮是空的"。统一回落到业务日期。
        const value = dateValue > 0 ? dateValue : defaultDateValue.value
        const year = Math.floor(value / 10000)
        const month = Math.floor(value / 100) % 100
        const day = value % 100
        const yearIndex = year - (currentYear.value - 2)
        months.value = allMonths.slice()
        days.value = allDays.slice(0, getMaxDay(year, month))
@@ -467,15 +467,23 @@
    }
    const confirmDateFilter = () => {
        if (startDateValue.value > 0 && endDateValue.value > 0 && startDateValue.value <= endDateValue.value) {
            datePickerVisible.value = false
            dateFilterText.value = '已选日期'
            dateFilterApplied.value = true
            reloadOrders()
            uni.showToast({ title: '日期区间已应用', icon: 'none' })
        } else {
            uni.showToast({ title: '请选择截止日期', icon: 'none' })
        // 开区间:只选一头也算有效筛选(后端 startDate / endDate 各自可空)。
        // 以前两头都必须选,且不管缺哪一头都提示"请选择截止日期":只选了截止日的人会以为
        // 系统没认下他选的那天,反复点确认也过不去。
        if (startDateValue.value == 0 && endDateValue.value == 0) {
            selectDateField('start')
            uni.showToast({ title: '请选择起始日期或截止日期', icon: 'none' })
            return
        }
        if (startDateValue.value > 0 && endDateValue.value > 0 && startDateValue.value > endDateValue.value) {
            uni.showToast({ title: '起始日不能晚于截止日', icon: 'none' })
            return
        }
        datePickerVisible.value = false
        dateFilterText.value = '已选日期'
        dateFilterApplied.value = true
        reloadOrders()
        uni.showToast({ title: '日期区间已应用', icon: 'none' })
    }
    const openOrder = (orderId: string) => {
rencaika_merchant/pages/product-detail/product-detail.uvue
@@ -12,8 +12,9 @@
                <input class="form-input" :class="nameError.length > 0 ? 'input-invalid' : ''" maxlength="40" v-model="productName" placeholder="请输入" placeholder-class="input-placeholder" @input="clearNameError" />
                <text v-if="nameError.length > 0" class="field-error">{{nameError}}</text>
                <view class="field-label field-spacing"><text class="required-mark">*</text><text>商品金额</text></view>
                <view class="amount-shell" :class="amountError.length > 0 ? 'input-invalid' : ''"><input class="amount-input" type="digit" maxlength="10" v-model="productAmount" placeholder="请输入" placeholder-class="input-placeholder" @input="clearAmountError" /><text class="currency">元</text></view>
                <!-- 体检医院在后台团单里没有「商品金额」,这一块整段不显示。 -->
                <view v-if="!isHealthCheckMerchant" class="field-label field-spacing"><text class="required-mark">*</text><text>商品金额</text></view>
                <view v-if="!isHealthCheckMerchant" class="amount-shell" :class="amountError.length > 0 ? 'input-invalid' : ''"><input class="amount-input" type="digit" maxlength="10" v-model="productAmount" placeholder="请输入" placeholder-class="input-placeholder" @input="clearAmountError" /><text class="currency">元</text></view>
                <text v-if="amountError.length > 0" class="field-error">{{amountError}}</text>
                <view class="field-label field-spacing"><text class="required-mark">*</text><text>商品封面图</text></view>
@@ -22,9 +23,35 @@
                <image class="cover-image" :src="coverImage" mode="aspectFill"></image>
                <text v-if="coverError.length > 0" class="field-error">{{coverError}}</text>
                <view class="change-cover" @tap="changeCover"><text class="change-plus">+</text><text>更换</text></view>
                <!-- 体检医院:可预约时间,对应后台团单的「可预约时间」 -->
                <view v-if="isHealthCheckMerchant" class="field-label field-spacing"><text class="required-mark">*</text><text>可预约时间</text></view>
                <view v-if="isHealthCheckMerchant" class="reserve-range">
                    <picker class="reserve-picker" mode="date" :value="reserveStartTerm" start="2000-01-01" end="2099-12-31" @change="onReserveStartChange">
                        <view class="select-shell" :class="reserveError.length > 0 ? 'field-invalid' : ''"><text :class="reserveStartTerm.length == 0 ? 'select-placeholder' : ''">{{reserveStartTerm.length > 0 ? reserveStartTerm : '开始日期'}}</text></view>
                    </picker>
                    <text class="reserve-separator">至</text>
                    <picker class="reserve-picker" mode="date" :value="reserveEndTerm" start="2000-01-01" end="2099-12-31" @change="onReserveEndChange">
                        <view class="select-shell" :class="reserveError.length > 0 ? 'field-invalid' : ''"><text :class="reserveEndTerm.length == 0 ? 'select-placeholder' : ''">{{reserveEndTerm.length > 0 ? reserveEndTerm : '结束日期'}}</text></view>
                    </picker>
                </view>
                <text v-if="reserveError.length > 0" class="field-error">{{reserveError}}</text>
            </view>
            <view class="form-card detail-card">
            <!-- 体检医院:商品详情就是检查项目表(后台团单同款字段),不再用富文本/JSON 文本框。 -->
            <view v-if="isHealthCheckMerchant" class="form-card detail-card">
                <text class="section-title">商品详情</text>
                <view v-for="(item, index) in checkItems" :key="index" class="check-card">
                    <view class="check-card-head"><text class="check-card-title">检查项目 {{index + 1}}</text><text class="check-remove" @tap="removeCheckItem(index)">删除</text></view>
                    <view class="notice-field"><text class="notice-label">检查项目</text><input class="form-input compact-input" :class="itemError.length > 0 && item.name.length == 0 ? 'input-invalid' : ''" maxlength="40" v-model="item.name" placeholder="请输入" placeholder-class="input-placeholder" /></view>
                    <view class="notice-field"><text class="notice-label">项目意义</text><input class="form-input compact-input" :class="itemError.length > 0 && item.meaning.length == 0 ? 'input-invalid' : ''" maxlength="60" v-model="item.meaning" placeholder="请输入" placeholder-class="input-placeholder" /></view>
                    <view class="notice-field last-notice"><text class="notice-label">适用性别</text><view class="gender-row"><text class="gender-chip" :class="hasGender(item, '男') ? 'gender-chip-active' : ''" @tap="toggleGender(index, '男')">男</text><text class="gender-chip" :class="hasGender(item, '女') ? 'gender-chip-active' : ''" @tap="toggleGender(index, '女')">女</text></view></view>
                </view>
                <text v-if="itemError.length > 0" class="field-error">{{itemError}}</text>
                <view class="change-cover" @tap="addCheckItem"><text class="change-plus">+</text><text>新增检查项目</text></view>
            </view>
            <view v-if="!isHealthCheckMerchant" class="form-card detail-card">
                <text class="section-title">图文详情</text>
                <view class="gallery-row">
                    <image v-for="(imagePath, index) in detailImages" :key="index" class="detail-thumb" :src="imagePath" mode="aspectFill"></image>
@@ -34,7 +61,7 @@
                <textarea class="detail-textarea" maxlength="1000" v-model="detailText" placeholder="请输入" placeholder-class="input-placeholder" :auto-height="false"></textarea>
            </view>
            <view class="form-card notice-card">
            <view v-if="!isHealthCheckMerchant" class="form-card notice-card">
                <text class="section-title">购买须知设置(非必填)</text>
                <view class="notice-field"><text class="notice-label">有效期</text><input class="form-input compact-input" maxlength="80" v-model="validity" placeholder="请输入" placeholder-class="input-placeholder" /></view>
                <view class="notice-field"><text class="notice-label">可用时间</text><input class="form-input compact-input" maxlength="80" v-model="availableTime" placeholder="请输入" placeholder-class="input-placeholder" /></view>
@@ -42,7 +69,7 @@
                <view class="notice-field last-notice"><text class="notice-label">其他规则</text><input class="form-input compact-input" maxlength="120" v-model="otherRules" placeholder="请输入" placeholder-class="input-placeholder" /></view>
            </view>
            <view class="form-card benefits-card">
            <view v-if="!isHealthCheckMerchant" class="form-card benefits-card">
                <text class="section-title">人才权益</text>
                <view class="benefit-row">
                    <view class="benefit-copy"><text class="benefit-name">卓越人才卡</text><text class="benefit-price">¥128.00</text></view>
@@ -90,12 +117,91 @@
    const traceId = ref('')
    const submitting = ref(false)
    const imageUploadCount = ref(0)
    // 体检医院在后台团单里是另一套字段:商品名称 / 封面图 / 可预约时间 / 检查项目,
    // 没有商品金额、图文详情、购买须知、人才权益。商家类型跟着商品详情一起返回。
    const isHealthCheckMerchant = ref(false)
    const reserveStartTerm = ref('')
    const reserveEndTerm = ref('')
    const reserveError = ref('')
    const noticeValidPeriod = ref('')
    const checkItems = ref<any[]>([])
    const itemError = ref('')
    let cancelProductRequest: (() => void) | null = null
    const getDraftStorageKey = (): string => 'merchant-cache-product-draft-' + productId.value
    const emptyCheckItem = (): any => ({ name: '', meaning: '', gender: [] as string[] })
    const serializeCheckItems = (): string => JSON.stringify(checkItems.value)
    const hasGender = (item: any, gender: string): boolean => (item.gender as string[]).indexOf(gender) >= 0
    const toggleGender = (index: number, gender: string) => {
        if (index < 0 || index >= checkItems.value.length) return
        const genders = checkItems.value[index].gender as string[]
        const position = genders.indexOf(gender)
        if (position >= 0) genders.splice(position, 1)
        else genders.push(gender)
        itemError.value = ''
    }
    const addCheckItem = () => {
        checkItems.value.push(emptyCheckItem())
        itemError.value = ''
    }
    const removeCheckItem = (index: number) => {
        if (index < 0 || index >= checkItems.value.length) return
        checkItems.value.splice(index, 1)
        if (checkItems.value.length == 0) checkItems.value.push(emptyCheckItem())
        itemError.value = ''
    }
    /** 检查项目在后台团单里存成 detailText(JSON 数组);坏数据或空值按一行空项目处理。 */
    const parseCheckItems = (text: string): any[] => {
        const items: any[] = []
        const source = text == null ? '' : ('' + text).trim()
        if (source.length > 0 && source.indexOf('[') == 0) {
            try {
                const parsed = JSON.parse(source)
                if (parsed instanceof Array) {
                    const records = parsed as any[]
                    for (let index = 0; index < records.length; index++) {
                        const record = records[index] as any
                        const genders: string[] = []
                        const value = record != null && record.gender != null ? record.gender : []
                        if (value instanceof Array) {
                            const rawGenders = value as any[]
                            for (let position = 0; position < rawGenders.length; position++) genders.push('' + rawGenders[position])
                        }
                        items.push({
                            name: record != null && record.name != null ? '' + record.name : '',
                            meaning: record != null && record.meaning != null ? '' + record.meaning : '',
                            gender: genders
                        })
                    }
                }
            } catch (error) {
            }
        }
        if (items.length == 0) items.push(emptyCheckItem())
        return items
    }
    const isTermDate = (value: string): boolean => {
        const parts = value.split('-')
        return parts.length == 3 && parts[0].length == 4 && parts[1].length > 0 && parts[1].length <= 2 && parts[2].length > 0 && parts[2].length <= 2
    }
    /** 可预约时间后台存成「2026-09-16 至 2026-09-18」,解析不出来就留空让商家重选。 */
    const parseReserveTerms = (value: string): string[] => {
        const parts = (value == null ? '' : '' + value).replace(/\//g, '-').split('至')
        if (parts.length != 2) return ['', '']
        const start = parts[0].trim()
        const end = parts[1].trim()
        return [isTermDate(start) ? start : '', isTermDate(end) ? end : '']
    }
    const buildSnapshot = (): string => {
        return [productName.value, productAmount.value, detailText.value, validity.value, availableTime.value, usage.value, otherRules.value, coverImage.value, detailImages.value.join(',')].join('|')
        return [productName.value, productAmount.value, detailText.value, validity.value, availableTime.value, usage.value, otherRules.value, coverImage.value, detailImages.value.join(','), noticeValidPeriod.value, reserveStartTerm.value, reserveEndTerm.value, serializeCheckItems()].join('|')
    }
    const applyDetail = (detail: MerchantProductDetail) => {
@@ -109,6 +215,12 @@
        otherRules.value = detail.purchaseNotice != null && detail.purchaseNotice.otherRules != null ? '' + detail.purchaseNotice.otherRules : ''
        coverImage.value = detail.coverUrl.length > 0 ? detail.coverUrl : '/static/product-sauerkraut-fish.jpg'
        detailImages.value = detail.productImages.length > 0 ? detail.productImages.slice() : []
        isHealthCheckMerchant.value = detail.merchantTypeCode == 'medical'
        noticeValidPeriod.value = detail.purchaseNotice != null && detail.purchaseNotice.validPeriod != null ? '' + detail.purchaseNotice.validPeriod : ''
        checkItems.value = parseCheckItems(detail.detailText)
        const terms = parseReserveTerms(noticeValidPeriod.value)
        reserveStartTerm.value = terms[0]
        reserveEndTerm.value = terms[1]
    }
    onLoad((options: any) => {
@@ -165,6 +277,10 @@
        usage.value = draft.usage != null ? draft.usage as string : usage.value
        otherRules.value = draft.otherRules != null ? draft.otherRules as string : otherRules.value
        coverImage.value = draft.coverImage != null ? draft.coverImage as string : coverImage.value
        noticeValidPeriod.value = draft.validPeriod != null ? draft.validPeriod as string : noticeValidPeriod.value
        reserveStartTerm.value = draft.reserveStart != null ? draft.reserveStart as string : reserveStartTerm.value
        reserveEndTerm.value = draft.reserveEnd != null ? draft.reserveEnd as string : reserveEndTerm.value
        if (draft.checkItems != null) checkItems.value = parseCheckItems('' + draft.checkItems)
    }
    const navigateBack = () => {
@@ -304,6 +420,8 @@
        nameError.value = ''
        amountError.value = ''
        coverError.value = ''
        reserveError.value = ''
        itemError.value = ''
        productName.value = productName.value.trim()
        productAmount.value = productAmount.value.trim()
        let hasError = false
@@ -311,12 +429,37 @@
            nameError.value = '请输入商品名称'
            hasError = true
        }
        if (productAmount.value.length == 0) {
            amountError.value = '请输入商品金额'
            hasError = true
        } else if (!(/^\d+(\.\d{1,2})?$/).test(productAmount.value) || parseFloat(productAmount.value) <= 0) {
            amountError.value = '请输入大于 0 且最多两位小数的金额'
            hasError = true
        // 体检医院没有「商品金额」这一项(后台按免费保存),其余商家仍然必填。
        if (!isHealthCheckMerchant.value) {
            if (productAmount.value.length == 0) {
                amountError.value = '请输入商品金额'
                hasError = true
            } else if (!(/^\d+(\.\d{1,2})?$/).test(productAmount.value) || parseFloat(productAmount.value) <= 0) {
                amountError.value = '请输入大于 0 且最多两位小数的金额'
                hasError = true
            }
        }
        if (isHealthCheckMerchant.value) {
            if (reserveStartTerm.value.length == 0 || reserveEndTerm.value.length == 0) {
                reserveError.value = '请选择可预约时间'
                hasError = true
            } else if (reserveStartTerm.value > reserveEndTerm.value) {
                reserveError.value = '结束日期不能早于开始日期'
                hasError = true
            }
            if (checkItems.value.length == 0) {
                itemError.value = '请至少添加一项检查项目'
                hasError = true
            } else {
                for (let index = 0; index < checkItems.value.length; index++) {
                    const item = checkItems.value[index]
                    if (('' + item.name).trim().length == 0 || ('' + item.meaning).trim().length == 0 || (item.gender as string[]).length == 0) {
                        itemError.value = '请完善检查项目信息(名称、意义、适用性别均不能为空)'
                        hasError = true
                        break
                    }
                }
            }
        }
        if (coverImage.value.length == 0) {
            coverError.value = '请上传商品封面图'
@@ -325,11 +468,13 @@
        if (hasError) return
        const payload = {
            productName: productName.value,
            salePrice: parseFloat(productAmount.value),
            salePrice: isHealthCheckMerchant.value ? null : parseFloat(productAmount.value),
            coverUrl: toMerchantUploadPath(coverImage.value),
            productImages: detailImages.value.map((item: string) => toMerchantUploadPath(item)),
            detailText: detailText.value,
            purchaseNotice: { validity: validity.value, availableTime: availableTime.value, usage: usage.value, otherRules: otherRules.value }
            detailText: isHealthCheckMerchant.value ? serializeCheckItems() : detailText.value,
            purchaseNotice: isHealthCheckMerchant.value
                ? { validity: validity.value, availableTime: '', validPeriod: reserveStartTerm.value + ' 至 ' + reserveEndTerm.value, usage: usage.value, otherRules: otherRules.value }
                : { validity: validity.value, availableTime: availableTime.value, validPeriod: noticeValidPeriod.value, usage: usage.value, otherRules: otherRules.value }
        }
        const finish = (detail: MerchantProductDetail) => {
            applyDetail(detail)
@@ -359,7 +504,11 @@
            availableTime: availableTime.value,
            usage: usage.value,
            otherRules: otherRules.value,
            coverImage: coverImage.value
            coverImage: coverImage.value,
            validPeriod: noticeValidPeriod.value,
            reserveStart: reserveStartTerm.value,
            reserveEnd: reserveEndTerm.value,
            checkItems: isHealthCheckMerchant.value ? serializeCheckItems() : ''
        })
            uni.showToast({ title: error.message != null ? error.message : '商品保存失败,已保留草稿', icon: 'none' })
        })
@@ -402,6 +551,21 @@
    .notice-field { margin-top: 25rpx; }
    .notice-label { color: #202B3E; font-size: 27rpx; line-height: 38rpx; font-weight: 600; }
    .compact-input { margin-top: 14rpx; }
    /* 体检医院:可预约时间与检查项目(与后台团单同一套字段) */
    .reserve-range { display: flex; flex-direction: row; align-items: center; margin-top: 16rpx; }
    .reserve-picker { flex: 1; }
    .select-shell { display: flex; height: 86rpx; padding: 0 24rpx; flex-direction: row; align-items: center; border-width: 1rpx; border-style: solid; border-color: #F0DDD5; border-radius: 20rpx; background-color: #FFFEFD; box-sizing: border-box; }
    .select-shell text { color: #202B3E; font-size: 27rpx; line-height: 38rpx; }
    .select-placeholder { color: #A8B0BF; }
    .field-invalid { border-color: #F0746D; background-color: #FFF9F8; }
    .reserve-separator { margin: 0 16rpx; color: #7A8496; font-size: 26rpx; line-height: 38rpx; }
    .check-card { padding: 24rpx 22rpx; margin-top: 20rpx; border-radius: 24rpx; background-color: #FBFCFD; }
    .check-card-head { display: flex; flex-direction: row; justify-content: space-between; align-items: center; }
    .check-card-title { color: #202B3E; font-size: 27rpx; line-height: 38rpx; font-weight: 700; }
    .check-remove { color: #F0746D; font-size: 25rpx; line-height: 36rpx; }
    .gender-row { display: flex; flex-direction: row; margin-top: 14rpx; }
    .gender-chip { padding: 10rpx 34rpx; margin-right: 20rpx; border-width: 1rpx; border-style: solid; border-color: #E3E8EF; border-radius: 30rpx; color: #4C5665; font-size: 26rpx; line-height: 38rpx; }
    .gender-chip-active { border-color: #FF9B70; color: #EF7046; background-color: #FFF3EC; }
    .last-notice { margin-bottom: 0; }
    .benefits-card { padding-bottom: 20rpx; }
rencaika_merchant/pages/scan/scan.uvue
@@ -124,6 +124,7 @@
                        + '&freeStayTotalCount=' + encodeURIComponent('' + result.freeStayTotalCount)
                        + '&freeStayRemainingCount=' + encodeURIComponent('' + result.freeStayRemainingCount)
                        + '&benefitText=' + encodeURIComponent(result.benefitText)
                        + '&discountText=' + encodeURIComponent(result.discountText)
                })
                return
            }
@@ -239,7 +240,9 @@
    .corner-br { right: 0; bottom: 0; border-right-width: 7rpx; border-bottom-width: 7rpx; }
    .scan-line { width: 100%; height: 3rpx; position: absolute; top: 0; left: 0; background-color: rgba(133, 255, 192, 0.9); box-shadow: 0 0 15rpx rgba(111, 241, 170, 0.8); animation: scan-line-move 1800ms ease-in-out infinite alternate; }
    .scan-tip { width: 100%; position: absolute; top: 975rpx; left: 0; z-index: 10; color: #FFFFFF; font-size: 27rpx; line-height: 38rpx; text-align: center; }
    .manual-entry { display: flex; width: 320rpx; height: 76rpx; position: absolute; top: 1060rpx; left: 215rpx; z-index: 10; justify-content: center; align-items: center; border-width: 2rpx; border-style: solid; border-color: rgba(255, 255, 255, 0.65); border-radius: 38rpx; color: #FFFFFF; font-size: 27rpx; line-height: 38rpx; }
    /* 这是 cover-view:原生相机层上的覆盖物只认有限样式,flex 居中在部分机型上不生效(文字贴在左边)。
       改成 text-align + 与按钮同高的 line-height,水平垂直都能居中,不依赖 flex。 */
    .manual-entry { display: flex; width: 320rpx; height: 76rpx; position: absolute; top: 1060rpx; left: 215rpx; z-index: 10; justify-content: center; align-items: center; border-width: 2rpx; border-style: solid; border-color: rgba(255, 255, 255, 0.65); border-radius: 38rpx; color: #FFFFFF; font-size: 27rpx; line-height: 76rpx; text-align: center; }
    .status-panel { display: none; }
    .loading-spinner { width: 58rpx; height: 58rpx; border-width: 6rpx; border-style: solid; border-color: #FFD5BF; border-top-color: #FF6B2C; border-radius: 50%; animation: loading-rotate 800ms linear infinite; }
    .status-mark { display: flex; width: 64rpx; height: 64rpx; justify-content: center; align-items: center; border-radius: 32rpx; color: #FFFFFF; font-size: 38rpx; line-height: 64rpx; font-weight: 700; text-align: center; }
rencaika_merchant/pages/verification-code-input/verification-code-input.uvue
@@ -82,7 +82,9 @@
    .code-placeholder { color: #B4BAC4; font-size: 30rpx; font-weight: 400; letter-spacing: 0; }
    .field-error { margin-top: 16rpx; color: #FF633A; font-size: 25rpx; line-height: 36rpx; }
    .tip { margin-top: 18rpx; color: #97A0AE; font-size: 23rpx; line-height: 34rpx; }
    .bottom-bar { display: flex; padding: 24rpx 44rpx; padding-bottom: calc(24rpx + env(safe-area-inset-bottom)); margin-top: auto; }
    /* 必须显式写 flex-direction: row:uvue 里 flex 容器默认是 column,那样按钮的 flex: 1 会作用在高度上
       (flex-basis 0 压过 height),按钮被压成一行字那么高,只剩一条细橙条。 */
    .bottom-bar { display: flex; flex-direction: row; padding: 24rpx 44rpx; padding-bottom: calc(24rpx + env(safe-area-inset-bottom)); margin-top: auto; }
    .submit-button { display: flex; flex: 1; height: 92rpx; justify-content: center; align-items: center; border-radius: 46rpx; background-image: linear-gradient(90deg, #FF7625 0%, #FF962F 100%); }
    .submit-button text { color: #FFFFFF; font-size: 29rpx; font-weight: 700; }
    .submit-disabled { opacity: .55; }
rencaika_merchant/pages/verification-confirm/verification-confirm.uvue
@@ -9,7 +9,7 @@
                <view class="status-icon-wrap"><image class="status-icon" src="/static/icons/lucide/badge-check.svg" mode="aspectFit"></image></view>
            </view>
            <view class="product-card">
                <view class="product-cover"></view>
                <image v-if="productImage.length > 0" class="product-cover" :src="productImage" mode="aspectFill"></image><view v-else class="product-cover"></view>
                <view class="product-copy"><text class="product-name">{{title}}</text><view class="product-price-row"><text class="unit-price">¥{{formatAmount(orderAmount)}}</text><view class="actual-price"><text class="actual-label">实付</text><text class="actual-amount">¥{{formatAmount(payAmount)}}</text></view></view></view>
            </view>
            <text class="section-title">订单信息</text>
@@ -49,6 +49,7 @@
    const couponAmount = ref(0)
    const payAmount = ref(0)
    const subsidyAmount = ref(0)
    const productImage = ref('')
    const createdAt = ref('')
    const loadError = ref('')
    const submitting = ref(false)
@@ -67,6 +68,7 @@
        couponAmount.value = order.couponAmount
        payAmount.value = order.payAmount
        subsidyAmount.value = order.subsidyAmount
        productImage.value = order.productImage
        createdAt.value = order.createdAt
        customerName.value = order.customerName.length > 0 ? order.customerName : customerName.value
        customerMobile.value = order.customerMobile.length > 0 ? order.customerMobile : customerMobile.value
rencaika_merchant/pages/verification-detail/verification-detail.uvue
@@ -6,7 +6,7 @@
            <view v-else-if="loadState == 'error'" class="page-state"><text>{{loadError}}</text><text class="retry-text" @tap="loadOrder">重新加载</text></view>
            <view v-else>
            <view class="status-panel"><view class="status-copy"><text class="status-title" :class="verified ? 'verified-title' : ''">{{verified ? '已完成' : '待核销'}}</text><text class="status-desc">{{verified ? '订单已完成核销。' : '订单已完成支付,请确认后进行核销。'}}</text></view><view class="status-icon-wrap"><image class="status-icon" :src="verified ? '/static/icons/lucide/badge-check.svg' : '/static/icons/lucide/clock-3.svg'" mode="aspectFit"></image></view></view>
            <view class="product-card"><view class="product-cover"></view><view class="product-copy"><text class="product-name">{{title}}</text><view class="product-price-row"><text class="unit-price">¥{{formatAmount(orderAmount)}}</text><view class="actual-price"><text class="actual-label">实付</text><text class="actual-amount">¥{{formatAmount(payAmount)}}</text></view></view></view></view>
            <view class="product-card"><image v-if="productImage.length > 0" class="product-cover" :src="productImage" mode="aspectFill"></image><view v-else class="product-cover"></view><view class="product-copy"><text class="product-name">{{title}}</text><view class="product-price-row"><text class="unit-price">¥{{formatAmount(orderAmount)}}</text><view class="actual-price"><text class="actual-label">实付</text><text class="actual-amount">¥{{formatAmount(payAmount)}}</text></view></view></view></view>
            <text class="section-title">订单信息</text>
            <view class="info-card">
                <view class="info-row"><text class="info-label">订单号</text><text class="info-value">{{orderNo}}</text></view>
@@ -34,6 +34,7 @@
    const orderAmount = ref(0)
    const payAmount = ref(0)
    const subsidyAmount = ref(0)
    const productImage = ref('')
    const createdAt = ref('')
    const verifyTime = ref('')
    const customerName = ref('')
@@ -55,6 +56,7 @@
        orderAmount.value = order.orderAmount
        payAmount.value = order.payAmount
        subsidyAmount.value = order.subsidyAmount
        productImage.value = order.productImage
        createdAt.value = order.createdAt
        verifyTime.value = order.verifyTime
        customerName.value = order.customerName
@@ -74,7 +76,15 @@
    const submitVerification = () => {
        if (submitting.value || orderNo.value.length == 0) return
        submitting.value = true
        confirmMerchantOrderVerification(orderNo.value, '', 'MERCHANT_ORDER', 'merchant-order-verify-' + orderNo.value + '-' + Date.now(), (order: MerchantOrderDetail, _traceId: string) => { submitting.value = false; applyOrder(order); uni.showToast({ title: '核销成功', icon: 'success' }) }, (error: any) => { submitting.value = false; uni.showToast({ title: error.message != null ? error.message : '核销失败,请重试', icon: 'none' }) })
        // 核销成功后统一进结果页(扫码核销、免费入住走的也是这一页),不再只弹一个 toast 停在详情页。
        // redirectTo 是为了让结果页返回时回到订单列表,而不是回到已经核销完的详情页。
        confirmMerchantOrderVerification(orderNo.value, '', 'MERCHANT_ORDER', 'merchant-order-verify-' + orderNo.value + '-' + Date.now(), (order: MerchantOrderDetail, _traceId: string) => {
            submitting.value = false
            uni.redirectTo({ url: '/pages/verification-success/verification-success?orderNo=' + encodeURIComponent(orderNo.value) })
        }, (error: any) => {
            submitting.value = false
            uni.showToast({ title: error.message != null ? error.message : '核销失败,请重试', icon: 'none' })
        })
    }
    const confirmVerification = () => {
rencaika_merchant/services/agreement.uts
@@ -1,4 +1,4 @@
import { getApiEndpoint } from '../config/api.uts'
import { getApiBaseUrl, getApiEndpoint } from '../config/api.uts'
import { HttpError, request } from './http.uts'
export type MerchantAgreement = {
@@ -9,6 +9,37 @@
    state: string
    message: string
    contentHtml: string
}
/**
 * 后台富文本落库时图片/链接存的是相对路径(`/uploads/xxx.png`)。小程序 rich-text 不认相对路径,
 * 直接渲染就是破图(只显示 alt 文字),所以这里统一补成接口域名下的绝对地址。
 *
 * 顺带处理两件后台内容与小程序渲染能力对不齐的事:
 * 1. 去掉 script/style、内联事件属性和 `javascript:` 协议——后台内容不该在小程序里执行脚本;
 * 2. `<s>/<strike>/<u>` 换成 rich-text 支持的 `<del>/<ins>`,并给 `<img>` 补内联样式——
 *    小程序的 rich-text 不继承页面样式,后台图片按原图尺寸会把卡片撑破。
 *
 * 规则与人才端 `rencaika/services/business.uts` 的 sanitizeRichText 保持一致,两端表现不要分叉。
 */
export const sanitizeAgreementHtml = (html: string): string => {
    if (html == null) return ''
    const base = getApiBaseUrl().replace(/\/+$/, '')
    const content = ('' + html)
        .replace(/<script[\s\S]*?<\/script>/gi, '')
        .replace(/<style[\s\S]*?<\/style>/gi, '')
        .replace(/\son[a-z]+\s*=\s*(['"]).*?\1/gi, '')
        .replace(/\s(href|src)\s*=\s*(['"])\s*javascript:[^'"]*\2/gi, '')
    if (base == '') return content
    return content
        .replace(/<\s*(\/?)\s*s(?=[\s>/])([^>]*)>/gi, '<$1del$2>')
        .replace(/<\s*(\/?)\s*strike(?=[\s>/])([^>]*)>/gi, '<$1del$2>')
        .replace(/<\s*(\/?)\s*u(?=[\s>/])([^>]*)>/gi, '<$1ins$2>')
        .replace(/(src|href)=(["'])\/\//gi, '$1=$2https://')
        .replace(/(\s(?:src|href)\s*=\s*(['"]))\/+/gi, '$1' + base + '/')
        .replace(/(\s(?:src|href)\s*=\s*(['"]))(?!https?:|\/\/|\/|data:|javascript:)/gi, '$1' + base + '/')
        .replace(/(<img\b[^>]*?\sstyle=(["']))([^"']*)(\2)/gi, '$1$3max-width:100%;height:auto;display:block;margin:10px auto;$4')
        .replace(/<img\b(?![^>]*\sstyle=)/gi, '<img style="max-width:100%;height:auto;display:block;margin:10px auto;"')
}
export const loadMerchantAgreement = (
@@ -22,5 +53,9 @@
        skipAuth: true,
        skipStoreContext: true,
        skipUnauthorizedRedirect: true
    }, onSuccess, onError)
    }, (agreement: MerchantAgreement) => {
        // 页面拿到的是可直接喂给 rich-text 的内容,不必各自再处理一遍相对路径与标签差异。
        if (agreement != null) agreement.contentHtml = sanitizeAgreementHtml(agreement.contentHtml)
        onSuccess(agreement)
    }, onError)
}
rencaika_merchant/services/merchant-management.uts
@@ -29,6 +29,8 @@
export type MerchantProductDetail = MerchantProduct & {
    productType: string
    /** 商家类型(如 medical = 体检医院):商品表单按类型决定显示哪些字段。 */
    merchantTypeCode: string
    originalPrice: number
    detailText: string
    purchaseNotice: any
@@ -81,6 +83,7 @@
    return {
        ...product,
        productType: stringValue(value != null ? value.productType : ''),
        merchantTypeCode: stringValue(value != null ? value.merchantTypeCode : ''),
        originalPrice: numberValue(value != null ? value.originalPrice : 0),
        detailText: stringValue(value != null ? value.detailText : ''),
        purchaseNotice: value != null && value.purchaseNotice != null ? value.purchaseNotice : {},
rencaika_merchant/services/orders.uts
@@ -1,4 +1,4 @@
import { getApiEndpoint } from '../config/api.uts'
import { getApiBaseUrl, getApiEndpoint } from '../config/api.uts'
import { request, HttpError } from './http.uts'
export type MerchantOrder = {
@@ -34,6 +34,8 @@
    talentDiscountAmount: number
    benefitName: string
    merchantName: string
    /** 商品图(无商品图时回落商家封面图),已补成可直接渲染的地址;都没有则为空。 */
    productImage: string
    canRevoke: boolean
    revokeDeadline: string
    revokeReason: string
@@ -105,6 +107,12 @@
    return field != null ? '' + field : fallback
}
/** 后台存的是相对路径(/uploads/xxx.jpg),小程序要绝对地址才能显示;空值原样返回。 */
const mediaUrl = (value: string): string => {
    if (value.length == 0 || value.indexOf('http://') == 0 || value.indexOf('https://') == 0 || value.indexOf('/static/') == 0) return value
    return value.indexOf('/') == 0 ? getApiBaseUrl().replace(/\/$/, '') + value : value
}
const toOrder = (value: any): MerchantOrder => ({
    id: textValue(value, 'id', 'id'),
    orderNo: textValue(value, 'orderNo', 'order_no'),
@@ -127,6 +135,8 @@
const toOrderDetail = (value: any): MerchantOrderDetail => {
    const order = toOrder(value)
    const productCover = textValue(value, 'productCoverUrl', 'product_cover_url')
    const merchantCover = textValue(value, 'merchantCoverUrl', 'merchant_cover_url')
    return {
        ...order,
        basePayAmount: order.basePayAmount != null ? order.basePayAmount : order.orderAmount,
@@ -134,6 +144,7 @@
        talentDiscountAmount: order.talentDiscountAmount != null ? order.talentDiscountAmount : 0,
        benefitName: textValue(value, 'benefitName', 'benefit_name', order.title),
        merchantName: textValue(value, 'merchantName', 'merchant_name'),
        productImage: mediaUrl(productCover.length > 0 ? productCover : merchantCover),
        canRevoke: fieldValue(value, 'canRevoke', 'can_revoke') == true,
        revokeDeadline: textValue(value, 'revokeDeadline', 'revoke_deadline'),
        revokeReason: textValue(value, 'revokeReason', 'revoke_reason')
rencaika_merchant/services/verification.uts
@@ -16,6 +16,8 @@
    subsidyAmount: number
    freeStayTotalCount: number
    freeStayRemainingCount: number
    /** 免费入住:超过免费天数后的折扣文案(如「门市价9折」,没配折扣时为空)。 */
    discountText: string
    benefitText: string
}
@@ -84,6 +86,7 @@
            subsidyAmount: data.subsidyAmount != null ? Number(data.subsidyAmount) : 0,
            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 : ''
        }
        onSuccess(result, traceId)
talent-card-admin/src/style.css
@@ -3696,6 +3696,13 @@
  line-height: 18px;
}
/* 上传图片处的推荐尺寸提示:各处复用同一套样式,避免每个页面各写一份。 */
.admin-image-size-tip {
  color: #8a94a6;
  font-size: 12px;
  line-height: 18px;
}
.equity-upload-row .el-button {
  width: 72px;
}
talent-card-admin/src/views/EquityImageView.vue
@@ -29,6 +29,19 @@
  }
}
/**
 * 每个分类的顶部大图在小程序里高度并不一样:舒心安居是 590rpx,其余分类都是 730rpx
 * (见 rencaika/pages/stay-benefits 与 premium-benefits/food-benefits/transport-benefits/
 * travel-benefits/shopping-benefits/entertainment-benefits 的 .hero 高度)。
 * 推荐尺寸必须跟着实际展示走,否则商户按提示传图仍会被裁掉。
 */
function imageSizeTip(categoryKey: string) {
  if (categoryKey === 'home' || categoryKey === 'stay') {
    return '推荐尺寸 750 × 590 px(宽高比约 1.27:1,与舒心安居页顶部大图一致)'
  }
  return '推荐尺寸 750 × 730 px(宽高比约 1:1,与小程序顶部大图一致)'
}
const configs = ref<EquityImageCategory[]>([
  {
    categoryKey: 'gift',
@@ -188,7 +201,7 @@
          </div>
          <el-button type="primary" :loading="uploadingImageId === item.id" @click="uploadImage(item)">上传</el-button>
          <el-button v-if="currentConfig().images.length > 1" @click="removeImage(index)">移除</el-button>
          <span class="equity-upload-tip">推荐尺寸 750 × 730 px(宽高比约 1:1,与小程序顶部大图一致)</span>
          <span class="equity-upload-tip">{{ imageSizeTip(currentConfig().categoryKey) }}</span>
        </div>
      </div>
talent-card-admin/src/views/MerchantCreateView.vue
@@ -2575,6 +2575,7 @@
                <el-icon v-else><Picture /></el-icon>
              </div>
              <el-button type="primary" :loading="imageUploading" @click="changeCover('merchant')">更换封面</el-button>
              <div class="admin-image-size-tip">推荐尺寸 750 × 445 px(小程序商家详情页顶部大图)</div>
            </el-form-item>
          </div>
@@ -3031,6 +3032,7 @@
        </el-table>
        <div class="merchant-parking-address-actions">
          <el-button plain :loading="imageUploading" @click="changeCover('fuelHero')">上传图片</el-button>
          <div class="admin-image-size-tip">推荐尺寸 750 × 445 px(小程序商家详情页顶部大图,与封面图同尺寸)</div>
        </div>
      </section>
@@ -3176,6 +3178,7 @@
                  <span v-if="productForm.coverUrl && !productCoverPreviewFailed" class="product-image-zoom">⌕</span>
                </div>
                <el-button type="primary" :loading="imageUploading" @click="changeCover('productCover')">更换封面</el-button>
                <div class="admin-image-size-tip">推荐尺寸 750 × 425 px(小程序商品详情页顶部大图)</div>
              </el-form-item>
              <el-form-item v-if="!isHealthCheckMerchant" class="product-image-row compact" label="商品图片" prop="productImages" required>
@@ -3189,6 +3192,7 @@
                  </div>
                </div>
                <el-button type="primary" :loading="imageUploading" @click="changeCover('productImage')">添加图片</el-button>
                <div class="admin-image-size-tip">推荐尺寸 750 × 425 px(小程序商品详情页顶部大图)</div>
              </el-form-item>
              <div v-if="isHealthCheckMerchant" class="product-form-row health-check-time-row" style="margin-top: 24px;">
talent-card-admin/src/views/MovieTicketView.vue
@@ -585,6 +585,7 @@
                <el-image v-if="form.coverUrl" class="movie-ticket-cover-preview" :src="form.coverUrl" fit="cover" />
                <div v-else class="movie-ticket-cover-placeholder"><span>+</span><small>上传图片</small></div>
              </el-upload>
              <div class="admin-image-size-tip">推荐尺寸 150 × 150 px(小程序电影券列表缩略图,1:1 方图)</div>
            </div>
          </el-form>
talent-card-admin/src/views/TalentNewsEditView.vue
@@ -150,6 +150,7 @@
            <el-image v-if="form.coverUrl" class="article-cover-preview" :src="form.coverUrl" fit="cover" />
            <div v-else class="article-cover-upload-card"><span>+</span><small>上传图片</small></div>
          </el-upload>
          <div class="admin-image-size-tip">推荐尺寸 236 × 168 px(小程序资讯列表缩略图;首页置顶大图会按 712 × 205 裁剪)</div>
        </el-form-item>
        <el-form-item>
talent-card-admin/src/views/TalentNewsPublishView.vue
@@ -103,6 +103,7 @@
            <el-image v-if="form.coverUrl" class="article-cover-preview" :src="form.coverUrl" fit="cover" />
            <div v-else class="article-cover-upload-card"><span>+</span><small>上传图片</small></div>
          </el-upload>
          <div class="admin-image-size-tip">推荐尺寸 236 × 168 px(小程序资讯列表缩略图;首页置顶大图会按 712 × 205 裁剪)</div>
        </el-form-item>
        <el-form-item>
talent-card-admin/src/views/TalentPolicyEditView.vue
@@ -42,6 +42,6 @@
</script>
<template>
  <div class="policy-edit-page" v-loading="loading"><section class="news-meta-panel"><h3>发布信息</h3><div class="news-meta-grid"><div><span>发布人</span><strong>{{ publisher }}</strong></div><div><span>发布时间</span><strong>{{ createdAt }}</strong></div><div><span>修改人</span><strong>{{ editor }}</strong></div><div><span>修改时间</span><strong>{{ updatedAt }}</strong></div></div></section><section class="policy-publish-panel"><h3>政策内容</h3><el-form ref="formRef" class="policy-publish-form" :model="form" :rules="rules" label-width="88px">
    <el-form-item label="政策标题" prop="title"><el-input v-model="form.title" /></el-form-item><el-form-item label="政策标签" prop="subtitle"><el-input v-model="form.subtitle" placeholder="请输入政策标签" /></el-form-item><el-form-item class="news-editor-form-item" label="政策内容" prop="content"><AdminRichTextEditor v-model="form.content" @update:model-value="validateContent" /></el-form-item><el-form-item label="封面图" prop="coverUrl"><el-upload class="article-cover-upload" accept="image/*" :auto-upload="false" :show-file-list="false" :on-change="uploadCover"><el-image v-if="form.coverUrl" class="article-cover-preview" :src="form.coverUrl" fit="cover" /><div v-else class="article-cover-upload-card"><span>+</span><small>上传图片</small></div></el-upload></el-form-item><el-form-item label="是否置顶"><el-switch v-model="form.isTop" /></el-form-item><el-form-item label="排序" prop="sortNo"><el-input v-model.number="form.sortNo" type="number" placeholder="请输入非负整数" /></el-form-item><el-form-item><el-button type="primary" @click="save">保存</el-button></el-form-item>
    <el-form-item label="政策标题" prop="title"><el-input v-model="form.title" /></el-form-item><el-form-item label="政策标签" prop="subtitle"><el-input v-model="form.subtitle" placeholder="请输入政策标签" /></el-form-item><el-form-item class="news-editor-form-item" label="政策内容" prop="content"><AdminRichTextEditor v-model="form.content" @update:model-value="validateContent" /></el-form-item><el-form-item label="封面图" prop="coverUrl"><el-upload class="article-cover-upload" accept="image/*" :auto-upload="false" :show-file-list="false" :on-change="uploadCover"><el-image v-if="form.coverUrl" class="article-cover-preview" :src="form.coverUrl" fit="cover" /><div v-else class="article-cover-upload-card"><span>+</span><small>上传图片</small></div></el-upload><div class="admin-image-size-tip">推荐尺寸 750 × 320 px(小程序政策详情页顶部大图)</div></el-form-item><el-form-item label="是否置顶"><el-switch v-model="form.isTop" /></el-form-item><el-form-item label="排序" prop="sortNo"><el-input v-model.number="form.sortNo" type="number" placeholder="请输入非负整数" /></el-form-item><el-form-item><el-button type="primary" @click="save">保存</el-button></el-form-item>
  </el-form></section></div>
</template>
talent-card-admin/src/views/TalentPolicyPublishView.vue
@@ -54,7 +54,7 @@
    <el-form-item label="政策标题" prop="title" required><el-input v-model="form.title" placeholder="请输入政策标题" /></el-form-item>
    <el-form-item label="政策标签" prop="subtitle" required><el-input v-model="form.subtitle" placeholder="请输入政策标签" /></el-form-item>
    <el-form-item class="news-editor-form-item" label="政策内容" prop="content" required><AdminRichTextEditor v-model="form.content" @update:model-value="validateContent" /></el-form-item>
    <el-form-item label="封面图" prop="coverUrl" required><el-upload class="article-cover-upload" accept="image/*" :auto-upload="false" :show-file-list="false" :on-change="uploadCover"><el-image v-if="form.coverUrl" class="article-cover-preview" :src="form.coverUrl" fit="cover" /><div v-else class="article-cover-upload-card"><span>+</span><small>上传图片</small></div></el-upload></el-form-item>
    <el-form-item label="封面图" prop="coverUrl" required><el-upload class="article-cover-upload" accept="image/*" :auto-upload="false" :show-file-list="false" :on-change="uploadCover"><el-image v-if="form.coverUrl" class="article-cover-preview" :src="form.coverUrl" fit="cover" /><div v-else class="article-cover-upload-card"><span>+</span><small>上传图片</small></div></el-upload><div class="admin-image-size-tip">推荐尺寸 750 × 320 px(小程序政策详情页顶部大图)</div></el-form-item>
    <el-form-item label="是否置顶"><el-switch v-model="form.isTop" /></el-form-item>
    <el-form-item label="排序" prop="sortNo" required><el-input v-model.number="form.sortNo" type="number" placeholder="请输入非负整数" /></el-form-item>
    <el-form-item><el-button type="primary" @click="save">保存</el-button></el-form-item>
talent-card-server/src/main/java/com/talentcard/server/business/merchant/ManualConsumptionService.java
@@ -162,6 +162,20 @@
        }).toList();
    }
    /**
     * 免费入住券要显示「超过免费天数按门市价X折」:X 就是这家店给该人才卡配的到店消费折扣
     * (走和到店消费页同一套解析,结构化配置和旧的门店规则表都能取到)。
     *
     * <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() + "折";
        }
        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/MerchantManagementService.java
@@ -21,7 +21,11 @@
@Service
public class MerchantManagementService {
    private static final List<String> NOTICE_FIELDS = List.of("validity", "availableTime", "usage", "otherRules");
    /**
     * 购买须知里要落库的字段。{@code validPeriod} 是体检医院在后台团单里叫「可预约时间」的那一项,
     * 后台保存时写在同一个 purchase_notice 里,商家端要能读写同一个键,两端才不会各存一份。
     */
    private static final List<String> NOTICE_FIELDS = List.of("validity", "availableTime", "validPeriod", "usage", "otherRules");
    private final JdbcTemplate jdbc;
@@ -41,6 +45,9 @@
        result.put("address", value(store.get("address"), value(merchant.get("address"), "")));
        result.put("coverUrl", value(merchant.get("coverUrl"), ""));
        result.put("businessStatus", isBusinessOpen(merchant, principal.storeId()) ? "open" : "closed");
        // 商家端要按商家类型决定商品表单显示哪些字段(体检医院只配可预约时间 + 检查项目),
        // 类型跟着资料一起返回,页面不用再打一次接口。
        result.put("merchantTypeCode", value(merchant.get("merchantTypeCode"), ""));
        result.put("updatedAt", merchant.get("updatedAt"));
        Long productCount = jdbc.queryForObject("select count(*) from merchant_product where merchant_id = ? and deleted = 0 and status = 1 and audit_status = 2", Long.class, principal.merchantId());
        result.put("onlineProductCount", productCount == null ? 0 : productCount);
@@ -116,7 +123,9 @@
                from merchant_product where id = ? and merchant_id = ? and deleted = 0
                """, productId, principal.merchantId());
        if (records.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "商品不存在");
        return toProductDetail(records.get(0));
        Map<String, Object> detail = toProductDetail(records.get(0));
        detail.put("merchantTypeCode", value(merchant(principal.merchantId()).get("merchantTypeCode"), ""));
        return detail;
    }
    @Transactional
@@ -125,6 +134,15 @@
        product(principal, productId);
        String coverUrl = request.coverUrl() == null ? "" : request.coverUrl().trim();
        if (coverUrl.isEmpty()) throw new BusinessException(ErrorCode.BAD_REQUEST, "请上传商品封面图");
        // 体检医院在后台团单里没有「商品金额」这一项(后台保存时落 0,人才端据此显示「免费」),
        // 所以这里按商家类型区分:非体检医院仍然必须有大于 0 的金额。
        BigDecimal salePrice = request.salePrice();
        if (salePrice == null || salePrice.signum() <= 0) {
            if (!"medical".equals(value(merchant(principal.merchantId()).get("merchantTypeCode"), ""))) {
                throw new BusinessException(ErrorCode.BAD_REQUEST, "商品金额必须大于0");
            }
            salePrice = BigDecimal.ZERO;
        }
        String images = jsonArray(request.productImages() == null ? List.of() : request.productImages());
        Map<String, String> notice = new LinkedHashMap<>();
        for (String field : NOTICE_FIELDS) notice.put(field, request.purchaseNotice() == null ? "" : value(request.purchaseNotice().get(field), ""));
@@ -133,7 +151,7 @@
                set product_name = ?, sale_price = ?, cover_url = ?, product_images = ?, detail_text = ?, purchase_notice = ?,
                    audit_status = 1, status = 0, submit_time = now(), audit_remark = null
                where id = ? and merchant_id = ? and deleted = 0
                """, request.productName().trim(), request.salePrice(), coverUrl, images,
                """, request.productName().trim(), salePrice, coverUrl, images,
                value(request.detailText(), ""), jsonObject(notice), productId, principal.merchantId());
        appendProductAuditRecord(productId);
        return product(principal, productId);
@@ -283,7 +301,7 @@
    private Map<String, Object> merchant(Long merchantId) {
        List<Map<String, Object>> merchants = rows("""
                select id, merchant_name, address, cover_url, business_status, status, updated_at
                select id, merchant_name, merchant_type_code, address, cover_url, business_status, status, updated_at
                from merchant where id = ? and deleted = 0
                """, merchantId);
        if (merchants.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "商户不存在");
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantProductUpdateRequest.java
@@ -4,13 +4,16 @@
import java.util.List;
import java.util.Map;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record MerchantProductUpdateRequest(
        @NotBlank(message = "商品名称不能为空") @Size(max = 128, message = "商品名称不能超过128个字符") String productName,
        @DecimalMin(value = "0.01", message = "商品金额必须大于0") BigDecimal salePrice,
        /**
         * 商品金额。体检医院在后台团单里没有这一项(免费预约),所以不能用注解卡死最小值,
         * 由服务层按商家类型判断:非体检医院必须大于 0。
         */
        BigDecimal salePrice,
        @Size(max = 500, message = "商品封面地址无效") String coverUrl,
        @Size(max = 6, message = "详情图片不能超过6张") List<@Size(max = 500, message = "图片地址无效") String> productImages,
        @Size(max = 10000, message = "图文详情不能超过10000个字符") String detailText,
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantVerificationService.java
@@ -185,6 +185,9 @@
        result.put("freeStayTotalCount", number(code.get("totalCount")));
        result.put("freeStayRemainingCount", Math.max(remaining, 0L));
        result.put("benefitText", freeStayBenefitText(code, principal));
        // 免费天数用完后按门店配置的到店消费折扣计价(如「门市价9折」),免费入住确认页要显示这段。
        result.put("discountText", manualConsumptionService.freeStayExceedDiscountText(
                principal.merchantId(), value(code.get("cardLevel"), ""), value(code.get("sourceAreaType"), "")));
        return result;
    }
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java
@@ -102,6 +102,7 @@
                       o.benefit_id, b.benefit_name,
                       o.merchant_id, m.merchant_name, m.merchant_rights_config,
                       o.product_id, o.product_name_snapshot, p.rights_config,
                       p.cover_url as product_cover_url, m.cover_url as merchant_cover_url,
                       o.order_type, o.order_amount, o.base_pay_amount, o.coupon_amount,
                       o.subsidy_amount, o.pay_amount, o.order_status, o.verify_status, o.refund_status, o.created_at,
                       vr.verify_time, vr.status as verify_record_status
@@ -118,6 +119,7 @@
            List<Map<String, Object>> freeStayRows = query("""
                    select r.id, r.verify_no as order_no, r.talent_id, tu.name as talent_name, tu.mobile as talent_mobile, tu.gender,
                           r.card_id, tc.card_no, tc.card_level, r.merchant_id, m.merchant_name, m.merchant_rights_config,
                           m.cover_url as merchant_cover_url,
                           '免费入住' as benefit_name, '1天免费入住' as product_name_snapshot,
                           'FREE_STAY' as order_type, 0 as order_amount, 0 as base_pay_amount, 0 as coupon_amount,
                           r.subsidy_amount, 0 as pay_amount, 1 as order_status, 1 as verify_status, 0 as refund_status,
talent-card-server/src/main/java/com/talentcard/server/business/portal/MedicalPackagePortalService.java
@@ -46,7 +46,7 @@
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("packageId", row.get("packageId"));
        result.put("name", row.get("packageName"));
        result.put("heroImage", row.get("heroImage"));
        result.put("heroImage", detailHeroImage(row));
        result.put("free", number(row.get("freeStatus")) == 1);
        result.put("freeLabel", number(row.get("freeStatus")) == 1 ? "免费" : "优惠");
        result.put("applicableCards", row.get("applicableCards"));
@@ -99,6 +99,7 @@
        if (packageId == null || packageId <= 0) throw new BusinessException(ErrorCode.BAD_REQUEST, "体检套餐信息不能为空");
        List<Map<String, Object>> rows = query("""
                select p.id as package_id, p.merchant_id, p.product_name as package_name, p.cover_url as hero_image,
                       p.product_images as product_images,
                       case when coalesce(p.sale_price, 0) = 0 then 1 else 0 end as free_status,
                       '' as applicable_cards,
                       coalesce(
@@ -117,6 +118,33 @@
        return rows.get(0);
    }
    /**
     * 详情页大图:优先用商户在「商品详情 → 图文详情」上传的第一张图({@code product_images}),
     * 没配时才回落商品封面图。
     *
     * <p>图文详情才是商户为详情页配的图,封面图只是列表里的缩略图。详情页直接取封面,
     * 商户改了图文详情也看不到任何变化,表现成"详情图片没生效"。
     */
    private String detailHeroImage(Map<String, Object> row) {
        for (String image : imageUrls(row.get("productImages"))) {
            if (!image.isBlank()) return image;
        }
        return text(row.get("heroImage"));
    }
    /** {@code product_images} 是 json 数组列(驱动按字符串返回);内容不是数组或格式坏掉时按"没配"处理。 */
    private List<String> imageUrls(Object value) {
        String json = text(value).trim();
        if (!json.startsWith("[")) return List.of();
        try {
            List<String> parsed = JSON.parseArray(json, String.class);
            if (parsed == null) return List.of();
            return parsed.stream().map(item -> item == null ? "" : item.trim()).toList();
        } catch (RuntimeException ignored) {
            return List.of();
        }
    }
    private Map<String, Object> packageListView(Map<String, Object> row) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("id", row.get("id"));
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantManagementServiceTests.java
@@ -86,6 +86,40 @@
                .isInstanceOf(BusinessException.class);
    }
    /**
     * 体检医院在后台团单里只有「商品名称 / 封面图 / 可预约时间 / 检查项目」,没有商品金额。
     * 商家端照这套字段保存时:可预约时间要落到 purchase_notice.validPeriod,金额允许为空并按 0(免费)落库。
     */
    @Test
    void savesHealthCheckProductWithoutPriceAndKeepsReservationWindow() {
        jdbc.update("update merchant set merchant_type_code = 'medical' where id = ?", merchantId);
        Long productId = jdbc.queryForObject("select id from merchant_product where merchant_id = ? and product_no like 'MANAGEMENT-ONLINE-%'", Long.class, merchantId);
        Map<String, Object> updated = service.updateProduct(principal, productId, new MerchantProductUpdateRequest(
                "查血", null, "https://example.com/cover.jpg", List.of(),
                "[{\"name\":\"查血\",\"meaning\":\"检查血脂\",\"gender\":[\"男\",\"女\"]}]",
                Map.of("validPeriod", "2026-09-16 至 2026-09-18")
        ));
        assertThat(updated).containsEntry("merchantTypeCode", "medical");
        assertThat((BigDecimal) updated.get("salePrice")).isEqualByComparingTo(BigDecimal.ZERO);
        assertThat((Map<String, String>) updated.get("purchaseNotice"))
                .containsEntry("validPeriod", "2026-09-16 至 2026-09-18");
        Map<String, Object> detail = service.product(principal, productId);
        assertThat(String.valueOf(detail.get("detailText"))).contains("检查血脂");
    }
    /** 非体检医院仍然要有商品金额:商家端少了金额字段时不能静默按免费存下去。 */
    @Test
    void rejectsMissingPriceForNonHealthCheckMerchant() {
        Long productId = jdbc.queryForObject("select id from merchant_product where merchant_id = ? and product_no like 'MANAGEMENT-ONLINE-%'", Long.class, merchantId);
        assertThatThrownBy(() -> service.updateProduct(principal, productId, new MerchantProductUpdateRequest(
                "普通商品", null, "https://example.com/cover.jpg", List.of("https://example.com/product-1.jpg"),
                "图文详情", Map.of()
        ))).isInstanceOf(BusinessException.class).hasMessageContaining("商品金额");
    }
    private void insertProduct(String marker, int status, int auditStatus) {
        jdbc.update("""
                insert into merchant_product(merchant_id, product_no, product_type, product_name, cover_url, original_price, sale_price,
talent-card-server/src/test/java/com/talentcard/server/business/portal/MedicalPackagePortalServiceTests.java
New file
@@ -0,0 +1,73 @@
package com.talentcard.server.business.portal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;
/**
 * 体检套餐详情的大图取哪一张:商户在「图文详情」上传的图才是详情页图,封面图只用于列表缩略图。
 */
@ExtendWith(MockitoExtension.class)
class MedicalPackagePortalServiceTests {
    @Mock
    private JdbcTemplate jdbc;
    @InjectMocks
    private MedicalPackagePortalService service;
    @Test
    void detailUsesFirstUploadedDetailImageInsteadOfCover() {
        givenPackageRow("[\"/uploads/detail-1.jpg\",\"/uploads/detail-2.jpg\"]", "/uploads/cover.jpg");
        assertThat(service.detail(38L)).containsEntry("heroImage", "/uploads/detail-1.jpg");
    }
    @Test
    void detailFallsBackToCoverWhenDetailImagesAreEmpty() {
        givenPackageRow("[]", "/uploads/cover.jpg");
        assertThat(service.detail(38L)).containsEntry("heroImage", "/uploads/cover.jpg");
    }
    @Test
    void detailFallsBackToCoverWhenDetailImagesAreNotConfigured() {
        givenPackageRow(null, "/uploads/cover.jpg");
        assertThat(service.detail(38L)).containsEntry("heroImage", "/uploads/cover.jpg");
    }
    private void givenPackageRow(String productImages, String coverUrl) {
        Map<String, Object> row = new LinkedHashMap<>();
        row.put("package_id", 38L);
        row.put("merchant_id", 126L);
        row.put("package_name", "查血");
        row.put("hero_image", coverUrl);
        row.put("product_images", productImages);
        row.put("free_status", 0);
        row.put("applicable_cards", "");
        row.put("open_time", "");
        row.put("stock", 10);
        row.put("status", 1);
        row.put("unavailable_reason", "");
        row.put("detail_text", "[{\"name\":\"查血\",\"meaning\":\"检查血脂\",\"gender\":[\"男\",\"女\"]}]");
        row.put("hospital_name", "体检医院-卡");
        row.put("hospital_address", "天府新谷");
        row.put("hospital_mobile", "13800000000");
        row.put("hospital_latitude", 26.5);
        row.put("hospital_longitude", 101.7);
        when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(List.of(row));
    }
}