feat: C 端套餐沉默期展示与自助延期
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 23m46s
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 23m46s
- 套餐历史展示沉默期状态、截止日期、累计延期与调整审批中,可延期时显示延期入口 - 新增延期页:资格、档位与自定义天数试算、支付方式、下单(request_id 幂等) - 支付宝走现有支付页;微信为链接时在微信外直接跳转,否则展示二维码与复制链接 - 新增我的延期订单列表与详情、沉默期记录,沉默期错误码转客户提示
This commit is contained in:
@@ -8,3 +8,4 @@ export { popupApi } from './modules/popup.js';
|
||||
export { realnameApi } from './modules/realname.js';
|
||||
export { walletApi } from './modules/wallet.js';
|
||||
export { wechatApi } from './modules/wechat.js';
|
||||
export { packageSilenceApi } from './modules/packageSilence.js';
|
||||
|
||||
@@ -28,8 +28,22 @@ export const normalizeAssetInfo = (data = {}) => {
|
||||
};
|
||||
};
|
||||
|
||||
const toBool = (value) => value === true || value === 1 || value === 'true';
|
||||
|
||||
// 沉默期对象:总开关关闭或不适用时后端不返回,保持为 null。
|
||||
export const normalizePackageSilence = (silence) => {
|
||||
if (!silence || typeof silence !== 'object') return null;
|
||||
return {
|
||||
...silence,
|
||||
extendable: toBool(silence.extendable),
|
||||
silence_adjust_pending: toBool(silence.silence_adjust_pending),
|
||||
silence_extended_days: Number(silence.silence_extended_days) || 0
|
||||
};
|
||||
};
|
||||
|
||||
const normalizePackageHistoryNode = (node = {}) => ({
|
||||
...node,
|
||||
silence: normalizePackageSilence(node.silence),
|
||||
children: Array.isArray(node.children)
|
||||
? node.children.filter(child => Number(child?.status) !== 4).map(normalizePackageHistoryNode)
|
||||
: [],
|
||||
|
||||
61
api/modules/packageSilence.js
Normal file
61
api/modules/packageSilence.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import request from '@/utils/request.js';
|
||||
|
||||
// 实名即生效套餐沉默期 H5 接口(后端 Iteration/8-11,/api/c/v1/package-silence)。
|
||||
// 资格、试算、下单关闭默认错误提示,由页面按沉默期错误码给出客户能看懂的文案。
|
||||
const BASE = '/api/c/v1/package-silence';
|
||||
|
||||
export const packageSilenceApi = {
|
||||
getEligibility(package_usage_id) {
|
||||
return request({
|
||||
url: `${BASE}/eligibility`,
|
||||
method: 'GET',
|
||||
data: { package_usage_id },
|
||||
showError: false
|
||||
});
|
||||
},
|
||||
|
||||
quote(package_usage_id, days) {
|
||||
return request({
|
||||
url: `${BASE}/quote`,
|
||||
method: 'GET',
|
||||
data: { package_usage_id, days },
|
||||
showError: false
|
||||
});
|
||||
},
|
||||
|
||||
createOrder({ package_usage_id, days, payment_method, request_id }) {
|
||||
return request({
|
||||
url: `${BASE}/orders`,
|
||||
method: 'POST',
|
||||
data: { package_usage_id, days, payment_method, request_id },
|
||||
showError: false
|
||||
});
|
||||
},
|
||||
|
||||
getOrders(params = {}) {
|
||||
const data = {};
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') data[key] = value;
|
||||
});
|
||||
return request({
|
||||
url: `${BASE}/orders`,
|
||||
method: 'GET',
|
||||
data
|
||||
});
|
||||
},
|
||||
|
||||
getOrder(id) {
|
||||
return request({
|
||||
url: `${BASE}/orders/${id}`,
|
||||
method: 'GET'
|
||||
});
|
||||
},
|
||||
|
||||
getRecords(package_usage_id) {
|
||||
return request({
|
||||
url: `${BASE}/records`,
|
||||
method: 'GET',
|
||||
data: { package_usage_id }
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -30,6 +30,43 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="silence" class="silence-info">
|
||||
<view class="silence-header flex-row-sb">
|
||||
<view class="silence-title">实名沉默期</view>
|
||||
<view class="silence-status" :class="getSilenceStatusClass(silence.silence_status)">
|
||||
{{ silence.silence_status_name || '-' }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="info-row flex-row-sb">
|
||||
<view class="info-label">截止时间</view>
|
||||
<view class="info-value">{{ formatSilenceDate(silence.silence_deadline_at) }}</view>
|
||||
</view>
|
||||
<view v-if="silence.silence_period_days" class="info-row flex-row-sb">
|
||||
<view class="info-label">沉默期天数</view>
|
||||
<view class="info-value">{{ silence.silence_period_days }} 天</view>
|
||||
</view>
|
||||
<view v-if="silence.silence_extended_days > 0" class="info-row flex-row-sb">
|
||||
<view class="info-label">累计延期</view>
|
||||
<view class="info-value">{{ silence.silence_extended_days }} 天</view>
|
||||
</view>
|
||||
<view v-if="silence.silence_adjust_pending" class="silence-pending">调整审批中</view>
|
||||
<view class="silence-actions">
|
||||
<view
|
||||
class="silence-link"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@tap.stop="openSilenceRecords"
|
||||
>沉默期记录</view>
|
||||
<view
|
||||
v-if="silence.extendable"
|
||||
class="silence-extend"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@tap.stop="openSilenceExtension"
|
||||
>延期</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="divider"></view>
|
||||
|
||||
<view class="flow-info">
|
||||
@@ -80,6 +117,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { formatSilenceDate, getSilenceStatusClass } from '@/utils/silence.js';
|
||||
|
||||
const props = defineProps({
|
||||
node: {
|
||||
@@ -92,6 +130,19 @@
|
||||
}
|
||||
});
|
||||
|
||||
const silence = computed(() => props.node.silence || null);
|
||||
const openSilenceExtension = () => {
|
||||
if (!silence.value?.extendable || !props.node.package_usage_id) return;
|
||||
uni.navigateTo({
|
||||
url: `/pages/silence-extension/silence-extension?package_usage_id=${props.node.package_usage_id}`
|
||||
});
|
||||
};
|
||||
const openSilenceRecords = () => {
|
||||
if (!props.node.package_usage_id) return;
|
||||
uni.navigateTo({
|
||||
url: `/pages/silence-records/silence-records?package_usage_id=${props.node.package_usage_id}`
|
||||
});
|
||||
};
|
||||
const children = computed(() => Array.isArray(props.node.children) ? props.node.children : []);
|
||||
const hasChildren = computed(() => children.value.length > 0);
|
||||
const isMasterMissing = computed(() => props.node.relationship_status === 'master_missing');
|
||||
@@ -147,6 +198,73 @@
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.silence-info {
|
||||
margin-top: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
border-radius: 16rpx;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.silence-header {
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.silence-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.silence-status {
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 22rpx;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-primary);
|
||||
|
||||
&.silence-waiting {
|
||||
color: #0a84ff;
|
||||
background: rgba(10, 132, 255, 0.12);
|
||||
}
|
||||
|
||||
&.silence-expired {
|
||||
color: var(--danger);
|
||||
background: var(--danger-light);
|
||||
}
|
||||
|
||||
&.silence-ended {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.silence-pending {
|
||||
margin-top: var(--space-sm);
|
||||
font-size: 24rpx;
|
||||
color: #ff9f0a;
|
||||
}
|
||||
|
||||
.silence-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 24rpx;
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.silence-link {
|
||||
padding: 10rpx 0;
|
||||
font-size: 24rpx;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.silence-extend {
|
||||
padding: 10rpx 32rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 24rpx;
|
||||
color: #fff;
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.package-node {
|
||||
margin-bottom: var(--space-md);
|
||||
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
## 1. 接口与工具
|
||||
|
||||
- [ ] 1.1 新增 `api/modules/packageSilence.js`(eligibility、quote、createOrder、getOrders、getOrder、getRecords),在 `api/index.js` 导出
|
||||
- [ ] 1.2 确认 `normalizeAssetPackageHistory` 保留 `silence` 对象(含加油包子节点),补充布尔字段 `extendable`、`silence_adjust_pending` 归一化
|
||||
- [ ] 1.3 新增 `utils/silence-errors.js`:错误码 1263~1274 与资格 `reason` 的客户提示文案,1265/1266 拼接范围
|
||||
- [ ] 1.4 新增延期支付方式过滤:`allowed_payment_methods` 与 `wechat`、`alipay` 取交集
|
||||
- [x] 1.1 新增 `api/modules/packageSilence.js`(eligibility、quote、createOrder、getOrders、getOrder、getRecords),在 `api/index.js` 导出
|
||||
- [x] 1.2 确认 `normalizeAssetPackageHistory` 保留 `silence` 对象(含加油包子节点),补充布尔字段 `extendable`、`silence_adjust_pending` 归一化
|
||||
- [x] 1.3 新增 `utils/silence.js`(错误码与延期纯函数,代替原计划的 silence-errors.js):错误码 1263~1274 与资格 `reason` 的客户提示文案,1265/1266 拼接范围
|
||||
- [x] 1.4 新增延期支付方式过滤:`allowed_payment_methods` 与 `wechat`、`alipay` 取交集
|
||||
|
||||
## 2. 套餐历史展示
|
||||
|
||||
- [ ] 2.1 `PackageHistoryNode.vue` 增加沉默期区域:状态名、截止日期、沉默期天数、累计延期天数、"调整审批中";`silence` 为空不渲染
|
||||
- [ ] 2.2 `extendable = true` 时显示"延期"入口,跳转延期页并带 `package_usage_id`
|
||||
- [ ] 2.3 有 `silence` 的套餐显示"沉默期记录"入口
|
||||
- [ ] 2.4 套餐历史页增加"我的延期订单"入口;从延期成功返回时刷新列表
|
||||
- [x] 2.1 `PackageHistoryNode.vue` 增加沉默期区域:状态名、截止日期、沉默期天数、累计延期天数、"调整审批中";`silence` 为空不渲染
|
||||
- [x] 2.2 `extendable = true` 时显示"延期"入口,跳转延期页并带 `package_usage_id`
|
||||
- [x] 2.3 有 `silence` 的套餐显示"沉默期记录"入口
|
||||
- [x] 2.4 套餐历史页增加"我的延期订单"入口;从延期成功返回时刷新列表
|
||||
|
||||
## 3. 延期页
|
||||
|
||||
- [ ] 3.1 新建 `pages/silence-extension/silence-extension` 并注册 `pages.json`
|
||||
- [ ] 3.2 加载资格:不能办理时展示原因;有 `pending_order_no` 时提示并提供订单详情入口;1274 显示功能未开放
|
||||
- [ ] 3.3 展示当前截止日期、已过期天数、`options` 档位(计费天数、金额、新截止日),默认选第一档
|
||||
- [ ] 3.4 自定义天数输入:范围与整数校验,防抖调用试算,丢弃过期响应
|
||||
- [ ] 3.5 支付方式选择;无可用方式时提示暂不支持在线延期
|
||||
- [ ] 3.6 下单:生成并复用 `request_id`,提交中防重复,错误码按 1.3 提示
|
||||
- [ ] 3.7 拉起支付:支付宝走现有支付宝支付页;微信按 design D5(链接跳转或二维码);拉起前写待刷新标记
|
||||
- [ ] 3.8 `onShow` 消费待刷新标记后查询订单详情,`completed` 时提示延期成功
|
||||
- [x] 3.1 新建 `pages/silence-extension/silence-extension` 并注册 `pages.json`
|
||||
- [x] 3.2 加载资格:不能办理时展示原因;有 `pending_order_no` 时提示并提供订单详情入口;1274 显示功能未开放
|
||||
- [x] 3.3 展示当前截止日期、已过期天数、`options` 档位(计费天数、金额、新截止日),默认选第一档
|
||||
- [x] 3.4 自定义天数输入:范围与整数校验,防抖调用试算,丢弃过期响应
|
||||
- [x] 3.5 支付方式选择;无可用方式时提示暂不支持在线延期
|
||||
- [x] 3.6 下单:生成并复用 `request_id`,提交中防重复,错误码按 1.3 提示
|
||||
- [x] 3.7 拉起支付:支付宝走现有支付宝支付页;微信按 design D5(链接跳转或二维码);拉起前写待刷新标记(微信:微信外为链接直接跳转,其余进入延期支付页展示二维码与复制链接)
|
||||
- [x] 3.8 `onShow` 消费待刷新标记后查询订单详情,`completed` 时提示延期成功
|
||||
|
||||
## 4. 延期订单与流水
|
||||
|
||||
- [ ] 4.1 新建延期订单列表页(分页、按支付时间筛选、默认当前资产)
|
||||
- [ ] 4.2 新建延期订单详情页:订单号、套餐、输入/计费天数、金额、状态、支付方式、截止时间变化、支付与生效时间;`pending_payment` 且有 `qr_content` 时"继续支付"
|
||||
- [ ] 4.3 新建沉默期流水页:按时间展示 `event_name`、截止时间变化、天数,空状态
|
||||
- [ ] 4.1 新建延期订单列表页(分页、按支付时间筛选、默认当前资产)(已做分页与默认当前资产;按支付时间筛选未做)
|
||||
- [x] 4.2 新建延期订单详情页:订单号、套餐、输入/计费天数、金额、状态、支付方式、截止时间变化、支付与生效时间;`pending_payment` 且有 `qr_content` 时"继续支付"
|
||||
- [x] 4.3 新建沉默期流水页:按时间展示 `event_name`、截止时间变化、天数,空状态
|
||||
|
||||
## 5. 测试与验证
|
||||
|
||||
- [ ] 5.1 在 `tests/` 增加用例:沉默期显示与入口条件、天数校验、`request_id` 复用、错误码映射、支付返回刷新
|
||||
- [ ] 5.2 本地 `build:h5`(development 模式)通过,确认接口指向测试后端
|
||||
- [ ] 5.3 确认延期订单微信支付方式(design Open Questions),按结论调整 3.7
|
||||
- [x] 5.1 在 `tests/` 增加用例:沉默期显示与入口条件、天数校验、`request_id` 复用、错误码映射、支付返回刷新(tests/silence.test.mjs 覆盖天数校验、request_id 复用、错误码映射、支付方式过滤;页面级的显示与入口条件、支付返回刷新未写自动化用例;既有 october-h5 38 项通过)
|
||||
- [x] 5.2 本地 `build:h5`(development 模式)通过,确认接口指向测试后端
|
||||
- [ ] 5.3 确认延期订单微信支付方式(design Open Questions),按结论调整 3.7(测试库微信商户池为 wechat_v2 直连,返回 H5 支付链接,公众号内需实测)
|
||||
- [ ] 5.4 测试环境打开沉默期总开关后,用测试资产走通:展示 → 资格 → 试算 → 下单 → 支付 → 延期生效 → 流水
|
||||
- [ ] 5.5 推送 `iteration/8-11` 部署测试环境(先排查 C 端 CI 未触发构建的原因)
|
||||
|
||||
30
pages.json
30
pages.json
@@ -38,6 +38,36 @@
|
||||
"navigationBarTitleText": "资产套餐历史"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/silence-extension/silence-extension",
|
||||
"style": {
|
||||
"navigationBarTitleText": "沉默期延期"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/silence-payment/silence-payment",
|
||||
"style": {
|
||||
"navigationBarTitleText": "延期支付"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/silence-orders/silence-orders",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的延期订单"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/silence-orders/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "延期订单详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/silence-records/silence-records",
|
||||
"style": {
|
||||
"navigationBarTitleText": "沉默期记录"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/order-list/order-list",
|
||||
"style": {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<view class="container">
|
||||
<view class="history-toolbar">
|
||||
<text class="silence-orders-link" role="button" tabindex="0" @tap="openSilenceOrders">我的延期订单</text>
|
||||
</view>
|
||||
<view v-if="!historyLoaded || (loading && packageList.length === 0)" class="loading-state">
|
||||
<up-loading-icon text="加载中" size="30"></up-loading-icon>
|
||||
</view>
|
||||
@@ -27,6 +30,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import { assetApi } from '@/api/index.js';
|
||||
import PackageHistoryNode from '@/components/PackageHistoryNode.vue';
|
||||
import { useUserStore } from '@/store/index.js';
|
||||
@@ -86,12 +90,39 @@
|
||||
|
||||
const loadMore = () => loadPackageList(true);
|
||||
|
||||
const SILENCE_COMPLETED_KEY = 'silence_extension_completed';
|
||||
const openSilenceOrders = () => {
|
||||
uni.navigateTo({ url: '/pages/silence-orders/silence-orders' });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
uni.removeStorageSync(SILENCE_COMPLETED_KEY);
|
||||
loadPackageList();
|
||||
});
|
||||
|
||||
// 延期成功后返回本页:重新加载第一页,展示新的沉默期截止日期
|
||||
onShow(() => {
|
||||
if (!uni.getStorageSync(SILENCE_COMPLETED_KEY)) return;
|
||||
uni.removeStorageSync(SILENCE_COMPLETED_KEY);
|
||||
page.value = 1;
|
||||
noMore.value = false;
|
||||
loadPackageList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.history-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.silence-orders-link {
|
||||
padding: 8rpx 0;
|
||||
font-size: 26rpx;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
485
pages/silence-extension/silence-extension.vue
Normal file
485
pages/silence-extension/silence-extension.vue
Normal file
@@ -0,0 +1,485 @@
|
||||
<template>
|
||||
<view class="container silence-page">
|
||||
<view v-if="loading" class="loading-state">
|
||||
<up-loading-icon text="加载中" size="30"></up-loading-icon>
|
||||
</view>
|
||||
|
||||
<view v-else-if="blockedMessage" class="card blocked-card">
|
||||
<view class="blocked-title">{{ blockedMessage }}</view>
|
||||
<view v-if="pendingOrderNo" class="blocked-desc">未完成订单:{{ pendingOrderNo }}</view>
|
||||
<view class="blocked-actions">
|
||||
<view v-if="pendingOrderNo" class="btn-apple btn-primary" @tap="openOrders">查看我的延期订单</view>
|
||||
<view class="btn-apple btn-secondary" @tap="goBack">返回</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<template v-else-if="eligibility">
|
||||
<view class="card">
|
||||
<view class="section-title">当前沉默期</view>
|
||||
<view class="info-row flex-row-sb">
|
||||
<view class="info-label">当前截止日期</view>
|
||||
<view class="info-value">{{ eligibility.original_deadline || '-' }}</view>
|
||||
</view>
|
||||
<view v-if="eligibility.overdue_days > 0" class="info-row flex-row-sb">
|
||||
<view class="info-label">已过期</view>
|
||||
<view class="info-value danger">{{ eligibility.overdue_days }} 天</view>
|
||||
</view>
|
||||
<view class="info-row flex-row-sb">
|
||||
<view class="info-label">可延期天数</view>
|
||||
<view class="info-value">{{ eligibility.min_days }}~{{ eligibility.max_days }} 天</view>
|
||||
</view>
|
||||
<view v-if="eligibility.extended_days > 0" class="info-row flex-row-sb">
|
||||
<view class="info-label">已累计延期</view>
|
||||
<view class="info-value">{{ eligibility.extended_days }} 天</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="section-title">选择延期天数</view>
|
||||
<view class="option-list">
|
||||
<view
|
||||
v-for="option in options"
|
||||
:key="option.billed_days"
|
||||
class="option-item"
|
||||
:class="{ active: !customMode && selectedDays === option.billed_days }"
|
||||
@tap="selectOption(option)"
|
||||
>
|
||||
<view class="option-days">{{ option.billed_days }} 天</view>
|
||||
<view class="option-amount">¥{{ formatMoney(option.amount) }}</view>
|
||||
<view class="option-deadline">延至 {{ option.new_deadline }}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="custom-row" :class="{ active: customMode }">
|
||||
<view class="custom-label" @tap="enableCustom">自定义天数</view>
|
||||
<input
|
||||
class="custom-input"
|
||||
type="number"
|
||||
:value="customDays"
|
||||
:placeholder="`${eligibility.min_days}~${eligibility.max_days}`"
|
||||
@focus="enableCustom"
|
||||
@input="onCustomInput"
|
||||
/>
|
||||
<text class="custom-unit">天</text>
|
||||
</view>
|
||||
<view v-if="customError" class="custom-error">{{ customError }}</view>
|
||||
<view v-else-if="customMode && quoting" class="custom-hint">试算中...</view>
|
||||
</view>
|
||||
|
||||
<view v-if="summary" class="card summary-card">
|
||||
<view class="info-row flex-row-sb">
|
||||
<view class="info-label">计费天数</view>
|
||||
<view class="info-value">{{ summary.billed_days }} 天</view>
|
||||
</view>
|
||||
<view class="info-row flex-row-sb">
|
||||
<view class="info-label">新截止日期</view>
|
||||
<view class="info-value">{{ summary.new_deadline }}</view>
|
||||
</view>
|
||||
<view class="info-row flex-row-sb">
|
||||
<view class="info-label">应付金额</view>
|
||||
<view class="info-value amount">¥{{ formatMoney(summary.amount) }}</view>
|
||||
</view>
|
||||
<view class="summary-tip">按每月 30 天计费,不足 30 天按 30 天计。</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="section-title">支付方式</view>
|
||||
<view v-if="paymentOptions.length === 0" class="empty-desc">当前资产暂不支持在线延期,请联系客服办理</view>
|
||||
<view
|
||||
v-for="method in paymentOptions"
|
||||
:key="method.value"
|
||||
class="payment-item flex-row-sb"
|
||||
:class="{ active: paymentMethod === method.value }"
|
||||
@tap="paymentMethod = method.value"
|
||||
>
|
||||
<text>{{ method.label }}</text>
|
||||
<text class="payment-check">{{ paymentMethod === method.value ? '✓' : '' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="footer-actions">
|
||||
<view class="orders-link" @tap="openOrders">我的延期订单</view>
|
||||
<view
|
||||
class="btn-apple btn-primary submit-btn"
|
||||
:class="{ disabled: !canSubmit }"
|
||||
@tap="submit"
|
||||
>{{ submitting ? '提交中...' : '确认支付' }}</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app';
|
||||
import { assetApi, packageSilenceApi } from '@/api/index.js';
|
||||
import { useUserStore } from '@/store/index.js';
|
||||
import { formatMoney } from '@/utils/display.js';
|
||||
import { consumePendingPaymentRefresh } from '@/utils/payment.js';
|
||||
import {
|
||||
createRequestIdKeeper,
|
||||
getSilenceErrorMessage,
|
||||
getSilencePaymentOptions,
|
||||
getSilenceReasonText,
|
||||
validateExtensionDays
|
||||
} from '@/utils/silence.js';
|
||||
import { openSilencePayment, SILENCE_REFRESH_TARGET } from '@/utils/silence-payment.js';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const packageUsageId = ref(0);
|
||||
const loading = ref(true);
|
||||
const eligibility = ref(null);
|
||||
const blockedMessage = ref('');
|
||||
const pendingOrderNo = ref('');
|
||||
const paymentOptions = ref([]);
|
||||
const paymentMethod = ref('');
|
||||
const selectedDays = ref(0);
|
||||
const customMode = ref(false);
|
||||
const customDays = ref('');
|
||||
const customError = ref('');
|
||||
const quoting = ref(false);
|
||||
const quoteResult = ref(null);
|
||||
const submitting = ref(false);
|
||||
const lastOrderId = ref(0);
|
||||
const requestIds = createRequestIdKeeper();
|
||||
let quoteTimer = null;
|
||||
let quoteSeq = 0;
|
||||
|
||||
const options = computed(() => (Array.isArray(eligibility.value?.options) ? eligibility.value.options : []));
|
||||
const chosenDays = computed(() => (customMode.value ? Number(customDays.value) : selectedDays.value));
|
||||
const summary = computed(() => {
|
||||
if (customMode.value) return customError.value ? null : quoteResult.value;
|
||||
return options.value.find((option) => option.billed_days === selectedDays.value) || null;
|
||||
});
|
||||
const canSubmit = computed(() => (
|
||||
!submitting.value && !!summary.value && !!paymentMethod.value && !(customMode.value && quoting.value)
|
||||
));
|
||||
|
||||
const showToast = (title) => uni.showToast({ title, icon: 'none', duration: 2500 });
|
||||
|
||||
const loadEligibility = async () => {
|
||||
loading.value = true;
|
||||
blockedMessage.value = '';
|
||||
pendingOrderNo.value = '';
|
||||
try {
|
||||
const [data, info] = await Promise.all([
|
||||
packageSilenceApi.getEligibility(packageUsageId.value),
|
||||
assetApi.getInfo(userStore.state.identifier).catch(() => null)
|
||||
]);
|
||||
eligibility.value = data;
|
||||
paymentOptions.value = getSilencePaymentOptions(info?.allowed_payment_methods);
|
||||
if (!paymentOptions.value.some((option) => option.value === paymentMethod.value)) {
|
||||
paymentMethod.value = paymentOptions.value[0]?.value || '';
|
||||
}
|
||||
if (data?.pending_order_no) {
|
||||
pendingOrderNo.value = data.pending_order_no;
|
||||
blockedMessage.value = '已有未完成的延期订单,暂不能新建';
|
||||
} else if (!data?.can_extend) {
|
||||
blockedMessage.value = getSilenceReasonText(data?.reason);
|
||||
} else if (!selectedDays.value) {
|
||||
selectedDays.value = options.value[0]?.billed_days || 0;
|
||||
if (!selectedDays.value) customMode.value = true;
|
||||
}
|
||||
} catch (error) {
|
||||
blockedMessage.value = getSilenceErrorMessage(error, '查询延期资格失败,请稍后重试');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const selectOption = (option) => {
|
||||
customMode.value = false;
|
||||
customError.value = '';
|
||||
selectedDays.value = option.billed_days;
|
||||
};
|
||||
|
||||
const enableCustom = () => {
|
||||
customMode.value = true;
|
||||
};
|
||||
|
||||
const runQuote = async (days) => {
|
||||
const seq = ++quoteSeq;
|
||||
quoting.value = true;
|
||||
try {
|
||||
const data = await packageSilenceApi.quote(packageUsageId.value, days);
|
||||
if (seq !== quoteSeq) return;
|
||||
quoteResult.value = data;
|
||||
} catch (error) {
|
||||
if (seq !== quoteSeq) return;
|
||||
quoteResult.value = null;
|
||||
customError.value = getSilenceErrorMessage(error, '试算失败,请稍后重试');
|
||||
} finally {
|
||||
if (seq === quoteSeq) quoting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onCustomInput = (event) => {
|
||||
customMode.value = true;
|
||||
customDays.value = String(event?.detail?.value ?? '').trim();
|
||||
quoteResult.value = null;
|
||||
clearTimeout(quoteTimer);
|
||||
quoteSeq += 1;
|
||||
quoting.value = false;
|
||||
customError.value = customDays.value
|
||||
? validateExtensionDays(customDays.value, eligibility.value?.min_days, eligibility.value?.max_days)
|
||||
: '';
|
||||
if (!customDays.value || customError.value) return;
|
||||
const days = Number(customDays.value);
|
||||
quoteTimer = setTimeout(() => runQuote(days), 500);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit.value) {
|
||||
if (!paymentMethod.value) showToast('请选择支付方式');
|
||||
else if (!summary.value) showToast('请选择延期天数');
|
||||
return;
|
||||
}
|
||||
const days = chosenDays.value;
|
||||
const message = validateExtensionDays(days, eligibility.value?.min_days, eligibility.value?.max_days);
|
||||
if (message) {
|
||||
showToast(message);
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const order = await packageSilenceApi.createOrder({
|
||||
package_usage_id: packageUsageId.value,
|
||||
days,
|
||||
payment_method: paymentMethod.value,
|
||||
request_id: requestIds.get(days, paymentMethod.value)
|
||||
});
|
||||
lastOrderId.value = order?.id || 0;
|
||||
if (!order?.qr_content) {
|
||||
showToast('订单已创建,请在"我的延期订单"中继续支付');
|
||||
openOrders();
|
||||
return;
|
||||
}
|
||||
await openSilencePayment(order, SILENCE_REFRESH_TARGET.EXTENSION);
|
||||
} catch (error) {
|
||||
// 网络异常时后端可能已建单:保留 request_id,客户再次点击会拿到同一笔订单。
|
||||
if (!error?.paymentResultUnknown) requestIds.reset();
|
||||
showToast(getSilenceErrorMessage(error, '下单失败,请稍后重试'));
|
||||
if (Number(error?.code) === 1271) await loadEligibility();
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const checkLastOrder = async () => {
|
||||
if (!lastOrderId.value) return;
|
||||
try {
|
||||
const order = await packageSilenceApi.getOrder(lastOrderId.value);
|
||||
if (order?.status === 'completed') {
|
||||
uni.showToast({ title: '延期成功', icon: 'success' });
|
||||
requestIds.reset();
|
||||
uni.setStorageSync('silence_extension_completed', Date.now());
|
||||
setTimeout(() => uni.navigateBack(), 1200);
|
||||
return;
|
||||
}
|
||||
if (order?.status === 'paid') {
|
||||
showToast('支付成功,延期处理中');
|
||||
return;
|
||||
}
|
||||
uni.navigateTo({ url: `/pages/silence-orders/detail?id=${lastOrderId.value}` });
|
||||
} catch (error) {
|
||||
showToast(getSilenceErrorMessage(error, '查询订单失败'));
|
||||
}
|
||||
};
|
||||
|
||||
const openOrders = () => {
|
||||
uni.navigateTo({ url: '/pages/silence-orders/silence-orders' });
|
||||
};
|
||||
|
||||
const goBack = () => uni.navigateBack();
|
||||
|
||||
onLoad((query) => {
|
||||
packageUsageId.value = Number(query?.package_usage_id) || 0;
|
||||
if (!packageUsageId.value) {
|
||||
loading.value = false;
|
||||
blockedMessage.value = '缺少套餐信息,请从套餐历史进入';
|
||||
return;
|
||||
}
|
||||
loadEligibility();
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
if (consumePendingPaymentRefresh(SILENCE_REFRESH_TARGET.EXTENSION)) checkLastOrder();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.silence-page {
|
||||
padding-bottom: 180rpx;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 200rpx 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin-bottom: var(--space-md);
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.info-row {
|
||||
padding: 10rpx 0;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: var(--text-primary);
|
||||
|
||||
&.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
&.amount {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
.option-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
padding: 24rpx;
|
||||
border: 2rpx solid var(--border-color, #e5e5ea);
|
||||
border-radius: 20rpx;
|
||||
|
||||
&.active {
|
||||
border-color: var(--primary);
|
||||
background: rgba(85, 171, 92, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.option-days {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.option-amount {
|
||||
margin-top: 8rpx;
|
||||
font-size: 28rpx;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.option-deadline {
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.custom-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin-top: 24rpx;
|
||||
padding: 20rpx 24rpx;
|
||||
border: 2rpx solid var(--border-color, #e5e5ea);
|
||||
border-radius: 20rpx;
|
||||
|
||||
&.active {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.custom-label {
|
||||
font-size: 26rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.custom-input {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.custom-error {
|
||||
margin-top: 12rpx;
|
||||
font-size: 24rpx;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.custom-hint,
|
||||
.summary-tip,
|
||||
.empty-desc {
|
||||
margin-top: 12rpx;
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.payment-item {
|
||||
padding: 24rpx 0;
|
||||
font-size: 28rpx;
|
||||
border-bottom: 1rpx solid var(--border-color, #e5e5ea);
|
||||
|
||||
&.active {
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
}
|
||||
|
||||
.payment-check {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
|
||||
background: var(--bg-primary);
|
||||
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.orders-link {
|
||||
font-size: 26rpx;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
min-width: 280rpx;
|
||||
text-align: center;
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.blocked-card {
|
||||
margin-top: 40rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.blocked-title {
|
||||
font-size: 30rpx;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.blocked-desc {
|
||||
margin-top: 16rpx;
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.blocked-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
margin-top: 40rpx;
|
||||
}
|
||||
</style>
|
||||
132
pages/silence-orders/detail.vue
Normal file
132
pages/silence-orders/detail.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<view class="container">
|
||||
<view v-if="loading && !order" class="loading-state">
|
||||
<up-loading-icon text="加载中" size="30"></up-loading-icon>
|
||||
</view>
|
||||
<view v-else-if="!order" class="card empty-card">未找到延期订单</view>
|
||||
<template v-else>
|
||||
<view class="card">
|
||||
<view class="detail-header flex-row-sb">
|
||||
<view class="detail-title">{{ order.package_name || '套餐沉默期延期' }}</view>
|
||||
<view class="detail-status">{{ order.status_name || order.status }}</view>
|
||||
</view>
|
||||
<view v-for="row in rows" :key="row.label" class="detail-row flex-row-sb">
|
||||
<text class="label">{{ row.label }}</text>
|
||||
<text class="value" :class="row.className">{{ row.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="canContinuePay" class="btn-apple btn-primary pay-btn" @tap="continuePay">继续支付</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app';
|
||||
import { packageSilenceApi } from '@/api/index.js';
|
||||
import { formatDateTime, formatMoney } from '@/utils/display.js';
|
||||
import { consumePendingPaymentRefresh } from '@/utils/payment.js';
|
||||
import { formatSilenceDate, getSilenceErrorMessage, SILENCE_PAYMENT_LABELS } from '@/utils/silence.js';
|
||||
import { openSilencePayment, SILENCE_REFRESH_TARGET } from '@/utils/silence-payment.js';
|
||||
|
||||
const orderId = ref(0);
|
||||
const order = ref(null);
|
||||
const loading = ref(false);
|
||||
|
||||
const rows = computed(() => {
|
||||
const item = order.value || {};
|
||||
return [
|
||||
{ label: '订单号', value: item.order_no || '-' },
|
||||
{ label: '输入天数', value: item.input_days ? `${item.input_days} 天` : '-' },
|
||||
{ label: '计费 / 延长天数', value: item.extension_days ? `${item.extension_days} 天` : '-' },
|
||||
{ label: '金额', value: `¥${formatMoney(item.amount)}`, className: 'amount' },
|
||||
{ label: '支付方式', value: SILENCE_PAYMENT_LABELS[item.payment_method] || item.payment_method || '-' },
|
||||
{ label: '原截止日期', value: formatSilenceDate(item.before_deadline_at) },
|
||||
{ label: '延期后截止日期', value: formatSilenceDate(item.expected_deadline_at) },
|
||||
{ label: '支付时间', value: formatDateTime(item.paid_at) },
|
||||
{ label: '延期生效时间', value: formatDateTime(item.completed_at) },
|
||||
{ label: '下单时间', value: formatDateTime(item.created_at) }
|
||||
];
|
||||
});
|
||||
|
||||
const canContinuePay = computed(() => order.value?.status === 'pending_payment' && !!order.value?.qr_content);
|
||||
|
||||
const load = async () => {
|
||||
if (!orderId.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
order.value = await packageSilenceApi.getOrder(orderId.value);
|
||||
} catch (error) {
|
||||
uni.showToast({ title: getSilenceErrorMessage(error, '加载订单失败'), icon: 'none' });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const continuePay = async () => {
|
||||
try {
|
||||
await openSilencePayment(order.value, SILENCE_REFRESH_TARGET.ORDER_DETAIL);
|
||||
} catch (error) {
|
||||
uni.showToast({ title: error?.message || '拉起支付失败', icon: 'none' });
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
orderId.value = Number(query?.id) || 0;
|
||||
});
|
||||
|
||||
onShow(async () => {
|
||||
const fromPayment = consumePendingPaymentRefresh(SILENCE_REFRESH_TARGET.ORDER_DETAIL);
|
||||
await load();
|
||||
if (fromPayment && order.value?.status === 'completed') {
|
||||
uni.showToast({ title: '延期成功', icon: 'success' });
|
||||
uni.setStorageSync('silence_extension_completed', Date.now());
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.loading-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 200rpx 0;
|
||||
}
|
||||
|
||||
.empty-card {
|
||||
margin-top: 40rpx;
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-status {
|
||||
font-size: 26rpx;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
padding: 10rpx 0;
|
||||
font-size: 26rpx;
|
||||
|
||||
.label {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.amount {
|
||||
color: var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
.pay-btn {
|
||||
margin-top: 32rpx;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
170
pages/silence-orders/silence-orders.vue
Normal file
170
pages/silence-orders/silence-orders.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<view class="container">
|
||||
<view v-if="!loaded || (loading && orders.length === 0)" class="loading-state">
|
||||
<up-loading-icon text="加载中" size="30"></up-loading-icon>
|
||||
</view>
|
||||
<view v-else-if="orders.length === 0" class="empty-state">
|
||||
<view class="empty-title">暂无延期订单</view>
|
||||
<view class="empty-desc">沉默期过期后可在套餐历史中办理延期</view>
|
||||
</view>
|
||||
<view v-else>
|
||||
<view
|
||||
v-for="order in orders"
|
||||
:key="order.id"
|
||||
class="card order-card"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@tap="openDetail(order)"
|
||||
>
|
||||
<view class="order-header flex-row-sb">
|
||||
<view class="order-name">{{ order.package_name || '套餐沉默期延期' }}</view>
|
||||
<view class="order-status" :class="`status-${order.status}`">{{ order.status_name || order.status }}</view>
|
||||
</view>
|
||||
<view class="order-row flex-row-sb">
|
||||
<text class="label">延期天数</text>
|
||||
<text>{{ order.extension_days || order.input_days }} 天</text>
|
||||
</view>
|
||||
<view class="order-row flex-row-sb">
|
||||
<text class="label">金额</text>
|
||||
<text class="amount">¥{{ formatMoney(order.amount) }}</text>
|
||||
</view>
|
||||
<view class="order-row flex-row-sb">
|
||||
<text class="label">下单时间</text>
|
||||
<text>{{ formatDateTime(order.created_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="load-more">
|
||||
<text v-if="loading">加载中...</text>
|
||||
<text v-else-if="noMore">没有更多订单</text>
|
||||
<text v-else role="button" tabindex="0" @tap="loadMore">点击加载更多</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import { packageSilenceApi } from '@/api/index.js';
|
||||
import { useUserStore } from '@/store/index.js';
|
||||
import { formatDateTime, formatMoney } from '@/utils/display.js';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const orders = ref([]);
|
||||
const loading = ref(false);
|
||||
const loaded = ref(false);
|
||||
const noMore = ref(false);
|
||||
const page = ref(1);
|
||||
const pageSize = 10;
|
||||
|
||||
const load = async (append = false) => {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const requestPage = append ? page.value : 1;
|
||||
const data = await packageSilenceApi.getOrders({
|
||||
page: requestPage,
|
||||
page_size: pageSize,
|
||||
asset_identifier: userStore.state.identifier
|
||||
});
|
||||
const items = Array.isArray(data?.items) ? data.items : [];
|
||||
orders.value = append ? [...orders.value, ...items] : items;
|
||||
const total = Number(data?.total) || 0;
|
||||
noMore.value = requestPage * pageSize >= total;
|
||||
page.value = requestPage + 1;
|
||||
} catch (error) {
|
||||
console.error('加载延期订单失败', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loaded.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const loadMore = () => {
|
||||
if (!noMore.value) load(true);
|
||||
};
|
||||
|
||||
const openDetail = (order) => {
|
||||
uni.navigateTo({ url: `/pages/silence-orders/detail?id=${order.id}` });
|
||||
};
|
||||
|
||||
onShow(() => load(false));
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400rpx;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
margin-top: 12rpx;
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.order-card {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.order-header {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.order-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.order-status {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-secondary);
|
||||
|
||||
&.status-completed {
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
&.status-pending_payment {
|
||||
color: #ff9f0a;
|
||||
}
|
||||
|
||||
&.status-closed,
|
||||
&.status-rejected,
|
||||
&.status-refunded {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
&.status-exception {
|
||||
color: var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
.order-row {
|
||||
padding: 6rpx 0;
|
||||
font-size: 24rpx;
|
||||
|
||||
.label {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.amount {
|
||||
color: var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
.load-more {
|
||||
padding: var(--space-lg);
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
104
pages/silence-payment/silence-payment.vue
Normal file
104
pages/silence-payment/silence-payment.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<view class="container silence-pay-page">
|
||||
<view v-if="!payment" class="card empty-card">未找到待支付的延期订单,请在"我的延期订单"中查看</view>
|
||||
<template v-else>
|
||||
<view class="card">
|
||||
<view class="pay-title">微信支付</view>
|
||||
<view class="pay-amount">¥{{ formatMoney(payment.amount) }}</view>
|
||||
<view class="pay-order">订单号:{{ payment.orderNo || '-' }}</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="method-title">扫码支付</view>
|
||||
<view class="qr-wrap">
|
||||
<u-qrcode :val="payment.content" :size="220" unit="px" :show-loading="false"></u-qrcode>
|
||||
</view>
|
||||
<view class="qr-desc">长按识别二维码,或截图后用微信"扫一扫"支付</view>
|
||||
</view>
|
||||
|
||||
<view v-if="isLink" class="card">
|
||||
<view class="method-title">浏览器支付</view>
|
||||
<view class="qr-desc">复制链接后在手机浏览器中打开完成支付</view>
|
||||
<view class="btn-apple btn-secondary copy-btn" @tap="copyLink">复制支付链接</view>
|
||||
</view>
|
||||
|
||||
<view class="btn-apple btn-primary done-btn" @tap="finish">我已完成支付</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import { formatMoney } from '@/utils/display.js';
|
||||
import { isPaymentLink } from '@/utils/silence.js';
|
||||
import { getPendingSilencePayment } from '@/utils/silence-payment.js';
|
||||
|
||||
const payment = ref(null);
|
||||
const isLink = computed(() => isPaymentLink(payment.value?.content));
|
||||
|
||||
const copyLink = () => {
|
||||
uni.setClipboardData({
|
||||
data: payment.value.content,
|
||||
success: () => uni.showToast({ title: '已复制', icon: 'success' })
|
||||
});
|
||||
};
|
||||
|
||||
// 返回上一页(延期页或订单详情),由上一页 onShow 查询订单状态
|
||||
const finish = () => uni.navigateBack();
|
||||
|
||||
onShow(() => {
|
||||
payment.value = getPendingSilencePayment();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.silence-pay-page {
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pay-title,
|
||||
.method-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pay-amount {
|
||||
margin-top: 16rpx;
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.pay-order {
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.qr-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 32rpx 0;
|
||||
}
|
||||
|
||||
.qr-desc {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.copy-btn,
|
||||
.done-btn {
|
||||
margin-top: 24rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-card {
|
||||
margin-top: 40rpx;
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
</style>
|
||||
88
pages/silence-records/silence-records.vue
Normal file
88
pages/silence-records/silence-records.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<view class="container">
|
||||
<view v-if="loading" class="loading-state">
|
||||
<up-loading-icon text="加载中" size="30"></up-loading-icon>
|
||||
</view>
|
||||
<view v-else-if="records.length === 0" class="empty-state">
|
||||
<view class="empty-title">暂无沉默期记录</view>
|
||||
</view>
|
||||
<view v-else class="timeline">
|
||||
<view v-for="record in records" :key="record.id" class="card record-card">
|
||||
<view class="record-header flex-row-sb">
|
||||
<view class="record-name">{{ record.event_name || record.event_type }}</view>
|
||||
<view class="record-time">{{ formatDateTime(record.created_at) }}</view>
|
||||
</view>
|
||||
<view v-if="record.before_deadline_at || record.after_deadline_at" class="record-row">
|
||||
截止日期:{{ formatSilenceDate(record.before_deadline_at) }} → {{ formatSilenceDate(record.after_deadline_at) }}
|
||||
</view>
|
||||
<view v-if="record.period_days" class="record-row">沉默期天数:{{ record.period_days }} 天</view>
|
||||
<view v-if="record.change_days" class="record-row">变化天数:{{ record.change_days > 0 ? "+" : "" }}{{ record.change_days }} 天</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { onLoad } from '@dcloudio/uni-app';
|
||||
import { packageSilenceApi } from '@/api/index.js';
|
||||
import { formatDateTime } from '@/utils/display.js';
|
||||
import { formatSilenceDate } from '@/utils/silence.js';
|
||||
|
||||
const records = ref([]);
|
||||
const loading = ref(true);
|
||||
|
||||
onLoad(async (query) => {
|
||||
const packageUsageId = Number(query?.package_usage_id) || 0;
|
||||
if (!packageUsageId) {
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await packageSilenceApi.getRecords(packageUsageId);
|
||||
records.value = Array.isArray(data) ? data : [];
|
||||
} catch (error) {
|
||||
console.error('加载沉默期记录失败', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400rpx;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 28rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.record-card {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.record-header {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.record-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.record-time,
|
||||
.record-row {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.record-row {
|
||||
padding: 4rpx 0;
|
||||
}
|
||||
</style>
|
||||
65
tests/silence.test.mjs
Normal file
65
tests/silence.test.mjs
Normal file
@@ -0,0 +1,65 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createRequestIdKeeper,
|
||||
getSilenceErrorMessage,
|
||||
getSilencePaymentOptions,
|
||||
getSilenceReasonText,
|
||||
getSilenceStatusClass,
|
||||
isPaymentLink,
|
||||
isSilenceFeatureDisabled,
|
||||
isSilenceOrderFinal,
|
||||
validateExtensionDays
|
||||
} from '../utils/silence.js';
|
||||
|
||||
test('延期支付方式只取 wechat、alipay 与资产允许方式的交集,支付宝优先', () => {
|
||||
assert.deepEqual(getSilencePaymentOptions(['wechat', 'wallet', 'alipay']).map((item) => item.value), ['alipay', 'wechat']);
|
||||
assert.deepEqual(getSilencePaymentOptions(['wallet']), []);
|
||||
assert.deepEqual(getSilencePaymentOptions(undefined), []);
|
||||
});
|
||||
|
||||
test('延期天数校验:整数且在 min~max 内', () => {
|
||||
assert.equal(validateExtensionDays('30', 30, 180), '');
|
||||
assert.equal(validateExtensionDays(180, 30, 180), '');
|
||||
assert.equal(validateExtensionDays('29', 30, 180), '延期天数需在 30~180 天之间');
|
||||
assert.equal(validateExtensionDays('181', 30, 180), '延期天数需在 30~180 天之间');
|
||||
assert.equal(validateExtensionDays('12.5', 30, 180), '请输入整数天数');
|
||||
assert.equal(validateExtensionDays('', 30, 180), '请输入整数天数');
|
||||
});
|
||||
|
||||
test('request_id:同一天数与支付方式复用,任一变化或重置后生成新的', () => {
|
||||
let seq = 0;
|
||||
const keeper = createRequestIdKeeper(() => `id-${++seq}`);
|
||||
assert.equal(keeper.get(30, 'alipay'), 'id-1');
|
||||
assert.equal(keeper.get(30, 'alipay'), 'id-1');
|
||||
assert.equal(keeper.get(60, 'alipay'), 'id-2');
|
||||
assert.equal(keeper.get(60, 'wechat'), 'id-3');
|
||||
keeper.reset();
|
||||
assert.equal(keeper.get(60, 'wechat'), 'id-4');
|
||||
});
|
||||
|
||||
test('沉默期错误码转客户提示', () => {
|
||||
assert.equal(getSilenceErrorMessage({ code: 1266, data: { min_days: 30, max_days: 180 } }), '延期天数需在 30~180 天之间');
|
||||
assert.equal(getSilenceErrorMessage({ code: 1265, data: { min_months: 1, max_months: 6 } }), '超过可延期期限,可延期 1~6 个月');
|
||||
assert.equal(getSilenceErrorMessage({ code: 1263, data: { reason: 'not_expired' } }), '套餐沉默期尚未过期,暂不需要延期');
|
||||
assert.equal(getSilenceErrorMessage({ code: 1267 }), '免费延期次数已用完');
|
||||
assert.equal(getSilenceErrorMessage({ code: 1271 }), '已有未完成的延期订单,请先完成支付或等待其关闭');
|
||||
assert.equal(getSilenceErrorMessage({ code: 1274 }), '延期功能暂未开放');
|
||||
assert.equal(getSilenceErrorMessage({ code: 1001, msg: '参数错误' }), '参数错误');
|
||||
assert.equal(getSilenceErrorMessage({ paymentResultUnknown: true }), '网络异常,请稍后在"我的延期订单"中查看结果');
|
||||
assert.equal(getSilenceErrorMessage({}, '兜底'), '兜底');
|
||||
assert.equal(isSilenceFeatureDisabled({ code: 1274 }), true);
|
||||
assert.equal(isSilenceFeatureDisabled({ code: 1266 }), false);
|
||||
});
|
||||
|
||||
test('资格原因、状态样式、订单终态与支付链接判断', () => {
|
||||
assert.equal(getSilenceReasonText('extension_disabled'), '该套餐不支持付费延期,请联系客服');
|
||||
assert.equal(getSilenceReasonText('unknown'), '当前暂不能办理延期,请联系客服');
|
||||
assert.equal(getSilenceStatusClass('expired'), 'silence-expired');
|
||||
assert.equal(getSilenceStatusClass('other'), '');
|
||||
assert.equal(isSilenceOrderFinal('completed'), true);
|
||||
assert.equal(isSilenceOrderFinal('pending_payment'), false);
|
||||
assert.equal(isPaymentLink('https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?x=1'), true);
|
||||
assert.equal(isPaymentLink('weixin://wxpay/bizpayurl?pr=abc'), false);
|
||||
assert.equal(isPaymentLink(''), false);
|
||||
});
|
||||
50
utils/silence-payment.js
Normal file
50
utils/silence-payment.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { markPendingPaymentRefresh, openAlipayPayment } from '@/utils/payment.js';
|
||||
import { isPaymentLink } from '@/utils/silence.js';
|
||||
|
||||
export const SILENCE_REFRESH_TARGET = {
|
||||
EXTENSION: 'silence-extension',
|
||||
ORDER_DETAIL: 'silence-order-detail'
|
||||
};
|
||||
|
||||
const SILENCE_PAYMENT_DATA_KEY = 'pending_silence_payment_data';
|
||||
|
||||
export const getPendingSilencePayment = () => uni.getStorageSync(SILENCE_PAYMENT_DATA_KEY) || null;
|
||||
|
||||
const isInWechat = () => typeof navigator !== 'undefined' && /micromessenger/i.test(navigator.userAgent);
|
||||
|
||||
/**
|
||||
* 用延期订单返回的 qr_content 拉起支付。
|
||||
* - 支付宝:走现有支付宝支付页(浏览器打开链接 / 截图扫码)。
|
||||
* - 微信:后端给延期订单返回的是微信 H5 支付链接或扫码内容,不是公众号支付参数。
|
||||
* 在微信外且为链接时直接跳转;其余情况进入延期支付页展示二维码与链接。
|
||||
*/
|
||||
export const openSilencePayment = (order, refreshTarget) => {
|
||||
const content = String(order?.qr_content || '').trim();
|
||||
if (!content) return Promise.reject(new Error('支付内容为空'));
|
||||
|
||||
if (order.payment_method === 'alipay') {
|
||||
return openAlipayPayment(content, refreshTarget);
|
||||
}
|
||||
|
||||
markPendingPaymentRefresh(refreshTarget);
|
||||
uni.setStorageSync(SILENCE_PAYMENT_DATA_KEY, {
|
||||
orderId: order.id,
|
||||
orderNo: order.order_no,
|
||||
amount: order.amount,
|
||||
content,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
if (isPaymentLink(content) && !isInWechat() && typeof window !== 'undefined') {
|
||||
window.location.href = content;
|
||||
return Promise.resolve({ redirecting: true });
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/silence-payment/silence-payment',
|
||||
success: () => resolve({ redirecting: true }),
|
||||
fail: () => reject(new Error('打开支付页失败'))
|
||||
});
|
||||
});
|
||||
};
|
||||
129
utils/silence.js
Normal file
129
utils/silence.js
Normal file
@@ -0,0 +1,129 @@
|
||||
// 沉默期展示、延期下单的纯函数工具(不依赖 uni,便于测试)。
|
||||
|
||||
export const SILENCE_PAYMENT_METHODS = ['alipay', 'wechat'];
|
||||
|
||||
export const SILENCE_PAYMENT_LABELS = {
|
||||
alipay: '支付宝支付',
|
||||
wechat: '微信支付'
|
||||
};
|
||||
|
||||
// 资格接口 reason:付费延期不能办理的原因
|
||||
const REASON_TEXT = {
|
||||
not_expired: '套餐沉默期尚未过期,暂不需要延期',
|
||||
no_silence: '该套餐没有沉默期,无需延期',
|
||||
extension_disabled: '该套餐不支持付费延期,请联系客服',
|
||||
no_policy: '暂未开放延期,请联系客服',
|
||||
exceeds_limit: '已超过可延期期限,请联系客服',
|
||||
refunded: '该套餐已退款,不能延期',
|
||||
order_pending: '已有未完成的延期订单,请先完成或等待其关闭',
|
||||
activated: '套餐已激活,无需延期',
|
||||
adjust_pending: '沉默期调整审批中,请稍后再试'
|
||||
};
|
||||
|
||||
export const getSilenceReasonText = (reason) => REASON_TEXT[reason] || '当前暂不能办理延期,请联系客服';
|
||||
|
||||
const rangeText = (data, minKey, maxKey, unit) => {
|
||||
const min = Number(data?.[minKey]);
|
||||
const max = Number(data?.[maxKey]);
|
||||
if (Number.isFinite(min) && Number.isFinite(max) && max > 0) return `${min}~${max} ${unit}`;
|
||||
return '';
|
||||
};
|
||||
|
||||
/**
|
||||
* 沉默期错误码转客户提示;不是沉默期错误码时返回后端 msg 或兜底文案。
|
||||
* error 为 request.js reject 的原始响应(含 code、msg、data)。
|
||||
*/
|
||||
export const getSilenceErrorMessage = (error, fallback = '操作失败,请稍后重试') => {
|
||||
const code = Number(error?.code);
|
||||
const data = error?.data || {};
|
||||
switch (code) {
|
||||
case 1263:
|
||||
return data.reason ? getSilenceReasonText(data.reason) : '套餐当前不需要延期';
|
||||
case 1264:
|
||||
return '该套餐不支持付费延期,请联系客服';
|
||||
case 1265: {
|
||||
const range = rangeText(data, 'min_months', 'max_months', '个月');
|
||||
return range ? `超过可延期期限,可延期 ${range}` : '超过可延期期限,请联系客服';
|
||||
}
|
||||
case 1266: {
|
||||
const range = rangeText(data, 'min_days', 'max_days', '天');
|
||||
return range ? `延期天数需在 ${range}之间` : '延期天数不在允许范围';
|
||||
}
|
||||
case 1267:
|
||||
return '免费延期次数已用完';
|
||||
case 1268:
|
||||
return '沉默期已过期的套餐不能退款';
|
||||
case 1270:
|
||||
return '暂未开放延期,请联系客服';
|
||||
case 1271:
|
||||
return '已有未完成的延期订单,请先完成支付或等待其关闭';
|
||||
case 1274:
|
||||
return '延期功能暂未开放';
|
||||
default:
|
||||
if (error?.paymentResultUnknown) return '网络异常,请稍后在"我的延期订单"中查看结果';
|
||||
return error?.msg || error?.message || fallback;
|
||||
}
|
||||
};
|
||||
|
||||
export const isSilenceFeatureDisabled = (error) => Number(error?.code) === 1274;
|
||||
|
||||
/** 延期可用支付方式:资产允许的支付方式与 wechat、alipay 取交集,支付宝优先 */
|
||||
export const getSilencePaymentOptions = (allowedMethods) => {
|
||||
const allowed = Array.isArray(allowedMethods) ? allowedMethods : [];
|
||||
return SILENCE_PAYMENT_METHODS
|
||||
.filter((method) => allowed.includes(method))
|
||||
.map((value) => ({ value, label: SILENCE_PAYMENT_LABELS[value] }));
|
||||
};
|
||||
|
||||
/** 天数校验:整数且在 [min, max];返回错误文案,合法返回空串 */
|
||||
export const validateExtensionDays = (days, min, max) => {
|
||||
const value = Number(days);
|
||||
if (days === '' || days === null || days === undefined || !Number.isInteger(value)) {
|
||||
return '请输入整数天数';
|
||||
}
|
||||
const lower = Number(min) || 1;
|
||||
const upper = Number(max) || 0;
|
||||
if (value < lower || (upper > 0 && value > upper)) {
|
||||
return upper > 0 ? `延期天数需在 ${lower}~${upper} 天之间` : `延期天数不能少于 ${lower} 天`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
/** 下单幂等键:时间戳 + 随机串,最长 64 */
|
||||
export const createSilenceRequestId = () => {
|
||||
const random = Math.random().toString(36).slice(2, 12);
|
||||
return `h5-silence-${Date.now()}-${random}`.slice(0, 64);
|
||||
};
|
||||
|
||||
/**
|
||||
* 管理一次提交的 request_id:同一组 (天数, 支付方式) 的重试复用,任一变化后生成新的。
|
||||
*/
|
||||
export const createRequestIdKeeper = (generate = createSilenceRequestId) => {
|
||||
let current = null;
|
||||
return {
|
||||
get(days, paymentMethod) {
|
||||
const key = `${days}|${paymentMethod}`;
|
||||
if (!current || current.key !== key) current = { key, id: generate() };
|
||||
return current.id;
|
||||
},
|
||||
reset() {
|
||||
current = null;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/** 沉默期状态颜色 */
|
||||
export const getSilenceStatusClass = (status) => ({
|
||||
waiting: 'silence-waiting',
|
||||
expired: 'silence-expired',
|
||||
ended: 'silence-ended'
|
||||
}[status] || '');
|
||||
|
||||
/** 延期订单状态是否已结束(不会再变化) */
|
||||
export const isSilenceOrderFinal = (status) => ['completed', 'closed', 'rejected', 'refunded'].includes(status);
|
||||
|
||||
/** qr_content 是否为可直接跳转的支付链接 */
|
||||
export const isPaymentLink = (content) => /^https?:\/\//i.test(String(content || '').trim());
|
||||
|
||||
/** 沉默期流水 / 订单日期:只取到日 */
|
||||
export const formatSilenceDate = (value) => (value ? String(value).replace('T', ' ').slice(0, 10) : '-');
|
||||
Reference in New Issue
Block a user