feat: 新增客户端问题主动上报与控制台问题汇总功能
1. 客户端 (ticketmachine.lua): 修改 showAlert 支持详细错误并主动上报,使用 pcall 包装主循环捕获 Fatal Error 并上报。 2. 服务端 (data.js, api.js): 增加 issues 数据表,提供 /report_issue 和 /issues 接口。 3. 控制台 (index.html, index.js): 添加“问题汇总”菜单及界面状态管理。
This commit is contained in:
@@ -1191,6 +1191,29 @@ router.get('/export', (req, res) => {
|
|||||||
res.json(DataService.buildExportPayload());
|
res.json(DataService.buildExportPayload());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Issues
|
||||||
|
router.post('/report_issue', async (req, res) => {
|
||||||
|
const r = req.body || {};
|
||||||
|
const item = {
|
||||||
|
ts: Number(r.ts || Date.now()),
|
||||||
|
device: String(r.device || 'unknown'),
|
||||||
|
machine_id: String(r.machine_id || ''),
|
||||||
|
station_code: String(r.station_code || ''),
|
||||||
|
error_msg: String(r.error_msg || ''),
|
||||||
|
detail: String(r.detail || ''),
|
||||||
|
type: 'issue'
|
||||||
|
};
|
||||||
|
await DataService.appendIssue(item);
|
||||||
|
io.emit('issue:new', item);
|
||||||
|
appendReqLog(req, { category: 'device', type: 'report_issue', detail: item });
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/issues', (req, res) => {
|
||||||
|
const issues = DataService.getIssues();
|
||||||
|
res.json({ ok: true, issues });
|
||||||
|
});
|
||||||
|
|
||||||
// Ticket Operations
|
// Ticket Operations
|
||||||
router.post('/tickets/sale', async (req, res) => {
|
router.post('/tickets/sale', async (req, res) => {
|
||||||
const { start, terminal, train_type, cost, station_code, device, trips_total, trips_remaining } = req.body || {};
|
const { start, terminal, train_type, cost, station_code, device, trips_total, trips_remaining } = req.body || {};
|
||||||
|
|||||||
+15
-1
@@ -64,7 +64,8 @@ const cache = {
|
|||||||
icCardIndex: {},
|
icCardIndex: {},
|
||||||
icCardEvents: [],
|
icCardEvents: [],
|
||||||
statsTicket: [],
|
statsTicket: [],
|
||||||
statsGate: []
|
statsGate: [],
|
||||||
|
issues: []
|
||||||
};
|
};
|
||||||
|
|
||||||
const DataService = {
|
const DataService = {
|
||||||
@@ -88,6 +89,7 @@ const DataService = {
|
|||||||
await conn.query(`CREATE TABLE IF NOT EXISTS ticket_events (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`);
|
await conn.query(`CREATE TABLE IF NOT EXISTS ticket_events (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`);
|
||||||
await conn.query(`CREATE TABLE IF NOT EXISTS stats_ticket (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`);
|
await conn.query(`CREATE TABLE IF NOT EXISTS stats_ticket (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`);
|
||||||
await conn.query(`CREATE TABLE IF NOT EXISTS stats_gate (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`);
|
await conn.query(`CREATE TABLE IF NOT EXISTS stats_gate (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`);
|
||||||
|
await conn.query(`CREATE TABLE IF NOT EXISTS issues (id INT AUTO_INCREMENT PRIMARY KEY, data JSON)`);
|
||||||
|
|
||||||
// Load Cache
|
// Load Cache
|
||||||
const [configs] = await conn.query('SELECT v FROM kv_store WHERE k = ?', ['config']);
|
const [configs] = await conn.query('SELECT v FROM kv_store WHERE k = ?', ['config']);
|
||||||
@@ -126,6 +128,9 @@ const DataService = {
|
|||||||
const [statsG] = await conn.query('SELECT data FROM stats_gate');
|
const [statsG] = await conn.query('SELECT data FROM stats_gate');
|
||||||
cache.statsGate = statsG.map(r => r.data);
|
cache.statsGate = statsG.map(r => r.data);
|
||||||
|
|
||||||
|
const [issues] = await conn.query('SELECT data FROM issues');
|
||||||
|
cache.issues = issues.map(r => r.data);
|
||||||
|
|
||||||
console.log('DataService initialized with MySQL');
|
console.log('DataService initialized with MySQL');
|
||||||
} finally {
|
} finally {
|
||||||
conn.release();
|
conn.release();
|
||||||
@@ -511,6 +516,15 @@ const DataService = {
|
|||||||
},
|
},
|
||||||
getStatsGate: () => cache.statsGate,
|
getStatsGate: () => cache.statsGate,
|
||||||
|
|
||||||
|
// Issues
|
||||||
|
appendIssue: async (item) => {
|
||||||
|
cache.issues.push(item);
|
||||||
|
try {
|
||||||
|
await pool.query('INSERT INTO issues (data) VALUES (?)', [JSON.stringify(item)]);
|
||||||
|
} catch(e) { console.error('appendIssue error', e); }
|
||||||
|
},
|
||||||
|
getIssues: () => cache.issues,
|
||||||
|
|
||||||
// Export
|
// Export
|
||||||
buildExportPayload: () => ({
|
buildExportPayload: () => ({
|
||||||
config: DataService.getConfig(),
|
config: DataService.getConfig(),
|
||||||
|
|||||||
+39
-10
@@ -327,6 +327,19 @@ local function enqueueTicketUpload(ticketData)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function reportIssue(errMsg, detail)
|
||||||
|
local url = API_BASE .. '/report_issue'
|
||||||
|
local payload = {
|
||||||
|
device = currentDeviceId(),
|
||||||
|
machine_id = currentDeviceId(),
|
||||||
|
station_code = state.stationCode or CURRENT_STATION_CODE,
|
||||||
|
error_msg = tostring(errMsg or ''),
|
||||||
|
detail = tostring(detail or ''),
|
||||||
|
ts = (os.epoch and os.epoch('utc')) or (os.time() * 1000)
|
||||||
|
}
|
||||||
|
postJSONResult(url, payload)
|
||||||
|
end
|
||||||
|
|
||||||
-- ###########################
|
-- ###########################
|
||||||
-- Peripheral discovery
|
-- Peripheral discovery
|
||||||
-- ###########################
|
-- ###########################
|
||||||
@@ -1286,14 +1299,22 @@ local function renderConfirmCancel()
|
|||||||
addButton(bx+4+bw, by+4, 'NO', bw, 1, {colors.green, colors.white}, function() ui_cancel_confirmed = false; ui_cancel_request = false end)
|
addButton(bx+4+bw, by+4, 'NO', bw, 1, {colors.green, colors.white}, function() ui_cancel_confirmed = false; ui_cancel_request = false end)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function showAlert(msg)
|
local function showAlert(msg, detail)
|
||||||
|
if detail then
|
||||||
|
reportIssue(msg, detail)
|
||||||
|
end
|
||||||
local boxW, boxH = math.max(24, math.min(32, w-4)), 6
|
local boxW, boxH = math.max(24, math.min(32, w-4)), 6
|
||||||
local bx, by = math.floor((w-boxW)/2)+1, math.floor((h-boxH)/2)+1
|
local bx, by = math.floor((w-boxW)/2)+1, math.floor((h-boxH)/2)+1
|
||||||
termDev.setBackgroundColor(colors.gray)
|
termDev.setBackgroundColor(colors.gray)
|
||||||
for i=0,boxH-1 do termDev.setCursorPos(bx, by+i); termDev.write(string.rep(' ', boxW)) end
|
for i=0,boxH-1 do termDev.setCursorPos(bx, by+i); termDev.write(string.rep(' ', boxW)) end
|
||||||
msg = tostring(msg or '')
|
local displayMsg = tostring(msg or '')
|
||||||
if #msg > boxW-4 then msg = msg:sub(1, boxW-4) end
|
if #displayMsg > boxW-4 then displayMsg = displayMsg:sub(1, boxW-4) end
|
||||||
termDev.setTextColor(colors.white); termDev.setCursorPos(bx+2, by+2); termDev.write(msg)
|
termDev.setTextColor(colors.white); termDev.setCursorPos(bx+2, by+2); termDev.write(displayMsg)
|
||||||
|
if detail then
|
||||||
|
local displayDetail = tostring(detail)
|
||||||
|
if #displayDetail > boxW-4 then displayDetail = displayDetail:sub(1, boxW-4) end
|
||||||
|
termDev.setCursorPos(bx+2, by+3); termDev.write(displayDetail)
|
||||||
|
end
|
||||||
Buttons = {}
|
Buttons = {}
|
||||||
addButton(bx+math.floor((boxW-6)/2), by+4, 'OK', 6, 1, {colors.green, colors.white}, function() end)
|
addButton(bx+math.floor((boxW-6)/2), by+4, 'OK', 6, 1, {colors.green, colors.white}, function() end)
|
||||||
waitButtons()
|
waitButtons()
|
||||||
@@ -1815,7 +1836,7 @@ local function showTerminal()
|
|||||||
invalidUntil = os.epoch('utc') + 1500
|
invalidUntil = os.epoch('utc') + 1500
|
||||||
sel = nil
|
sel = nil
|
||||||
state.terminal = nil
|
state.terminal = nil
|
||||||
showAlert('Same station')
|
showAlert('Same station', 'Dep and Term are identical')
|
||||||
else
|
else
|
||||||
state.terminal = sel
|
state.terminal = sel
|
||||||
end
|
end
|
||||||
@@ -2073,7 +2094,7 @@ local function showOrderAndAudio()
|
|||||||
else
|
else
|
||||||
local unit = computeCost(state.departure or '', state.terminal or '', state.trainType or 'Local')
|
local unit = computeCost(state.departure or '', state.terminal or '', state.trainType or 'Local')
|
||||||
if not unit then
|
if not unit then
|
||||||
showAlert('No route')
|
showAlert('No route', 'Cannot find fare path')
|
||||||
state.page = 'terminal'
|
state.page = 'terminal'
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -2084,7 +2105,7 @@ local function showOrderAndAudio()
|
|||||||
end
|
end
|
||||||
|
|
||||||
if state.productMode ~= 'card' and state.trainType ~= 'single' and sameLogicalStation(state.departure, state.terminal) then
|
if state.productMode ~= 'card' and state.trainType ~= 'single' and sameLogicalStation(state.departure, state.terminal) then
|
||||||
showAlert('Same station')
|
showAlert('Same station', 'Cannot purchase to same station')
|
||||||
state.page = state.voucher_code and 'online' or 'terminal'
|
state.page = state.voucher_code and 'online' or 'terminal'
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -2097,7 +2118,7 @@ local function showOrderAndAudio()
|
|||||||
local function confirmAction()
|
local function confirmAction()
|
||||||
if processing or confirmed then return end
|
if processing or confirmed then return end
|
||||||
if state.productMode ~= 'card' and sameLogicalStation(state.departure, state.terminal) then
|
if state.productMode ~= 'card' and sameLogicalStation(state.departure, state.terminal) then
|
||||||
showAlert('Same station')
|
showAlert('Same station', 'Dep and Term are identical on confirm')
|
||||||
if render then render() end
|
if render then render() end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -2411,7 +2432,7 @@ local function showDone()
|
|||||||
local issueError = tostring(issueMethod or 'ticket_issue_failed')
|
local issueError = tostring(issueMethod or 'ticket_issue_failed')
|
||||||
print('Ticket issue failed: ' .. issueError)
|
print('Ticket issue failed: ' .. issueError)
|
||||||
_G.TICKET_MACHINE_LAST_TICKET.ticket_issue_error = issueError
|
_G.TICKET_MACHINE_LAST_TICKET.ticket_issue_error = issueError
|
||||||
showAlert('Ticket issue failed')
|
showAlert('Ticket issue failed', issueError)
|
||||||
resetTicketFlow()
|
resetTicketFlow()
|
||||||
state.page = 'home'
|
state.page = 'home'
|
||||||
return
|
return
|
||||||
@@ -2608,4 +2629,12 @@ local function mainPageLoop()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
parallel.waitForAny(mainPageLoop, backgroundSyncTask, backgroundTicketUploadTask, backgroundPeripheralTask)
|
local function safeMain()
|
||||||
|
local ok, err = pcall(parallel.waitForAny, mainPageLoop, backgroundSyncTask, backgroundTicketUploadTask, backgroundPeripheralTask)
|
||||||
|
if not ok then
|
||||||
|
reportIssue("Fatal Error", tostring(err))
|
||||||
|
error(err)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
safeMain()
|
||||||
|
|||||||
@@ -73,6 +73,9 @@
|
|||||||
<div class="nav-item" :class="{active: currentView === 'iccards'}" @click="currentView = 'iccards'">
|
<div class="nav-item" :class="{active: currentView === 'iccards'}" @click="currentView = 'iccards'">
|
||||||
<span class="nav-icon"><i class="fas fa-credit-card"></i></span> IC卡管理
|
<span class="nav-icon"><i class="fas fa-credit-card"></i></span> IC卡管理
|
||||||
</div>
|
</div>
|
||||||
|
<div class="nav-item" :class="{active: currentView === 'issues'}" @click="currentView = 'issues'">
|
||||||
|
<span class="nav-icon"><i class="fas fa-exclamation-triangle"></i></span> 问题汇总
|
||||||
|
</div>
|
||||||
<div class="nav-item" :class="{active: currentView === 'assets'}" @click="currentView = 'assets'">
|
<div class="nav-item" :class="{active: currentView === 'assets'}" @click="currentView = 'assets'">
|
||||||
<span class="nav-icon"><i class="fas fa-route"></i></span> 线路图上传
|
<span class="nav-icon"><i class="fas fa-route"></i></span> 线路图上传
|
||||||
</div>
|
</div>
|
||||||
@@ -893,6 +896,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 日志 -->
|
||||||
<div v-if="currentView === 'logs'">
|
<div v-if="currentView === 'logs'">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="flex between mb-4" style="flex-wrap: wrap; gap: 10px;">
|
<div class="flex between mb-4" style="flex-wrap: wrap; gap: 10px;">
|
||||||
@@ -947,6 +951,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 问题汇总 -->
|
||||||
|
<div v-if="currentView === 'issues'">
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex between mb-4" style="flex-wrap: wrap; gap: 10px;">
|
||||||
|
<h4>问题汇总</h4>
|
||||||
|
<div class="flex" style="gap: 8px;">
|
||||||
|
<button @click="fetchIssues" title="刷新"><i class="fas fa-sync-alt"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table v-if="issues && issues.length > 0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>设备</th>
|
||||||
|
<th>车站</th>
|
||||||
|
<th>错误信息</th>
|
||||||
|
<th>详细信息</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<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>
|
||||||
|
<span v-else class="text-muted">无</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<i class="fas fa-check-circle" style="font-size: 48px; margin-bottom: 16px; opacity: 0.3; color: var(--success);"></i>
|
||||||
|
<p>暂无问题上报记录</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">粤ICP备2025450093号</a>
|
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">粤ICP备2025450093号</a>
|
||||||
<span class="version">v1.0.12</span>
|
<span class="version">v1.0.12</span>
|
||||||
|
|||||||
+24
-1
@@ -43,6 +43,7 @@ createApp({
|
|||||||
const lines = ref([]);
|
const lines = ref([]);
|
||||||
const fares = ref([]);
|
const fares = ref([]);
|
||||||
const tickets = ref([]);
|
const tickets = ref([]);
|
||||||
|
const issues = ref([]);
|
||||||
const stats = reactive({ sold_tickets: 0, revenue: 0 });
|
const stats = reactive({ sold_tickets: 0, revenue: 0 });
|
||||||
const config = reactive({
|
const config = reactive({
|
||||||
api_base: '',
|
api_base: '',
|
||||||
@@ -77,6 +78,7 @@ createApp({
|
|||||||
let ticketDataLoaded = false;
|
let ticketDataLoaded = false;
|
||||||
let orderDataLoaded = false;
|
let orderDataLoaded = false;
|
||||||
let logDataLoaded = false;
|
let logDataLoaded = false;
|
||||||
|
let issueDataLoaded = false;
|
||||||
let assetsLoaded = false;
|
let assetsLoaded = false;
|
||||||
let fareMapLoaded = false;
|
let fareMapLoaded = false;
|
||||||
const loadingState = reactive({
|
const loadingState = reactive({
|
||||||
@@ -84,7 +86,8 @@ createApp({
|
|||||||
tickets: false,
|
tickets: false,
|
||||||
orders: false,
|
orders: false,
|
||||||
logs: false,
|
logs: false,
|
||||||
iccards: false
|
iccards: false,
|
||||||
|
issues: false
|
||||||
});
|
});
|
||||||
const lastSyncAt = ref(0);
|
const lastSyncAt = ref(0);
|
||||||
|
|
||||||
@@ -861,6 +864,24 @@ createApp({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Issues Management ---
|
||||||
|
const fetchIssues = async () => {
|
||||||
|
if (loadingState.issues) return;
|
||||||
|
loadingState.issues = true;
|
||||||
|
try {
|
||||||
|
const res = await requestJson('/api/issues');
|
||||||
|
if (res && res.ok) {
|
||||||
|
issues.value = res.issues || [];
|
||||||
|
issueDataLoaded = true;
|
||||||
|
markSynced();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
loadingState.issues = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchIcCards = async (keepSelection = true) => {
|
const fetchIcCards = async (keepSelection = true) => {
|
||||||
if (loadingState.iccards) return;
|
if (loadingState.iccards) return;
|
||||||
loadingState.iccards = true;
|
loadingState.iccards = true;
|
||||||
@@ -1147,6 +1168,7 @@ createApp({
|
|||||||
if (view === 'tickets' && (force || !ticketDataLoaded)) await fetchTicketData();
|
if (view === 'tickets' && (force || !ticketDataLoaded)) await fetchTicketData();
|
||||||
if (view === 'vouchers' && (force || !orderDataLoaded)) await fetchOrders();
|
if (view === 'vouchers' && (force || !orderDataLoaded)) await fetchOrders();
|
||||||
if (view === 'logs' && (force || !logDataLoaded)) await fetchLogs();
|
if (view === 'logs' && (force || !logDataLoaded)) await fetchLogs();
|
||||||
|
if (view === 'issues' && (force || !issueDataLoaded)) await fetchIssues();
|
||||||
if (view === 'iccards') {
|
if (view === 'iccards') {
|
||||||
await fetchIcCards(true);
|
await fetchIcCards(true);
|
||||||
if (icSelectedId.value) {
|
if (icSelectedId.value) {
|
||||||
@@ -2013,6 +2035,7 @@ createApp({
|
|||||||
stations, lines, fares, stats, config, recentLogs, ticketList, ticketStats, orderStats,
|
stations, lines, fares, stats, config, recentLogs, ticketList, ticketStats, orderStats,
|
||||||
logs, logCategory, logTypeFilter, logQuery, logMax, logLoading, fetchLogs,
|
logs, logCategory, logTypeFilter, logQuery, logMax, logLoading, fetchLogs,
|
||||||
orders, orderList, fetchOrders, deleteOrder,
|
orders, orderList, fetchOrders, deleteOrder,
|
||||||
|
issues, fetchIssues,
|
||||||
icCards, icCardSearch, icSelectedId, icSelectedCard, icSelectedEvents, icCreateForm, icDetailForm, icCardStats,
|
icCards, icCardSearch, icSelectedId, icSelectedCard, icSelectedEvents, icCreateForm, icDetailForm, icCardStats,
|
||||||
createIcCard, loadIcCard, saveIcCard, topupIcCard, deleteIcCard, icStatusInfo, icStatusColor, icEventTitle, formatIcEventDetail, cardOrderCode, displayIcCardId, formatMoney,
|
createIcCard, loadIcCard, saveIcCard, topupIcCard, deleteIcCard, icStatusInfo, icStatusColor, icEventTitle, formatIcEventDetail, cardOrderCode, displayIcCardId, formatMoney,
|
||||||
showAddLine, showAddStation, newLine, newStation, fareMapSvg, ticketSearch,
|
showAddLine, showAddStation, newLine, newStation, fareMapSvg, ticketSearch,
|
||||||
|
|||||||
Reference in New Issue
Block a user