App 崩溃监控后台功能

4 阅读2分钟

项目结构:

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        # 【自动生成】锁定依赖版本

代码

// server.js
const express = require('express');
const fs = require('fs');
const path = require('path');
const app = express();
const port = 3000;

app.use(express.json()); // 允许解析 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 {}; }
}

// 接口 1: 获取合并后的所有数据
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);
    });
});

// 接口 2: 更新处理状态并保存到硬盘
app.post('/api/update-metadata', (req, res) => {
    const { id, extra } = req.body;
    const metadata = getMetadata();
    
    // 更新指定 ID 的数据
    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;
            // 提示:版本筛选暂留空做逻辑 todo

            const filtered = rawData.filter(item => {
                // 合并 localStorage 中的处理状态进行判断
                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);
        }

        // 1. 修改渲染逻辑中的数据读取路径
        function renderTable(items) {
            const tbody = document.getElementById('crash-tbody');
            tbody.innerHTML = items.map(item => {
                // 直接从后端返回的 _extra 中读取状态
                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('');
        }

        // 2. 修改保存逻辑:发送数据到后端
        async function updateRecord(id, key, val) {
            // 先在本地 rawData 中找到该条目并更新,保证 UI 响应快
            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);
            }
        }

        // UI 交互:折叠面板
        function toggleDetail(id) {
            const el = document.getElementById('detail-' + id);
            const isHidden = el.classList.contains('hidden');
            // 先隐藏所有其他的详情 (可选,为了保持界面清爽)
            // document.querySelectorAll('[id^="detail-"]').forEach(d => d.classList.add('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>