diff --git a/gate.lua b/gate.lua index bd55d66..67c2834 100644 --- a/gate.lua +++ b/gate.lua @@ -1,7 +1,7 @@ local DEFAULT_SERVER_BASE = "https://ticket.fse-media.group" local DEFAULT_SERVER_PATH = "/api/tickets/check" local GATE_OPEN_SECONDS = 2 -local VERSION = "v1.5.26" +local VERSION = "v1.5.27" local VERSION_CHECK_INTERVAL = 5 local serverURL = nil @@ -86,9 +86,11 @@ local function stationSetFromList(list) return set end +local nativeTerm = (term and type(term.current) == "function" and term.current()) or term local monitor = peripheral.find("monitor") local speaker = peripheral.find("speaker") local inspection = peripheral.find("ticket_inspection_machine") +local monitorSide = nil local serverConnected = nil local isUpdating = false @@ -168,13 +170,49 @@ local function setServerConnected(ok) serverLastChangeTs = os.epoch("utc") end -local termDev = term -if monitor then - pcall(monitor.setTextScale, 0.5) - termDev = monitor +local termDev = nativeTerm +if peripheral and type(peripheral.getName) == "function" and monitor then + pcall(function() monitorSide = peripheral.getName(monitor) end) +end + +local function refreshDisplayTarget() + local nextMonitor = nil + local nextMonitorSide = monitorSide + if peripheral then + if nextMonitorSide and type(peripheral.isPresent) == "function" and peripheral.isPresent(nextMonitorSide) and type(peripheral.wrap) == "function" then + local okWrap, wrapped = pcall(peripheral.wrap, nextMonitorSide) + if okWrap and wrapped then + nextMonitor = wrapped + end + end + if not nextMonitor and type(peripheral.find) == "function" then + local foundName = nil + local found = peripheral.find("monitor", function(name, wrapped) + foundName = name + nextMonitor = wrapped + return true + end) + if found then + nextMonitor = found + nextMonitorSide = foundName + else + nextMonitorSide = nil + end + end + end + monitor = nextMonitor + monitorSide = nextMonitorSide + if monitor then + pcall(monitor.setTextScale, 0.5) + termDev = monitor + else + termDev = nativeTerm or term + end + return termDev end local function clear() + refreshDisplayTarget() termDev.setBackgroundColor(colors.black) termDev.setTextColor(colors.white) termDev.clear() @@ -1479,6 +1517,8 @@ local function mainLoop() processInspectionEvent(ev[1], ev) os.sleep(0.35) drawReadyScreen() + elseif ev[1] == "peripheral" or ev[1] == "peripheral_detach" or ev[1] == "monitor_resize" or ev[1] == "config_updated" then + pcall(drawReadyScreen) end end end diff --git a/ticketmachine.lua b/ticketmachine.lua index 2696569..6a48f61 100644 --- a/ticketmachine.lua +++ b/ticketmachine.lua @@ -1,6 +1,6 @@ local CURRENT_STATION_CODE = 'Ticket-Machine' local API_BASE = 'https://ticket.fse-media.group/api' -local VERSION = 'v1.5.24' +local VERSION = 'v1.5.25' local UPDATE_CHECK_INTERVAL = 5 -- ########################### @@ -1081,6 +1081,16 @@ local function nearestCCColor(val) return colors.gray end +local function contrastTextColor(bg) + for _, c in ipairs(CC_PALETTE) do + if c.val == bg then + local yiq = (c.rgb[1] * 299 + c.rgb[2] * 587 + c.rgb[3] * 114) / 1000 + return yiq >= 140 and colors.black or colors.white + end + end + return colors.white +end + local function clear() termDev.setBackgroundColor(colors.black) termDev.clear() @@ -1115,6 +1125,31 @@ local function addButton(x, y, label, wBtn, hBtn, colorsPair, onClick) termDev.write(label) end +local function drawVerticalScrollbar(sbX, startY, endY, offset, maxOffset, step, onUp, onDown) + addButton(sbX, startY - 1, '^', 3, 1, {colors.black, colors.white}, onUp) + addButton(sbX, endY + 1, 'v', 3, 1, {colors.black, colors.white}, onDown) + for py = startY, endY do + termDev.setBackgroundColor(colors.black) + termDev.setCursorPos(sbX, py) + termDev.write(' ') + termDev.setBackgroundColor(colors.gray) + termDev.setCursorPos(sbX, py) + termDev.write(' ') + end + if maxOffset <= 0 then + return + end + local railH = endY - startY + 1 + local thumbH = math.max(2, math.floor((railH * railH) / (railH + maxOffset))) + if thumbH > railH then thumbH = railH end + local thumbY = startY + math.floor((railH - thumbH) * (offset / maxOffset)) + for py = thumbY, thumbY + thumbH - 1 do + termDev.setBackgroundColor(colors.lightGray) + termDev.setCursorPos(sbX, py) + termDev.write(' ') + end +end + local function inRect(btn, px, py) return px >= btn.x and px <= (btn.x + btn.w - 1) and py >= btn.y and py <= (btn.y + btn.h - 1) end @@ -1381,8 +1416,12 @@ local function showSingleSegmentSelection() local startIdx = singleSegmentScroll * itemsPerPage + 1 local endIdx = math.min(startIdx + itemsPerPage - 1, #singleSegments) - local bw = math.floor((w - 6) / 2) - local bx1, bx2 = 2, w - bw - 1 + local sbX = w - 3 + local contentRight = sbX - 2 + local colGap = 2 + local bw = math.floor((contentRight - 1 - colGap) / 2) + if bw < 8 then bw = 8 end + local bx1, bx2 = 2, 2 + bw + colGap local by = 5 for i = startIdx, endIdx do local seg = singleSegments[i] @@ -1391,20 +1430,27 @@ local function showSingleSegmentSelection() local x = colIdx == 0 and bx1 or bx2 local y = by + rowIdx * 4 - local label = seg.lineName .. ' cogs:' .. tostring(seg.cost) - if #label > bw then label = label:sub(1, bw) end + local lineColor = nearestCCColor(seg.color) + local textColor = contrastTextColor(lineColor) local isSelected = (state.singleSegment == seg) - addButton(x, y, label, bw, 3, {isSelected and colors.green or colors.blue, colors.white}, function() + local label = seg.lineName .. ' ' .. tostring(seg.cost) .. 'c' + if isSelected then label = '>' .. label end + if #label > bw then label = label:sub(1, bw) end + addButton(x, y, label, bw, 3, {lineColor, textColor}, function() state.singleSegment = seg end) end - if singleSegmentScroll > 0 then - addButton(w-8, 2, 'UP', 6, 1, {colors.gray, colors.white}, function() singleSegmentScroll = singleSegmentScroll - 1 end) - end - if singleSegmentScroll < maxScroll then - addButton(w-8, h-5, 'DN', 6, 1, {colors.gray, colors.white}, function() singleSegmentScroll = singleSegmentScroll + 1 end) - end + drawVerticalScrollbar( + sbX, + by, + h - 6, + singleSegmentScroll * itemsPerPage, + maxScroll * itemsPerPage, + itemsPerPage, + function() singleSegmentScroll = math.max(0, singleSegmentScroll - 1) end, + function() singleSegmentScroll = math.min(maxScroll, singleSegmentScroll + 1) end + ) addCancelButton() addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'home' end) @@ -1801,8 +1847,16 @@ local function renderLinesSelection(title, selectedCode, backPage, nextPage, inf local ch = computeHeight(); local maxO = math.max(0, ch - (endY-startY+1)) ui_scroll_offset = math.min(ui_scroll_offset, maxO) - addButton(sbX, startY-1, '^', 3, 1, {colors.black, colors.white}, function() ui_scroll_offset = math.max(0, ui_scroll_offset-4) end) - addButton(sbX, endY+1, 'v', 3, 1, {colors.black, colors.white}, function() ui_scroll_offset = math.min(maxO, ui_scroll_offset+4) end) + drawVerticalScrollbar( + sbX, + startY, + endY, + ui_scroll_offset, + maxO, + 4, + function() ui_scroll_offset = math.max(0, ui_scroll_offset-4) end, + function() ui_scroll_offset = math.min(maxO, ui_scroll_offset+4) end + ) local y = startY for _, l in ipairs(CFG.lines) do diff --git a/web/index.html b/web/index.html index ba93d05..c5dbb1e 100644 --- a/web/index.html +++ b/web/index.html @@ -961,7 +961,7 @@ - +
@@ -974,14 +974,33 @@ - - - + + + diff --git a/web/index.js b/web/index.js index e8df46c..30a6578 100644 --- a/web/index.js +++ b/web/index.js @@ -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} 条日志` diff --git a/web/style.css b/web/style.css index 384e3ba..a47810b 100644 --- a/web/style.css +++ b/web/style.css @@ -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;
时间
{{ formatTime(issue.ts) }}{{ issue.device || issue.machine_id }}{{ issue.station_code }}{{ issue.error_msg }} -
- 查看详情 -
{{ issue.detail }}
-
+
{{ issue.displayDevice }}
+
{{ issue.machine_id }}
+
+
{{ issue.displayStation }}
+
{{ issue.station_code }}
+
+
{{ issue.displayError }}
+
+
+
+
+ {{ entry.label }} + {{ entry.value }} +
+
+
+ 查看原始内容 +
{{ issue.detailRaw }}
+
+
+
+
{{ issue.detailRaw }}
+
无