feat: 新增错误问题追踪与管理功能
- 升级程序版本至 v1.5.25 - 新增对比度文本颜色计算与垂直滚动条绘制工具函数,优化选择界面滚动显示 - 网页端新增问题记录管理页面,配套完整的表格与详情展示样式 - 实现issue记录标准化处理、实时事件推送接收与按时间倒序排序展示 - 重构原有选择界面的滚动逻辑,替换为通用滚动条绘制函数
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
local DEFAULT_SERVER_BASE = "https://ticket.fse-media.group"
|
local DEFAULT_SERVER_BASE = "https://ticket.fse-media.group"
|
||||||
local DEFAULT_SERVER_PATH = "/api/tickets/check"
|
local DEFAULT_SERVER_PATH = "/api/tickets/check"
|
||||||
local GATE_OPEN_SECONDS = 2
|
local GATE_OPEN_SECONDS = 2
|
||||||
local VERSION = "v1.5.26"
|
local VERSION = "v1.5.27"
|
||||||
local VERSION_CHECK_INTERVAL = 5
|
local VERSION_CHECK_INTERVAL = 5
|
||||||
|
|
||||||
local serverURL = nil
|
local serverURL = nil
|
||||||
@@ -86,9 +86,11 @@ local function stationSetFromList(list)
|
|||||||
return set
|
return set
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local nativeTerm = (term and type(term.current) == "function" and term.current()) or term
|
||||||
local monitor = peripheral.find("monitor")
|
local monitor = peripheral.find("monitor")
|
||||||
local speaker = peripheral.find("speaker")
|
local speaker = peripheral.find("speaker")
|
||||||
local inspection = peripheral.find("ticket_inspection_machine")
|
local inspection = peripheral.find("ticket_inspection_machine")
|
||||||
|
local monitorSide = nil
|
||||||
|
|
||||||
local serverConnected = nil
|
local serverConnected = nil
|
||||||
local isUpdating = false
|
local isUpdating = false
|
||||||
@@ -168,13 +170,49 @@ local function setServerConnected(ok)
|
|||||||
serverLastChangeTs = os.epoch("utc")
|
serverLastChangeTs = os.epoch("utc")
|
||||||
end
|
end
|
||||||
|
|
||||||
local termDev = term
|
local termDev = nativeTerm
|
||||||
if monitor then
|
if peripheral and type(peripheral.getName) == "function" and monitor then
|
||||||
pcall(monitor.setTextScale, 0.5)
|
pcall(function() monitorSide = peripheral.getName(monitor) end)
|
||||||
termDev = monitor
|
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
|
end
|
||||||
|
|
||||||
local function clear()
|
local function clear()
|
||||||
|
refreshDisplayTarget()
|
||||||
termDev.setBackgroundColor(colors.black)
|
termDev.setBackgroundColor(colors.black)
|
||||||
termDev.setTextColor(colors.white)
|
termDev.setTextColor(colors.white)
|
||||||
termDev.clear()
|
termDev.clear()
|
||||||
@@ -1479,6 +1517,8 @@ local function mainLoop()
|
|||||||
processInspectionEvent(ev[1], ev)
|
processInspectionEvent(ev[1], ev)
|
||||||
os.sleep(0.35)
|
os.sleep(0.35)
|
||||||
drawReadyScreen()
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+68
-14
@@ -1,6 +1,6 @@
|
|||||||
local CURRENT_STATION_CODE = 'Ticket-Machine'
|
local CURRENT_STATION_CODE = 'Ticket-Machine'
|
||||||
local API_BASE = 'https://ticket.fse-media.group/api'
|
local API_BASE = 'https://ticket.fse-media.group/api'
|
||||||
local VERSION = 'v1.5.24'
|
local VERSION = 'v1.5.25'
|
||||||
local UPDATE_CHECK_INTERVAL = 5
|
local UPDATE_CHECK_INTERVAL = 5
|
||||||
|
|
||||||
-- ###########################
|
-- ###########################
|
||||||
@@ -1081,6 +1081,16 @@ local function nearestCCColor(val)
|
|||||||
return colors.gray
|
return colors.gray
|
||||||
end
|
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()
|
local function clear()
|
||||||
termDev.setBackgroundColor(colors.black)
|
termDev.setBackgroundColor(colors.black)
|
||||||
termDev.clear()
|
termDev.clear()
|
||||||
@@ -1115,6 +1125,31 @@ local function addButton(x, y, label, wBtn, hBtn, colorsPair, onClick)
|
|||||||
termDev.write(label)
|
termDev.write(label)
|
||||||
end
|
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)
|
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)
|
return px >= btn.x and px <= (btn.x + btn.w - 1) and py >= btn.y and py <= (btn.y + btn.h - 1)
|
||||||
end
|
end
|
||||||
@@ -1381,8 +1416,12 @@ local function showSingleSegmentSelection()
|
|||||||
local startIdx = singleSegmentScroll * itemsPerPage + 1
|
local startIdx = singleSegmentScroll * itemsPerPage + 1
|
||||||
local endIdx = math.min(startIdx + itemsPerPage - 1, #singleSegments)
|
local endIdx = math.min(startIdx + itemsPerPage - 1, #singleSegments)
|
||||||
|
|
||||||
local bw = math.floor((w - 6) / 2)
|
local sbX = w - 3
|
||||||
local bx1, bx2 = 2, w - bw - 1
|
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
|
local by = 5
|
||||||
for i = startIdx, endIdx do
|
for i = startIdx, endIdx do
|
||||||
local seg = singleSegments[i]
|
local seg = singleSegments[i]
|
||||||
@@ -1391,20 +1430,27 @@ local function showSingleSegmentSelection()
|
|||||||
local x = colIdx == 0 and bx1 or bx2
|
local x = colIdx == 0 and bx1 or bx2
|
||||||
local y = by + rowIdx * 4
|
local y = by + rowIdx * 4
|
||||||
|
|
||||||
local label = seg.lineName .. ' cogs:' .. tostring(seg.cost)
|
local lineColor = nearestCCColor(seg.color)
|
||||||
if #label > bw then label = label:sub(1, bw) end
|
local textColor = contrastTextColor(lineColor)
|
||||||
local isSelected = (state.singleSegment == seg)
|
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
|
state.singleSegment = seg
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
if singleSegmentScroll > 0 then
|
drawVerticalScrollbar(
|
||||||
addButton(w-8, 2, 'UP', 6, 1, {colors.gray, colors.white}, function() singleSegmentScroll = singleSegmentScroll - 1 end)
|
sbX,
|
||||||
end
|
by,
|
||||||
if singleSegmentScroll < maxScroll then
|
h - 6,
|
||||||
addButton(w-8, h-5, 'DN', 6, 1, {colors.gray, colors.white}, function() singleSegmentScroll = singleSegmentScroll + 1 end)
|
singleSegmentScroll * itemsPerPage,
|
||||||
end
|
maxScroll * itemsPerPage,
|
||||||
|
itemsPerPage,
|
||||||
|
function() singleSegmentScroll = math.max(0, singleSegmentScroll - 1) end,
|
||||||
|
function() singleSegmentScroll = math.min(maxScroll, singleSegmentScroll + 1) end
|
||||||
|
)
|
||||||
|
|
||||||
addCancelButton()
|
addCancelButton()
|
||||||
addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'home' end)
|
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))
|
local ch = computeHeight(); local maxO = math.max(0, ch - (endY-startY+1))
|
||||||
ui_scroll_offset = math.min(ui_scroll_offset, maxO)
|
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)
|
drawVerticalScrollbar(
|
||||||
addButton(sbX, endY+1, 'v', 3, 1, {colors.black, colors.white}, function() ui_scroll_offset = math.min(maxO, ui_scroll_offset+4) end)
|
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
|
local y = startY
|
||||||
for _, l in ipairs(CFG.lines) do
|
for _, l in ipairs(CFG.lines) do
|
||||||
|
|||||||
+27
-8
@@ -961,7 +961,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table v-if="issues && issues.length > 0">
|
<table v-if="issues && issues.length > 0" class="jr-issue-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>时间</th>
|
<th>时间</th>
|
||||||
@@ -974,14 +974,33 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(issue, idx) in issues" :key="idx">
|
<tr v-for="(issue, idx) in issues" :key="idx">
|
||||||
<td style="white-space: nowrap;">{{ formatTime(issue.ts) }}</td>
|
<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>
|
<td>
|
||||||
<details v-if="issue.detail">
|
<div class="jr-issue-primary mono">{{ issue.displayDevice }}</div>
|
||||||
<summary class="text-muted" style="cursor: pointer;">查看详情</summary>
|
<div v-if="issue.machine_id && issue.machine_id !== issue.displayDevice" class="jr-issue-sub mono">{{ issue.machine_id }}</div>
|
||||||
<pre style="white-space: pre-wrap; margin: 8px 0 0; font-size: 0.85rem; line-height: 1.35; color: #fff;">{{ issue.detail }}</pre>
|
</td>
|
||||||
</details>
|
<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>
|
<span v-else class="text-muted">无</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
+137
-1
@@ -27,6 +27,7 @@ createApp({
|
|||||||
tickets: '车票记录',
|
tickets: '车票记录',
|
||||||
vouchers: '凭证管理',
|
vouchers: '凭证管理',
|
||||||
iccards: 'IC 卡管理',
|
iccards: 'IC 卡管理',
|
||||||
|
issues: '问题汇总',
|
||||||
assets: '线路图',
|
assets: '线路图',
|
||||||
settings: '系统设置',
|
settings: '系统设置',
|
||||||
logs: '日志'
|
logs: '日志'
|
||||||
@@ -163,6 +164,132 @@ createApp({
|
|||||||
try { return JSON.parse(t); } catch (e) { return null; }
|
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 parseCsvSimple = (text) => {
|
||||||
const out = { headers: [], rows: [] };
|
const out = { headers: [], rows: [] };
|
||||||
const raw = String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
const raw = String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
@@ -871,7 +998,9 @@ createApp({
|
|||||||
try {
|
try {
|
||||||
const res = await requestJson('/api/issues');
|
const res = await requestJson('/api/issues');
|
||||||
if (res && res.ok) {
|
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;
|
issueDataLoaded = true;
|
||||||
markSynced();
|
markSynced();
|
||||||
}
|
}
|
||||||
@@ -1638,6 +1767,12 @@ createApp({
|
|||||||
stats.sold_tickets += item.sold_tickets;
|
stats.sold_tickets += item.sold_tickets;
|
||||||
stats.revenue += item.revenue;
|
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:created', () => { fetchIcCards(false).catch(console.error); });
|
||||||
socket.on('ic-card:opened', () => { fetchIcCards(false).catch(console.error); });
|
socket.on('ic-card:opened', () => { fetchIcCards(false).catch(console.error); });
|
||||||
socket.on('ic-card:check', () => {
|
socket.on('ic-card:check', () => {
|
||||||
@@ -1950,6 +2085,7 @@ createApp({
|
|||||||
tickets: `已加载 ${ticketList.value.length} 条车票记录`,
|
tickets: `已加载 ${ticketList.value.length} 条车票记录`,
|
||||||
vouchers: `已加载 ${orders.value.length} 条凭证记录`,
|
vouchers: `已加载 ${orders.value.length} 条凭证记录`,
|
||||||
iccards: `当前检索到 ${icCards.value.length} 张 IC 卡`,
|
iccards: `当前检索到 ${icCards.value.length} 张 IC 卡`,
|
||||||
|
issues: `当前共有 ${issues.value.length} 条问题记录`,
|
||||||
assets: assetsManifest.routeMap ? `已上传线路图 ${assetsManifest.routeMap}` : '尚未上传线路图',
|
assets: assetsManifest.routeMap ? `已上传线路图 ${assetsManifest.routeMap}` : '尚未上传线路图',
|
||||||
settings: '可维护优惠活动与导出数据',
|
settings: '可维护优惠活动与导出数据',
|
||||||
logs: `当前筛选结果 ${logs.value.length} 条日志`
|
logs: `当前筛选结果 ${logs.value.length} 条日志`
|
||||||
|
|||||||
+117
@@ -4237,6 +4237,123 @@ body.jr-admin-login-page {
|
|||||||
gap: 16px;
|
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 {
|
.jr-admin-login-shell {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user