Files
flvx/vue-frontend/src/views/Index.vue
T
2025-06-27 11:56:59 +08:00

1458 lines
34 KiB
Vue
Raw 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>
<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>