feat: 新增错误问题追踪与管理功能

- 升级程序版本至 v1.5.25
- 新增对比度文本颜色计算与垂直滚动条绘制工具函数,优化选择界面滚动显示
- 网页端新增问题记录管理页面,配套完整的表格与详情展示样式
- 实现issue记录标准化处理、实时事件推送接收与按时间倒序排序展示
- 重构原有选择界面的滚动逻辑,替换为通用滚动条绘制函数
This commit is contained in:
2026-07-29 12:29:54 +08:00
parent 0e8f86b478
commit 7e72bbeae5
5 changed files with 394 additions and 28 deletions
+27 -8
View File
@@ -961,7 +961,7 @@
</div>
</div>
<table v-if="issues && issues.length > 0">
<table v-if="issues && issues.length > 0" class="jr-issue-table">
<thead>
<tr>
<th>时间</th>
@@ -974,14 +974,33 @@
<tbody>
<tr v-for="(issue, idx) in issues" :key="idx">
<td style="white-space: nowrap;">{{ formatTime(issue.ts) }}</td>
<td class="mono">{{ issue.device || issue.machine_id }}</td>
<td>{{ issue.station_code }}</td>
<td style="color: var(--danger); font-weight: bold;">{{ issue.error_msg }}</td>
<td>
<details v-if="issue.detail">
<summary class="text-muted" style="cursor: pointer;">查看详情</summary>
<pre style="white-space: pre-wrap; margin: 8px 0 0; font-size: 0.85rem; line-height: 1.35; color: #fff;">{{ issue.detail }}</pre>
</details>
<div class="jr-issue-primary mono">{{ issue.displayDevice }}</div>
<div v-if="issue.machine_id && issue.machine_id !== issue.displayDevice" class="jr-issue-sub mono">{{ issue.machine_id }}</div>
</td>
<td>
<div class="jr-issue-primary">{{ issue.displayStation }}</div>
<div v-if="issue.station_code" class="jr-issue-sub mono">{{ issue.station_code }}</div>
</td>
<td>
<div class="jr-issue-error">{{ issue.displayError }}</div>
</td>
<td class="jr-issue-detail-cell">
<div v-if="issue.detailEntries && issue.detailEntries.length" class="jr-issue-detail-card">
<div class="jr-issue-detail-grid">
<div v-for="entry in issue.detailEntries" :key="`${idx}-${entry.key}`" class="jr-issue-detail-item">
<span>{{ entry.label }}</span>
<strong>{{ entry.value }}</strong>
</div>
</div>
<details v-if="issue.detailRaw" class="jr-issue-raw-block">
<summary>查看原始内容</summary>
<pre>{{ issue.detailRaw }}</pre>
</details>
</div>
<div v-else-if="issue.detailRaw" class="jr-issue-plain-detail">
<pre>{{ issue.detailRaw }}</pre>
</div>
<span v-else class="text-muted">无</span>
</td>
</tr>
+137 -1
View File
@@ -27,6 +27,7 @@ createApp({
tickets: '车票记录',
vouchers: '凭证管理',
iccards: 'IC 卡管理',
issues: '问题汇总',
assets: '线路图',
settings: '系统设置',
logs: '日志'
@@ -163,6 +164,132 @@ createApp({
try { return JSON.parse(t); } catch (e) { return null; }
};
const safeJsonStringify = (value) => {
try {
return JSON.stringify(value, null, 2);
} catch (e) {
return String(value);
}
};
const formatIssueKey = (key) => {
const map = {
device: '设备',
device_id: '设备编号',
machine_id: '机器 ID',
station_code: '车站编号',
station_name: '车站名称',
station: '车站',
error_msg: '错误信息',
ticket_id: '车票 ID',
card_id: '卡号',
holder_name: '持卡人',
balance: '余额',
amount: '金额',
price: '价格',
order_code: '订单号',
voucher_code: '凭证码',
method: '方法',
source: '来源',
reason: '原因',
status: '状态',
action: '动作',
line: '线路',
route: '区间',
from: '起点',
to: '终点',
time: '时间',
ts: '时间戳'
};
const raw = String(key || '').trim();
if (!raw) return '字段';
if (map[raw]) return map[raw];
return raw
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/^\w/, (ch) => ch.toUpperCase());
};
const formatIssueValue = (value) => {
if (value == null || value === '') return '---';
if (typeof value === 'boolean') return value ? '是' : '否';
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : '---';
if (typeof value === 'string') return value.trim() || '---';
if (Array.isArray(value)) {
if (!value.length) return '---';
if (value.every((item) => item == null || ['string', 'number', 'boolean'].includes(typeof item))) {
return value.map(formatIssueValue).join(' / ');
}
return safeJsonStringify(value);
}
if (typeof value === 'object') return safeJsonStringify(value);
return String(value);
};
const buildIssueDetailView = (detail) => {
const rawText = String(detail || '').trim();
if (!rawText) return { entries: [], rawText: '', rawExpanded: false };
const hiddenKeys = new Set(['device', 'device_id', 'machine_id', 'station_code', 'station_name', 'error_msg', 'detail', 'type']);
const parsed = parseJsonSafe(rawText);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const entries = Object.entries(parsed)
.filter(([key, value]) => !hiddenKeys.has(String(key || '').trim()) && value != null && value !== '')
.map(([key, value]) => ({
key: String(key),
label: formatIssueKey(key),
value: formatIssueValue(value)
}));
return {
entries,
rawText: entries.length ? rawText : '',
rawExpanded: false
};
}
const segments = rawText
.split(/\r?\n|;|;/)
.map((item) => item.trim())
.filter(Boolean);
const entries = [];
for (const segment of segments) {
const matched = /^([^:=:]{1,30})\s*[:=:]\s*(.+)$/.exec(segment);
if (!matched) continue;
const key = String(matched[1] || '').trim();
if (!key || hiddenKeys.has(key)) continue;
entries.push({
key,
label: formatIssueKey(key),
value: formatIssueValue(matched[2])
});
}
const hasPlainBody = segments.length <= 1 && entries.length <= 1;
return {
entries: hasPlainBody ? [] : entries,
rawText,
rawExpanded: hasPlainBody
};
};
const normalizeIssueRecord = (issue) => {
const item = issue && typeof issue === 'object' ? issue : {};
const device = String(item.device || item.machine_id || '').trim();
const stationCode = String(item.station_code || '').trim();
const stationName = stationCode ? getStationName(stationCode) : '';
const detailView = buildIssueDetailView(item.detail);
return {
...item,
displayDevice: device || '---',
displayStation: stationName && stationName !== stationCode ? `${stationName} (${stationCode})` : (stationCode || '---'),
displayError: String(item.error_msg || '').trim() || '未提供错误信息',
detailEntries: detailView.entries,
detailRaw: detailView.rawText,
detailRawExpanded: detailView.rawExpanded
};
};
const parseCsvSimple = (text) => {
const out = { headers: [], rows: [] };
const raw = String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
@@ -871,7 +998,9 @@ createApp({
try {
const res = await requestJson('/api/issues');
if (res && res.ok) {
issues.value = res.issues || [];
issues.value = (res.issues || [])
.map(normalizeIssueRecord)
.sort((a, b) => Number(b?.ts || 0) - Number(a?.ts || 0));
issueDataLoaded = true;
markSynced();
}
@@ -1638,6 +1767,12 @@ createApp({
stats.sold_tickets += item.sold_tickets;
stats.revenue += item.revenue;
});
socket.on('issue:new', (item) => {
issues.value = [normalizeIssueRecord(item), ...issues.value]
.sort((a, b) => Number(b?.ts || 0) - Number(a?.ts || 0));
issueDataLoaded = true;
markSynced();
});
socket.on('ic-card:created', () => { fetchIcCards(false).catch(console.error); });
socket.on('ic-card:opened', () => { fetchIcCards(false).catch(console.error); });
socket.on('ic-card:check', () => {
@@ -1950,6 +2085,7 @@ createApp({
tickets: `已加载 ${ticketList.value.length} 条车票记录`,
vouchers: `已加载 ${orders.value.length} 条凭证记录`,
iccards: `当前检索到 ${icCards.value.length} 张 IC 卡`,
issues: `当前共有 ${issues.value.length} 条问题记录`,
assets: assetsManifest.routeMap ? `已上传线路图 ${assetsManifest.routeMap}` : '尚未上传线路图',
settings: '可维护优惠活动与导出数据',
logs: `当前筛选结果 ${logs.value.length} 条日志`
+117
View File
@@ -4237,6 +4237,123 @@ body.jr-admin-login-page {
gap: 16px;
}
.jr-issue-table {
width: 100%;
table-layout: fixed;
}
.jr-issue-table th:nth-child(1) {
width: 168px;
}
.jr-issue-table th:nth-child(2) {
width: 150px;
}
.jr-issue-table th:nth-child(3) {
width: 190px;
}
.jr-issue-table th:nth-child(4) {
width: 220px;
}
.jr-issue-table td {
vertical-align: top;
}
.jr-issue-primary {
font-weight: 700;
line-height: 1.45;
overflow-wrap: anywhere;
}
.jr-issue-sub {
margin-top: 4px;
font-size: 0.78rem;
color: #6d7f73;
overflow-wrap: anywhere;
}
.jr-issue-error {
color: #c43f3f;
font-weight: 700;
line-height: 1.6;
}
.jr-issue-detail-cell {
min-width: 0;
}
.jr-issue-detail-card,
.jr-issue-plain-detail {
border: 1px solid #dbe4d7;
background: #f8fbf7;
padding: 12px;
border-radius: 0;
}
.jr-issue-detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 8px;
}
.jr-issue-detail-item {
border: 1px solid #dbe4d7;
background: #ffffff;
padding: 10px 12px;
min-width: 0;
}
.jr-issue-detail-item span {
display: block;
margin-bottom: 4px;
color: #6d7f73;
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.jr-issue-detail-item strong {
display: block;
color: #183225;
font-size: 0.92rem;
line-height: 1.55;
font-weight: 600;
overflow-wrap: anywhere;
}
.jr-issue-raw-block {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed #dbe4d7;
}
.jr-issue-raw-block summary {
cursor: pointer;
color: #607466;
font-size: 0.82rem;
font-weight: 700;
}
.jr-issue-raw-block pre,
.jr-issue-plain-detail pre {
margin: 8px 0 0;
padding: 12px;
border: 1px solid #1e293b;
background: #0f172a;
color: #e2e8f0;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.82rem;
line-height: 1.5;
overflow: auto;
user-select: text;
-webkit-user-select: text;
}
.jr-admin-login-shell {
min-height: 100vh;
display: flex;