mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
1458 lines
34 KiB
Vue
1458 lines
34 KiB
Vue
<template>
|
||
<div class="package-container">
|
||
|
||
<div class="overview-section">
|
||
<div class="overview-card">
|
||
<div class="card-header">
|
||
<h3>账户概览</h3>
|
||
<el-tag v-if="userInfo.expTime" :type="getExpStatus(userInfo.expTime).type">
|
||
{{ getExpStatus(userInfo.expTime).text }}
|
||
</el-tag>
|
||
<el-tag v-else type="success">永久账户</el-tag>
|
||
</div>
|
||
<div class="overview-stats">
|
||
<div class="stat-item">
|
||
<div class="stat-icon">
|
||
<i class="el-icon-pie-chart"></i>
|
||
</div>
|
||
<div class="stat-content">
|
||
<div class="stat-value">{{ formatFlow(userInfo.flow, 'gb') }}</div>
|
||
<div class="stat-label">总流量配额</div>
|
||
</div>
|
||
</div>
|
||
<div class="stat-item">
|
||
<div class="stat-icon">
|
||
<i class="el-icon-upload"></i>
|
||
</div>
|
||
<div class="stat-content">
|
||
<div class="stat-value">{{ formatFlow(calculateUserTotalUsedFlow()) }}</div>
|
||
<div class="stat-label">已用流量</div>
|
||
</div>
|
||
</div>
|
||
<div class="stat-item">
|
||
<div class="stat-icon">
|
||
<i class="el-icon-connection"></i>
|
||
</div>
|
||
<div class="stat-content">
|
||
<div class="stat-value">{{ userInfo.num || 0 }}</div>
|
||
<div class="stat-label">转发配额</div>
|
||
</div>
|
||
</div>
|
||
<div class="stat-item">
|
||
<div class="stat-icon">
|
||
<i class="el-icon-s-operation"></i>
|
||
</div>
|
||
<div class="stat-content">
|
||
<div class="stat-value">{{ totalUsedForwards }}</div>
|
||
<div class="stat-label">已用转发</div>
|
||
</div>
|
||
</div>
|
||
<div class="stat-item" v-if="userInfo.flowResetTime">
|
||
<div class="stat-icon">
|
||
<i class="el-icon-refresh"></i>
|
||
</div>
|
||
<div class="stat-content">
|
||
<div class="stat-value">{{ formatResetTime(userInfo.flowResetTime) }}</div>
|
||
<div class="stat-label">流量重置时间</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="progress-section" v-if="userInfo.flow > 0">
|
||
<div class="progress-item">
|
||
<div class="progress-header">
|
||
<span>流量使用率</span>
|
||
<span>{{ calculateUsagePercentage('flow').toFixed(1) }}%</span>
|
||
</div>
|
||
<el-progress
|
||
:percentage="calculateUsagePercentage('flow')"
|
||
:status="getUsageStatus('flow')"
|
||
:stroke-width="8"
|
||
></el-progress>
|
||
</div>
|
||
<div class="progress-item">
|
||
<div class="progress-header">
|
||
<span>转发使用率</span>
|
||
<span>{{ calculateUsagePercentage('forwards').toFixed(1) }}%</span>
|
||
</div>
|
||
<el-progress
|
||
:percentage="calculateUsagePercentage('forwards')"
|
||
:status="getUsageStatus('forwards')"
|
||
:stroke-width="8"
|
||
></el-progress>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 隧道权限列表 -->
|
||
<div class="tunnels-section">
|
||
<h3 class="section-title">隧道权限</h3>
|
||
<div v-loading="loading" class="tunnels-grid">
|
||
<!-- 空状态也使用卡片容器,保持样式统一 -->
|
||
<div v-if="userTunnels.length === 0 && !loading" class="tunnel-card empty-card">
|
||
<EmptyState
|
||
description="暂无隧道权限"
|
||
size="large"
|
||
/>
|
||
</div>
|
||
|
||
<div
|
||
v-for="tunnel in userTunnels"
|
||
:key="tunnel.id"
|
||
class="tunnel-card"
|
||
>
|
||
<div class="tunnel-header">
|
||
<div class="tunnel-name">
|
||
<i class="el-icon-link"></i>
|
||
{{ tunnel.tunnelName }}
|
||
</div>
|
||
<div class="tunnel-status">
|
||
<el-tag v-if="tunnel.expTime" :type="getTunnelExpStatus(tunnel.expTime).type" size="mini">
|
||
{{ getTunnelExpStatus(tunnel.expTime).text }}
|
||
</el-tag>
|
||
<el-tag v-else type="success" size="mini">永久</el-tag>
|
||
<el-tag v-if="tunnel.tunnelFlow === 1" type="info" size="mini">单向计费</el-tag>
|
||
<el-tag v-else type="warning" size="mini">双向计费</el-tag>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tunnel-stats">
|
||
<div class="tunnel-stat-row">
|
||
<div class="tunnel-stat-item">
|
||
<span class="stat-label">流量配额:</span>
|
||
<span class="stat-value">{{ formatFlow(tunnel.flow, 'gb') }}</span>
|
||
</div>
|
||
<div class="tunnel-stat-item">
|
||
<span class="stat-label">已用流量:</span>
|
||
<span class="stat-value used">{{ formatFlow(calculateTunnelUsedFlow(tunnel)) }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="tunnel-stat-row">
|
||
<div class="tunnel-stat-item">
|
||
<span class="stat-label">转发配额:</span>
|
||
<span class="stat-value">{{ tunnel.num }}</span>
|
||
</div>
|
||
<div class="tunnel-stat-item">
|
||
<span class="stat-label">已用转发:</span>
|
||
<span class="stat-value used">{{ getTunnelUsedForwards(tunnel.tunnelId) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tunnel-progress">
|
||
<div class="progress-mini">
|
||
<div class="progress-mini-header">
|
||
<span>流量使用</span>
|
||
<span>{{ calculateTunnelFlowPercentage(tunnel).toFixed(1) }}%</span>
|
||
</div>
|
||
<el-progress
|
||
:percentage="calculateTunnelFlowPercentage(tunnel)"
|
||
:status="getTunnelFlowStatus(tunnel)"
|
||
:stroke-width="6"
|
||
:show-text="false"
|
||
></el-progress>
|
||
</div>
|
||
<div class="progress-mini">
|
||
<div class="progress-mini-header">
|
||
<span>转发使用</span>
|
||
<span>{{ calculateTunnelForwardPercentage(tunnel).toFixed(1) }}%</span>
|
||
</div>
|
||
<el-progress
|
||
:percentage="calculateTunnelForwardPercentage(tunnel)"
|
||
:status="getTunnelForwardStatus(tunnel)"
|
||
:stroke-width="6"
|
||
:show-text="false"
|
||
></el-progress>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 流量重置时间 -->
|
||
<div class="tunnel-footer" v-if="tunnel.flowResetTime">
|
||
<span class="reset-time">
|
||
<i class="el-icon-time"></i>
|
||
流量重置: {{ formatTunnelResetTime(tunnel.flowResetTime) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 转发详情 -->
|
||
<div class="forwards-section">
|
||
<h3 class="section-title">转发详情</h3>
|
||
<div v-loading="loading" class="forwards-container">
|
||
<!-- 空状态保持在容器内,与有数据时样式统一 -->
|
||
<EmptyState
|
||
v-if="groupedForwards.length === 0 && !loading"
|
||
description="暂无转发配置"
|
||
size="large"
|
||
/>
|
||
|
||
<div v-for="group in groupedForwards" :key="group.tunnelName" class="forward-group">
|
||
<div class="group-header">
|
||
<h4>{{ group.tunnelName }}</h4>
|
||
<el-tag type="info" size="mini">{{ group.forwards.length }} 个转发</el-tag>
|
||
</div>
|
||
<div class="forward-list">
|
||
<div v-for="forward in group.forwards" :key="forward.id" class="forward-item">
|
||
<div class="forward-info">
|
||
<div class="forward-name">{{ forward.name }}</div>
|
||
<div class="forward-address">
|
||
<div
|
||
class="address-source"
|
||
@click="showAddressDialog(forward.inIp, forward.inPort, '入口地址')"
|
||
:class="{ 'clickable': hasMultipleIps(forward.inIp) }"
|
||
>
|
||
{{ formatInAddress(forward.inIp, forward.inPort) }}
|
||
</div>
|
||
<div class="address-arrow">↓</div>
|
||
<div class="address-target">{{ forward.remoteAddr }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="forward-traffic">
|
||
<div class="traffic-item">
|
||
<span class="traffic-label">入站:</span>
|
||
<span class="traffic-value in">{{ formatFlow(forward.inFlow || 0) }}</span>
|
||
</div>
|
||
<div class="traffic-item">
|
||
<span class="traffic-label">出站:</span>
|
||
<span class="traffic-value out">{{ formatFlow(forward.outFlow || 0) }}</span>
|
||
</div>
|
||
<div class="traffic-item total">
|
||
<span class="traffic-label">计费:</span>
|
||
<span class="traffic-value total">{{ formatFlow(calculateForwardBillingFlow(forward)) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 地址列表弹窗 -->
|
||
<el-dialog
|
||
:title="addressDialogTitle"
|
||
:visible.sync="addressDialogVisible"
|
||
width="600px"
|
||
:before-close="() => { addressDialogVisible = false }"
|
||
>
|
||
<div class="address-list">
|
||
<div class="list-header">
|
||
<el-button
|
||
type="primary"
|
||
size="small"
|
||
@click="copyAllAddresses"
|
||
icon="el-icon-copy-document"
|
||
>
|
||
复制全部
|
||
</el-button>
|
||
</div>
|
||
|
||
<div class="addresses">
|
||
<div
|
||
v-for="item in addressList"
|
||
:key="item.id"
|
||
class="address-item"
|
||
>
|
||
<div class="address-text">{{ item.address }}</div>
|
||
<el-button
|
||
type="text"
|
||
size="small"
|
||
:loading="item.copying"
|
||
@click="copyAddress(item)"
|
||
icon="el-icon-copy-document"
|
||
class="copy-btn"
|
||
>
|
||
复制
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script>
|
||
import { getUserPackageInfo } from "@/api";
|
||
import EmptyState from "@/components/EmptyState.vue";
|
||
|
||
export default {
|
||
name: "Index",
|
||
components: {
|
||
EmptyState
|
||
},
|
||
data() {
|
||
return {
|
||
refreshLoading: false,
|
||
loading: false,
|
||
userInfo: {},
|
||
userTunnels: [],
|
||
forwardList: [],
|
||
|
||
// 地址列表弹窗状态
|
||
addressDialogVisible: false,
|
||
addressDialogTitle: '',
|
||
addressList: [],
|
||
};
|
||
},
|
||
computed: {
|
||
totalUsedForwards() {
|
||
return this.forwardList.length;
|
||
},
|
||
|
||
groupedForwards() {
|
||
const groups = {};
|
||
this.forwardList.forEach(forward => {
|
||
const tunnelName = forward.tunnelName || '未知隧道';
|
||
if (!groups[tunnelName]) {
|
||
groups[tunnelName] = {
|
||
tunnelName,
|
||
forwards: []
|
||
};
|
||
}
|
||
groups[tunnelName].forwards.push(forward);
|
||
});
|
||
return Object.values(groups);
|
||
}
|
||
},
|
||
mounted() {
|
||
this.loadPackageData();
|
||
},
|
||
created() {
|
||
localStorage.setItem('e','/index')
|
||
},
|
||
methods: {
|
||
async loadPackageData() {
|
||
this.loading = true;
|
||
try {
|
||
const res = await getUserPackageInfo();
|
||
if (res.code === 0) {
|
||
const data = res.data;
|
||
this.userInfo = data.userInfo;
|
||
this.userTunnels = data.tunnelPermissions || [];
|
||
this.forwardList = data.forwards || [];
|
||
} else {
|
||
this.$message.error(res.msg || '获取套餐信息失败');
|
||
}
|
||
} catch (error) {
|
||
console.error('获取套餐信息失败:', error);
|
||
this.$message.error('获取套餐信息失败');
|
||
} finally {
|
||
this.loading = false;
|
||
}
|
||
},
|
||
|
||
async refreshData() {
|
||
this.refreshLoading = true;
|
||
try {
|
||
await this.loadPackageData();
|
||
this.$message.success('数据刷新成功');
|
||
} catch (error) {
|
||
this.$message.error('数据刷新失败');
|
||
} finally {
|
||
this.refreshLoading = false;
|
||
}
|
||
},
|
||
|
||
formatFlow(value, unit = 'bytes') {
|
||
if (unit === 'gb') {
|
||
return value + ' GB';
|
||
} else {
|
||
if (value === 0) return '0 B';
|
||
if (value < 1024) return value + ' B';
|
||
if (value < 1024 * 1024) return (value / 1024).toFixed(2) + ' KB';
|
||
if (value < 1024 * 1024 * 1024) return (value / (1024 * 1024)).toFixed(2) + ' MB';
|
||
return (value / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||
}
|
||
},
|
||
|
||
getExpStatus(expTime) {
|
||
if (!expTime) {
|
||
return { type: 'success', text: '永久有效' };
|
||
}
|
||
|
||
const now = new Date();
|
||
let expDate;
|
||
|
||
// 处理不同的时间格式
|
||
if (typeof expTime === 'number') {
|
||
// 如果是数字,可能是时间戳
|
||
expDate = new Date(expTime);
|
||
} else if (typeof expTime === 'string') {
|
||
// 如果是字符串,尝试解析
|
||
expDate = new Date(expTime);
|
||
} else {
|
||
expDate = new Date(expTime);
|
||
}
|
||
|
||
// 检查日期是否有效
|
||
if (isNaN(expDate.getTime())) {
|
||
return { type: 'warning', text: '时间格式错误' };
|
||
}
|
||
|
||
if (expDate < now) {
|
||
return { type: 'danger', text: '已过期' };
|
||
}
|
||
|
||
const diffTime = expDate - now;
|
||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||
|
||
if (diffDays <= 7) {
|
||
return { type: 'danger', text: `${diffDays}天后过期` };
|
||
} else if (diffDays <= 30) {
|
||
return { type: 'warning', text: `${diffDays}天后过期` };
|
||
} else {
|
||
return { type: 'success', text: '有效期充足' };
|
||
}
|
||
},
|
||
|
||
getTunnelExpStatus(expTime) {
|
||
return this.getExpStatus(expTime);
|
||
},
|
||
|
||
calculateUsagePercentage(type) {
|
||
if (type === 'flow') {
|
||
const totalUsed = this.calculateUserTotalUsedFlow();
|
||
const totalLimit = (this.userInfo.flow || 0) * 1024 * 1024 * 1024;
|
||
return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0;
|
||
} else if (type === 'forwards') {
|
||
const totalUsed = this.totalUsedForwards;
|
||
const totalLimit = this.userInfo.num || 0;
|
||
return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0;
|
||
}
|
||
return 0;
|
||
},
|
||
|
||
getUsageStatus(type) {
|
||
const percentage = this.calculateUsagePercentage(type);
|
||
if (percentage >= 90) return 'exception';
|
||
if (percentage >= 70) return 'warning';
|
||
return 'success';
|
||
},
|
||
|
||
calculateTunnelFlowPercentage(tunnel) {
|
||
const totalUsed = this.calculateTunnelUsedFlow(tunnel);
|
||
const totalLimit = (tunnel.flow || 0) * 1024 * 1024 * 1024;
|
||
return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0;
|
||
},
|
||
|
||
calculateTunnelForwardPercentage(tunnel) {
|
||
const totalUsed = this.getTunnelUsedForwards(tunnel.tunnelId);
|
||
const totalLimit = tunnel.num || 0;
|
||
return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0;
|
||
},
|
||
|
||
getTunnelFlowStatus(tunnel) {
|
||
const percentage = this.calculateTunnelFlowPercentage(tunnel);
|
||
if (percentage >= 90) return 'exception';
|
||
if (percentage >= 70) return 'warning';
|
||
return 'success';
|
||
},
|
||
|
||
getTunnelForwardStatus(tunnel) {
|
||
const percentage = this.calculateTunnelForwardPercentage(tunnel);
|
||
if (percentage >= 90) return 'exception';
|
||
if (percentage >= 70) return 'warning';
|
||
return 'success';
|
||
},
|
||
|
||
getTunnelUsedForwards(tunnelId) {
|
||
return this.forwardList.filter(forward => forward.tunnelId === tunnelId).length;
|
||
},
|
||
|
||
/**
|
||
* 计算隧道已用流量
|
||
* 根据隧道的流量计算类型(tunnelFlow)决定单向还是双向计算
|
||
*/
|
||
calculateTunnelUsedFlow(tunnel) {
|
||
if (!tunnel) return 0;
|
||
|
||
const inFlow = tunnel.inFlow || 0; // 下载流量
|
||
const outFlow = tunnel.outFlow || 0; // 上传流量
|
||
|
||
// tunnelFlow: 1-单向计算(仅上传),2-双向计算(上传+下载)
|
||
if (tunnel.tunnelFlow === 1) {
|
||
// 单向计算:只计算上传流量(outFlow)
|
||
return outFlow;
|
||
} else {
|
||
// 双向计算或默认:计算上传和下载的总流量
|
||
return inFlow + outFlow;
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 计算用户总已用流量
|
||
* 直接使用后端计算好的用户流量统计
|
||
*/
|
||
calculateUserTotalUsedFlow() {
|
||
// 后端已经根据隧道类型计算了实际流量,直接使用
|
||
return (this.userInfo.inFlow || 0) + (this.userInfo.outFlow || 0);
|
||
},
|
||
|
||
// 格式化隧道重置时间显示
|
||
formatTunnelResetTime(resetDay) {
|
||
if (!resetDay) return '未设置';
|
||
|
||
// resetDay 是数字 1-31,表示每月第几号重置
|
||
const now = new Date();
|
||
const currentDay = now.getDate();
|
||
|
||
// 计算下次重置还有多少天
|
||
let daysUntilReset;
|
||
if (resetDay > currentDay) {
|
||
daysUntilReset = resetDay - currentDay;
|
||
} else if (resetDay < currentDay) {
|
||
// 下个月的重置日
|
||
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, resetDay);
|
||
const diffTime = nextMonth - now;
|
||
daysUntilReset = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||
} else {
|
||
// 今天就是重置日
|
||
daysUntilReset = 0;
|
||
}
|
||
|
||
if (daysUntilReset === 0) {
|
||
return `今天重置(${resetDay}号)`;
|
||
} else if (daysUntilReset === 1) {
|
||
return `明天重置(${resetDay}号)`;
|
||
} else {
|
||
return `${daysUntilReset}天后重置(${resetDay}号)`;
|
||
}
|
||
},
|
||
|
||
// 格式化用户重置时间显示(保留原有方法)
|
||
formatResetTime(resetDay) {
|
||
return this.formatTunnelResetTime(resetDay);
|
||
},
|
||
|
||
/**
|
||
* 计算转发的计费流量
|
||
* 根据对应隧道的流量计算类型决定单向还是双向计算
|
||
*/
|
||
calculateForwardBillingFlow(forward) {
|
||
if (!forward) return 0;
|
||
|
||
// 查找对应的隧道
|
||
const tunnel = this.userTunnels.find(t => t.tunnelId === forward.tunnelId);
|
||
if (!tunnel) {
|
||
// 如果找不到隧道信息,默认使用双向计算
|
||
return (forward.inFlow || 0) + (forward.outFlow || 0);
|
||
}
|
||
|
||
const inFlow = forward.inFlow || 0; // 下载流量
|
||
const outFlow = forward.outFlow || 0; // 上传流量
|
||
|
||
// 根据隧道的流量计算类型
|
||
if (tunnel.tunnelFlow === 1) {
|
||
// 单向计算:只计算上传流量(outFlow)
|
||
return outFlow;
|
||
} else {
|
||
// 双向计算:计算上传和下载的总流量
|
||
return inFlow + outFlow;
|
||
}
|
||
},
|
||
|
||
// 格式化入口地址,支持多IP优化显示
|
||
formatInAddress(ipString, port) {
|
||
if (!ipString || !port) return '';
|
||
|
||
// 分割IP字符串,处理多个IP的情况
|
||
const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
|
||
|
||
if (ips.length === 0) return '';
|
||
|
||
// 只有一个IP时,正常格式化
|
||
if (ips.length === 1) {
|
||
const ip = ips[0];
|
||
// 检查是否为IPv6地址
|
||
if (ip.includes(':') && !ip.startsWith('[')) {
|
||
// IPv6地址需要用方括号包裹
|
||
return `[${ip}]:${port}`;
|
||
} else {
|
||
// IPv4地址或已包含方括号的IPv6地址直接拼接
|
||
return `${ip}:${port}`;
|
||
}
|
||
}
|
||
|
||
// 多个IP时,显示第一个IP + 数量提示
|
||
const firstIp = ips[0];
|
||
let formattedFirstIp;
|
||
|
||
// 格式化第一个IP
|
||
if (firstIp.includes(':') && !firstIp.startsWith('[')) {
|
||
formattedFirstIp = `[${firstIp}]`;
|
||
} else {
|
||
formattedFirstIp = firstIp;
|
||
}
|
||
|
||
return `${formattedFirstIp}:${port} 等${ips.length}个入口`;
|
||
},
|
||
|
||
// 检查是否有多个IP
|
||
hasMultipleIps(ipString) {
|
||
if (!ipString) return false;
|
||
const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
|
||
return ips.length > 1;
|
||
},
|
||
|
||
// 显示地址列表弹窗
|
||
showAddressDialog(ipString, port, title) {
|
||
if (!ipString || !port) return;
|
||
|
||
const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
|
||
|
||
// 如果只有一个IP,直接复制,不显示弹窗
|
||
if (ips.length <= 1) {
|
||
this.copyToClipboard(this.formatInAddress(ipString, port), title);
|
||
return;
|
||
}
|
||
|
||
// 格式化地址列表
|
||
this.addressList = ips.map((ip, index) => {
|
||
let formattedAddress;
|
||
if (ip.includes(':') && !ip.startsWith('[')) {
|
||
formattedAddress = `[${ip}]:${port}`;
|
||
} else {
|
||
formattedAddress = `${ip}:${port}`;
|
||
}
|
||
return {
|
||
id: index,
|
||
ip: ip,
|
||
address: formattedAddress,
|
||
copying: false // 复制状态
|
||
};
|
||
});
|
||
|
||
this.addressDialogTitle = `${title}列表 (共${ips.length}个)`;
|
||
this.addressDialogVisible = true;
|
||
},
|
||
|
||
// 复制单个地址
|
||
async copyAddress(addressItem) {
|
||
try {
|
||
this.$set(addressItem, 'copying', true);
|
||
await this.copyToClipboard(addressItem.address, '地址');
|
||
} catch (error) {
|
||
this.$message.error('复制失败');
|
||
} finally {
|
||
this.$set(addressItem, 'copying', false);
|
||
}
|
||
},
|
||
|
||
// 复制所有地址
|
||
async copyAllAddresses() {
|
||
if (this.addressList.length === 0) return;
|
||
|
||
const allAddresses = this.addressList.map(item => item.address).join('\n');
|
||
await this.copyToClipboard(allAddresses, '所有地址');
|
||
},
|
||
|
||
// 复制到剪贴板
|
||
async copyToClipboard(text, label = '内容') {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
this.$message.success(`${label}已复制到剪贴板`);
|
||
} catch (error) {
|
||
// 降级方案:使用传统方法
|
||
const textArea = document.createElement('textarea');
|
||
textArea.value = text;
|
||
document.body.appendChild(textArea);
|
||
textArea.select();
|
||
try {
|
||
document.execCommand('copy');
|
||
this.$message.success(`${label}已复制到剪贴板`);
|
||
} catch (err) {
|
||
this.$message.error('复制失败,请手动复制');
|
||
}
|
||
document.body.removeChild(textArea);
|
||
}
|
||
}
|
||
},
|
||
|
||
filters: {
|
||
dateFormat(value) {
|
||
if (!value) return '';
|
||
const date = new Date(value);
|
||
return date.toLocaleString();
|
||
}
|
||
}
|
||
};
|
||
</script>
|
||
|
||
<style scoped>
|
||
.package-container {
|
||
padding: 20px;
|
||
}
|
||
|
||
.page-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 24px;
|
||
padding: 20px;
|
||
background: white;
|
||
border-radius: 8px;
|
||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.page-title {
|
||
font-size: 24px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
margin: 0;
|
||
}
|
||
|
||
.overview-section {
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.overview-card {
|
||
background: white;
|
||
border-radius: 12px;
|
||
padding: 24px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.card-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.card-header h3 {
|
||
margin: 0;
|
||
font-size: 20px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
}
|
||
|
||
.overview-stats {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
gap: 20px;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.stat-item {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 16px;
|
||
background: #f8f9fa;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.stat-icon {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 8px;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-right: 16px;
|
||
}
|
||
|
||
.stat-icon i {
|
||
font-size: 24px;
|
||
color: white;
|
||
}
|
||
|
||
.stat-content {
|
||
flex: 1;
|
||
}
|
||
|
||
.stat-value {
|
||
font-size: 20px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 14px;
|
||
color: #666;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.progress-section {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||
gap: 20px;
|
||
}
|
||
|
||
.progress-item {
|
||
padding: 16px;
|
||
background: #f8f9fa;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.progress-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.tunnels-section, .forwards-section {
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.tunnels-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
||
gap: 20px;
|
||
}
|
||
|
||
.tunnel-card {
|
||
background: white;
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||
transition: transform 0.2s ease;
|
||
}
|
||
|
||
.tunnel-card:hover {
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
.tunnel-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.tunnel-name {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.tunnel-name i {
|
||
margin-right: 8px;
|
||
color: #409EFF;
|
||
}
|
||
|
||
.tunnel-status {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.tunnel-stats {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.tunnel-stat-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.tunnel-stat-item {
|
||
flex: 1;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.tunnel-stat-item:first-child {
|
||
margin-right: 16px;
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 12px;
|
||
color: #666;
|
||
}
|
||
|
||
.stat-value {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: #333;
|
||
}
|
||
|
||
.stat-value.used {
|
||
color: #F56C6C;
|
||
}
|
||
|
||
.tunnel-progress {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.progress-mini {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.progress-mini:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.progress-mini-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 4px;
|
||
font-size: 12px;
|
||
color: #666;
|
||
}
|
||
|
||
.tunnel-footer {
|
||
border-top: 1px solid #f0f0f0;
|
||
padding-top: 12px;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.reset-time {
|
||
font-size: 12px;
|
||
color: #666;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.reset-time i {
|
||
margin-right: 4px;
|
||
}
|
||
|
||
.forwards-container {
|
||
background: white;
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.forward-group {
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.forward-group:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.group-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
padding-bottom: 8px;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
}
|
||
|
||
.group-header h4 {
|
||
margin: 0;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
}
|
||
|
||
.forward-list {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.forward-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 16px;
|
||
background: #ffffff;
|
||
border-radius: 12px;
|
||
border-left: 4px solid #409EFF;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.forward-item:hover {
|
||
background: #f8fafe;
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 6px 16px rgba(64, 158, 255, 0.2);
|
||
}
|
||
|
||
.forward-info {
|
||
width: 100%;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.forward-name {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
margin-bottom: 8px;
|
||
text-align: center;
|
||
}
|
||
|
||
.forward-address {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 8px;
|
||
background: #f5f7fa;
|
||
border-radius: 8px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.address-source,
|
||
.address-target {
|
||
font-size: 15px;
|
||
color: #333;
|
||
font-family: 'Courier New', monospace;
|
||
font-weight: 600;
|
||
text-align: center;
|
||
padding: 4px 12px;
|
||
background: #ebeef5;
|
||
border-radius: 4px;
|
||
min-width: 120px;
|
||
width: 100%;
|
||
white-space: nowrap; /* 不换行 */
|
||
overflow: hidden; /* 超出隐藏 */
|
||
text-overflow: ellipsis; /* 超出显示省略号 */
|
||
word-break: break-all; /* 移动端强制换行 */
|
||
}
|
||
|
||
.address-source {
|
||
color: #52c41a;
|
||
}
|
||
|
||
.address-target {
|
||
color: #1890ff;
|
||
}
|
||
|
||
.address-arrow {
|
||
font-size: 18px;
|
||
color: #409EFF;
|
||
font-weight: bold;
|
||
margin: 2px 0;
|
||
}
|
||
|
||
.forward-traffic {
|
||
display: flex;
|
||
justify-content: space-around;
|
||
align-items: center;
|
||
background: #f5f7fa;
|
||
border-radius: 8px;
|
||
padding: 8px;
|
||
}
|
||
|
||
.traffic-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
flex: 1;
|
||
}
|
||
|
||
.traffic-label {
|
||
font-size: 12px;
|
||
color: #666;
|
||
margin-bottom: 4px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.traffic-value {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.traffic-value.in {
|
||
color: #52c41a;
|
||
background: #f6ffed;
|
||
}
|
||
|
||
.traffic-value.out {
|
||
color: #fa8c16;
|
||
background: #fff7e6;
|
||
}
|
||
|
||
.traffic-value.total {
|
||
color: #722ed1;
|
||
background: #f9f0ff;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* 隧道权限空状态卡片样式 */
|
||
.tunnel-card.empty-card {
|
||
grid-column: 1 / -1;
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 20px;
|
||
}
|
||
|
||
/* 转发详情的空状态样式由EmptyState组件内置处理 */
|
||
|
||
/* 响应式设计 */
|
||
@media (max-width: 768px) {
|
||
.package-container {
|
||
padding: 8px;
|
||
background-color: #f5f5f5;
|
||
}
|
||
|
||
.overview-card {
|
||
padding: 16px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.card-header {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.card-header h3 {
|
||
font-size: 18px;
|
||
}
|
||
|
||
.overview-stats {
|
||
grid-template-columns: 1fr;
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.stat-item {
|
||
padding: 12px;
|
||
}
|
||
|
||
.stat-icon {
|
||
width: 40px;
|
||
height: 40px;
|
||
margin-right: 12px;
|
||
}
|
||
|
||
.stat-icon i {
|
||
font-size: 20px;
|
||
}
|
||
|
||
.stat-value {
|
||
font-size: 18px;
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 13px;
|
||
}
|
||
|
||
.progress-section {
|
||
grid-template-columns: 1fr;
|
||
gap: 12px;
|
||
}
|
||
|
||
.progress-item {
|
||
padding: 12px;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 16px;
|
||
margin-bottom: 12px;
|
||
padding: 0 4px;
|
||
}
|
||
|
||
.tunnels-grid {
|
||
grid-template-columns: 1fr;
|
||
gap: 12px;
|
||
}
|
||
|
||
.tunnel-card {
|
||
padding: 16px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.tunnel-card.empty-card {
|
||
width: 100%;
|
||
margin-bottom: 12px;
|
||
padding: 16px;
|
||
}
|
||
|
||
.tunnel-header {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.tunnel-name {
|
||
font-size: 15px;
|
||
width: 100%;
|
||
}
|
||
|
||
.tunnel-status {
|
||
justify-content: flex-start;
|
||
width: 100%;
|
||
}
|
||
|
||
.tunnel-stat-row {
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.tunnel-stat-item {
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
padding: 6px 8px;
|
||
background: #f8f9fa;
|
||
border-radius: 4px;
|
||
margin-right: 0;
|
||
}
|
||
|
||
.tunnel-stat-item:first-child {
|
||
margin-right: 0;
|
||
}
|
||
|
||
.progress-mini-header {
|
||
font-size: 11px;
|
||
}
|
||
|
||
.tunnel-footer {
|
||
margin-top: 12px;
|
||
padding-top: 8px;
|
||
}
|
||
|
||
.reset-time {
|
||
font-size: 11px;
|
||
}
|
||
|
||
.forwards-container {
|
||
padding: 16px;
|
||
margin: 0 -4px;
|
||
}
|
||
|
||
.forward-group {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.group-header {
|
||
margin-bottom: 8px;
|
||
padding-bottom: 6px;
|
||
}
|
||
|
||
.group-header h4 {
|
||
font-size: 15px;
|
||
}
|
||
|
||
.forward-list {
|
||
grid-template-columns: 1fr;
|
||
gap: 8px;
|
||
}
|
||
|
||
.forward-item {
|
||
padding: 12px;
|
||
border-left-width: 3px;
|
||
}
|
||
|
||
.forward-item:hover {
|
||
transform: none;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.forward-name {
|
||
font-size: 15px;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.forward-address {
|
||
gap: 2px;
|
||
padding: 6px;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.address-source,
|
||
.address-target {
|
||
font-size: 13px;
|
||
padding: 3px 8px;
|
||
min-width: 100px;
|
||
max-width: 100%;
|
||
white-space: normal; /* 移动端允许换行 */
|
||
word-break: break-all; /* 强制换行 */
|
||
text-overflow: inherit; /* 取消省略号 */
|
||
overflow: visible; /* 显示完整内容 */
|
||
}
|
||
|
||
.address-arrow {
|
||
font-size: 16px;
|
||
margin: 1px 0;
|
||
}
|
||
|
||
.forward-traffic {
|
||
padding: 6px;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.traffic-item {
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 4px 0;
|
||
}
|
||
|
||
.traffic-label {
|
||
font-size: 11px;
|
||
margin-bottom: 0;
|
||
margin-right: 8px;
|
||
}
|
||
|
||
.traffic-value {
|
||
font-size: 13px;
|
||
padding: 1px 4px;
|
||
}
|
||
|
||
.empty-state {
|
||
height: 150px;
|
||
}
|
||
|
||
.empty-state .el-empty {
|
||
padding: 20px 0;
|
||
}
|
||
|
||
/* 移动端进度条调整 */
|
||
.el-progress {
|
||
margin: 4px 0;
|
||
}
|
||
|
||
.el-progress-bar__outer {
|
||
height: 6px !important;
|
||
}
|
||
|
||
.el-progress-bar__inner {
|
||
height: 6px !important;
|
||
}
|
||
|
||
/* 移动端标签样式优化 */
|
||
.el-tag {
|
||
font-size: 11px !important;
|
||
padding: 0 6px !important;
|
||
height: 20px !important;
|
||
line-height: 18px !important;
|
||
}
|
||
|
||
/* 移动端卡片阴影减少 */
|
||
.overview-card,
|
||
.tunnel-card,
|
||
.forwards-container {
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06) !important;
|
||
}
|
||
|
||
/* 移动端触摸友好的按钮间距 */
|
||
.tunnel-status .el-tag {
|
||
margin: 2px 4px 2px 0 !important;
|
||
}
|
||
|
||
/* 移动端图标调整 */
|
||
.tunnel-name i,
|
||
.reset-time i {
|
||
font-size: 14px;
|
||
}
|
||
|
||
/* 避免移动端横向滚动 */
|
||
.package-container,
|
||
.overview-card,
|
||
.tunnel-card,
|
||
.forwards-container,
|
||
.forward-item {
|
||
overflow-x: hidden;
|
||
}
|
||
}
|
||
|
||
/* 可点击地址样式 */
|
||
.address-source.clickable {
|
||
cursor: pointer;
|
||
transition: background-color 0.2s;
|
||
}
|
||
|
||
.address-source.clickable:hover {
|
||
background-color: #d9ecff;
|
||
color: #1890ff;
|
||
}
|
||
|
||
/* 地址列表弹窗样式 */
|
||
.address-list {
|
||
max-height: 500px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.list-header {
|
||
margin-bottom: 16px;
|
||
padding-bottom: 12px;
|
||
border-bottom: 1px solid #e8e8e8;
|
||
text-align: right;
|
||
}
|
||
|
||
.addresses {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.address-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 12px 16px;
|
||
background: #f5f7fa;
|
||
border-radius: 8px;
|
||
border: 1px solid #e8e8e8;
|
||
transition: background-color 0.2s;
|
||
}
|
||
|
||
.address-item:hover {
|
||
background: #e8f4fd;
|
||
border-color: #409EFF;
|
||
}
|
||
|
||
.address-text {
|
||
font-family: 'Courier New', monospace;
|
||
font-size: 14px;
|
||
color: #333;
|
||
font-weight: 500;
|
||
flex: 1;
|
||
word-break: break-all;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.copy-btn {
|
||
margin-left: 12px;
|
||
padding: 4px 8px !important;
|
||
font-size: 12px !important;
|
||
color: #409EFF !important;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* 移动端弹窗优化 */
|
||
@media (max-width: 768px) {
|
||
.el-dialog {
|
||
width: 90% !important;
|
||
margin: 0 auto !important;
|
||
}
|
||
|
||
.address-item {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
padding: 12px;
|
||
gap: 8px;
|
||
}
|
||
|
||
.address-text {
|
||
width: 100%;
|
||
text-align: left;
|
||
}
|
||
|
||
.copy-btn {
|
||
align-self: flex-end;
|
||
margin-left: 0;
|
||
}
|
||
}
|
||
</style> |