最近有扫描本地飞牛设备的需要,本来想着写个前端在线网页扫描,以实现像群晖Synology Web Assistant一样功能,无奈前端限制太多加之个人能力有限无法实现,于是有了后面结合DeepSeek回答后写的代码(前端HTML后端Python),打包成一个exe可执行文件。
一、文件结构
lan-scanner-project/
├── main.py # 后端Python程序
├── templates/
│ └── index.html # 前端HTML代码
├── static/ # 静态文件目录
│ └── icon.ico # exe文件图标
└── requirements.txt # 依赖文件
将相应文件(夹)创建好备用
二、前端文件
将index.html文件内写入以下内容并保存
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>飞牛设备扫描器</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
}
body {
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
min-height: 100vh;
padding: 20px;
color: #333;
}
.container {
max-width: 1400px;
margin: 0 auto;
background-color: white;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
header {
background: linear-gradient(90deg, #4b6cb7 0%, #182848 100%);
color: white;
padding: 25px 30px;
text-align: center;
}
h1 {
font-size: 2.4rem;
margin-bottom: 10px;
letter-spacing: 0.5px;
}
.subtitle {
font-size: 1.1rem;
opacity: 0.85;
font-weight: 300;
}
.content {
padding: 30px;
}
.dashboard {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 30px;
margin-bottom: 30px;
}
[url=home.php?mod=space&uid=945662]@media[/url] (max-width: 1024px) {
.dashboard {
grid-template-columns: 1fr;
}
}
.panel {
background-color: #f8f9fa;
border-radius: 10px;
padding: 25px;
border-left: 5px solid #4b6cb7;
}
.panel.full-width {
grid-column: 1 / -1;
}
.panel-title {
font-size: 1.4rem;
color: #182848;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.panel-title i {
color: #4b6cb7;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #182848;
}
.input-row {
display: flex;
flex-wrap: wrap;
gap: 15px;
align-items: flex-end;
}
.input-group {
flex: 1;
min-width: 180px;
}
input, select {
width: 100%;
padding: 12px 15px;
border: 2px solid #ddd;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.3s;
}
input:focus, select:focus {
border-color: #4b6cb7;
outline: none;
}
.btn {
background-color: #4b6cb7;
color: white;
border: none;
border-radius: 8px;
padding: 12px 25px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn:hover {
background-color: #3a5ca9;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(75, 108, 183, 0.3);
}
.btn:active {
transform: translateY(0);
}
.btn-primary {
background-color: #4b6cb7;
}
.btn-primary:hover {
background-color: #3a5ca9;
}
.btn-success {
background-color: #28a745;
}
.btn-success:hover {
background-color: #218838;
}
.btn-danger {
background-color: #dc3545;
}
.btn-danger:hover {
background-color: #c82333;
}
.btn-warning {
background-color: #ffc107;
color: #212529;
}
.btn-warning:hover {
background-color: #e0a800;
}
.btn-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.network-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}
.network-card {
background-color: white;
border-radius: 8px;
padding: 15px;
border: 2px solid #e9ecef;
cursor: pointer;
transition: all 0.3s;
}
.network-card:hover {
border-color: #4b6cb7;
transform: translateY(-3px);
}
.network-card.active {
border-color: #4b6cb7;
background-color: #e7f3ff;
}
.network-base {
font-weight: 700;
font-size: 1.2rem;
color: #182848;
}
.network-brand {
font-size: 0.9rem;
color: #6c757d;
margin-top: 5px;
}
.local-network {
background-color: #fff3cd;
border-color: #ffc107;
}
.progress-container {
margin-top: 20px;
}
.progress-label {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
}
.progress-bar {
height: 12px;
background-color: #e9ecef;
border-radius: 6px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #4b6cb7 0%, #28a745 100%);
width: 0%;
transition: width 0.5s;
}
.task-info {
background-color: white;
border-radius: 8px;
padding: 15px;
margin-top: 15px;
border-left: 4px solid #4b6cb7;
}
.task-info .info-row {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
}
.task-info .info-label {
font-weight: 600;
color: #6c757d;
}
.task-info .info-value {
color: #182848;
}
.results-container {
margin-top: 30px;
}
.results-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #e9ecef;
}
.results-title {
font-size: 1.4rem;
color: #182848;
}
.device-count {
background-color: #4b6cb7;
color: white;
padding: 5px 12px;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 600;
}
.device-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
}
.device-card {
background-color: white;
border-radius: 10px;
padding: 20px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08);
border-top: 4px solid #4b6cb7;
transition: transform 0.3s;
}
.device-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
.device-card.active {
border-top-color: #28a745;
}
.device-ip {
font-size: 1.3rem;
font-weight: 700;
color: #182848;
margin-bottom: 5px;
}
.device-hostname {
color: #6c757d;
font-size: 0.9rem;
margin-bottom: 10px;
}
.device-status {
display: inline-block;
padding: 4px 10px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 15px;
}
.status-open {
background-color: rgba(40, 167, 69, 0.15);
color: #218838;
}
.status-closed {
background-color: rgba(220, 53, 69, 0.15);
color: #c82333;
}
.device-info {
margin-bottom: 15px;
}
.device-info p {
margin-bottom: 5px;
display: flex;
align-items: center;
gap: 8px;
}
.device-actions {
display: flex;
gap: 10px;
}
.btn-access {
background-color: #6c757d;
padding: 8px 15px;
font-size: 0.9rem;
flex: 1;
}
.btn-access:hover {
background-color: #5a6268;
}
.loading {
display: none;
text-align: center;
padding: 40px;
}
.loading.active {
display: block;
}
.spinner {
border: 5px solid #f3f3f3;
border-top: 5px solid #4b6cb7;
border-radius: 50%;
width: 50px;
height: 50px;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.no-results {
text-align: center;
padding: 40px;
color: #6c757d;
font-size: 1.1rem;
}
.alert {
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
display: none;
}
.alert.active {
display: block;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border-left: 4px solid #28a745;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border-left: 4px solid #dc3545;
}
.alert-info {
background-color: #d1ecf1;
color: #0c5460;
border-left: 4px solid #17a2b8;
}
.status-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
}
.status-pending {
background-color: #fff3cd;
color: #856404;
}
.status-scanning {
background-color: #d1ecf1;
color: #0c5460;
}
.status-completed {
background-color: #d4edda;
color: #155724;
}
.status-stopped {
background-color: #f8d7da;
color: #721c24;
}
.tab-container {
margin-top: 20px;
}
.tabs {
display: flex;
border-bottom: 2px solid #dee2e6;
}
.tab {
padding: 10px 20px;
cursor: pointer;
font-weight: 600;
color: #6c757d;
border-bottom: 3px solid transparent;
transition: all 0.3s;
}
.tab:hover {
color: #4b6cb7;
}
.tab.active {
color: #4b6cb7;
border-bottom-color: #4b6cb7;
}
.tab-content {
display: none;
padding: 20px 0;
}
.tab-content.active {
display: block;
}
@media (max-width: 768px) {
.content {
padding: 20px;
}
.input-row {
flex-direction: column;
}
.device-list {
grid-template-columns: 1fr;
}
h1 {
font-size: 1.8rem;
}
.device-actions {
flex-direction: column;
}
}
</style>
</head>
<body>
<div class="container">
<header>
<h1><i class="fas fa-network-wired"></i>飞牛设备扫描器</h1>
<p class="subtitle">扫描本地局域网飞牛设备</p>
</header>
<div class="content">
<div class="alert" id="alert-message"></div>
<div class="dashboard">
<div class="panel">
<h2 class="panel-title"><i class="fas fa-cogs"></i> 扫描配置</h2>
<div class="form-group">
<label for="network-base">网络地址段</label>
<input type="text" id="network-base" value="192.168.1" placeholder="例如: 192.168.1">
</div>
<div class="form-group">
<label>IP范围</label>
<div class="input-row">
<div class="input-group">
<input type="number" id="start-ip" min="1" max="254" value="1" placeholder="起始IP">
</div>
<div class="input-group">
<span style="text-align: center; padding: 12px;">到</span>
</div>
<div class="input-group">
<input type="number" id="end-ip" min="1" max="254" value="254" placeholder="结束IP">
</div>
</div>
</div>
<div class="form-group">
<label for="port">端口号</label>
<input type="number" id="port" min="1" max="65535" value="5666" placeholder="端口号">
</div>
<div class="btn-group">
<button class="btn btn-success" id="start-scan">
<i class="fas fa-search"></i> 开始扫描
</button>
<button class="btn btn-danger" id="stop-scan" disabled>
<i class="fas fa-stop"></i> 停止扫描
</button>
<button class="btn btn-warning" id="quick-scan">
<i class="fas fa-bolt"></i> 快速扫描
</button>
</div>
</div>
<div class="panel">
<h2 class="panel-title"><i class="fas fa-info-circle"></i> 当前任务</h2>
<div class="task-info" id="task-info" style="display: none;">
<div class="info-row">
<span class="info-label">任务ID:</span>
<span class="info-value" id="task-id">-</span>
</div>
<div class="info-row">
<span class="info-label">状态:</span>
<span class="info-value">
<span class="status-badge" id="task-status">-</span>
</span>
</div>
<div class="info-row">
<span class="info-label">进度:</span>
<span class="info-value" id="task-progress">0%</span>
</div>
<div class="info-row">
<span class="info-label">已扫描:</span>
<span class="info-value" id="task-scanned">0 / 0</span>
</div>
<div class="info-row">
<span class="info-label">发现设备:</span>
<span class="info-value" id="task-found">0</span>
</div>
</div>
<div class="progress-container" id="progress-container" style="display: none;">
<div class="progress-label">
<span>扫描进度</span>
<span id="progress-text">0%</span>
</div>
<div class="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
</div>
</div>
<div style="text-align: center; padding: 20px; color: #6c757d;" id="no-task">
<i class="fas fa-info-circle fa-2x" style="margin-bottom: 10px;"></i>
<p>暂无扫描任务</p>
</div>
</div>
<div class="panel full-width">
<h2 class="panel-title"><i class="fas fa-network-wired"></i> 网络选择</h2>
<div class="tab-container">
<div class="tabs">
<div class="tab active" data-tab="local">本地网络</div>
<div class="tab" data-tab="common">常见网段</div>
<div class="tab" data-tab="custom">自定义</div>
</div>
<div class="tab-content active" id="tab-local">
<div style="text-align: center; padding: 20px;" id="loading-networks">
<div class="spinner" style="width: 30px; height: 30px; margin-bottom: 10px;"></div>
<p>正在检测本地网络...</p>
</div>
<div class="network-cards" id="local-networks"></div>
</div>
<div class="tab-content" id="tab-common">
<p>选择常见路由器网段:</p>
<div class="network-cards" id="common-networks"></div>
</div>
<div class="tab-content" id="tab-custom">
<p>手动输入网络地址段:</p>
<div class="input-row">
<div class="input-group">
<input type="text" id="custom-network" placeholder="例如: 192.168.3">
</div>
<button class="btn btn-primary" id="apply-custom">
<i class="fas fa-check"></i> 应用
</button>
</div>
</div>
</div>
</div>
</div>
<div class="loading" id="loading">
<div class="spinner"></div>
<p>正在扫描网络,请稍候...</p>
<p id="scan-status">准备开始扫描</p>
</div>
<div class="results-container">
<div class="results-header">
<h2 class="results-title">扫描结果</h2>
<div class="device-count">找到设备: <span id="device-count">0</span></div>
</div>
<div id="device-list" class="device-list">
<div class="no-results" id="no-results">
<i class="fas fa-search fa-3x" style="margin-bottom: 15px; opacity: 0.5;"></i>
<p>等待扫描结果...</p>
<p style="font-size: 0.9rem; margin-top: 10px; color: #888;">扫描到的设备将显示在这里</p>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// DOM元素
const elements = {
startScanBtn: document.getElementById('start-scan'),
stopScanBtn: document.getElementById('stop-scan'),
quickScanBtn: document.getElementById('quick-scan'),
networkBaseInput: document.getElementById('network-base'),
startIpInput: document.getElementById('start-ip'),
endIpInput: document.getElementById('end-ip'),
portInput: document.getElementById('port'),
deviceList: document.getElementById('device-list'),
noResults: document.getElementById('no-results'),
deviceCount: document.getElementById('device-count'),
loading: document.getElementById('loading'),
progressContainer: document.getElementById('progress-container'),
progressFill: document.getElementById('progress-fill'),
progressText: document.getElementById('progress-text'),
scanStatus: document.getElementById('scan-status'),
taskInfo: document.getElementById('task-info'),
noTask: document.getElementById('no-task'),
taskId: document.getElementById('task-id'),
taskStatus: document.getElementById('task-status'),
taskProgress: document.getElementById('task-progress'),
taskScanned: document.getElementById('task-scanned'),
taskFound: document.getElementById('task-found'),
alertMessage: document.getElementById('alert-message'),
localNetworks: document.getElementById('local-networks'),
commonNetworks: document.getElementById('common-networks'),
loadingNetworks: document.getElementById('loading-networks'),
customNetworkInput: document.getElementById('custom-network'),
applyCustomBtn: document.getElementById('apply-custom')
};
// 全局变量
let currentTaskId = null;
let statusPollInterval = null;
let currentTab = 'local';
// API基础URL
const API_BASE = window.location.origin;
// 显示提示消息
function showAlert(message, type = 'info') {
elements.alertMessage.textContent = message;
elements.alertMessage.className = `alert alert-${type} active`;
setTimeout(() => {
elements.alertMessage.classList.remove('active');
}, 5000);
}
// 获取网络信息
async function loadNetworkInfo() {
try {
const response = await fetch(`${API_BASE}/api/networks`);
const data = await response.json();
// 隐藏加载指示器
elements.loadingNetworks.style.display = 'none';
// 显示本地网络
if (data.local_networks && data.local_networks.length > 0) {
let html = '';
data.local_networks.forEach(network => {
const networkBase = network.network.split('.').slice(0, 3).join('.');
html += `
<div class="network-card local-network" data-base="${networkBase}">
<div class="network-base">${networkBase}.0/24</div>
<div class="network-brand">本地网络 (${network.interface})</div>
<div style="font-size: 0.8rem; margin-top: 5px; color: #6c757d;">
${network.ip} / ${network.netmask}
</div>
</div>
`;
});
elements.localNetworks.innerHTML = html;
// 默认选择第一个本地网络
const firstNetwork = data.local_networks[0];
const firstNetworkBase = firstNetwork.network.split('.').slice(0, 3).join('.');
elements.networkBaseInput.value = firstNetworkBase;
// 添加点击事件
document.querySelectorAll('#local-networks .network-card').forEach(card => {
card.addEventListener('click', function() {
const base = this.dataset.base;
elements.networkBaseInput.value = base;
// 更新卡片选中状态
document.querySelectorAll('#local-networks .network-card').forEach(c => {
c.classList.remove('active');
});
this.classList.add('active');
});
});
// 选中第一个
if (document.querySelector('#local-networks .network-card')) {
document.querySelector('#local-networks .network-card').classList.add('active');
}
} else {
elements.localNetworks.innerHTML = `
<div style="text-align: center; padding: 20px; color: #6c757d; grid-column: 1 / -1;">
<i class="fas fa-exclamation-triangle fa-2x" style="margin-bottom: 10px;"></i>
<p>未检测到本地网络</p>
<p style="font-size: 0.9rem; margin-top: 5px;">请检查网络连接</p>
</div>
`;
}
// 显示常见网络
if (data.common_networks && data.common_networks.length > 0) {
let html = '';
data.common_networks.forEach(network => {
html += `
<div class="network-card" data-base="${network.base}">
<div class="network-base">${network.base}.0/24</div>
<div class="network-brand">${network.brand}</div>
</div>
`;
});
elements.commonNetworks.innerHTML = html;
// 添加点击事件
document.querySelectorAll('#common-networks .network-card').forEach(card => {
card.addEventListener('click', function() {
const base = this.dataset.base;
elements.networkBaseInput.value = base;
// 切换到自定义标签页
switchTab('custom');
elements.customNetworkInput.value = base;
// 更新卡片选中状态
document.querySelectorAll('#common-networks .network-card').forEach(c => {
c.classList.remove('active');
});
this.classList.add('active');
});
});
}
} catch (error) {
console.error('加载网络信息失败:', error);
elements.loadingNetworks.innerHTML = `
<div style="text-align: center; padding: 20px; color: #dc3545;">
<i class="fas fa-exclamation-circle fa-2x" style="margin-bottom: 10px;"></i>
<p>加载网络信息失败</p>
<p style="font-size: 0.9rem; margin-top: 5px;">${error.message}</p>
</div>
`;
}
}
// 切换标签页
function switchTab(tabName) {
currentTab = tabName;
// 更新标签
document.querySelectorAll('.tab').forEach(tab => {
tab.classList.remove('active');
if (tab.dataset.tab === tabName) {
tab.classList.add('active');
}
});
// 更新内容
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
if (content.id === `tab-${tabName}`) {
content.classList.add('active');
}
});
}
// 开始扫描
async function startScan() {
const networkBase = elements.networkBaseInput.value.trim();
const startIp = parseInt(elements.startIpInput.value);
const endIp = parseInt(elements.endIpInput.value);
const port = parseInt(elements.portInput.value);
// 验证输入
if (!networkBase.match(/^\d{1,3}\.\d{1,3}\.\d{1,3}$/)) {
showAlert('请输入有效的网络地址段(如 192.168.1)', 'error');
return;
}
if (isNaN(startIp) || isNaN(endIp) || startIp < 1 || endIp > 254 || startIp > endIp) {
showAlert('请输入有效的IP范围(1-254)', 'error');
return;
}
if (isNaN(port) || port < 1 || port > 65535) {
showAlert('请输入有效的端口号(1-65535)', 'error');
return;
}
// 显示加载状态
elements.loading.classList.add('active');
elements.startScanBtn.disabled = true;
elements.stopScanBtn.disabled = false;
try {
const response = await fetch(`${API_BASE}/api/scan`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
network_base: networkBase,
start_ip: startIp,
end_ip: endIp,
port: port
})
});
const data = await response.json();
if (response.ok) {
currentTaskId = data.task_id;
showAlert('扫描任务已启动', 'success');
// 显示任务信息
elements.taskInfo.style.display = 'block';
elements.noTask.style.display = 'none';
elements.progressContainer.style.display = 'block';
// 开始轮询任务状态
startStatusPolling();
} else {
showAlert(data.error || '启动扫描失败', 'error');
elements.loading.classList.remove('active');
elements.startScanBtn.disabled = false;
}
} catch (error) {
showAlert(`网络错误: ${error.message}`, 'error');
elements.loading.classList.remove('active');
elements.startScanBtn.disabled = false;
}
}
// 快速扫描
async function quickScan() {
const port = parseInt(elements.portInput.value);
if (isNaN(port) || port < 1 || port > 65535) {
showAlert('请输入有效的端口号(1-65535)', 'error');
return;
}
elements.loading.classList.add('active');
elements.quickScanBtn.disabled = true;
try {
const response = await fetch(`${API_BASE}/api/quick-scan`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
port: port
})
});
const data = await response.json();
if (response.ok) {
// 显示结果
displayResults(data.results);
showAlert(`快速扫描完成,发现 ${data.found} 个设备`, 'success');
} else {
showAlert(data.error || '快速扫描失败', 'error');
}
} catch (error) {
showAlert(`网络错误: ${error.message}`, 'error');
} finally {
elements.loading.classList.remove('active');
elements.quickScanBtn.disabled = false;
}
}
// 停止扫描
async function stopScan() {
if (!currentTaskId) return;
try {
const response = await fetch(`${API_BASE}/api/scan/${currentTaskId}`, {
method: 'DELETE'
});
if (response.ok) {
showAlert('扫描任务已停止', 'info');
elements.stopScanBtn.disabled = true;
}
} catch (error) {
showAlert(`停止扫描失败: ${error.message}`, 'error');
}
}
// 开始轮询任务状态
function startStatusPolling() {
if (statusPollInterval) {
clearInterval(statusPollInterval);
}
statusPollInterval = setInterval(async () => {
await updateTaskStatus();
}, 1000); // 每秒更新一次
}
// 更新任务状态
async function updateTaskStatus() {
if (!currentTaskId) return;
try {
const response = await fetch(`${API_BASE}/api/scan/${currentTaskId}`);
const task = await response.json();
if (response.ok) {
// 更新任务信息
elements.taskId.textContent = task.id;
elements.taskStatus.textContent = task.status;
elements.taskStatus.className = `status-badge status-${task.status}`;
elements.taskProgress.textContent = `${task.progress.toFixed(1)}%`;
elements.taskScanned.textContent = `${task.scanned} / ${task.total}`;
elements.taskFound.textContent = task.found;
// 更新进度条
elements.progressFill.style.width = `${task.progress}%`;
elements.progressText.textContent = `${task.progress.toFixed(1)}%`;
elements.scanStatus.textContent = `已扫描 ${task.scanned}/${task.total} 个IP,发现 ${task.found} 个设备`;
// 显示结果
if (task.results && task.results.length > 0) {
displayResults(task.results);
}
// 如果任务完成或停止,清理轮询
if (task.status === 'completed' || task.status === 'stopped') {
clearInterval(statusPollInterval);
elements.loading.classList.remove('active');
elements.startScanBtn.disabled = false;
elements.stopScanBtn.disabled = true;
if (task.status === 'completed') {
showAlert(`扫描完成,共发现 ${task.found} 个设备`, 'success');
}
}
} else {
// 任务不存在或出错
clearInterval(statusPollInterval);
showAlert('扫描任务出错或不存在', 'error');
elements.loading.classList.remove('active');
elements.startScanBtn.disabled = false;
elements.stopScanBtn.disabled = true;
}
} catch (error) {
console.error('获取任务状态失败:', error);
}
}
// 显示结果
function displayResults(devices) {
if (devices.length === 0) {
elements.noResults.style.display = 'block';
elements.deviceCount.textContent = '0';
return;
}
elements.noResults.style.display = 'none';
elements.deviceCount.textContent = devices.length;
let html = '';
devices.forEach(device => {
const statusClass = device.open ? 'status-open' : 'status-closed';
const statusText = device.open ? '端口开放' : '端口关闭';
const hostname = device.hostname || '未知';
html += `
<div class="device-card ${device.open ? 'active' : ''}">
<div class="device-ip">${device.ip}:${device.port}</div>
<div class="device-hostname">${hostname}</div>
<div class="device-status ${statusClass}">
<i class="fas fa-${device.open ? 'check-circle' : 'times-circle'}"></i> ${statusText}
</div>
<div class="device-info">
<p><i class="fas fa-clock"></i> 检测时间: ${new Date(device.timestamp).toLocaleTimeString()}</p>
${device.service ? `<p><i class="fas fa-server"></i> 可能服务: ${device.service}</p>` : ''}
</div>
<div class="device-actions">
<a href="http://${device.ip}:${device.port}" target="_blank" class="btn btn-access">
<i class="fas fa-external-link-alt"></i> 访问设备
</a>
<button class="btn btn-access" style="background-color: #17a2b8;">
<i class="fas fa-info-circle"></i> 详细信息
</button>
</div>
</div>
`;
});
elements.deviceList.innerHTML = html;
}
// 获取设备详细信息
window.getDeviceInfo = async function(ip, port) {
try {
const response = await fetch(`${API_BASE}/api/device/${ip}/${port}`);
const device = await response.json();
let message = `设备信息:\n`;
message += `IP地址: ${device.ip}\n`;
message += `端口: ${device.port} (${device.port_open ? '开放' : '关闭'})\n`;
if (device.hostname) {
message += `主机名: ${device.hostname}\n`;
}
if (device.service) {
message += `可能服务: ${device.service}\n`;
}
message += `检测时间: ${new Date(device.checked_at).toLocaleString()}`;
alert(message);
} catch (error) {
alert(`获取设备信息失败: ${error.message}`);
}
};
// 事件监听器
elements.startScanBtn.addEventListener('click', startScan);
elements.stopScanBtn.addEventListener('click', stopScan);
elements.quickScanBtn.addEventListener('click', quickScan);
elements.applyCustomBtn.addEventListener('click', function() {
const customNetwork = elements.customNetworkInput.value.trim();
if (customNetwork.match(/^\d{1,3}\.\d{1,3}\.\d{1,3}$/)) {
elements.networkBaseInput.value = customNetwork;
showAlert(`已应用网络地址段: ${customNetwork}`, 'success');
} else {
showAlert('请输入有效的网络地址段(如 192.168.1)', 'error');
}
});
// 标签页切换
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', function() {
switchTab(this.dataset.tab);
});
});
// 初始化
loadNetworkInfo();
// 页面卸载时清理轮询
window.addEventListener('beforeunload', function() {
if (statusPollInterval) {
clearInterval(statusPollInterval);
}
});
});
</script>
</body>
</html>
三、后端文件
在main.py文件内写入以下内容并保存
from flask import Flask, request, jsonify, render_template
from flask_cors import CORS
import socket
import ipaddress
import threading
import time
import json
from datetime import datetime
import subprocess
import platform
import os
from concurrent.futures import ThreadPoolExecutor, as_completed
import sys
# 获取资源路径(支持打包后运行)
def get_resource_path(relative_path):
"""获取资源文件的绝对路径,支持开发和打包后的环境"""
if hasattr(sys, '_MEIPASS'):
# 如果是PyInstaller打包后的exe
base_path = sys._MEIPASS
else:
base_path = os.path.abspath(".")
return os.path.join(base_path, relative_path)
# 初始化Flask应用,使用正确的资源路径
app = Flask(__name__,
template_folder=get_resource_path('templates'),
static_folder=get_resource_path('static'))
CORS(app)
# 存储扫描任务的状态和结果
scan_tasks = {}
task_counter = 0
# 常见路由器品牌对应的网段
COMMON_NETWORKS = [
{"base": "192.168.1", "brand": "TP-Link/水星/迅捷"},
{"base": "192.168.0", "brand": "TP-Link/D-Link"},
{"base": "192.168.3", "brand": "华为"},
{"base": "192.168.31", "brand": "小米"},
{"base": "192.168.10", "brand": "腾达"},
{"base": "192.168.11", "brand": "Buffalo"},
{"base": "192.168.18", "brand": "华硕"},
{"base": "192.168.50", "brand": "网件/领势"},
{"base": "192.168.123", "brand": "极路由"},
{"base": "10.0.0", "brand": "部分企业级"},
{"base": "10.1.1", "brand": "Apple/部分企业级"},
{"base": "172.16.0", "brand": "企业级网络"},
{"base": "172.16.1", "brand": "企业级网络"},
]
def is_port_open(ip, port, timeout=1):
"""检查指定IP的端口是否开放"""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)
result = sock.connect_ex((ip, port))
sock.close()
return result == 0
except Exception as e:
return False
def scan_single_ip(task_id, ip, port, timeout=1):
"""扫描单个IP地址"""
try:
if task_id in scan_tasks and scan_tasks[task_id].get('stopped', False):
return None
# 检查端口
is_open = is_port_open(ip, port, timeout)
# 尝试获取主机名
hostname = ""
try:
hostname = socket.gethostbyaddr(ip)[0]
except:
pass
return {
"ip": ip,
"port": port,
"open": is_open,
"hostname": hostname,
"timestamp": datetime.now().isoformat()
}
except Exception as e:
return {
"ip": ip,
"port": port,
"open": False,
"error": str(e),
"timestamp": datetime.now().isoformat()
}
def scan_network_range(task_id, network_base, start_ip, end_ip, port, max_workers=50):
"""扫描网络范围"""
if task_id not in scan_tasks:
return []
task_info = scan_tasks[task_id]
task_info['status'] = 'scanning'
task_info['start_time'] = datetime.now().isoformat()
total_ips = end_ip - start_ip + 1
scanned_ips = 0
found_devices = []
# 使用线程池并发扫描
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {}
for i in range(start_ip, end_ip + 1):
if task_info.get('stopped', False):
break
ip = f"{network_base}.{i}"
future = executor.submit(scan_single_ip, task_id, ip, port)
futures[future] = ip
for future in as_completed(futures):
if task_info.get('stopped', False):
break
scanned_ips += 1
result = future.result()
if result:
if result.get('open', False):
found_devices.append(result)
# 更新进度
task_info['progress'] = (scanned_ips / total_ips) * 100
task_info['scanned'] = scanned_ips
task_info['total'] = total_ips
task_info['found'] = len(found_devices)
task_info['status'] = 'completed'
task_info['end_time'] = datetime.now().isoformat()
task_info['results'] = found_devices
return found_devices
def get_local_networks():
"""获取本地网络接口信息(使用纯Python方法)"""
networks = []
try:
# 获取本机主机名
hostname = socket.gethostname()
# 获取本机IP地址
local_ip = socket.gethostbyname(hostname)
# 如果是私有IP地址,添加到网络列表
if local_ip.startswith('192.168.') or local_ip.startswith('10.') or local_ip.startswith('172.'):
# 提取网络地址段
ip_parts = local_ip.split('.')
if len(ip_parts) >= 3:
network_base = f"{ip_parts[0]}.{ip_parts[1]}.{ip_parts[2]}"
networks.append({
"interface": "本地连接",
"ip": local_ip,
"netmask": "255.255.255.0", # 假设标准子网掩码
"network": f"{network_base}.0",
"cidr": f"{network_base}.0/24"
})
# 尝试获取更多网络接口信息(Windows系统)
if platform.system() == 'Windows':
try:
# 执行ipconfig命令获取网络信息
result = subprocess.run(['ipconfig'], capture_output=True, text=True, encoding='gbk')
output = result.stdout
# 解析ipconfig输出
lines = output.split('\n')
current_adapter = ""
for i, line in enumerate(lines):
line = line.strip()
# 查找适配器名称
if line and not line.startswith(' ') and ':' in line:
current_adapter = line.replace(':', '')
# 查找IPv4地址
if 'IPv4' in line and '地址' in line and ':' in line:
ip_part = line.split(':')[1].strip()
if ip_part and not ip_part.startswith('169.254'): # 排除APIPA地址
# 提取网络地址段
ip_parts = ip_part.split('.')
if len(ip_parts) >= 3:
network_base = f"{ip_parts[0]}.{ip_parts[1]}.{ip_parts[2]}"
networks.append({
"interface": current_adapter,
"ip": ip_part,
"netmask": "255.255.255.0", # 假设标准子网掩码
"network": f"{network_base}.0",
"cidr": f"{network_base}.0/24"
})
except:
pass
# 如果没有找到任何网络,添加常见网络作为备用
if not networks:
networks.append({
"interface": "默认网络",
"ip": "192.168.1.100",
"netmask": "255.255.255.0",
"network": "192.168.1.0",
"cidr": "192.168.1.0/24"
})
except Exception as e:
print(f"获取网络接口失败: {e}")
# 添加默认网络
networks.append({
"interface": "默认网络",
"ip": "192.168.1.100",
"netmask": "255.255.255.0",
"network": "192.168.1.0",
"cidr": "192.168.1.0/24"
})
return networks
@app.route('/')
def index():
"""主页面"""
return render_template('index.html')
@app.route('/api/scan', methods=['POST'])
def start_scan():
"""开始扫描任务"""
global task_counter
data = request.json
network_base = data.get('network_base', '192.168.1')
start_ip = int(data.get('start_ip', 1))
end_ip = int(data.get('end_ip', 254))
port = int(data.get('port', 5666))
# 验证参数
if start_ip < 1 or end_ip > 254 or start_ip > end_ip:
return jsonify({"error": "无效的IP范围"}), 400
if port < 1 or port > 65535:
return jsonify({"error": "无效的端口号"}), 400
# 创建任务
task_counter += 1
task_id = f"task_{task_counter}_{int(time.time())}"
scan_tasks[task_id] = {
'id': task_id,
'network_base': network_base,
'start_ip': start_ip,
'end_ip': end_ip,
'port': port,
'status': 'pending',
'progress': 0,
'scanned': 0,
'total': end_ip - start_ip + 1,
'found': 0,
'results': [],
'stopped': False,
'created_at': datetime.now().isoformat()
}
# 启动后台扫描线程
def run_scan():
scan_network_range(task_id, network_base, start_ip, end_ip, port)
thread = threading.Thread(target=run_scan)
thread.daemon = True
thread.start()
return jsonify({
"task_id": task_id,
"message": "扫描任务已启动",
"status_url": f"/api/scan/{task_id}"
})
@app.route('/api/scan/<task_id>', methods=['GET'])
def get_scan_status(task_id):
"""获取扫描任务状态"""
if task_id not in scan_tasks:
return jsonify({"error": "任务不存在"}), 404
task_info = scan_tasks[task_id].copy()
# 清理敏感信息或不必要的信息
if 'stopped' in task_info:
del task_info['stopped']
return jsonify(task_info)
@app.route('/api/scan/<task_id>', methods=['DELETE'])
def stop_scan(task_id):
"""停止扫描任务"""
if task_id not in scan_tasks:
return jsonify({"error": "任务不存在"}), 404
scan_tasks[task_id]['stopped'] = True
scan_tasks[task_id]['status'] = 'stopped'
return jsonify({
"message": "扫描任务已停止",
"task_id": task_id
})
@app.route('/api/networks', methods=['GET'])
def get_networks():
"""获取本地网络信息"""
local_networks = get_local_networks()
return jsonify({
"local_networks": local_networks,
"common_networks": COMMON_NETWORKS
})
@app.route('/api/device/<ip>/<port>', methods=['GET'])
def get_device_info(ip, port):
"""获取设备详细信息"""
try:
# 检查端口状态
is_open = is_port_open(ip, int(port), timeout=2)
# 尝试获取更多信息
info = {
"ip": ip,
"port": port,
"port_open": is_open,
"checked_at": datetime.now().isoformat()
}
# 尝试获取主机名
try:
hostname, aliases, addresses = socket.gethostbyaddr(ip)
info["hostname"] = hostname
except:
info["hostname"] = ""
# 尝试获取服务信息(如果有已知服务)
known_services = {
80: "HTTP Web服务",
443: "HTTPS Web服务",
22: "SSH服务",
21: "FTP服务",
23: "Telnet服务",
25: "SMTP服务",
3389: "远程桌面",
5900: "VNC服务",
5666: "可能为NAS/监控设备",
8080: "备用HTTP服务",
8888: "常见管理端口"
}
port_int = int(port)
if port_int in known_services:
info["service"] = known_services[port_int]
return jsonify(info)
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/quick-scan', methods=['POST'])
def quick_scan():
"""快速扫描常见网段"""
data = request.json
port = data.get('port', 5666)
results = []
# 只扫描常见网段的前10个IP以加快速度
for network in COMMON_NETWORKS[:5]:
network_base = network["base"]
for i in range(1, 11): # 只扫描1-10
ip = f"{network_base}.{i}"
is_open = is_port_open(ip, port, timeout=0.5)
if is_open:
results.append({
"ip": ip,
"port": port,
"open": True,
"network": network_base,
"brand": network["brand"]
})
return jsonify({
"results": results,
"total_scanned": 50, # 5个网段 * 10个IP
"found": len(results)
})
# 清理旧任务(每10分钟清理一次超过1小时的任务)
def cleanup_old_tasks():
while True:
time.sleep(600) # 10分钟
now = datetime.now()
to_delete = []
for task_id, task_info in scan_tasks.items():
created_at_str = task_info.get('created_at', '')
if created_at_str:
try:
created_at = datetime.fromisoformat(created_at_str)
if (now - created_at).total_seconds() > 3600: # 1小时
to_delete.append(task_id)
except:
pass
for task_id in to_delete:
del scan_tasks[task_id]
# 启动清理线程
cleanup_thread = threading.Thread(target=cleanup_old_tasks)
cleanup_thread.daemon = True
cleanup_thread.start()
if __name__ == '__main__':
# 检查是否以exe形式运行
is_exe = getattr(sys, 'frozen', False)
print("=" * 60)
print("局域网扫描服务器")
print("=" * 60)
if is_exe:
print("运行模式: EXE应用程序")
print("注意: 按Ctrl+C停止服务器")
else:
print("运行模式: Python脚本")
print("访问 http://localhost:5000 使用扫描工具")
print("按 Ctrl+C 停止服务器")
print("=" * 60)
# 自动打开浏览器(仅Windows且为exe运行时)
if is_exe and platform.system() == 'Windows':
import webbrowser
def open_browser():
time.sleep(2) # 等待服务器启动
webbrowser.open('http://localhost:5000')
browser_thread = threading.Thread(target=open_browser)
browser_thread.daemon = True
browser_thread.start()
try:
app.run(host='0.0.0.0', port=5000, debug=False)
except KeyboardInterrupt:
print("\n服务器已停止")
except Exception as e:
print(f"服务器启动失败: {e}")
if is_exe:
input("按回车键退出...")
四、环境依赖
在requirements.txt文件内写入以下内容并保存
Flask==2.3.3
flask-cors==4.0.0
五、编译安装
5.1 安装Python
去官网下载安装:https://www.python.org/downloads/windows/
5.2 安装依赖
在lan-scanner-project文件夹右键-在终端中打开,执行以下命令
pip install -r requirements.txt
5.3 安装工具
PyInstaller 是Python 应用程序打包成独立可执行文件的工具
pip install pyinstaller
5.4 编译文件
pyinstaller --onefile --windowed --name LANScanner --icon=static/icon.ico --hidden-import flask --hidden-import flask_cors --hidden-import netifaces main.py
六、成品使用
在 dist 文件夹中找到 LANScanner.exe,双击运行,允许通过防火墙,程序会自动打开浏览器访问 http://localhost:5000飞牛默认端口为5666,点开始扫描等待即可。由于手头没有飞牛设备,这里测试扫描海康威视摄像头/录像机本地默认8000端口,可全部扫描出。

