From 95893a5431a1e90d8f3c98fdb9a1895eae925706 Mon Sep 17 00:00:00 2001 From: HenryDu8133 <813367384@qq.com> Date: Sun, 5 Jul 2026 14:55:12 +0800 Subject: [PATCH] =?UTF-8?q?refactor(web):=20=E9=87=8D=E6=9E=84=E5=85=A8?= =?UTF-8?q?=E7=AB=99=E7=95=8C=E9=9D=A2=E4=B8=8E=E4=BA=8B=E4=BB=B6=E6=97=A5?= =?UTF-8?q?=E5=BF=97=EF=BC=8C=E4=BC=98=E5=8C=96=E5=8A=9F=E8=83=BD=E4=B8=8E?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 重构并统一票务、IC卡的事件日志格式化展示逻辑,替换原有零散的格式化函数,提升事件展示的一致性与可读性 2. 为AI助手面板新增折叠按钮与折叠逻辑,支持收起/展开面板,优化界面空间利用率 3. 移除多个页面的冗余介绍、提醒区块,精简页面结构 4. 修复IC卡购卡页面的充值选项按钮布局问题,将button元素改为div适配样式规范 5. 新增移动端卡片布局的横向滚动适配,优化小屏设备上的卡片展示与浏览体验 6. 更新后台管理页面的导航样式、文案与统计数据展示,新增车票、凭证的统计卡片 7. 统一全站品牌文案与页面格式,修复部分页面的DOCTYPE多余空格问题 8. 简化车票订单页面的地图加载逻辑,修复原有的冗余代码与加载异常问题 --- server/services/svg-generator.js | 328 +++++++++++++++---------------- web/ai-assistant.js | 16 ++ web/home.html | 73 +------ web/ic-card-admin.html | 9 +- web/ic-card-admin.js | 39 +++- web/ic-card-order.html | 12 -- web/ic-card-order.js | 8 +- web/ic-card-search.html | 61 +----- web/ic-card-search.js | 33 +++- web/index.html | 131 +++++------- web/index.js | 97 ++++++--- web/login.html | 4 +- web/style.css | 43 +++- web/ticket-order.html | 80 +------- web/ticket-order.js | 225 ++------------------- web/ticket-route.html | 16 +- web/ticket-route.js | 42 ++-- web/ticket-search.html | 20 +- web/ticket-search.js | 39 ++-- 19 files changed, 483 insertions(+), 793 deletions(-) diff --git a/server/services/svg-generator.js b/server/services/svg-generator.js index b896f35..5ae12f4 100644 --- a/server/services/svg-generator.js +++ b/server/services/svg-generator.js @@ -3,197 +3,187 @@ const fs = require('fs'); const escapeHTML = (s) => String(s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); function generate(stations, lines, fares, nameByCode, transfers) { - const enNameByCode = (() => { - try { - const map = {}; for (const s of stations) { map[String(s.code || '')] = s.en_name || s.enName || ''; } - return map; - } catch (_) { return {}; } - })(); - - // Build linesForStation for coloring (still needed for visualization) - const linesForStation = {}; - (lines || []).forEach(li => { - const id = li.id || ''; - const name = li.name || li.cn_name || ''; - const color = li.color || '#93a2b7'; - const arr = (Array.isArray(li.stations) ? li.stations : (Array.isArray(li.stops) ? li.stops : [])).map(x => String(x)); - for (const code of arr) { - if (!linesForStation[code]) linesForStation[code] = []; - linesForStation[code].push({ id, name, color, stations: arr }); - } - }); + const enNameByCode = {}; + stations.forEach(s => { enNameByCode[String(s.code || '')] = s.en_name || s.enName || ''; }); - const groupsMap = {}; - // Optional: We can still use groups for coloring logic, but NOT for graph edges if we want strict mode. - // But let's keep groupsMap for color logic (lines 30-46) as it helps determine if lines connect visually. - stations.forEach(s => { - const k = String(s.name || s.cn_name || s.station_name || s.en_name || '').trim(); - if (!k) return; (groupsMap[k] ||= []).push(String(s.code || '')); - }); - - const groupsForColors = Object.values(groupsMap).filter(arr => arr.length > 1); - const colorsForRoute = (a, b) => { - const la = linesForStation[a] || []; - const lb = linesForStation[b] || []; - const same = la.find(x => lb.some(y => y.id === x.id)); - if (same) return [same.color || '#93a2b7']; - // Logic to find connecting colors via transfer groups - for (const g of groupsForColors) { - const startLine = la.find(li => (li.stations || []).some(code => g.includes(code))); - const endLine = lb.find(li => (li.stations || []).some(code => g.includes(code))); - if (startLine && endLine) { - const c1 = startLine.color || '#93a2b7'; - const c2 = endLine.color || '#93a2b7'; - return (startLine.id === endLine.id) ? [c1] : [c1, c2]; - } - } - return []; - }; + // 1. Build Graph + const validStationCodes = new Set(stations.map(s => String(s.code || ''))); + const adj = new Map(); + const addNode = (c) => { if (!adj.has(c)) adj.set(c, new Map()); }; + const addEdge = (u, v, w) => { addNode(u); addNode(v); adj.get(u).set(v, w); adj.get(v).set(u, w); }; - const buildGraph = () => { - const adj = new Map(); - const addNode = (c) => { if (!adj.has(c)) adj.set(c, new Map()); }; - const addEdge = (u, v, w) => { addNode(u); addNode(v); adj.get(u).set(v, w); adj.get(v).set(u, w); }; - - // Create a set of valid station codes for fast lookup - const validStationCodes = new Set(stations.map(s => String(s.code || ''))); + stations.forEach(s => { const c = String(s.code || ''); if (c) addNode(c); }); - // 1. Edges from explicit FARES only - (fares || []).forEach(f => { - const a = String(f.from || ''), b = String(f.to || ''); - if (!a || !b) return; - if (!validStationCodes.has(a) || !validStationCodes.has(b)) return; - + const edgeCosts = new Map(); + (fares || []).forEach(f => { + const a = String(f.from || ''), b = String(f.to || ''); + if (a && b && validStationCodes.has(a) && validStationCodes.has(b)) { const w = Number(f.cost_regular ?? f.cost) || 0; addEdge(a, b, w); + edgeCosts.set(`${a}|${b}`, w); + edgeCosts.set(`${b}|${a}`, w); + } + }); + + const transferSet = new Set(); + if (Array.isArray(transfers)) { + transfers.forEach(p => { + const u = String(p[0] || ''), v = String(p[1] || ''); + if (u && v && validStationCodes.has(u) && validStationCodes.has(v)) { + addEdge(u, v, 0); + transferSet.add(`${u}|${v}`); + transferSet.add(`${v}|${u}`); + } }); + } - // 2. Edges from TRANSFERS (0 cost) - if (Array.isArray(transfers)) { - transfers.forEach(p => { - const a = String(p[1] || ''), b = String(p[2] || ''); // startup.lua used index 1,2? No, lua is 1-based. JS is 0-based. - // Wait, config transfers is [[a,b], [c,d]]. - // Let's check how it's stored. usually JSON array of arrays. - // In startup.lua: p[1], p[2]. - // In JS: p[0], p[1]. - // But let's check standard. - // If I look at startup.lua: `local a, b = tostring(p[1] or ''), tostring(p[2] or '')` (Lua 1-based index) - // So in JSON it is `["A", "B"]`. - // In JS `p[0]` is "A", `p[1]` is "B". - // Let's be safe and check both or iterate. - - const u = String(p[0] || ''); - const v = String(p[1] || ''); - - if (u && v && validStationCodes.has(u) && validStationCodes.has(v)) { - addEdge(u, v, 0); - } - }); - } + // 2. Force Directed Layout + const nodes = Array.from(validStationCodes).map(code => ({ + code, + x: Math.random() * 800, + y: Math.random() * 600, + vx: 0, vy: 0, + name: nameByCode[code] || code + })); + const nodeMap = new Map(nodes.map(n => [n.code, n])); - // REMOVED: Edges from Lines (implicit 0 cost) - // REMOVED: Edges from Groups (implicit 0 cost) - unless we want to keep it as fallback? - // User said "Strictly use database data". If transfers are in DB, we use them. - // If user relies on name grouping but didn't put it in DB transfers, they might be unhappy. - // But "Strictly use database data" implies we shouldn't automagically connect things. - // I will remove the auto-grouping for graph edges to be consistent with startup.lua changes. - - stations.forEach(s => { const c = String(s.code || ''); if (c) addNode(c); }); - return adj; - }; - const adj = buildGraph(); - const codes = Array.from(adj.keys()).sort((a, b) => a.localeCompare(b)); - - const neighborsOf = (u) => adj.get(u) || new Map(); - const dijkstra = (src) => { - const dist = {}; const visited = new Set(); - for (const n of adj.keys()) dist[n] = Infinity; dist[src] = 0; - while (true) { - let u = null; let best = Infinity; - for (const n of adj.keys()) { if (!visited.has(n) && dist[n] < best) { best = dist[n]; u = n; } } - if (u === null) break; visited.add(u); - for (const [v, w] of neighborsOf(u)) { - const nd = dist[u] + Number(w || 0); - if (nd < dist[v]) dist[v] = nd; + // Add line connections to edges for layout purposes + const layoutEdges = []; + (lines || []).forEach(li => { + const arr = (Array.isArray(li.stations) ? li.stations : (Array.isArray(li.stops) ? li.stops : [])).map(x => String(x)); + for (let i = 0; i < arr.length - 1; i++) { + if (nodeMap.has(arr[i]) && nodeMap.has(arr[i+1])) { + layoutEdges.push({ source: nodeMap.get(arr[i]), target: nodeMap.get(arr[i+1]), type: 'line' }); } } - return dist; - }; - const distAll = {}; codes.forEach(c => { distAll[c] = dijkstra(c); }); + }); - let maxFare = 1; - for (const r of codes) { - for (const c of codes) { if (r === c) continue; const v = Number(distAll[r][c] || 0); if (v > maxFare) maxFare = v; } + for (const [u, edges] of adj.entries()) { + for (const [v, w] of edges.entries()) { + if (u < v && nodeMap.has(u) && nodeMap.has(v)) { + layoutEdges.push({ source: nodeMap.get(u), target: nodeMap.get(v), type: 'fare' }); + } + } } - const margin = 8; - const cellW = 56; - const cellH = 40; - const estimateWidth = (text, size) => Math.ceil(String(text || '').length * (size <= 10 ? 7 : 10)); - const maxCnW = Math.max(1, ...codes.map(c => estimateWidth(nameByCode[c] || c, 14))); - const maxEnW = Math.max(1, ...codes.map(c => estimateWidth(enNameByCode[c] || '', 10))); - const maxCodeW = Math.max(1, ...codes.map(c => estimateWidth(c, 10))); - const leftW = Math.max(140, 24 + Math.max(maxCnW, maxEnW, maxCodeW)); - const topH = 64; - const n = codes.length; - const width = margin + leftW + n * cellW + margin; - const height = margin + topH + n * cellH + margin; - let svg = ``; - // Column headers - for (let j = 0; j < n; j++) { - const code = codes[j]; const name = nameByCode[code] || code; const en = enNameByCode[code] || ''; - const x = margin + leftW + j * cellW + cellW / 2; const y = margin + 20; - svg += `${escapeHTML(name)}`; - if (en) svg += `${escapeHTML(en)}`; - svg += `${escapeHTML(code)}`; + const iterations = 300; + const k = Math.sqrt((1200 * 800) / nodes.length) || 100; + for (let i = 0; i < iterations; i++) { + const t = 1 - i / iterations; + + // Repulsion + for (let a = 0; a < nodes.length; a++) { + for (let b = a + 1; b < nodes.length; b++) { + const dx = nodes[a].x - nodes[b].x; + const dy = nodes[a].y - nodes[b].y; + let dist = Math.sqrt(dx * dx + dy * dy); + if (dist === 0) { dist = 0.01; } + if (dist < k * 2) { + const force = (k * k) / dist; + const fx = (dx / dist) * force; + const fy = (dy / dist) * force; + nodes[a].vx += fx; nodes[a].vy += fy; + nodes[b].vx -= fx; nodes[b].vy -= fy; + } + } + } + + // Attraction + for (const e of layoutEdges) { + const dx = e.target.x - e.source.x; + const dy = e.target.y - e.source.y; + let dist = Math.sqrt(dx * dx + dy * dy); + if (dist === 0) dist = 0.01; + const idealDist = e.type === 'line' ? k * 0.8 : k * 1.5; + const force = (dist * dist) / idealDist; + const fx = (dx / dist) * force; + const fy = (dy / dist) * force; + e.source.vx += fx; e.source.vy += fy; + e.target.vx -= fx; e.target.vy -= fy; + } + + // Gravity + for (const n of nodes) { + const dx = 600 - n.x; + const dy = 400 - n.y; + n.vx += dx * 0.05; + n.vy += dy * 0.05; + } + + for (const n of nodes) { + n.x += n.vx * t * 0.1; + n.y += n.vy * t * 0.1; + n.vx *= 0.8; + n.vy *= 0.8; + } } - for (let i = 0; i < n; i++) { - const code = codes[i]; const name = nameByCode[code] || code; const en = enNameByCode[code] || ''; - const xText = margin + 6; const baseY = margin + topH + i * cellH + 22; - svg += `${escapeHTML(name)}`; - if (en) svg += `${escapeHTML(en)}`; - svg += `${escapeHTML(code)}`; - for (let j = 0; j < n; j++) { - const x0 = margin + leftW + j * cellW; const y0 = margin + topH + i * cellH; - svg += ``; - if (i === j) { - svg += `-`; - } else { - const val = Math.round(Number(distAll[code][codes[j]] || 0)); - const intensity = maxFare > 0 ? Math.min(1, val / maxFare) : 0; + // Normalize coordinates + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + nodes.forEach(n => { + minX = Math.min(minX, n.x); minY = Math.min(minY, n.y); + maxX = Math.max(maxX, n.x); maxY = Math.max(maxY, n.y); + }); + + const margin = 100; + nodes.forEach(n => { + n.x = n.x - minX + margin; + n.y = n.y - minY + margin; + }); + + const width = maxX - minX + margin * 2; + const height = maxY - minY + margin * 2; + + let svg = ``; + + // Draw lines + (lines || []).forEach(li => { + const arr = (Array.isArray(li.stations) ? li.stations : (Array.isArray(li.stops) ? li.stops : [])).map(x => String(x)); + const color = li.color || '#93a2b7'; + if (arr.length < 2) return; + let d = ''; + for (let i = 0; i < arr.length; i++) { + const n = nodeMap.get(arr[i]); + if (n) { + d += (i === 0 ? 'M' : 'L') + ` ${n.x} ${n.y}`; + } + } + if (d) { + svg += ``; + } + }); + + // Draw edges (fares and transfers) + const drawnEdges = new Set(); + for (const [u, edges] of adj.entries()) { + for (const [v, w] of edges.entries()) { + if (u < v && nodeMap.has(u) && nodeMap.has(v)) { + const nu = nodeMap.get(u), nv = nodeMap.get(v); + const isTransfer = transferSet.has(`${u}|${v}`); - // Get route colors - const routeColors = colorsForRoute(code, codes[j]); - - // Draw background strips for multiple lines - if (routeColors.length > 0) { - const stripW = cellW / routeColors.length; - for (let k = 0; k < routeColors.length; k++) { - const c = routeColors[k] || '#93a2b7'; - const stripX = x0 + k * stripW; - // Use fill-opacity for robustness against any color format - svg += ``; - } - } else { - svg += ``; + if (isTransfer) { + svg += ``; } - // Add cell border - svg += ``; - - svg += `${escapeHTML(String(val))}`; - - // Show small color indicators at top left - const dots = routeColors.slice(0, 4); - for (let k = 0; k < dots.length; k++) { - const cx = x0 + 8 + k * 10; const cy = y0 + 10; - const dotColor = dots[k] || '#93a2b7'; - svg += ``; + if (w > 0) { + const cx = (nu.x + nv.x) / 2; + const cy = (nu.y + nv.y) / 2; + svg += ``; + svg += `${w}`; } } } } + + // Draw nodes + nodes.forEach(n => { + svg += ``; + svg += ``; + svg += `${escapeHTML(n.name)}`; + svg += `${escapeHTML(n.code)}`; + svg += ``; + }); + svg += ``; return svg; } diff --git a/web/ai-assistant.js b/web/ai-assistant.js index a084617..33c492a 100644 --- a/web/ai-assistant.js +++ b/web/ai-assistant.js @@ -250,6 +250,12 @@ box-shadow: 0 20px 38px rgba(36, 74, 50, 0.14); border-color: #cadecd; } + .tm-ai-toggle.is-folded .tm-ai-toggle-copy { + display: none; + } + .tm-ai-toggle.is-folded .tm-ai-fold-btn i { + transform: rotate(180deg); + } .tm-ai-toggle-badge { width: 28px; height: 28px; @@ -649,6 +655,9 @@ 票务客服台 咨询订票、票号与凭证 + + +
@@ -872,6 +881,13 @@ } }; + const foldBtn = root.querySelector('.tm-ai-fold-btn'); + + foldBtn.addEventListener('click', (e) => { + e.stopPropagation(); + toggleButton.classList.toggle('is-folded'); + }); + toggleButton.addEventListener('click', () => { if (root.classList.contains('is-open')) closePanel(); else openPanel(); diff --git a/web/home.html b/web/home.html index 91d336d..5a7b6da 100644 --- a/web/home.html +++ b/web/home.html @@ -1,4 +1,4 @@ - + @@ -44,67 +44,6 @@
-
-
- FSE PUBLIC TICKET PORTAL -

FSE 铁路运输票务系统

-

- ⌈票行千里,智通未来⌋ - 购票、查询、办卡 -

- -
-
- 线上预定 - 生成凭证码并在站内兑票 -
-
- 电子票查询 - 支持票号、区间和日期检索 -
-
- 线路资源 - 查看线路图与票价图 -
-
-
- - -
- -
-
- - 旅客提醒 -
-

线上预定生成的凭证码与IC卡订单号请及时保存;如需补查订单、车票状态或IC卡信息,可在对应查询页继续检索。

-
- -
- SERVICE ENTRY -

从首页直接进入票务服务

-

线上预定 / 车票查询 / 凭证核验 / IC卡

-
-
@@ -141,16 +80,6 @@ 输入卡号或订单号,查看卡状态、余额与最近记录。 -
- POPULAR -

常用操作

-
    -
  • 先订票,再截图保存凭证码。
  • -
  • 线上购卡后请同时保存卡号与订单号。
  • -
  • 如票据状态不明,优先进入查询页核验。
  • -
  • 查看线路资源时可对照票价图与线路图。
  • -
-
diff --git a/web/ic-card-admin.html b/web/ic-card-admin.html index 30340f8..cb3eb60 100644 --- a/web/ic-card-admin.html +++ b/web/ic-card-admin.html @@ -88,15 +88,8 @@
IC MANAGEMENT

IC 卡发行与状态管理

-

延续公共页面的白底门户风格,让发卡、储值与事件记录在同一块工作区域中保持清晰易读。

-
-
-
- - 业务范围 -
-

当前页面用于处理 IC 卡创建、余额管理、持卡人资料和事件流查看,适合作为后台卡务管理的单独入口。

+
IC 卡总数
diff --git a/web/ic-card-admin.js b/web/ic-card-admin.js index d188242..6c87391 100644 --- a/web/ic-card-admin.js +++ b/web/ic-card-admin.js @@ -188,19 +188,50 @@
`; + const formatIcCardEventUnified = (ev) => { + const type = String(ev?.type || '').toLowerCase(); + const action = String(ev?.action || '').toLowerCase(); + const deviceId = ev?.device || ev?.device_id || ev?.["设备编号"] || ev?.["机器ID"] || '---'; + const timeStr = formatTime(ev?.ts); + const balance = ev?.balance ?? ev?.["余额"] ?? 0; + + if (type === 'create' || type === 'order_created' || type === 'activated') { + const cardType = ev?.detail?.card_type || ev?.card_type || ev?.["类型"] || '储值卡'; + return `[${cardType}];余额: ${balance};时间: ${timeStr}`; + } + if (type === 'topup' || type === 'refill' || action === 'topup') { + const amount = ev?.amount ?? ev?.["充值数额"] ?? 0; + return `机器ID: ${deviceId};充值数额: ${amount};余额: ${balance};时间: ${timeStr}`; + } + if (action === 'entry' || type === 'entry') { + const code = ev?.station_code || ev?.["站点编号"] || '---'; + const name = ev?.station_name || ev?.["站点中文"] || ev?.["发生站"] || '---'; + return `机器ID: ${deviceId};站点编号: ${code};站点中文: ${name};余额: ${balance};时间: ${timeStr}`; + } + if (action === 'exit' || type === 'exit') { + const code = ev?.station_code || ev?.["站点编号"] || '---'; + const name = ev?.station_name || ev?.["站点中文"] || ev?.["发生站"] || '---'; + return `机器ID: ${deviceId};站点编号: ${code};站点中文: ${name};余额: ${balance};时间: ${timeStr}`; + } + + // fallback + return `时间: ${timeStr};${typeof ev?.detail === 'string' ? ev.detail : JSON.stringify(ev?.detail || {}, null, 2)}`; + }; + const events = state.selectedEvents; eventEl.innerHTML = events.length ? events.map((event) => `
- ${escapeHtml(eventTitle(event))} - ${escapeHtml(formatTime(event.ts))} + ${escapeHtml(eventTitle(event))} +
+
+ ${escapeHtml(formatIcCardEventUnified(event))}
-
${escapeHtml(typeof event.detail === 'string' ? event.detail : JSON.stringify(event.detail || {}, null, 2))}
- `).join('') : '
暂无事件记录。
'; + `).join('') : '
暂无事件记录
'; }; const validateHolderName = (value) => { diff --git a/web/ic-card-order.html b/web/ic-card-order.html index 9ac55e3..5f91488 100644 --- a/web/ic-card-order.html +++ b/web/ic-card-order.html @@ -44,18 +44,6 @@
-
- IC CARD ORDER -

在线购买 IC 卡并生成领卡凭证

-

提交持卡人姓名并选择首次充值金额后,系统会即时生成卡号和 5 位凭证码,旅客可凭凭证码到站内办理领卡。

-
-
-
- - 购卡提醒 -
-

线上购卡创建后默认状态为“待领卡”;持卡人姓名仅支持英文与常用符号。如需补查凭证或卡片状态,可前往 IC 卡查询页输入卡号或凭证码检索。

-
diff --git a/web/ic-card-order.js b/web/ic-card-order.js index 8a86479..1e641d4 100644 --- a/web/ic-card-order.js +++ b/web/ic-card-order.js @@ -69,15 +69,15 @@ ? state.rechargeOptions : [5, 10, 15, 20]; rechargeOptionListEl.innerHTML = options.map((amount) => ` - +
`).join('') + ` - + `; if (customRechargeBoxEl) { customRechargeBoxEl.classList.toggle('is-active', !!state.customMode); diff --git a/web/ic-card-search.html b/web/ic-card-search.html index 5561875..d599dde 100644 --- a/web/ic-card-search.html +++ b/web/ic-card-search.html @@ -49,23 +49,7 @@

按卡号或凭证码查询 IC 卡状态

支持检索 IC 卡当前状态、余额和最近操作记录;输入线上购卡生成的凭证码,也能反查对应卡片。

-
-
- 检索方式 - 卡号 / 凭证码 -

支持凭证码反查对应卡片,也支持直接输入卡号查看当前状态与余额。

-
-
- 结果浏览 - 列表与详情并排 -

左侧浏览卡片列表,右侧查看卡片详情、状态提示和最近操作记录。

-
-
- 移动端 - 触达区更大 -

手机端自动切换为单列阅读,卡片点击区域与按钮尺寸都更适合触屏操作。

-
-
+

检索条件

@@ -116,48 +100,7 @@
-
-
-
-

状态说明

- Card Status -
-
-
- 正常 - 卡片已启用,可在检票设备直接刷卡进出站。 -
-
- 待领卡 - 请持购卡凭证码前往站内售票机完成领卡后再使用。 -
-
- 不可用 - 卡片已停用、挂失或退款,建议联系站务进行处理。 -
-
-
-
-
-

查询提示

- Search Guide -
-
-
- 留空查询 - 不输入关键字时,会按建卡时间倒序展示全部 IC 卡记录。 -
-
- 凭证反查 - 购卡后若未领卡,可直接使用凭证码快速定位对应卡片。 -
-
- 手机查看 - 移动端会把结果列表、详情和事件记录按顺序折叠为单列阅读。 -
-
-
-
+