feat: C 端套餐沉默期展示与自助延期
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 23m46s

- 套餐历史展示沉默期状态、截止日期、累计延期与调整审批中,可延期时显示延期入口
- 新增延期页:资格、档位与自定义天数试算、支付方式、下单(request_id 幂等)
- 支付宝走现有支付页;微信为链接时在微信外直接跳转,否则展示二维码与复制链接
- 新增我的延期订单列表与详情、沉默期记录,沉默期错误码转客户提示
This commit is contained in:
邹进
2026-11-07 13:19:33 +09:00
parent b2f802e6f8
commit 31ff18d66a
15 changed files with 1500 additions and 22 deletions

View File

@@ -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';

View File

@@ -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)
: [],

View 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 }
});
}
};

View File

@@ -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);

View File

@@ -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 未触发构建的原因)

View File

@@ -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": {

View File

@@ -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;

View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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
View 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) : '-');