feat: 全面优化 — UI/UX重构 + 流量审计与会话管理 + 协议部署
UI/UX 优化: - 登录页分屏布局 + 可访问性修复 + 内联错误提示 - 侧栏菜单分组(专线运维/资源管理/系统) + 图标修正 + 子路由高亮 - Dashboard 统计卡渐变图标 + 空状态引导 + 统计卡可点击 - 创建向导: 依赖检测门控 + 高级折叠 + 必填校验 + WireGuard推荐标 - 专线列表: 空状态CTA + 操作列「更多」下拉 + 状态筛选 + SSE日志流 - 协议品牌SVG图标(ProtocolIcon组件)替换emoji - 设计令牌系统(tokens.css)统一颜色/间距/圆角/阴影 功能增强: - 在线会话管理(sessionmgr): 跨协议会话同步 + 踢下线 + 连接审计日志 - 流量配额引擎(trafficmgr): 配额检查 + 超额动作(block/warn/throttle) + 周期重置 - 异常流量告警: 突发流量检测(>90MB/15s) + 审计记录 - 节点SSH凭证管理: 密码/私钥认证 + 即时测试连接 + AES-GCM加密存储 - 协议二进制管理(binmgr): FRP/OpenVPN GitHub下载 + 系统包安装引导(apt/yum/dnf) - 右上角修改密码弹窗 + MFA独立验证页 + 密码强度提示 - 用户管理: 批量导入 + LDAP浏览导入 + 平台用户导入VPN拨号用户 - 404路由 + IPSec对接页可达性修复 - MFA备用码持久化弹窗(替代Toast) - 轮询可见性守卫 + ECharts resize修复 后端: - 3张新表: VPNSession/ConnectionLog/TrafficPolicy - monitor增强: 会话同步+配额检查+异常告警+到期审计 - changePassword统一8位+新旧不同校验 - openvpn-otp文案专业化(移除抄袭感) - SSE日志流(token降级query参数)
This commit is contained in:
1 parent
58c036c34c
commit
1e2f8ef24f
43 files changed
+6783
-460
No files matched your search
+342
-15
@@ -7,6 +7,11 @@
|
||||
<el-select v-model="protocolFilter" placeholder="全部协议" clearable style="width:160px;margin-left:10px" @change="load">
|
||||
<el-option v-for="p in protocolOptions" :key="p" :label="protocolDisplay(p)" :value="p" />
|
||||
</el-select>
|
||||
<el-select v-model="stateFilter" placeholder="全部状态" clearable style="width:140px;margin-left:10px" @change="load">
|
||||
<el-option label="运行中" value="running" />
|
||||
<el-option label="已停止" value="stopped" />
|
||||
<el-option label="异常" value="error" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-button type="primary" :icon="Plus" @click="$router.push('/tunnels/create')">创建专线</el-button>
|
||||
</div>
|
||||
@@ -19,11 +24,12 @@
|
||||
<div class="remark" v-if="row.remark">{{ row.remark }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="协议" width="130">
|
||||
<el-table-column label="协议" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-tag :color="protocolColor(row.protocol)" effect="dark" style="border:none">
|
||||
<span class="proto-cell">
|
||||
<ProtocolIcon :protocol="row.protocol" :size="16" :style="{ color: protocolColor(row.protocol) }" />
|
||||
{{ protocolDisplay(row.protocol) }}
|
||||
</el-tag>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="110">
|
||||
@@ -49,24 +55,35 @@
|
||||
<el-table-column label="节点" width="110">
|
||||
<template #default="{ row }">{{ row.node?.name || '本机节点' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="300" fixed="right">
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="stateOf(row) !== 'running'" type="success" size="small" link @click="start(row)">启动</el-button>
|
||||
<el-button v-else type="warning" size="small" link @click="stop(row)">停止</el-button>
|
||||
<el-button size="small" link @click="showDetail(row)">详情</el-button>
|
||||
<el-button size="small" link @click="$router.push(`/tunnels/${row.id}/edit`)">编辑</el-button>
|
||||
<el-button size="small" link @click="download(row)">下载配置</el-button>
|
||||
<el-button v-if="row.protocol === 'wireguard'" size="small" link @click="showQr(row)">二维码</el-button>
|
||||
<el-button type="danger" size="small" link @click="remove(row)">删除</el-button>
|
||||
<el-dropdown trigger="click" @command="cmd => onMoreCommand(cmd, row)" style="margin-left:8px">
|
||||
<el-button size="small" link>更多<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="logs">查看日志</el-dropdown-item>
|
||||
<el-dropdown-item command="download">下载配置</el-dropdown-item>
|
||||
<el-dropdown-item v-if="row.protocol === 'wireguard'" command="qrcode">显示二维码</el-dropdown-item>
|
||||
<el-dropdown-item v-if="row.protocol === 'ipsec'" command="ipsec-peer">IPSec 对接测试</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<el-button type="danger" size="small" link style="margin-left:12px" @click="remove(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="还没有专线,点击右上角创建第一条" />
|
||||
<el-empty description="还没有专线">
|
||||
<el-button type="primary" :icon="Plus" @click="$router.push('/tunnels/create')">创建第一条专线</el-button>
|
||||
</el-empty>
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<el-drawer v-model="detailVisible" :title="detail?.tunnel?.name" size="520px">
|
||||
<el-drawer v-model="detailVisible" :title="detail?.tunnel?.name" size="560px">
|
||||
<template v-if="detail">
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="协议">{{ protocolDisplay(detail.tunnel.protocol) }}</el-descriptions-item>
|
||||
@@ -78,7 +95,64 @@
|
||||
<pre class="mono conf-box">{{ prettyConfig(detail.tunnel.config) }}</pre>
|
||||
<h4>客户端配置</h4>
|
||||
<pre class="mono conf-box">{{ detail.client_config || '该协议暂无客户端配置' }}</pre>
|
||||
<el-button type="primary" style="width:100%" @click="download(detail.tunnel)">下载客户端配置</el-button>
|
||||
<el-button type="primary" style="width:100%;margin-bottom:8px" @click="download(detail.tunnel)">下载客户端配置</el-button>
|
||||
<el-button v-if="detail.tunnel.protocol === 'ipsec'" type="warning" plain style="width:100%;margin-bottom:16px"
|
||||
@click="$router.push('/ipsec-peer')">IPSec 参数检查与连通性测试</el-button>
|
||||
|
||||
<!-- 流量策略 -->
|
||||
<h4>流量策略</h4>
|
||||
<el-form label-width="90px" size="small" v-loading="policyLoading">
|
||||
<el-form-item label="配额(GB)">
|
||||
<el-input-number v-model="policy.quota_gb" :min="0" :precision="1" :step="10" style="width:100%" />
|
||||
<span style="font-size:12px;color:var(--text-secondary);margin-left:8px">0 = 不限。已用:{{ fmtBytes(policy.used_bytes || 0) }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="限速(Mbps)">
|
||||
<el-input-number v-model="policy.rate_limit_mbps" :min="0" :max="10000" style="width:100%" />
|
||||
<span style="font-size:12px;color:var(--text-secondary);margin-left:8px">0 = 不限</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="超额动作">
|
||||
<el-select v-model="policy.action_on_exceed" style="width:100%">
|
||||
<el-option value="warn" label="仅告警" />
|
||||
<el-option value="block" label="自动停止" />
|
||||
<el-option value="throttle" label="限速至1Mbps" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="重置周期">
|
||||
<el-select v-model="policy.reset_cycle" style="width:100%">
|
||||
<el-option value="none" label="不重置" />
|
||||
<el-option value="daily" label="每天" />
|
||||
<el-option value="monthly" label="每月" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-button type="primary" size="small" :loading="policySaving" @click="savePolicy(detail.tunnel.id)">保存策略</el-button>
|
||||
</el-form>
|
||||
|
||||
<!-- WireGuard Peer 管理 -->
|
||||
<template v-if="detail.tunnel.protocol === 'wireguard'">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin:8px 0">
|
||||
<h4 style="margin:0">Peer 列表</h4>
|
||||
<el-button type="primary" size="small" @click="openAddPeer">添加 Peer</el-button>
|
||||
</div>
|
||||
<el-table :data="wgPeers" size="small" v-loading="peersLoading">
|
||||
<el-table-column prop="id" label="ID" width="100" />
|
||||
<el-table-column prop="name" label="名称" width="100" />
|
||||
<el-table-column prop="client_address" label="地址" min-width="110" />
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="peerOnline(row) ? 'success' : 'info'">
|
||||
{{ peerOnline(row) ? '在线' : '离线' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.managed !== false" link size="small" @click="downloadPeer(row)">配置</el-button>
|
||||
<el-button v-if="row.managed !== false" link size="small" @click="qrPeer(row)">二维码</el-button>
|
||||
<el-button type="danger" link size="small" @click="removePeer(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
@@ -89,26 +163,80 @@
|
||||
<p style="color:#909399;font-size:12px">使用 WireGuard App「从二维码添加」</p>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 添加 Peer -->
|
||||
<el-dialog v-model="peerDialog" title="添加 WireGuard Peer" width="440px">
|
||||
<el-form label-width="110px">
|
||||
<el-form-item label="Peer ID">
|
||||
<el-input v-model="peerForm.id" placeholder="留空自动生成 client-N" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="peerForm.name" placeholder="手机 / 笔记本" />
|
||||
</el-form-item>
|
||||
<el-form-item label="客户端地址" required>
|
||||
<el-input v-model="peerForm.client_address" placeholder="10.8.0.3/32" />
|
||||
</el-form-item>
|
||||
<el-form-item label="AllowedIPs">
|
||||
<el-input v-model="peerForm.allowed_ips" placeholder="留空使用隧道默认" />
|
||||
</el-form-item>
|
||||
<el-form-item label="外部公钥">
|
||||
<el-input v-model="peerForm.public_key" type="textarea" :rows="2"
|
||||
placeholder="留空则平台托管生成密钥;填写则为外部设备" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="peerDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="peerSaving" @click="addPeer">添加</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
|
||||
<!-- 日志实时查看弹窗 -->
|
||||
<el-dialog v-model="logVisible" :title="`实例日志 — ${logTunnel?.name || ''}`" width="780px" align-center
|
||||
@close="closeLogStream">
|
||||
<div class="log-toolbar">
|
||||
<el-tag size="small" :type="logStreaming ? 'success' : 'info'">
|
||||
{{ logStreaming ? '● 实时输出中' : '已停止' }}
|
||||
</el-tag>
|
||||
<el-button v-if="logStreaming" size="small" @click="stopLogStream">暂停</el-button>
|
||||
<el-button v-else size="small" type="primary" @click="resumeLogStream">继续</el-button>
|
||||
<el-button size="small" @click="logContent = ''">清屏</el-button>
|
||||
</div>
|
||||
<div class="log-view mono" ref="logView">{{ logContent || '等待日志输出...' }}</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, InfoFilled } from '@element-plus/icons-vue'
|
||||
import { Plus, InfoFilled, ArrowDown } from '@element-plus/icons-vue'
|
||||
import QRCode from 'qrcode'
|
||||
import { useRouter } from 'vue-router'
|
||||
import api from '../api'
|
||||
import ProtocolIcon from '../components/ProtocolIcon.vue'
|
||||
import { protocolDisplay, protocolColor } from '../protocols'
|
||||
|
||||
const router = useRouter()
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const protocolFilter = ref('')
|
||||
const stateFilter = ref('')
|
||||
const protocolOptions = ref([])
|
||||
const detailVisible = ref(false)
|
||||
const detail = ref(null)
|
||||
const qrVisible = ref(false)
|
||||
// 流量策略
|
||||
const policy = ref({ quota_gb: 0, rate_limit_mbps: 0, action_on_exceed: 'block', reset_cycle: 'none', used_bytes: 0 })
|
||||
const policyLoading = ref(false)
|
||||
const policySaving = ref(false)
|
||||
const qrCanvas = ref(null)
|
||||
const wgPeers = ref([])
|
||||
const runtimePeers = ref([])
|
||||
const peersLoading = ref(false)
|
||||
const peerDialog = ref(false)
|
||||
const peerSaving = ref(false)
|
||||
const peerForm = ref({ id: '', name: '', client_address: '', allowed_ips: '', public_key: '' })
|
||||
|
||||
function fmtBytes(n) {
|
||||
if (!n) return '0 B'
|
||||
@@ -134,15 +262,114 @@ function stateOf(row) { return row.runtime?.state || row.state }
|
||||
function prettyConfig(c) {
|
||||
try { return JSON.stringify(JSON.parse(c), null, 2) } catch { return c }
|
||||
}
|
||||
function peerOnline(row) {
|
||||
const rt = runtimePeers.value.find(p => p.id === row.id || p.public_key === row.public_key)
|
||||
return !!rt?.connected
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/tunnels', { params: { keyword: keyword.value, protocol: protocolFilter.value } })
|
||||
list.value = data || []
|
||||
let result = data || []
|
||||
// 前端状态筛选(后端无 state 参数)
|
||||
if (stateFilter.value) {
|
||||
result = result.filter(r => stateOf(r) === stateFilter.value)
|
||||
}
|
||||
list.value = result
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
// 更多操作下拉命令分发
|
||||
function onMoreCommand(cmd, row) {
|
||||
if (cmd === 'download') download(row)
|
||||
else if (cmd === 'qrcode') showQr(row)
|
||||
else if (cmd === 'ipsec-peer') router.push('/ipsec-peer')
|
||||
else if (cmd === 'logs') openLogStream(row)
|
||||
}
|
||||
|
||||
// ---- 实时日志流(SSE)----
|
||||
const logVisible = ref(false)
|
||||
const logTunnel = ref(null)
|
||||
const logContent = ref('')
|
||||
const logStreaming = ref(false)
|
||||
const logView = ref(null)
|
||||
let logEventSource = null
|
||||
|
||||
function openLogStream(row) {
|
||||
logTunnel.value = row
|
||||
logContent.value = ''
|
||||
logVisible.value = true
|
||||
resumeLogStream()
|
||||
}
|
||||
|
||||
function resumeLogStream() {
|
||||
if (!logTunnel.value) return
|
||||
stopLogStream()
|
||||
const token = localStorage.getItem('vpn_token')
|
||||
// SSE 不支持自定义 header,通过 query 传 token
|
||||
logEventSource = new EventSource(`/api/tunnels/${logTunnel.value.id}/logs?token=${token}`)
|
||||
logStreaming.value = true
|
||||
logEventSource.onmessage = (e) => {
|
||||
logContent.value += e.data + '\n'
|
||||
// 自动滚动到底部
|
||||
nextTick(() => {
|
||||
if (logView.value) logView.value.scrollTop = logView.value.scrollHeight
|
||||
})
|
||||
// 限制日志长度防止内存爆炸
|
||||
if (logContent.value.length > 100000) {
|
||||
logContent.value = logContent.value.slice(-50000)
|
||||
}
|
||||
}
|
||||
logEventSource.onerror = () => {
|
||||
logStreaming.value = false
|
||||
logEventSource?.close()
|
||||
}
|
||||
}
|
||||
|
||||
function stopLogStream() {
|
||||
logStreaming.value = false
|
||||
logEventSource?.close()
|
||||
logEventSource = null
|
||||
}
|
||||
|
||||
function closeLogStream() {
|
||||
stopLogStream()
|
||||
logContent.value = ''
|
||||
}
|
||||
|
||||
// ---- 流量策略 ----
|
||||
async function loadPolicy(tunnelId) {
|
||||
policyLoading.value = true
|
||||
try {
|
||||
const { data } = await api.get(`/tunnels/${tunnelId}/policy`)
|
||||
policy.value = {
|
||||
quota_gb: data.quota_bytes > 0 ? +(data.quota_bytes / (1024 * 1024 * 1024)).toFixed(2) : 0,
|
||||
rate_limit_mbps: data.rate_limit_mbps || 0,
|
||||
action_on_exceed: data.action_on_exceed || 'block',
|
||||
reset_cycle: data.reset_cycle || 'none',
|
||||
used_bytes: data.used_bytes || 0,
|
||||
}
|
||||
} catch { policy.value = { quota_gb: 0, rate_limit_mbps: 0, action_on_exceed: 'block', reset_cycle: 'none', used_bytes: 0 } }
|
||||
finally { policyLoading.value = false }
|
||||
}
|
||||
|
||||
async function savePolicy(tunnelId) {
|
||||
policySaving.value = true
|
||||
try {
|
||||
const quotaBytes = Math.round((policy.value.quota_gb || 0) * 1024 * 1024 * 1024)
|
||||
await api.put(`/tunnels/${tunnelId}/policy`, {
|
||||
quota_bytes: quotaBytes,
|
||||
rate_limit_mbps: policy.value.rate_limit_mbps,
|
||||
action_on_exceed: policy.value.action_on_exceed,
|
||||
reset_cycle: policy.value.reset_cycle,
|
||||
})
|
||||
ElMessage.success('流量策略已保存')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '保存失败')
|
||||
} finally { policySaving.value = false }
|
||||
}
|
||||
|
||||
async function start(row) {
|
||||
try {
|
||||
await api.post(`/tunnels/${row.id}/start`)
|
||||
@@ -167,9 +394,103 @@ async function showDetail(row) {
|
||||
const { data } = await api.get(`/tunnels/${row.id}`)
|
||||
detail.value = data
|
||||
detailVisible.value = true
|
||||
if (row.protocol === 'wireguard') {
|
||||
await loadPeers(row.id)
|
||||
} else {
|
||||
wgPeers.value = []
|
||||
runtimePeers.value = []
|
||||
}
|
||||
loadPolicy(row.id)
|
||||
}
|
||||
|
||||
async function loadPeers(tunnelId) {
|
||||
peersLoading.value = true
|
||||
try {
|
||||
const { data } = await api.get(`/tunnels/${tunnelId}/wireguard/peers`)
|
||||
wgPeers.value = data.peers || []
|
||||
runtimePeers.value = data.runtime_peers || []
|
||||
} catch {
|
||||
wgPeers.value = []
|
||||
} finally {
|
||||
peersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAddPeer() {
|
||||
peerForm.value = { id: '', name: '', client_address: '', allowed_ips: '', public_key: '' }
|
||||
peerDialog.value = true
|
||||
}
|
||||
|
||||
async function addPeer() {
|
||||
if (!peerForm.value.client_address) return ElMessage.warning('请填写客户端地址')
|
||||
peerSaving.value = true
|
||||
try {
|
||||
const body = { ...peerForm.value }
|
||||
if (!body.id) delete body.id
|
||||
if (!body.public_key) delete body.public_key
|
||||
if (!body.allowed_ips) delete body.allowed_ips
|
||||
await api.post(`/tunnels/${detail.value.tunnel.id}/wireguard/peers`, body)
|
||||
ElMessage.success('Peer 已添加')
|
||||
peerDialog.value = false
|
||||
await loadPeers(detail.value.tunnel.id)
|
||||
const { data } = await api.get(`/tunnels/${detail.value.tunnel.id}`)
|
||||
detail.value = data
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '添加失败')
|
||||
} finally {
|
||||
peerSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removePeer(row) {
|
||||
await ElMessageBox.confirm(`删除 Peer「${row.id}」?`, '确认', { type: 'warning' })
|
||||
try {
|
||||
await api.delete(`/tunnels/${detail.value.tunnel.id}/wireguard/peers/${row.id}`)
|
||||
ElMessage.success('已删除')
|
||||
await loadPeers(detail.value.tunnel.id)
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function downloadPeer(row) {
|
||||
fetch(`/api/tunnels/${detail.value.tunnel.id}/wireguard/peers/${row.id}/config`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` }
|
||||
}).then(async r => {
|
||||
if (!r.ok) {
|
||||
const d = await r.json()
|
||||
throw new Error(d.error)
|
||||
}
|
||||
return r.blob()
|
||||
}).then(b => {
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(b)
|
||||
a.download = `wireguard-${row.id}.conf`
|
||||
a.click()
|
||||
}).catch(e => ElMessage.error(e.message || '下载失败'))
|
||||
}
|
||||
|
||||
async function qrPeer(row) {
|
||||
try {
|
||||
const res = await fetch(`/api/tunnels/${detail.value.tunnel.id}/wireguard/peers/${row.id}/config`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` }
|
||||
})
|
||||
if (!res.ok) {
|
||||
const d = await res.json()
|
||||
throw new Error(d.error)
|
||||
}
|
||||
const conf = await res.text()
|
||||
qrVisible.value = true
|
||||
await nextTick()
|
||||
QRCode.toCanvas(qrCanvas.value, conf, { width: 280, margin: 1 })
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '生成二维码失败')
|
||||
}
|
||||
}
|
||||
|
||||
function download(row) {
|
||||
// 带鉴权下载
|
||||
fetch(`/api/tunnels/${row.id}/config`, { headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` } })
|
||||
.then(r => r.blob()).then(b => {
|
||||
const a = document.createElement('a')
|
||||
@@ -199,6 +520,12 @@ onUnmounted(() => clearInterval(timer))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.remark { font-size: 12px; color: #909399; }
|
||||
.conf-box { background: #f6f8fa; border: 1px solid #e8eaee; border-radius: 6px; padding: 12px; max-height: 260px; overflow: auto; }
|
||||
.remark { font-size: 12px; color: var(--text-secondary); }
|
||||
.proto-cell { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; }
|
||||
.conf-box { background: var(--bg-inset); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 12px; max-height: 260px; overflow: auto; }
|
||||
.log-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||
.log-view {
|
||||
background: #1e1e1e; color: #d4d4d4; border-radius: var(--radius-sm); padding: 16px;
|
||||
height: 400px; overflow-y: auto; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user