Files
device-voice-h5/components/PackageHistoryNode.vue
邹进 31ff18d66a
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 23m46s
feat: C 端套餐沉默期展示与自助延期
- 套餐历史展示沉默期状态、截止日期、累计延期与调整审批中,可延期时显示延期入口
- 新增延期页:资格、档位与自定义天数试算、支付方式、下单(request_id 幂等)
- 支付宝走现有支付页;微信为链接时在微信外直接跳转,否则展示二维码与复制链接
- 新增我的延期订单列表与详情、沉默期记录,沉默期错误码转客户提示
2026-11-07 13:19:33 +09:00

405 lines
9.9 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="card package-node" :class="{ 'package-node-child': depth > 0, 'relationship-exception': isMasterMissing }">
<view class="package-header flex-row-sb">
<view class="package-title-wrap">
<view class="package-name">{{ node.package_name }}</view>
<text v-if="packageTypeName" class="package-type">{{ packageTypeName }}</text>
</view>
<view class="tag-apple" :class="getStatusClass(node.status)">{{ node.status_name }}</view>
</view>
<view v-if="isMasterMissing" class="relationship-status">
{{ node.relationship_status_name || node.relationship_status }}
</view>
<view v-if="hasMasterUsageId" class="relationship-meta">
关联主套餐使用记录:{{ node.master_usage_id }}
</view>
<view class="package-info">
<view class="info-row flex-row-sb">
<view class="info-label">购买时间</view>
<view class="info-value">{{ formatDate(node.created_at) }}</view>
</view>
<view class="info-row flex-row-sb">
<view class="info-label">激活时间</view>
<view class="info-value">{{ formatDate(node.activated_at) }}</view>
</view>
<view class="info-row flex-row-sb">
<view class="info-label">到期时间</view>
<view class="info-value">{{ formatDate(node.expires_at) }}</view>
</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">
<view class="flow-title">流量信息</view>
<view class="flow-stats">
<view class="flow-item">
<view class="flow-label">已使用</view>
<view class="flow-value">{{ getUsedFlow(node) }}</view>
</view>
<view class="flow-item">
<view class="flow-label">总流量</view>
<view class="flow-value">{{ getTotalFlow(node) }}</view>
</view>
<view class="flow-item">
<view class="flow-label">剩余</view>
<view class="flow-value">{{ getRemainFlow(node) }}</view>
</view>
</view>
<view class="progress-section">
<view class="progress-apple">
<view class="progress-fill" :style="{ width: getUsagePercent(node) }"></view>
</view>
</view>
</view>
<view
v-if="hasChildren"
class="expand-control"
role="button"
tabindex="0"
:aria-expanded="String(expanded)"
@tap.stop="toggleExpanded"
>
<text>{{ expanded ? '收起关联加油包' : '展开关联加油包' }}</text>
<text class="expand-count">({{ children.length }})</text>
</view>
<view v-if="expanded && hasChildren" class="child-list">
<PackageHistoryNode
v-for="(child, index) in children"
:key="getNodeKey(child, index)"
:node="child"
:depth="depth + 1"
/>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue';
import { formatSilenceDate, getSilenceStatusClass } from '@/utils/silence.js';
const props = defineProps({
node: {
type: Object,
default: () => ({})
},
depth: {
type: Number,
default: 0
}
});
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');
const hasMasterUsageId = computed(() => (
props.node.master_usage_id !== null && props.node.master_usage_id !== undefined
));
const packageTypeName = computed(() => ({
formal: '正式套餐',
addon: '加油包'
}[props.node.package_type] || props.node.package_type || ''));
const expanded = ref(props.node.expand_by_default === true);
const getStatusClass = (status) => ({
0: 'tag-warning',
1: 'tag-success',
2: 'tag-primary',
3: 'tag-secondary',
4: 'tag-danger'
}[Number(status)] || '');
const toNumber = (value) => {
const number = Number(value);
return Number.isFinite(number) ? number : 0;
};
const formatMB = (value) => {
const mb = toNumber(value);
if (mb >= 1024) {
return `${(mb / 1024).toFixed(2)} GB`;
}
return `${mb.toFixed(2)} MB`;
};
const getUsedAmount = (item) => item.enable_virtual_data
? toNumber(item.virtual_used_mb)
: toNumber(item.real_used_mb);
const getUsedFlow = (item) => formatMB(getUsedAmount(item));
const getTotalFlow = (item) => formatMB(item.real_total_mb);
const getRemainFlow = (item) => formatMB(Math.max(toNumber(item.real_total_mb) - getUsedAmount(item), 0));
const getUsagePercent = (item) => {
const total = toNumber(item.real_total_mb);
if (!total) return '0%';
return `${Math.min((getUsedAmount(item) / total) * 100, 100).toFixed(2)}%`;
};
const formatDate = (value) => value ? String(value).replace('T', ' ').slice(0, 19) : '-';
const getNodeKey = (item, index) => item.package_usage_id ?? `${props.depth}-${index}`;
const toggleExpanded = () => {
expanded.value = !expanded.value;
};
</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);
&.package-node-child {
margin: var(--space-sm) 0 0;
border-left: 6rpx solid var(--primary-light);
box-shadow: none;
}
&.relationship-exception {
border-left: 6rpx solid var(--danger);
}
}
.package-header {
align-items: flex-start;
margin-bottom: var(--space-md);
}
.package-title-wrap {
display: flex;
align-items: center;
min-width: 0;
gap: var(--space-xs);
}
.package-name {
font-size: 32rpx;
font-weight: 600;
color: var(--text-primary);
}
.package-type,
.relationship-meta {
font-size: 22rpx;
color: var(--text-tertiary);
}
.relationship-status {
margin-bottom: var(--space-sm);
padding: 12rpx 16rpx;
border-radius: var(--radius-small);
background: var(--danger-light, #fff2f0);
color: var(--danger);
font-size: 24rpx;
}
.relationship-meta {
margin-bottom: var(--space-sm);
}
.package-info .info-row {
padding: var(--space-xs) 0;
}
.info-label {
font-size: 24rpx;
color: var(--text-tertiary);
}
.info-value {
font-size: 24rpx;
color: var(--text-primary);
}
.divider {
height: 1rpx;
margin: var(--space-md) 0;
background: var(--gray-200);
}
.flow-title {
margin-bottom: var(--space-sm);
font-size: 26rpx;
font-weight: 600;
color: var(--text-primary);
}
.flow-stats {
display: flex;
justify-content: space-between;
margin-bottom: var(--space-sm);
}
.flow-item {
text-align: center;
}
.flow-label {
margin-bottom: 4rpx;
font-size: 20rpx;
color: var(--text-tertiary);
}
.flow-value {
font-size: 26rpx;
font-weight: 600;
color: var(--text-primary);
}
.progress-apple {
overflow: hidden;
width: 100%;
height: 8rpx;
border-radius: var(--radius-small);
background: var(--gray-200);
}
.progress-fill {
height: 100%;
border-radius: var(--radius-small);
background: linear-gradient(90deg, var(--primary), var(--primary-light));
}
.expand-control {
display: inline-flex;
margin-top: var(--space-md);
padding: 12rpx 16rpx;
border-radius: var(--radius-small);
background: var(--primary-light);
color: var(--primary);
font-size: 24rpx;
}
.expand-count {
margin-left: 4rpx;
}
.child-list {
margin-top: var(--space-sm);
}
.tag-secondary {
background: var(--gray-200) !important;
color: var(--gray-600) !important;
}
</style>