diff --git a/api/index.js b/api/index.js
index 3830824..6b5d801 100644
--- a/api/index.js
+++ b/api/index.js
@@ -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';
diff --git a/api/modules/asset.js b/api/modules/asset.js
index ade50d8..de70544 100644
--- a/api/modules/asset.js
+++ b/api/modules/asset.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)
: [],
diff --git a/api/modules/packageSilence.js b/api/modules/packageSilence.js
new file mode 100644
index 0000000..688782b
--- /dev/null
+++ b/api/modules/packageSilence.js
@@ -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 }
+ });
+ }
+};
diff --git a/components/PackageHistoryNode.vue b/components/PackageHistoryNode.vue
index 2de3dfe..815c6e3 100644
--- a/components/PackageHistoryNode.vue
+++ b/components/PackageHistoryNode.vue
@@ -30,6 +30,43 @@
+
+
+
+ 截止时间
+ {{ formatSilenceDate(silence.silence_deadline_at) }}
+
+
+ 沉默期天数
+ {{ silence.silence_period_days }} 天
+
+
+ 累计延期
+ {{ silence.silence_extended_days }} 天
+
+ 调整审批中
+
+ 沉默期记录
+ 延期
+
+
+
@@ -80,6 +117,7 @@
diff --git a/pages/silence-orders/detail.vue b/pages/silence-orders/detail.vue
new file mode 100644
index 0000000..3d51e9b
--- /dev/null
+++ b/pages/silence-orders/detail.vue
@@ -0,0 +1,132 @@
+
+
+
+
+
+ 未找到延期订单
+
+
+
+
+ {{ row.label }}
+ {{ row.value }}
+
+
+ 继续支付
+
+
+
+
+
+
+
diff --git a/pages/silence-orders/silence-orders.vue b/pages/silence-orders/silence-orders.vue
new file mode 100644
index 0000000..d6d5b5c
--- /dev/null
+++ b/pages/silence-orders/silence-orders.vue
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+ 暂无延期订单
+ 沉默期过期后可在套餐历史中办理延期
+
+
+
+
+
+ 延期天数
+ {{ order.extension_days || order.input_days }} 天
+
+
+ 金额
+ ¥{{ formatMoney(order.amount) }}
+
+
+ 下单时间
+ {{ formatDateTime(order.created_at) }}
+
+
+
+ 加载中...
+ 没有更多订单
+ 点击加载更多
+
+
+
+
+
+
+
+
diff --git a/pages/silence-payment/silence-payment.vue b/pages/silence-payment/silence-payment.vue
new file mode 100644
index 0000000..20ed9d7
--- /dev/null
+++ b/pages/silence-payment/silence-payment.vue
@@ -0,0 +1,104 @@
+
+
+ 未找到待支付的延期订单,请在"我的延期订单"中查看
+
+
+ 微信支付
+ ¥{{ formatMoney(payment.amount) }}
+ 订单号:{{ payment.orderNo || '-' }}
+
+
+
+ 扫码支付
+
+
+
+ 长按识别二维码,或截图后用微信"扫一扫"支付
+
+
+
+ 浏览器支付
+ 复制链接后在手机浏览器中打开完成支付
+ 复制支付链接
+
+
+ 我已完成支付
+
+
+
+
+
+
+
diff --git a/pages/silence-records/silence-records.vue b/pages/silence-records/silence-records.vue
new file mode 100644
index 0000000..6617a1f
--- /dev/null
+++ b/pages/silence-records/silence-records.vue
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+ 暂无沉默期记录
+
+
+
+
+
+ 截止日期:{{ formatSilenceDate(record.before_deadline_at) }} → {{ formatSilenceDate(record.after_deadline_at) }}
+
+ 沉默期天数:{{ record.period_days }} 天
+ 变化天数:{{ record.change_days > 0 ? "+" : "" }}{{ record.change_days }} 天
+
+
+
+
+
+
+
+
diff --git a/tests/silence.test.mjs b/tests/silence.test.mjs
new file mode 100644
index 0000000..35becbd
--- /dev/null
+++ b/tests/silence.test.mjs
@@ -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);
+});
diff --git a/utils/silence-payment.js b/utils/silence-payment.js
new file mode 100644
index 0000000..339b8bc
--- /dev/null
+++ b/utils/silence-payment.js
@@ -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('打开支付页失败'))
+ });
+ });
+};
diff --git a/utils/silence.js b/utils/silence.js
new file mode 100644
index 0000000..273db3a
--- /dev/null
+++ b/utils/silence.js
@@ -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) : '-');