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 @@ + + + 实名沉默期 + + {{ silence.silence_status_name || '-' }} + + + + 截止时间 + {{ 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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) : '-');