码桶
发现社区成员的开源项目
HomeView.vue10.3 KB
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { NSelect, useMessage } from 'naive-ui'
import { useNodesStore } from '@/stores/nodes'
import { usePanelStore } from '@/stores/panel'
import { useSettingsStore } from '@/stores/settings'
import { useSpeedRunner } from '@/composables/useSpeedRunner'
import { validateSpeedNodeUrl } from '@/utils/nodeConnectivity'
import NetworkPanel from '@/components/panel/NetworkPanel.vue'
import RealtimeChart from '@/components/chart/RealtimeChart.vue'
import ExitIpInfo from '@/components/ip/ExitIpInfo.vue'
import StatsRow from '@/components/panel/StatsRow.vue'
import RankingsModal from '@/components/rankings/RankingsModal.vue'
import ScreenSaver from '@/components/screensaver/ScreenSaver.vue'
import AppDownloadFab from '@/components/app/AppDownloadFab.vue'
const nodesStore = useNodesStore()
const panel = usePanelStore()
const settings = useSettingsStore()
const message = useMessage()
watch(() => panel.lastError, (err) => {
if (err) message.error(err)
})
watch(() => panel.lastWarning, (warn) => {
if (warn) {
message.warning(warn, { duration: 0, closable: true })
panel.lastWarning = ''
}
})
watch(() => nodesStore.lastNodeSwitchReason, (reason) => {
if (reason) {
message.error(reason)
nodesStore.lastNodeSwitchReason = ''
}
})
const chartRef = ref<InstanceType<typeof RealtimeChart> | null>(null)
const rankingsOpen = ref(false)
const screenOpen = ref(false)
const limitDialog = ref<'flow' | 'speed' | null>(null)
const limitValue = ref<number | null>(null)
const limitUnit = ref('GB')
const limitUnitOptions = computed(() => (
limitDialog.value === 'flow'
? [
{ label: 'MB', value: 'MB' },
{ label: 'GB', value: 'GB' },
{ label: 'TB', value: 'TB' },
]
: [
{ label: 'Kbps', value: 'Kbps' },
{ label: 'Mbps', value: 'Mbps' },
{ label: 'Gbps', value: 'Gbps' },
]
))
const runner = useSpeedRunner()
const statusChipClass = computed(() => panel.isRunning ? 'fl-chip-success' : '')
const statusLabel = computed(() => {
if (panel.isChecking) return '检测中'
return panel.isRunning ? '运行中' : '待命'
})
function handleToggleRun() {
void runner.tryStart()
}
function handleOpenRankings() {
rankingsOpen.value = true
}
function handleOpenScreen() {
screenOpen.value = true
}
function handleSetFlowMax() {
limitDialog.value = 'flow'
limitValue.value = null
limitUnit.value = 'GB'
}
function handleSetLimitMax() {
limitDialog.value = 'speed'
limitValue.value = null
limitUnit.value = 'Mbps'
}
function saveLimit() {
const value = Math.max(0, Number(limitValue.value) || 0)
if (limitDialog.value === 'flow') {
const map: Record<string, number> = {
MB: 1024 * 1024,
GB: 1024 * 1024 * 1024,
TB: 1024 * 1024 * 1024 * 1024,
}
settings.maxUse = Math.floor(value * map[limitUnit.value])
} else if (limitDialog.value === 'speed') {
const map: Record<string, number> = {
Kbps: 1024,
Mbps: 1024 * 1024,
Gbps: 1024 * 1024 * 1024,
}
settings.maxSpeed = Math.floor(value * map[limitUnit.value])
}
limitDialog.value = null
}
onMounted(() => {
runner.init()
runner.setChartTarget(chartRef.value)
void nodesStore.fetchNodes().then(() => {
if (settings.autoStart) void runner.tryStart()
})
window.addEventListener('paste', handlePaste)
})
onUnmounted(() => {
runner.destroy()
window.removeEventListener('paste', handlePaste)
})
function parseUrl(text: string): string {
const match = text.match(/https?:\/\/([\w-]+\.)+[\w-]+(:[0-9]+)?(\/\S*)?/)
return match ? match[0] : ''
}
async function handlePaste(e: ClipboardEvent) {
const target = e.target as HTMLElement | null
if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA' || target?.isContentEditable) return
const text = e.clipboardData?.getData('text') || ''
const url = parseUrl(text)
if (!url) return
message.info('读取剪切板链接,正在检测可用性...')
const result = await validateSpeedNodeUrl(url)
if (result.status) {
settings.urlValue = url
message.success('链接可用,已设为当前测速节点')
} else {
message.error(`链接不可用:${result.info}`)
}
}
</script>
<template>
<div class="fl-shell pt-5 pb-14">
<!-- Hero strip -->
<section class="hero-strip">
<div>
<h1 class="text-[28px] font-semibold tracking-tight" style="color: var(--text-primary); letter-spacing: -0.03em">
网络面板
</h1>
<p class="mt-1 text-[14px] home-tagline" style="color: var(--text-secondary)">
<span>极简</span><span>极速</span><span>实时</span><span>压测</span>
</p>
</div>
<div class="fl-chip status-pill" :class="statusChipClass">
<span v-if="panel.isRunning" class="fl-pulse-dot" />
<span v-else class="status-dot" />
{{ statusLabel }}
</div>
</section>
<!-- Bento grid -->
<div class="home-bento">
<div class="bento-panel min-w-0">
<NetworkPanel
@toggle-run="handleToggleRun"
@open-rankings="handleOpenRankings"
@open-screen="handleOpenScreen"
@set-flow-max="handleSetFlowMax"
@set-limit-max="handleSetLimitMax"
/>
</div>
<div class="bento-sidebar min-w-0">
<StatsRow
@set-flow-max="handleSetFlowMax"
@set-limit-max="handleSetLimitMax"
/>
<ExitIpInfo />
</div>
<div class="bento-chart min-w-0">
<RealtimeChart ref="chartRef" />
</div>
</div>
<RankingsModal :show="rankingsOpen" @close="rankingsOpen = false" />
<ScreenSaver :show="screenOpen" @close="screenOpen = false" />
<Teleport to="body">
<Transition name="modal-fade">
<div v-if="limitDialog" class="limit-backdrop" @click.self="limitDialog = null">
<section class="limit-modal">
<header>
<h3>{{ limitDialog === 'flow' ? '设置流量上限' : '设置带宽上限' }}</h3>
<button @click="limitDialog = null">×</button>
</header>
<div class="limit-body">
<label>{{ limitDialog === 'flow' ? '流量数值' : '速率数值' }}</label>
<input v-model.number="limitValue" type="number" min="0" placeholder="留空则无上限" />
<label>单位</label>
<NSelect
v-model:value="limitUnit"
:options="limitUnitOptions"
class="limit-unit-select"
size="medium"
/>
<p v-if="limitDialog === 'speed'">浏览器限速只能限制平均速度,峰值可能仍有波动。</p>
</div>
<footer>
<button class="ghost" @click="limitDialog = null">取消</button>
<button class="primary" @click="saveLimit">保存</button>
</footer>
</section>
</div>
</Transition>
</Teleport>
<AppDownloadFab />
</div>
</template>
<style scoped>
.home-bento {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
.bento-sidebar {
display: contents;
}
.bento-sidebar > :last-child {
order: 4;
}
.bento-chart {
order: 3;
}
@media (min-width: 1024px) {
.home-bento {
grid-template-columns: 7fr 5fr;
}
.bento-sidebar {
display: flex;
flex-direction: column;
gap: 16px;
}
.bento-sidebar > :last-child {
order: unset;
flex: 1;
}
.bento-chart {
grid-column: 1 / -1;
order: unset;
}
}
.home-tagline {
display: flex;
align-items: center;
gap: 0;
}
.home-tagline span + span::before {
content: '';
display: inline-block;
width: 4px;
height: 4px;
margin: 0 8px;
border-radius: 50%;
background: currentColor;
opacity: 0.4;
vertical-align: middle;
}
.status-pill {
margin-top: 12px;
min-height: 26px;
padding: 5px 10px;
border-radius: 7px;
gap: 8px;
font-size: 12px;
font-weight: 600;
line-height: 1;
background: var(--bg-card);
box-shadow: none;
}
.status-dot {
width: 7px;
height: 7px;
flex: 0 0 auto;
border-radius: 50%;
background: var(--text-muted);
}
@media (max-width: 520px) {
.hero-strip {
align-items: flex-start;
}
.status-pill {
margin-top: 2px;
}
}
.hero-strip {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-top: 3px;
margin-bottom: 14px;
}
.limit-backdrop {
position: fixed;
inset: 0;
z-index: 110;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(0, 0, 0, 0.28);
}
.limit-modal {
width: min(360px, 100%);
border: 1px solid var(--border);
border-radius: 12px;
background: var(--bg-card);
box-shadow: var(--shadow-lg);
}
.limit-modal header,
.limit-modal footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 16px;
}
.limit-modal header {
border-bottom: 1px solid var(--border);
}
.limit-modal h3 {
color: var(--text-primary);
font-size: 15px;
font-weight: 700;
}
.limit-modal header button {
width: 30px;
height: 30px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
color: var(--text-muted);
cursor: pointer;
}
.limit-body {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
padding: 16px;
}
.limit-body label {
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
}
.limit-body input {
height: 38px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-input);
color: var(--text-primary);
padding: 0 10px;
outline: none;
}
.limit-unit-select {
width: 100%;
}
.limit-body p {
margin-top: 4px;
color: var(--text-muted);
font-size: 12px;
line-height: 1.5;
}
.limit-modal footer {
justify-content: flex-end;
border-top: 1px solid var(--border-subtle);
}
.limit-modal footer button {
height: 34px;
min-width: 76px;
border-radius: 8px;
border: 1px solid var(--border);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.limit-modal .ghost {
background: var(--bg-card);
color: var(--text-secondary);
}
.limit-modal .primary {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: var(--accent-on-primary);
}
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.16s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
</style>