287 lines
6.9 KiB
Vue
287 lines
6.9 KiB
Vue
<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 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';
|
||
|
||
const props = defineProps({
|
||
node: {
|
||
type: Object,
|
||
default: () => ({})
|
||
},
|
||
depth: {
|
||
type: Number,
|
||
default: 0
|
||
}
|
||
});
|
||
|
||
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>
|
||
.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>
|