feat: 天地图瓦片缓存(tile-cache)、拓扑标注助手与文档更新
- tile-cache: 瓦片缓存服务(vec/cva)、启动探针、详细日志、上游超时可配置(UPSTREAM_TIMEOUT_MS) - central: docker-compose 集成 tile-cache,env.example 增加 TILE_CACHE_* / TIANDITU_TK - topology-editor: 天地图/缓存加载、GPS 安全来源错误提示、TIANDITU 文档(403/白名单、localhost 测试说明) - doc: README 部署步骤与 GPS 安全来源说明,TIANDITU_CONFIG 完善 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
267
topology-editor/server.js
Normal file
267
topology-editor/server.js
Normal file
@@ -0,0 +1,267 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const { exec } = require('child_process');
|
||||
const multer = require('multer');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 4080;
|
||||
const TILE_CACHE_URL = (process.env.TILE_CACHE_URL || '').trim();
|
||||
|
||||
const ROOT_DIR = path.join(__dirname, '..');
|
||||
const CONFIG_DIR = process.env.CONFIG_DIR || null;
|
||||
const TARGETS_CSV = CONFIG_DIR ? path.join(CONFIG_DIR, 'targets.csv') : null;
|
||||
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
|
||||
// 内存模式(与 central-server 一起跑、无挂载时):存一份 targets 的解析结果
|
||||
const DEFAULT_HEADER = ['type', 'ip', 'name', 'role', 'parent', 'uplink_type', 'network', 'device_type', 'model', 'location', 'username', 'password', 'onvif_port', 'lat', 'lon'];
|
||||
let memoryStore = {
|
||||
comments: ['# 统一监控 + 拓扑目标配置文件\n', '# 格式: type,ip,name,role,parent,uplink_type,...\n'],
|
||||
header: DEFAULT_HEADER,
|
||||
rows: [],
|
||||
};
|
||||
|
||||
function isFileMode() {
|
||||
return CONFIG_DIR && TARGETS_CSV && fs.existsSync(TARGETS_CSV);
|
||||
}
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
function parseCsv(text) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
const comments = [];
|
||||
let headerLineIndex = -1;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line || line.startsWith('#')) {
|
||||
comments.push(lines[i]);
|
||||
continue;
|
||||
}
|
||||
headerLineIndex = i;
|
||||
break;
|
||||
}
|
||||
|
||||
if (headerLineIndex === -1) {
|
||||
return { comments: lines, header: DEFAULT_HEADER, rows: [] };
|
||||
}
|
||||
|
||||
const header = lines[headerLineIndex].split(',').map(s => s.trim());
|
||||
const rows = [];
|
||||
|
||||
for (let i = headerLineIndex + 1; i < lines.length; i++) {
|
||||
const raw = lines[i];
|
||||
if (!raw || raw.trim().startsWith('#')) continue;
|
||||
const parts = raw.split(',');
|
||||
const obj = {};
|
||||
header.forEach((h, idx) => {
|
||||
obj[h] = (parts[idx] || '').trim();
|
||||
});
|
||||
if (Object.values(obj).every(v => v === '')) continue;
|
||||
rows.push(obj);
|
||||
}
|
||||
|
||||
return { comments, header, rows };
|
||||
}
|
||||
|
||||
function stringifyCsv(comments, header, rows) {
|
||||
const lines = [];
|
||||
if (comments && comments.length) {
|
||||
lines.push(...comments);
|
||||
}
|
||||
if (header && header.length) {
|
||||
lines.push(header.join(','));
|
||||
}
|
||||
for (const row of rows) {
|
||||
const line = header.map(h => (row[h] || '').replace(/,/g, ' ')).join(',');
|
||||
lines.push(line);
|
||||
}
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
function loadTargets() {
|
||||
if (isFileMode()) {
|
||||
const text = fs.readFileSync(TARGETS_CSV, 'utf8');
|
||||
return parseCsv(text);
|
||||
}
|
||||
return { ...memoryStore, rows: [...memoryStore.rows] };
|
||||
}
|
||||
|
||||
function saveTargets(parsed) {
|
||||
if (isFileMode()) {
|
||||
const text = stringifyCsv(parsed.comments, parsed.header, parsed.rows);
|
||||
fs.writeFileSync(TARGETS_CSV, text, 'utf8');
|
||||
return;
|
||||
}
|
||||
memoryStore = {
|
||||
comments: parsed.comments || memoryStore.comments,
|
||||
header: parsed.header || memoryStore.header,
|
||||
rows: parsed.rows || memoryStore.rows,
|
||||
};
|
||||
}
|
||||
|
||||
function rebuildConfigs() {
|
||||
if (!CONFIG_DIR) return;
|
||||
const cmd = `cd "${CONFIG_DIR}" && ./update-configs.sh && ./csv-to-topology-geojson.sh targets.csv topology.geojson`;
|
||||
exec(cmd, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
console.error('重建配置失败:', err.message);
|
||||
if (stderr) console.error(stderr);
|
||||
return;
|
||||
}
|
||||
if (stdout) console.log(stdout);
|
||||
});
|
||||
}
|
||||
|
||||
app.get('/api/targets', (req, res) => {
|
||||
try {
|
||||
const parsed = loadTargets();
|
||||
res.json(parsed.rows);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: '读取 targets 失败' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/mode', (req, res) => {
|
||||
res.json({ mode: isFileMode() ? 'file' : 'memory' });
|
||||
});
|
||||
|
||||
app.get('/api/map-config', (req, res) => {
|
||||
res.json({ cacheBaseUrl: TILE_CACHE_URL ? '/tiles' : '' });
|
||||
});
|
||||
|
||||
function proxyToTileCache(pathname, res) {
|
||||
const url = TILE_CACHE_URL + pathname;
|
||||
const u = new URL(url);
|
||||
const client = u.protocol === 'https:' ? require('https') : require('http');
|
||||
client.get(url, (proxyRes) => {
|
||||
res.status(proxyRes.statusCode || 200);
|
||||
if (proxyRes.headers['content-type']) res.setHeader('Content-Type', proxyRes.headers['content-type']);
|
||||
if (proxyRes.headers['cache-control']) res.setHeader('Cache-Control', proxyRes.headers['cache-control']);
|
||||
proxyRes.pipe(res);
|
||||
}).on('error', (err) => {
|
||||
res.status(502).json({ error: 'Tile cache unreachable: ' + err.message });
|
||||
});
|
||||
}
|
||||
|
||||
app.get('/tiles/vec/:z/:x/:y', (req, res) => {
|
||||
if (!TILE_CACHE_URL) return res.status(503).send('Tile cache not configured');
|
||||
proxyToTileCache(`/vec/${req.params.z}/${req.params.x}/${req.params.y}`, res);
|
||||
});
|
||||
|
||||
app.get('/tiles/cva/:z/:x/:y', (req, res) => {
|
||||
if (!TILE_CACHE_URL) return res.status(503).send('Tile cache not configured');
|
||||
proxyToTileCache(`/cva/${req.params.z}/${req.params.x}/${req.params.y}`, res);
|
||||
});
|
||||
|
||||
app.get('/api/download', (req, res) => {
|
||||
try {
|
||||
const parsed = loadTargets();
|
||||
const text = stringifyCsv(parsed.comments, parsed.header, parsed.rows);
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename=targets.csv');
|
||||
res.send(text);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: '生成下载失败' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/upload', upload.single('file'), (req, res) => {
|
||||
try {
|
||||
if (!req.file || !req.file.buffer) {
|
||||
return res.status(400).json({ error: '请选择 CSV 文件上传' });
|
||||
}
|
||||
const text = req.file.buffer.toString('utf8');
|
||||
const parsed = parseCsv(text);
|
||||
if (!isFileMode()) {
|
||||
memoryStore = parsed;
|
||||
} else {
|
||||
saveTargets(parsed);
|
||||
}
|
||||
res.json({ ok: true, rows: parsed.rows.length });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: '解析 CSV 失败' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/targets', (req, res) => {
|
||||
try {
|
||||
const {
|
||||
type = '',
|
||||
ip = '',
|
||||
name = '',
|
||||
role = '',
|
||||
parent = '',
|
||||
uplink_type = '',
|
||||
network = '',
|
||||
device_type = '',
|
||||
model = '',
|
||||
location = '',
|
||||
username = '',
|
||||
password = '',
|
||||
onvif_port = '',
|
||||
lat = '',
|
||||
lon = '',
|
||||
} = req.body || {};
|
||||
|
||||
if (!type || !name) {
|
||||
return res.status(400).json({ error: 'type 与 name 为必填字段' });
|
||||
}
|
||||
|
||||
const parsed = loadTargets();
|
||||
const { header, rows } = parsed;
|
||||
|
||||
const idx = rows.findIndex((r) => r.name === name);
|
||||
const row = {};
|
||||
header.forEach((h) => {
|
||||
row[h] = '';
|
||||
});
|
||||
|
||||
Object.assign(row, {
|
||||
type,
|
||||
ip,
|
||||
name,
|
||||
role,
|
||||
parent,
|
||||
uplink_type,
|
||||
network,
|
||||
device_type,
|
||||
model,
|
||||
location,
|
||||
username,
|
||||
password,
|
||||
onvif_port,
|
||||
lat,
|
||||
lon,
|
||||
});
|
||||
|
||||
if (idx >= 0) {
|
||||
rows[idx] = row;
|
||||
} else {
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
saveTargets({ ...parsed, rows });
|
||||
rebuildConfigs();
|
||||
|
||||
res.json({ ok: true, row });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: '写入失败' });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Topology editor listening on http://0.0.0.0:${PORT}`);
|
||||
if (CONFIG_DIR) {
|
||||
console.log(`CONFIG_DIR=${CONFIG_DIR} (file mode)`);
|
||||
} else {
|
||||
console.log('Running in memory mode: use Upload CSV / Download CSV');
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user