项目结构:
crash-monitor-system/
├── data/ # 【核心数据】存放 App 上报的所有原始崩溃 JSON 文件
│ ├── crash_20231024_01.json
│ ├── crash_20231024_02.json
│ └── ... (更多 json)
├── node_modules/ # 【自动生成】存放 Node.js 依赖包 (执行 npm install 后出现)
├── index.html # 【前端界面】管理后台 UI,包含所有展示和筛选逻辑
├── server.js # 【后端服务】处理文件读取、聚合数据、保存处理状态
├── metadata.json # 【自动生成】存放处理状态、负责人、备注(保证数据不丢失)
├── package.json # 【配置文件】定义项目名称和依赖 (执行 npm init 后出现)
└── package-lock.json # 【自动生成】锁定依赖版本
代码
const express = require('express');
const fs = require('fs');
const path = require('path');
const app = express();
const port = 3000;
app.use(express.json());
app.use(express.static(__dirname));
const METADATA_PATH = path.join(__dirname, 'metadata.json');
function getMetadata() {
if (!fs.existsSync(METADATA_PATH)) return {};
try {
return JSON.parse(fs.readFileSync(METADATA_PATH, 'utf8'));
} catch (e) { return {}; }
}
app.get('/api/all-crashes', (req, res) => {
const dirPath = path.join(__dirname, 'data');
const metadata = getMetadata();
fs.readdir(dirPath, (err, files) => {
if (err) return res.status(500).json([]);
const jsonFiles = files.filter(f => f.endsWith('.json'));
let allData = [];
jsonFiles.forEach(file => {
try {
const content = fs.readFileSync(path.join(dirPath, file), 'utf8');
const items = Array.isArray(JSON.parse(content)) ? JSON.parse(content) : [JSON.parse(content)];
items.forEach((item, index) => {
const id = `${file}_${index}`;
allData.push({
...item,
_id: id,
_sourceFile: file,
_extra: metadata[id] || { status: '待处理', assignee: '未指派', notes: '' }
});
});
} catch (e) {}
});
res.json(allData);
});
});
app.post('/api/update-metadata', (req, res) => {
const { id, extra } = req.body;
const metadata = getMetadata();
metadata[id] = extra;
fs.writeFileSync(METADATA_PATH, JSON.stringify(metadata, null, 2));
res.json({ success: true });
});
app.listen(port, () => console.log(`已启动: http://localhost:${port}`));
index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>App 崩溃管理后台</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
.stack-trace {
font-family: 'Fira Code', monospace;
background: #1a1a1a;
color: #adbac7;
}
.row-expanded {
background-color: #f9fafb;
border-bottom: 2px solid #e5e7eb;
}
.truncate-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
</head>
<body class="bg-gray-100 min-h-screen">
<header class="bg-white border-b sticky top-0 z-20 shadow-sm">
<div class="max-w-[1400px] mx-auto px-6 py-4">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<h1 class="text-xl font-bold text-blue-600 flex items-center">
🛡️ CrashConsole <span class="ml-2 text-xs font-normal text-gray-400">内部版</span>
</h1>
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center space-x-2">
<span class="text-xs text-gray-500 font-medium">版本:</span>
<select id="filter-version" onchange="applyFilters()"
class="text-sm border rounded-md px-2 py-1 outline-none focus:ring-2 focus:ring-blue-500">
<option value="all">全部版本 (Todo)</option>
<option value="7.3.40">7.3.40</option>
<option value="7.3.39">7.3.39</option>
</select>
</div>
<div class="flex items-center space-x-2">
<span class="text-xs text-gray-500 font-medium">状态:</span>
<select id="filter-status" onchange="applyFilters()"
class="text-sm border rounded-md px-2 py-1 outline-none focus:ring-2 focus:ring-blue-500">
<option value="all">所有状态</option>
<option value="待处理">待处理</option>
<option value="分析中">分析中</option>
<option value="已修复">已修复</option>
<option value="忽略">忽略</option>
</select>
</div>
<div class="flex items-center space-x-2">
<span class="text-xs text-gray-500 font-medium">负责人:</span>
<select id="filter-assignee" onchange="applyFilters()"
class="text-sm border rounded-md px-2 py-1 outline-none focus:ring-2 focus:ring-blue-500">
<option value="all">全部人员</option>
<option value="未指派">未指派</option>
<option value="张三">张三</option>
<option value="李四">李四</option>
</select>
</div>
<button onclick="fetchAllCrashes()" class="ml-2 p-1.5 hover:bg-gray-100 rounded-full transition"
title="刷新数据">
🔄
</button>
</div>
</div>
</div>
</header>
<main class="max-w-[1400px] mx-auto p-6">
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
<table class="w-full text-left border-collapse">
<thead class="bg-gray-50 border-b">
<tr>
<th class="px-6 py-3 text-xs font-bold text-gray-400 uppercase">源文件/版本</th>
<th class="px-6 py-3 text-xs font-bold text-gray-400 uppercase w-1/3">崩溃异常信息</th>
<th class="px-6 py-3 text-xs font-bold text-gray-400 uppercase text-center">状态</th>
<th class="px-6 py-3 text-xs font-bold text-gray-400 uppercase">处理人</th>
<th class="px-6 py-3 text-xs font-bold text-gray-400 uppercase text-right">操作</th>
</tr>
</thead>
<tbody id="crash-tbody">
<tr>
<td colspan="5" class="p-10 text-center text-gray-400">正在获取数据...</td>
</tr>
</tbody>
</table>
</div>
</main>
<script>
let rawData = [];
window.onload = fetchAllCrashes;
async function fetchAllCrashes() {
try {
const res = await fetch('/api/all-crashes');
rawData = await res.json();
applyFilters();
} catch (e) {
alert("加载失败: " + e.message);
}
}
function applyFilters() {
const statusFilter = document.getElementById('filter-status').value;
const assigneeFilter = document.getElementById('filter-assignee').value;
const filtered = rawData.filter(item => {
const saved = JSON.parse(localStorage.getItem('status_' + item._id) || '{"status":"待处理","assignee":"未指派"}');
const matchStatus = (statusFilter === 'all' || saved.status === statusFilter);
const matchAssignee = (assigneeFilter === 'all' || saved.assignee === assigneeFilter);
return matchStatus && matchAssignee;
});
renderTable(filtered);
}
function renderTable(items) {
const tbody = document.getElementById('crash-tbody');
tbody.innerHTML = items.map(item => {
const saved = item._extra;
const version = item["log.applicationInformation.applicationVersionNumber"] || 'N/A';
return `
<!-- 主行 -->
<tr onclick="toggleDetail('${item._id}')" class="hover:bg-blue-50 cursor-pointer border-b last:border-0 transition-colors">
<td class="px-6 py-4">
<div class="text-xs font-bold text-gray-700 font-mono">${item._sourceFile}</div>
<div class="text-[10px] text-gray-400 mt-1 uppercase">Version: ${version}</div>
</td>
<td class="px-6 py-4">
<div class="text-sm font-semibold text-red-600 truncate-2">${item["log.crashInfo.crashMessage"]}</div>
</td>
<td class="px-6 py-4 text-center">
<span class="px-2 py-1 rounded text-[10px] font-bold ${getStatusStyle(saved.status)}">${saved.status}</span>
</td>
<td class="px-6 py-4 text-sm text-gray-500">${saved.assignee}</td>
<td class="px-6 py-4 text-right text-blue-500 text-sm font-medium">查看详情 →</td>
</tr>
<!-- 详情面板 (默认隐藏) -->
<tr id="detail-${item._id}" class="hidden bg-gray-50 shadow-inner">
<td colspan="5" class="px-8 py-6">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div class="lg:col-span-2 space-y-4">
<h4 class="text-xs font-bold text-gray-400 uppercase">完整堆栈追踪</h4>
<pre class="stack-trace p-4 rounded-lg text-xs leading-relaxed overflow-x-auto max-h-[400px] whitespace-pre-wrap">${item["log.crashInfo.totalCrashTrace"]}</pre>
</div>
<div class="space-y-6">
<div class="bg-white p-4 rounded border">
<h4 class="text-xs font-bold text-gray-400 uppercase mb-3">处理处理状态</h4>
<div class="space-y-3">
<div>
<label class="text-[10px] text-gray-400 block mb-1">修改状态</label>
<select onchange="updateRecord('${item._id}', 'status', this.value)" class="w-full text-sm border rounded p-1.5">
<option ${saved.status === '待处理' ? 'selected' : ''}>待处理</option>
<option ${saved.status === '分析中' ? 'selected' : ''}>分析中</option>
<option ${saved.status === '已修复' ? 'selected' : ''}>已修复</option>
<option ${saved.status === '忽略' ? 'selected' : ''}>忽略</option>
</select>
</div>
<div>
<label class="text-[10px] text-gray-400 block mb-1">指派负责人</label>
<select onchange="updateRecord('${item._id}', 'assignee', this.value)" class="w-full text-sm border rounded p-1.5">
<option ${saved.assignee === '未指派' ? 'selected' : ''}>未指派</option>
<option ${saved.assignee === '张三' ? 'selected' : ''}>张三</option>
<option ${saved.assignee === '李四' ? 'selected' : ''}>李四</option>
</select>
</div>
</div>
</div>
<div class="bg-white p-4 rounded border">
<h4 class="text-xs font-bold text-gray-400 uppercase mb-2">分析备注</h4>
<textarea
oninput="updateRecord('${item._id}', 'notes', this.value)"
placeholder="输入排查备注..."
class="w-full text-sm h-32 border-none focus:ring-0 p-0 resize-none text-gray-600"
>${saved.notes}</textarea>
</div>
</div>
</div>
</td>
</tr>
`;
}).join('');
}
async function updateRecord(id, key, val) {
const item = rawData.find(d => d._id === id);
if (item) {
item._extra[key] = val;
}
try {
await fetch('/api/update-metadata', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: id,
extra: item._extra
})
});
console.log("服务器同步成功");
if (key === 'status' || key === 'assignee') {
applyFilters();
}
} catch (e) {
console.error("同步失败", e);
}
}
function toggleDetail(id) {
const el = document.getElementById('detail-' + id);
const isHidden = el.classList.contains('hidden');
if (isHidden) {
el.classList.remove('hidden');
} else {
el.classList.add('hidden');
}
}
function getStatusStyle(status) {
switch (status) {
case '待处理': return 'bg-red-100 text-red-600';
case '分析中': return 'bg-yellow-100 text-yellow-600';
case '已修复': return 'bg-green-100 text-green-600';
default: return 'bg-gray-100 text-gray-500';
}
}
</script>
</body>
</html>