feat(web/route): 适配移动端响应式布局 + 统一站点拖拽逻辑

1. 线路规划页面(/route)移动端适配:
   - 新增 980px / 768px / 480px 三档响应式CSS
   - 侧边栏改为抽屉式叠加层,主容器垂直流式布局
   - 顶部导航、工具栏、编辑器堆叠式排列
   - SVG线路容器开启横向惯性滚动,最小宽度720px
   - 票价地图、表格、模态框均针对小屏优化
   - 按钮/输入框统一最小高度44px,满足可访问性

2. 重构站点拖拽逻辑(修复电脑端拖拽失效):
   - 原实现:mousemove绑定单站点<g>,鼠标离开即丢失拖拽
   - 新实现:mousedown/touchstart仅记录起点,
     window级别监听 mousemove/mouseup/touchmove/touchend
   - 通过 SVG createSVGPoint + getScreenCTM().inverse()
     统一将屏幕坐标转换为SVG逻辑坐标再换算站点索引
   - 共用 swapStationByIndex() / saveDragResult() 核心函数

3. 静态资源版本号升级: style.css v16, ticket-route.js v5
This commit is contained in:
2026-08-07 23:01:33 +08:00
parent b7cdcc570a
commit 1046aa8dda
4 changed files with 1398 additions and 936 deletions
+231 -119
View File
@@ -53,33 +53,44 @@
<p class="jr-admin-sidebar-copy">集中处理票务、线路、资源和运营日志。</p> <p class="jr-admin-sidebar-copy">集中处理票务、线路、资源和运营日志。</p>
</div> </div>
<div class="nav"> <div class="nav">
<a href="https://ticket.fse-media.group" id="homeLink" class="nav-item" style="text-decoration: none;"> <a href="https://ticket.fse-media.group" id="homeLink" class="nav-item"
style="text-decoration: none;">
<span class="nav-icon"><i class="fas fa-home"></i></span> 返回首页 <span class="nav-icon"><i class="fas fa-home"></i></span> 返回首页
</a> </a>
<div class="nav-item" :class="{active: currentView === 'dashboard'}" @click="currentView = 'dashboard'"> <div class="nav-item" :class="{active: currentView === 'dashboard'}"
<span class="nav-icon"><i class="fas fa-chart-pie"></i></span> 仪表盘 </div> @click="currentView = 'dashboard'">
<span class="nav-icon"><i class="fas fa-chart-pie"></i></span> 仪表盘
</div>
<div class="nav-item" :class="{active: currentView === 'management'}" <div class="nav-item" :class="{active: currentView === 'management'}"
@click="currentView = 'management'"> @click="currentView = 'management'">
<span class="nav-icon"><i class="fas fa-network-wired"></i></span> 线路与票价 </div> <span class="nav-icon"><i class="fas fa-network-wired"></i></span> 线路与票价
<div class="nav-item" :class="{active: currentView === 'faremap'}" @click="currentView = 'faremap'"> </div>
<div class="nav-item" :class="{active: currentView === 'faremap'}"
@click="currentView = 'faremap'">
<span class="nav-icon"><i class="fas fa-map"></i></span> 票价地图 <span class="nav-icon"><i class="fas fa-map"></i></span> 票价地图
</div> </div>
<div class="nav-item" :class="{active: currentView === 'tickets'}" @click="currentView = 'tickets'"> <div class="nav-item" :class="{active: currentView === 'tickets'}"
@click="currentView = 'tickets'">
<span class="nav-icon"><i class="fas fa-ticket-alt"></i></span> 车票记录 <span class="nav-icon"><i class="fas fa-ticket-alt"></i></span> 车票记录
</div> </div>
<div class="nav-item" :class="{active: currentView === 'vouchers'}" @click="currentView = 'vouchers'"> <div class="nav-item" :class="{active: currentView === 'vouchers'}"
@click="currentView = 'vouchers'">
<span class="nav-icon"><i class="fas fa-receipt"></i></span> 凭证记录 <span class="nav-icon"><i class="fas fa-receipt"></i></span> 凭证记录
</div> </div>
<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'"> <div class="nav-item" :class="{active: currentView === 'issues'}"
@click="currentView = 'issues'">
<span class="nav-icon"><i class="fas fa-exclamation-triangle"></i></span> 问题汇总 <span class="nav-icon"><i class="fas fa-exclamation-triangle"></i></span> 问题汇总
</div> </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>
<div class="nav-item" :class="{active: currentView === 'settings'}" @click="currentView = 'settings'"> <div class="nav-item" :class="{active: currentView === 'settings'}"
@click="currentView = 'settings'">
<span class="nav-icon"><i class="fas fa-cog"></i></span> 系统设置 <span class="nav-icon"><i class="fas fa-cog"></i></span> 系统设置
</div> </div>
<div class="nav-item" :class="{active: currentView === 'logs'}" @click="currentView = 'logs'"> <div class="nav-item" :class="{active: currentView === 'logs'}" @click="currentView = 'logs'">
@@ -93,7 +104,8 @@
<div class="header"> <div class="header">
<div class="jr-admin-header-copy"> <div class="jr-admin-header-copy">
<div class="flex" style="gap: 12px;"> <div class="flex" style="gap: 12px;">
<button class="icon-btn mobile-only" @click="sidebarOpen = !sidebarOpen" title="菜单"><i class="fas fa-bars"></i></button> <button class="icon-btn mobile-only" @click="sidebarOpen = !sidebarOpen" title="菜单"><i
class="fas fa-bars"></i></button>
<div> <div>
<span class="jr-kicker">OPERATIONS CONSOLE</span> <span class="jr-kicker">OPERATIONS CONSOLE</span>
<h3 style="margin: 0;">{{ viewTitle }}</h3> <h3 style="margin: 0;">{{ viewTitle }}</h3>
@@ -146,7 +158,8 @@
style="height: 100%; display: flex; flex-direction: column; margin-bottom: 0;"> style="height: 100%; display: flex; flex-direction: column; margin-bottom: 0;">
<div class="flex between mb-4"> <div class="flex between mb-4">
<h4>线路列表</h4> <h4>线路列表</h4>
<button @click="showAddLine = true" title="新建线路"><i class="fas fa-plus"></i></button> <button @click="showAddLine = true" title="新建线路"><i
class="fas fa-plus"></i></button>
</div> </div>
<!--添加车站--> <!--添加车站-->
@@ -159,7 +172,8 @@
<input v-model="newLine.en_name" placeholder="英文名称" <input v-model="newLine.en_name" placeholder="英文名称"
style="margin-bottom: 8px; width: 100%;"> style="margin-bottom: 8px; width: 100%;">
<div class="flex"> <div class="flex">
<input type="text" v-model="newLine.color" placeholder="#HEX颜色" style="flex: 1;"> <input type="text" v-model="newLine.color" placeholder="#HEX颜色"
style="flex: 1;">
<input type="color" v-model="newLine.color" title="选择颜色" <input type="color" v-model="newLine.color" title="选择颜色"
style="width: 40px; padding: 0; border: none; height: 32px;"> style="width: 40px; padding: 0; border: none; height: 32px;">
<button @click="createLine" style="padding: 0 12px;" title="确认创建线路"><i <button @click="createLine" style="padding: 0 12px;" title="确认创建线路"><i
@@ -170,14 +184,16 @@
</div> </div>
<div class="list-lines" style="flex: 1; overflow-y: auto;"> <div class="list-lines" style="flex: 1; overflow-y: auto;">
<div v-for="l in lines" :key="l.id" class="line-item" <div v-for="l in lines" :key="l.id" class="line-item"
:class="{active: selectedLine && selectedLine.id === l.id}" @click="selectLine(l)"> :class="{active: selectedLine && selectedLine.id === l.id}"
@click="selectLine(l)">
<div class="line-color-dot" :style="{background: l.color}"></div> <div class="line-color-dot" :style="{background: l.color}"></div>
<div class="line-info"> <div class="line-info">
<div class="line-name">{{ l.name || l.id }}</div> <div class="line-name">{{ l.name || l.id }}</div>
<div class="line-meta">{{ (l.stations || []).length }} 站</div> <div class="line-meta">{{ (l.stations || []).length }} 站</div>
</div> </div>
<div class="line-actions" v-if="selectedLine && selectedLine.id === l.id"> <div class="line-actions" v-if="selectedLine && selectedLine.id === l.id">
<button class="danger sm" @click.stop="deleteLine(l.id)"><i class="fas fa-trash"></i></button> <button class="danger sm" @click.stop="deleteLine(l.id)"><i
class="fas fa-trash"></i></button>
</div> </div>
</div> </div>
</div> </div>
@@ -189,13 +205,15 @@
<div class="flex between"> <div class="flex between">
<div v-if="selectedLine"> <div v-if="selectedLine">
<div class="flex"> <div class="flex">
<h3 :style="{color: selectedLine.color}">{{ selectedLine.name || selectedLine.id <h3 :style="{color: selectedLine.color}">{{ selectedLine.name ||
selectedLine.id
}}</h3> }}</h3>
<span class="badge">{{ selectedLine.id }}</span> <span class="badge">{{ selectedLine.id }}</span>
</div> </div>
<div class="flex mt-2" style="align-items:center; gap:8px;"> <div class="flex mt-2" style="align-items:center; gap:8px;">
<label style="font-size:0.8em; color:var(--muted);">EN:</label> <label style="font-size:0.8em; color:var(--muted);">EN:</label>
<span style="font-size:0.9em;">{{ selectedLine.en_name || 'N/A' }}</span> <span style="font-size:0.9em;">{{ selectedLine.en_name || 'N/A'
}}</span>
</div> </div>
</div> </div>
<div v-else> <div v-else>
@@ -203,8 +221,10 @@
</div> </div>
<div class="flex"> <div class="flex">
<button v-if="selectedLine" @click="openLineModal" title="编辑线路"><i class="fas fa-pen"></i></button> <button v-if="selectedLine" @click="openLineModal" title="编辑线路"><i
<button @click="refreshData" title="刷新"><i class="fas fa-sync-alt"></i></button> class="fas fa-pen"></i></button>
<button @click="refreshData" title="刷新"><i
class="fas fa-sync-alt"></i></button>
</div> </div>
</div> </div>
</div> </div>
@@ -216,17 +236,20 @@
<label class="switch-label"> <label class="switch-label">
<input type="checkbox" v-model="fareMode"> <input type="checkbox" v-model="fareMode">
<span class="slider"></span> <span class="slider"></span>
<span class="label-text"><i class="fas fa-coins"></i> 票价设置/车站编辑模式</span> <span class="label-text"><i class="fas fa-coins"></i> 票价设置</span>
</label> </label>
<label class="switch-label" style="margin-left: 10px;"> <label class="switch-label" style="margin-left: 10px;">
<input type="checkbox" v-model="stationEditMode"> <input type="checkbox" v-model="stationEditMode">
<span class="slider"></span> <span class="slider"></span>
<span class="label-text"><i class="fas fa-exchange-alt"></i> 换乘设置模式</span> <span class="label-text"><i class="fas fa-exchange-alt"></i>
换乘设置模式/车站编辑模式</span>
</label> </label>
<div v-if="fareMode" class="hint-text text-warning"> <div v-if="fareMode" class="hint-text text-warning">
<i class="fas fa-info-circle"></i> 点击两个站点以设置票价 </div> <i class="fas fa-info-circle"></i> 点击两个站点以设置票价
</div>
<div v-else-if="stationEditMode" class="hint-text text-info"> <div v-else-if="stationEditMode" class="hint-text text-info">
<i class="fas fa-info-circle"></i> 点击站点以设置换乘 </div> <i class="fas fa-info-circle"></i> 点击站点以设置换乘/编辑车站
</div>
<div v-else class="hint-text text-muted"> <div v-else class="hint-text text-muted">
<i class="fas fa-info-circle"></i> 点击站点删除 <i class="fas fa-info-circle"></i> 点击站点删除
</div> </div>
@@ -235,29 +258,31 @@
<div class="flex" <div class="flex"
style="background: rgba(255,255,255,0.05); padding: 8px; border-radius: 6px;"> style="background: rgba(255,255,255,0.05); padding: 8px; border-radius: 6px;">
<div style="font-weight: bold; margin-right: 8px;">添加站点:</div> <div style="font-weight: bold; margin-right: 8px;">添加站点:</div>
<input v-model="newStation.code" placeholder="编号 (01-01)" style="width: 100px;"> <input v-model="newStation.code" placeholder="编号 (01-01)"
style="width: 100px;">
<input v-model="newStation.name" placeholder="中文名" style="width: 120px;"> <input v-model="newStation.name" placeholder="中文名" style="width: 120px;">
<input v-model="newStation.en_name" placeholder="英文名" style="width: 120px;"> <input v-model="newStation.en_name" placeholder="英文名" style="width: 120px;">
<button @click="addStationToLine" <button @click="addStationToLine"
:disabled="!newStation.code || !newStation.name"><i class="fas fa-plus"></i> :disabled="!newStation.code || !newStation.name"><i
class="fas fa-plus"></i>
添加</button> 添加</button>
<button @click="addBreakpointToLine" class="btn" style="margin-left: 8px;" title="在末尾插入一个线路断点"> <button @click="addBreakpointToLine" class="btn" style="margin-left: 8px;"
title="在末尾插入一个线路断点">
<i class="fas fa-cut"></i> 插入断点 <i class="fas fa-cut"></i> 插入断点
</button> </button>
</div> </div>
</div> </div>
<!-- 可视化线路编辑--> <!-- 可视化线路编辑-->
<div class="visual-line-container" <div class="visual-line-container" ref="visualLineViewport"
ref="visualLineViewport"
:class="{ 'is-panning': lineViewportPan.active }" :class="{ 'is-panning': lineViewportPan.active }"
@mousedown="startLineViewportPan" @mousedown="startLineViewportPan" @mousemove="moveLineViewportPan">
@mousemove="moveLineViewportPan">
<svg :width="lineEditorSvgWidth" height="200" <svg :width="lineEditorSvgWidth" height="200"
v-if="selectedLine.stations && selectedLine.stations.length > 0"> v-if="selectedLine.stations && selectedLine.stations.length > 0">
<!--站点连接线--> <!--站点连接线-->
<line x1="50" y1="100" :x2="50 + (selectedLine.stations.length - 1) * 120" y2="100" <line x1="50" y1="100" :x2="50 + (selectedLine.stations.length - 1) * 120"
:stroke="selectedLine.color" stroke-width="4" stroke-linecap="round" /> y2="100" :stroke="selectedLine.color" stroke-width="4"
stroke-linecap="round" />
<!--票价显示--> <!--票价显示-->
<g v-for="(s, i) in selectedLine.stations.slice(0, selectedLine.stations.length-1)" <g v-for="(s, i) in selectedLine.stations.slice(0, selectedLine.stations.length-1)"
@@ -269,42 +294,57 @@
<!--车站节点--> <!--车站节点-->
<g v-for="(sCode, index) in selectedLine.stations" :key="index" <g v-for="(sCode, index) in selectedLine.stations" :key="index"
@mousedown="onStationDragStart(index)" @mouseup="onStationDrop" @mousedown="onStationDragStart(index)" @mouseup="onStationDrop"
@mousemove="onStationDragOver(index)" @click="handleStationClick(sCode, index)" @mousemove="onStationDragOver(index)"
class="station-node" :class="{ @click="handleStationClick(sCode, index)" class="station-node" :class="{
'selected': isStationSelected(sCode), 'selected': isStationSelected(sCode),
'fare-source': fareSelection[0] === sCode, 'fare-source': fareSelection[0] === sCode,
'fare-target': fareSelection[1] === sCode 'fare-target': fareSelection[1] === sCode
}"> }">
<template v-if="sCode === '-'"> <template v-if="sCode === '-'">
<rect :x="50 + index * 120 - 10" :y="90" width="20" height="20" fill="var(--bg)" stroke="var(--danger)" stroke-dasharray="2,2" /> <rect :x="50 + index * 120 - 10" :y="90" width="20" height="20"
<text :x="50 + index * 120" y="105" text-anchor="middle" fill="var(--danger)" font-size="12" style="pointer-events: none;">✂</text> fill="var(--bg)" stroke="var(--danger)"
<text :x="50 + index * 120" y="135" text-anchor="middle" fill="var(--danger)" font-size="10" style="pointer-events: none;">断点</text> stroke-dasharray="2,2" />
<text :x="50 + index * 120" y="105" text-anchor="middle"
fill="var(--danger)" font-size="12"
style="pointer-events: none;">✂</text>
<text :x="50 + index * 120" y="135" text-anchor="middle"
fill="var(--danger)" font-size="10"
style="pointer-events: none;">断点</text>
</template> </template>
<template v-else> <template v-else>
<!--车站节点图形--> <!--车站节点图形-->
<circle :cx="50 + index * 120" cy="100" r="14" fill="var(--bg)" <circle :cx="50 + index * 120" cy="100" r="14" fill="var(--bg)"
:stroke="selectedLine.color" stroke-width="3" /> :stroke="selectedLine.color" stroke-width="3" />
<circle v-if="isStationSelected(sCode)" :cx="50 + index * 120" cy="100" r="8" <circle v-if="isStationSelected(sCode)" :cx="50 + index * 120"
:fill="selectedLine.color" /> cy="100" r="8" :fill="selectedLine.color" />
<!--节点标签--> <!--节点标签-->
<text :x="50 + index * 120" y="70" text-anchor="middle" fill="var(--text)" <text :x="50 + index * 120" y="70" text-anchor="middle"
font-weight="bold" font-size="12" style="pointer-events: none;">{{ fill="var(--text)" font-weight="bold" font-size="12"
style="pointer-events: none;">{{
getStationName(sCode) }}</text> getStationName(sCode) }}</text>
<text :x="50 + index * 120" y="135" text-anchor="middle" fill="var(--muted)" <text :x="50 + index * 120" y="135" text-anchor="middle"
font-size="10" style="pointer-events: none;">{{ sCode }}</text> fill="var(--muted)" font-size="10"
style="pointer-events: none;">{{ sCode }}</text>
<g v-if="getTransferLineBadges(sCode).length > 0"> <g v-if="getTransferLineBadges(sCode).length > 0">
<g v-for="(li, liIdx) in getTransferLineBadges(sCode)" :key="`${sCode}-xfer-${li.id}`"> <g v-for="(li, liIdx) in getTransferLineBadges(sCode)"
<circle :cx="(50 + index * 120) + (liIdx - (getTransferLineBadges(sCode).length - 1) / 2) * 14" cy="150" r="5" :key="`${sCode}-xfer-${li.id}`">
:fill="li.color" stroke="#ffffff" stroke-width="1" /> <circle
<text :x="(50 + index * 120) + (liIdx - (getTransferLineBadges(sCode).length - 1) / 2) * 14" y="165" text-anchor="middle" :cx="(50 + index * 120) + (liIdx - (getTransferLineBadges(sCode).length - 1) / 2) * 14"
fill="var(--muted)" font-size="7" style="pointer-events: none;">{{ li.id }}</text> cy="150" r="5" :fill="li.color" stroke="#ffffff"
stroke-width="1" />
<text
:x="(50 + index * 120) + (liIdx - (getTransferLineBadges(sCode).length - 1) / 2) * 14"
y="165" text-anchor="middle" fill="var(--muted)"
font-size="7" style="pointer-events: none;">{{ li.id
}}</text>
</g> </g>
</g> </g>
<!--删除--> <!--删除-->
<title>{{ getStationName(sCode) }} ({{ sCode }}){{ getTransferTitleSuffix(sCode) }}</title> <title>{{ getStationName(sCode) }} ({{ sCode }}){{
getTransferTitleSuffix(sCode) }}</title>
</template> </template>
</g> </g>
</svg> </svg>
@@ -330,11 +370,13 @@
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label>常规票价</label> <label>常规票价</label>
<input v-model.number="currentFare.cost_regular" type="number" class="w-100"> <input v-model.number="currentFare.cost_regular" type="number"
class="w-100">
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label>特急票价</label> <label>特急票价</label>
<input v-model.number="currentFare.cost_express" type="number" class="w-100"> <input v-model.number="currentFare.cost_express" type="number"
class="w-100">
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="danger" @click="deleteCurrentFare" <button class="danger" @click="deleteCurrentFare"
@@ -367,27 +409,34 @@
<span class="label-text">设置跨名/强制换乘</span> <span class="label-text">设置跨名/强制换乘</span>
</label> </label>
<p class="text-muted" style="font-size: 0.85em; margin-top: 6px;"> <p class="text-muted" style="font-size: 0.85em; margin-top: 6px;">
<i class="fas fa-magic text-primary"></i> 提示:系统会自动将中文名或英文名相同的站点连接为换乘站,无需手动设置。 <i class="fas fa-magic text-primary"></i>
提示:系统会自动将中文名或英文名相同的站点连接为换乘站,无需手动设置。
</p> </p>
</div> </div>
<div class="mb-4" v-if="stationForm.transfer_enabled"> <div class="mb-4" v-if="stationForm.transfer_enabled">
<label>可换乘到的站点</label> <label>可换乘到的站点</label>
<select v-model="stationForm.transfer_to" multiple class="w-100" style="height: 180px;"> <select v-model="stationForm.transfer_to" multiple class="w-100"
<optgroup label="同名站点 (已自动换乘)" v-if="transferTargets.sameName.length > 0"> style="height: 180px;">
<option v-for="t in transferTargets.sameName" :key="t.code" :value="t.code"> <optgroup label="同名站点 (已自动换乘)"
v-if="transferTargets.sameName.length > 0">
<option v-for="t in transferTargets.sameName" :key="t.code"
:value="t.code">
{{ t.name }} ({{ t.en_name }}) - {{ t.code }} {{ t.name }} ({{ t.en_name }}) - {{ t.code }}
</option> </option>
</optgroup> </optgroup>
<optgroup label="其他站点"> <optgroup label="其他站点">
<option v-for="t in transferTargets.others" :key="t.code" :value="t.code"> <option v-for="t in transferTargets.others" :key="t.code"
:value="t.code">
{{ t.name }} ({{ t.en_name }}) - {{ t.code }} {{ t.name }} ({{ t.en_name }}) - {{ t.code }}
</option> </option>
</optgroup> </optgroup>
</select> </select>
<p class="text-muted" style="font-size: 0.8em; margin-top: 4px;">按住 Ctrl / Cmd 键多选</p> <p class="text-muted" style="font-size: 0.8em; margin-top: 4px;">按住 Ctrl /
Cmd 键多选</p>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="danger" @click="deleteStation(stationFormOriginalCode)">删除</button> <button class="danger"
@click="deleteStation(stationFormOriginalCode)">删除</button>
<button @click="saveStationSettings">保存</button> <button @click="saveStationSettings">保存</button>
<button class="danger" @click="closeStationModal">取消</button> <button class="danger" @click="closeStationModal">取消</button>
</div> </div>
@@ -412,7 +461,8 @@
<div class="mb-4"> <div class="mb-4">
<label>颜色</label> <label>颜色</label>
<div class="flex" style="gap:8px;"> <div class="flex" style="gap:8px;">
<input type="text" v-model="lineForm.color" class="w-100" placeholder="#3366cc"> <input type="text" v-model="lineForm.color" class="w-100"
placeholder="#3366cc">
<input type="color" v-model="lineForm.color" title="选择颜色" <input type="color" v-model="lineForm.color" title="选择颜色"
style="width: 48px; padding: 0; border: none; height: 32px;"> style="width: 48px; padding: 0; border: none; height: 32px;">
</div> </div>
@@ -435,14 +485,17 @@
<button @click="loadFareMap" title="刷新"><i class="fas fa-sync-alt"></i></button> <button @click="loadFareMap" title="刷新"><i class="fas fa-sync-alt"></i></button>
<button @click="zoomFareMapOut" title="缩小"><i class="fas fa-minus"></i></button> <button @click="zoomFareMapOut" title="缩小"><i class="fas fa-minus"></i></button>
<button @click="zoomFareMapIn" title="放大"><i class="fas fa-plus"></i></button> <button @click="zoomFareMapIn" title="放大"><i class="fas fa-plus"></i></button>
<button @click="zoomFareMapReset" title="重置"><i class="fas fa-crosshairs"></i></button> <button @click="zoomFareMapReset" title="重置"><i
<button @click="exportFareMap" title="导出图像"><i class="fas fa-download"></i></button> class="fas fa-crosshairs"></i></button>
<button @click="exportFareMap" title="导出图像"><i
class="fas fa-download"></i></button>
</div> </div>
</div> </div>
<div v-if="fareMapLoading" class="loading">加载中...</div> <div v-if="fareMapLoading" class="loading">加载中...</div>
<div v-else-if="fareMapError" class="loading">{{ fareMapError }}</div> <div v-else-if="fareMapError" class="loading">{{ fareMapError }}</div>
<div v-else class="faremap-viewport"> <div v-else class="faremap-viewport">
<div class="faremap-canvas" :style="{ transform: `scale(${fareMapScale})` }" v-html="fareMapSvg"></div> <div class="faremap-canvas" :style="{ transform: `scale(${fareMapScale})` }"
v-html="fareMapSvg"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -488,12 +541,14 @@
</thead> </thead>
<tbody> <tbody>
<tr v-for="o in orderList" :key="o.code"> <tr v-for="o in orderList" :key="o.code">
<td class="mono" style="font-weight:bold; font-size:1.1em;">{{ o.code }}</td> <td class="mono" style="font-weight:bold; font-size:1.1em;">{{ o.code }}
</td>
<td>{{ o.start_name }} <i class="fas fa-arrow-right text-muted"></i> {{ <td>{{ o.start_name }} <i class="fas fa-arrow-right text-muted"></i> {{
o.terminal_name }}</td> o.terminal_name }}</td>
<td>{{ formatTrainType(o.train_type) }}</td> <td>{{ formatTrainType(o.train_type) }}</td>
<td>{{ o.price }}</td> <td>{{ o.price }}</td>
<td><span class="badge" :class="formatTicketStatus(o.status).class">{{ formatTicketStatus(o.status).text }}</span></td> <td><span class="badge" :class="formatTicketStatus(o.status).class">{{
formatTicketStatus(o.status).text }}</span></td>
<td>{{ formatTime(o.created_ts) }}</td> <td>{{ formatTime(o.created_ts) }}</td>
<td> <td>
<div class="flex" style="gap:4px;"> <div class="flex" style="gap:4px;">
@@ -536,7 +591,8 @@
<div class="management-container ic-admin-layout"> <div class="management-container ic-admin-layout">
<div class="management-sidebar"> <div class="management-sidebar">
<div class="card jr-admin-list-card" style="margin-bottom:0; display:flex; flex-direction:column; min-height: 520px;"> <div class="card jr-admin-list-card"
style="margin-bottom:0; display:flex; flex-direction:column; min-height: 520px;">
<div class="flex between mb-4"> <div class="flex between mb-4">
<div> <div>
<h4>卡片列表</h4> <h4>卡片列表</h4>
@@ -545,27 +601,29 @@
<span class="badge">{{ icCards.length }}</span> <span class="badge">{{ icCards.length }}</span>
</div> </div>
<div class="flex mb-4" style="flex-wrap:wrap;"> <div class="flex mb-4" style="flex-wrap:wrap;">
<input v-model="icCardSearch" placeholder="搜索卡号 / 订单号 / 凭证码 / 姓名" style="flex:1;"> <input v-model="icCardSearch" placeholder="搜索卡号 / 订单号 / 凭证码 / 姓名"
style="flex:1;">
</div> </div>
<div class="list-lines jr-scroll-box" style="flex:1; min-height:320px;"> <div class="list-lines jr-scroll-box" style="flex:1; min-height:320px;">
<div v-if="!icCards.length" class="empty-state" style="padding:24px 0;"> <div v-if="!icCards.length" class="empty-state" style="padding:24px 0;">
<p>暂无 IC 卡记录。</p> <p>暂无 IC 卡记录。</p>
</div> </div>
<div <div v-for="card in icCards" :key="card.card_id"
v-for="card in icCards"
:key="card.card_id"
class="line-item ic-card-item" class="line-item ic-card-item"
:class="{ active: icSelectedId === card.card_id }" :class="{ active: icSelectedId === card.card_id }"
@click="loadIcCard(card.card_id)" @click="loadIcCard(card.card_id)">
> <div class="line-color-dot"
<div class="line-color-dot" :style="{ background: icStatusColor(card.status) }"></div> :style="{ background: icStatusColor(card.status) }"></div>
<div class="line-info"> <div class="line-info">
<div class="line-name">{{ displayIcCardId(card) }}</div> <div class="line-name">{{ displayIcCardId(card) }}</div>
<div class="line-meta">{{ card.holder_name || '未登记持卡人' }} · IC 储值卡</div> <div class="line-meta">{{ card.holder_name || '未登记持卡人' }} · IC 储值卡
<div class="line-meta">订单 {{ cardOrderCode(card) }} · 余额 {{ formatMoney(card.balance) }}</div> </div>
<div class="line-meta">订单 {{ cardOrderCode(card) }} · 余额 {{
formatMoney(card.balance) }}</div>
</div> </div>
<div class="line-actions" style="opacity:1;"> <div class="line-actions" style="opacity:1;">
<span class="badge" :class="icStatusInfo(card.status).className">{{ icStatusInfo(card.status).text }}</span> <span class="badge" :class="icStatusInfo(card.status).className">{{
icStatusInfo(card.status).text }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -579,10 +637,15 @@
<h4>卡片详情</h4> <h4>卡片详情</h4>
</div> </div>
<div class="flex" style="gap:8px;"> <div class="flex" style="gap:8px;">
<button class="btn" @click="loadIcCard(icSelectedId)" :disabled="!icSelectedCard || isViewBusy"><i class="fas fa-rotate-right"></i> 刷新</button> <button class="btn" @click="loadIcCard(icSelectedId)"
:disabled="!icSelectedCard || isViewBusy"><i
class="fas fa-rotate-right"></i> 刷新</button>
<button class="btn danger" @click="deleteIcCard" :disabled="!icSelectedCard"><i class="fas fa-trash"></i> 删除卡</button> <button class="btn danger" @click="deleteIcCard"
<button class="btn primary" @click="saveIcCard" :disabled="!icSelectedCard"><i class="fas fa-save"></i> 保存</button> :disabled="!icSelectedCard"><i class="fas fa-trash"></i>
删除卡</button>
<button class="btn primary" @click="saveIcCard"
:disabled="!icSelectedCard"><i class="fas fa-save"></i> 保存</button>
</div> </div>
</div> </div>
<div v-if="!icSelectedCard" class="empty-state"> <div v-if="!icSelectedCard" class="empty-state">
@@ -592,10 +655,15 @@
<div v-else> <div v-else>
<div class="flex between mb-4" style="align-items:flex-start;"> <div class="flex between mb-4" style="align-items:flex-start;">
<div> <div>
<div class="mono" style="font-size:1.4rem; font-weight:700;">{{ displayIcCardId(icSelectedCard) }}</div> <div class="mono" style="font-size:1.4rem; font-weight:700;">{{
<div class="text-muted" style="margin-top:6px;">订单号 {{ cardOrderCode(icSelectedCard) }} · 来源 {{ icSelectedCard.source || '---' }}</div> displayIcCardId(icSelectedCard) }}</div>
<div class="text-muted" style="margin-top:6px;">订单号 {{
cardOrderCode(icSelectedCard) }} · 来源 {{ icSelectedCard.source
|| '---' }}</div>
</div> </div>
<span class="badge" :class="icStatusInfo(icSelectedCard.status).className">{{ icStatusInfo(icSelectedCard.status).text }}</span> <span class="badge"
:class="icStatusInfo(icSelectedCard.status).className">{{
icStatusInfo(icSelectedCard.status).text }}</span>
</div> </div>
<div class="jr-admin-summary-grid"> <div class="jr-admin-summary-grid">
<div class="jr-admin-summary-item"> <div class="jr-admin-summary-item">
@@ -635,10 +703,15 @@
</label> </label>
</div> </div>
<div class="ic-inline-meta"> <div class="ic-inline-meta">
<div class="list-item"><span class="k">创建时间</span><span class="v">{{ formatTime(icSelectedCard.created_ts) }}</span></div> <div class="list-item"><span class="k">创建时间</span><span class="v">{{
<div class="list-item"><span class="k">最后更新</span><span class="v">{{ formatTime(icSelectedCard.last_update_ts) }}</span></div> formatTime(icSelectedCard.created_ts) }}</span></div>
<div class="list-item"><span class="k">首次充值</span><span class="v">{{ formatMoney(icSelectedCard.purchase_amount ?? icSelectedCard.balance) }}</span></div> <div class="list-item"><span class="k">最后更新</span><span class="v">{{
<div class="list-item"><span class="k">购卡金额</span><span class="v">{{ formatMoney(icSelectedCard.purchase_amount) }}</span></div> formatTime(icSelectedCard.last_update_ts) }}</span></div>
<div class="list-item"><span class="k">首次充值</span><span class="v">{{
formatMoney(icSelectedCard.purchase_amount ??
icSelectedCard.balance) }}</span></div>
<div class="list-item"><span class="k">购卡金额</span><span class="v">{{
formatMoney(icSelectedCard.purchase_amount) }}</span></div>
</div> </div>
</div> </div>
</div> </div>
@@ -650,14 +723,18 @@
<div class="timeline"> <div class="timeline">
<div v-if="!icSelectedCard" class="loading">选择卡片后显示事件流。</div> <div v-if="!icSelectedCard" class="loading">选择卡片后显示事件流。</div>
<div v-else-if="!icSelectedEvents.length" class="loading">暂无事件记录。</div> <div v-else-if="!icSelectedEvents.length" class="loading">暂无事件记录。</div>
<div v-for="(event, idx) in icSelectedEvents" :key="`${event.ts || 0}-${event.type || 'event'}-${idx}`" class="timeline-item"> <div v-for="(event, idx) in icSelectedEvents"
:key="`${event.ts || 0}-${event.type || 'event'}-${idx}`"
class="timeline-item">
<div class="timeline-dot"></div> <div class="timeline-dot"></div>
<div class="timeline-content"> <div class="timeline-content">
<div class="flex between"> <div class="flex between">
<span style="font-weight:600;">{{ icEventTitle(event) }}</span> <span style="font-weight:600;">{{ icEventTitle(event) }}</span>
<span class="text-muted" style="font-size:0.8rem;">{{ formatTime(event.ts) }}</span> <span class="text-muted" style="font-size:0.8rem;">{{
formatTime(event.ts) }}</span>
</div> </div>
<div class="log-detail" style="margin-top:8px;">{{ formatIcEventDetail(event) }}</div> <div class="log-detail" style="margin-top:8px;">{{
formatIcEventDetail(event) }}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -686,7 +763,8 @@
<div class="flex between mb-4"> <div class="flex between mb-4">
<h4>车票记录</h4> <h4>车票记录</h4>
<div class="flex"> <div class="flex">
<input v-model="ticketSearch" placeholder="搜索 Ticket ID / 站点" style="width: 200px;"> <input v-model="ticketSearch" placeholder="搜索 Ticket ID / 站点"
style="width: 200px;">
<button @click="refreshData"><i class="fas fa-sync-alt"></i></button> <button @click="refreshData"><i class="fas fa-sync-alt"></i></button>
</div> </div>
</div> </div>
@@ -717,7 +795,8 @@
<td> <td>
<div class="st-container"> <div class="st-container">
<div class="st-main-row"> <div class="st-main-row">
<span class="st-name">{{ getStationInfo(t.terminal).name }}</span> <span class="st-name">{{ getStationInfo(t.terminal).name
}}</span>
<span class="st-code">{{ t.terminal }}</span> <span class="st-code">{{ t.terminal }}</span>
</div> </div>
<div class="st-en">{{ getStationInfo(t.terminal).en_name }}</div> <div class="st-en">{{ getStationInfo(t.terminal).en_name }}</div>
@@ -767,23 +846,27 @@
selectedTicket.index.terminal }}</span> selectedTicket.index.terminal }}</span>
<span class="st-code">{{ selectedTicket.index.terminal }}</span> <span class="st-code">{{ selectedTicket.index.terminal }}</span>
</div> </div>
<div class="st-en">{{ selectedTicket.index.terminal_en || '' }}</div> <div class="st-en">{{ selectedTicket.index.terminal_en || '' }}
</div>
</div> </div>
</div> </div>
<div class="text-muted mt-2" style="font-size: 0.9rem;"> <div class="text-muted mt-2" style="font-size: 0.9rem;">
类型: {{ formatTrainType(selectedTicket.index.type || 类型: {{ formatTrainType(selectedTicket.index.type ||
selectedTicket.index.train_type) }} | 票价: {{ selectedTicket.index.price || selectedTicket.index.train_type) }} | 票价: {{ selectedTicket.index.price
||
selectedTicket.index.cost }} selectedTicket.index.cost }}
</div> </div>
</div> </div>
<h5>行程记录</h5> <h5>行程记录</h5>
<div class="timeline"> <div class="timeline">
<div v-for="ev in selectedTicket.events" :key="ev.ts || ev['时间戳']" class="timeline-item"> <div v-for="ev in selectedTicket.events" :key="ev.ts || ev['时间戳']"
class="timeline-item">
<div class="timeline-dot"></div> <div class="timeline-dot"></div>
<div class="timeline-content"> <div class="timeline-content">
<div class="flex between"> <div class="flex between">
<span style="font-weight: 600;">{{ formatTicketEvent(ev) }}</span> <span style="font-weight: 600;">{{ formatTicketEvent(ev)
}}</span>
</div> </div>
<div class="text-muted" style="font-size: 0.9rem; margin-top: 4px;"> <div class="text-muted" style="font-size: 0.9rem; margin-top: 4px;">
{{ formatTicketEventUnified(ev) }} {{ formatTicketEventUnified(ev) }}
@@ -807,50 +890,70 @@
<div class="flex" style="gap: 8px; flex-wrap: wrap;"> <div class="flex" style="gap: 8px; flex-wrap: wrap;">
<label class="btn primary" style="cursor: pointer;"> <label class="btn primary" style="cursor: pointer;">
<i class="fas fa-upload"></i> 上传线路图 <i class="fas fa-upload"></i> 上传线路图
<input type="file" accept=".png,.jpg,.jpeg,.webp,.svg" style="display: none;" @change="uploadRouteMap"> <input type="file" accept=".png,.jpg,.jpeg,.webp,.svg"
style="display: none;" @change="uploadRouteMap">
</label> </label>
<button class="btn" @click="deleteRouteMap" :disabled="!assetsManifest.routeMap || mutationBusy"> <button class="btn" @click="deleteRouteMap"
:disabled="!assetsManifest.routeMap || mutationBusy">
<i class="fas fa-trash"></i> 删除当前线路图 <i class="fas fa-trash"></i> 删除当前线路图
</button> </button>
</div> </div>
</div> </div>
<div class="grid" style="display: grid; grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); gap: 16px; align-items: start;"> <div class="grid"
<div style="border: 1px solid var(--border); padding: 16px; background: var(--surface);"> style="display: grid; grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); gap: 16px; align-items: start;">
<div
style="border: 1px solid var(--border); padding: 16px; background: var(--surface);">
<div style="font-weight: 700; margin-bottom: 12px;">上传说明</div> <div style="font-weight: 700; margin-bottom: 12px;">上传说明</div>
<div class="text-muted" style="display: grid; gap: 8px; line-height: 1.7;"> <div class="text-muted" style="display: grid; gap: 8px; line-height: 1.7;">
<div>支持格式:PNG、JPG、WEBP、SVG</div> <div>支持格式:PNG、JPG、WEBP、SVG</div>
<div>建议上传横向长图,便于首页和购票页展示。</div> <div>建议上传横向长图,便于首页和购票页展示。</div>
<div>再次上传会自动替换旧文件。</div> <div>再次上传会自动替换旧文件。</div>
</div> </div>
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); display: grid; gap: 10px;"> <div
style="margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); display: grid; gap: 10px;">
<label style="display: grid; gap: 6px;"> <label style="display: grid; gap: 6px;">
<span style="font-weight: 700;">线路图署名</span> <span style="font-weight: 700;">线路图署名</span>
<input v-model="assetsForm.routeMapCredit" class="input" type="text" maxlength="200" placeholder="例如:OpenStreetMap contributors / 作者名 / 来源名"> <input v-model="assetsForm.routeMapCredit" class="input" type="text"
maxlength="200"
placeholder="例如:OpenStreetMap contributors / 作者名 / 来源名">
</label> </label>
<div class="text-muted">许可协议固定为 <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener noreferrer">CC BY 4.0</a></div> <div class="text-muted">许可协议固定为 <a
href="https://creativecommons.org/licenses/by/4.0/" target="_blank"
rel="noopener noreferrer">CC BY 4.0</a></div>
<div class="flex" style="gap: 8px; flex-wrap: wrap;"> <div class="flex" style="gap: 8px; flex-wrap: wrap;">
<button class="btn" @click="saveRouteMapCredit" :disabled="mutationBusy"> <button class="btn" @click="saveRouteMapCredit"
:disabled="mutationBusy">
<i class="fas fa-pen"></i> 保存署名 <i class="fas fa-pen"></i> 保存署名
</button> </button>
</div> </div>
</div> </div>
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); display: grid; gap: 8px;"> <div
style="margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); display: grid; gap: 8px;">
<div><strong>当前文件:</strong> {{ assetsManifest.routeMap || '未上传' }}</div> <div><strong>当前文件:</strong> {{ assetsManifest.routeMap || '未上传' }}</div>
<div><strong>当前署名:</strong> {{ assetsManifest.routeMapCredit || '未填写' }}</div> <div><strong>当前署名:</strong> {{ assetsManifest.routeMapCredit || '未填写' }}
<div><strong>许可协议:</strong> <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener noreferrer">{{ assetsManifest.routeMapLicense }}</a></div> </div>
<div><strong>更新时间:</strong> {{ assetsManifest.updatedAt ? formatTime(assetsManifest.updatedAt) : '暂无' }}</div> <div><strong>许可协议:</strong> <a
href="https://creativecommons.org/licenses/by/4.0/" target="_blank"
rel="noopener noreferrer">{{ assetsManifest.routeMapLicense }}</a>
</div>
<div><strong>更新时间:</strong> {{ assetsManifest.updatedAt ?
formatTime(assetsManifest.updatedAt) : '暂无' }}</div>
</div> </div>
</div> </div>
<div style="border: 1px solid var(--border); background: #0f172a; min-height: 420px; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 16px; box-sizing: border-box;"> <div
style="border: 1px solid var(--border); background: #0f172a; min-height: 420px; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 16px; box-sizing: border-box;">
<div style="width: 100%;"> <div style="width: 100%;">
<template v-if="assetsRouteMapUrl"> <template v-if="assetsRouteMapUrl">
<img :src="assetsRouteMapUrl" alt="线路图预览" style="display: block; max-width: 100%; height: auto; background: #ffffff; margin: 0 auto;"> <img :src="assetsRouteMapUrl" alt="线路图预览"
style="display: block; max-width: 100%; height: auto; background: #ffffff; margin: 0 auto;">
<div class="jr-asset-credit" style="color: #cbd5e1;"> <div class="jr-asset-credit" style="color: #cbd5e1;">
<span>线路图署名:{{ assetsManifest.routeMapCredit || '未填写' }}</span> <span>线路图署名:{{ assetsManifest.routeMapCredit || '未填写' }}</span>
<span>许可协议:</span> <span>许可协议:</span>
<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener noreferrer" style="color: #93c5fd;">{{ assetsManifest.routeMapLicense }}</a> <a href="https://creativecommons.org/licenses/by/4.0/"
target="_blank" rel="noopener noreferrer"
style="color: #93c5fd;">{{ assetsManifest.routeMapLicense }}</a>
</div> </div>
</template> </template>
<div v-else class="text-muted" style="padding: 24px; text-align: center;"> <div v-else class="text-muted" style="padding: 24px; text-align: center;">
@@ -879,7 +982,8 @@
<div class="flex" style="flex-direction: column; gap: 10px;"> <div class="flex" style="flex-direction: column; gap: 10px;">
<div> <div>
<button @click="pushLuaUpdate" :disabled="isPushingUpdate"> <button @click="pushLuaUpdate" :disabled="isPushingUpdate">
<i class="fas fa-satellite-dish"></i> {{ isPushingUpdate ? '推送中...' : '手动推送更新' }} <i class="fas fa-satellite-dish"></i> {{ isPushingUpdate ? '推送中...' :
'手动推送更新' }}
</button> </button>
</div> </div>
<div class="text-muted" style="font-size: 0.9rem;"> <div class="text-muted" style="font-size: 0.9rem;">
@@ -916,7 +1020,8 @@
</select> </select>
<input v-model="logTypeFilter" placeholder="type (可逗号分隔)" style="width: 260px;"> <input v-model="logTypeFilter" placeholder="type (可逗号分隔)" style="width: 260px;">
<input v-model="logQuery" placeholder="关键字" style="width: 220px;"> <input v-model="logQuery" placeholder="关键字" style="width: 220px;">
<input v-model.number="logMax" type="number" min="10" max="5000" step="10" style="width: 120px;"> <input v-model.number="logMax" type="number" min="10" max="5000" step="10"
style="width: 120px;">
<button @click="fetchLogs" :disabled="logLoading"> <button @click="fetchLogs" :disabled="logLoading">
<i class="fas" :class="logLoading ? 'fa-spinner fa-spin' : 'fa-filter'"></i> 筛选 <i class="fas" :class="logLoading ? 'fa-spinner fa-spin' : 'fa-filter'"></i> 筛选
</button> </button>
@@ -942,7 +1047,8 @@
<td> <td>
<details> <details>
<summary class="text-muted" style="cursor: pointer;">查看</summary> <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;">{{ formatLogDetail(l) }}</pre> <pre
style="white-space: pre-wrap; margin: 8px 0 0; font-size: 0.85rem; line-height: 1.35;">{{ formatLogDetail(l) }}</pre>
</details> </details>
</td> </td>
</tr> </tr>
@@ -976,19 +1082,23 @@
<td style="white-space: nowrap;">{{ formatTime(issue.ts) }}</td> <td style="white-space: nowrap;">{{ formatTime(issue.ts) }}</td>
<td> <td>
<div class="jr-issue-primary mono">{{ issue.displayDevice }}</div> <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> <div v-if="issue.machine_id && issue.machine_id !== issue.displayDevice"
class="jr-issue-sub mono">{{ issue.machine_id }}</div>
</td> </td>
<td> <td>
<div class="jr-issue-primary">{{ issue.displayStation }}</div> <div class="jr-issue-primary">{{ issue.displayStation }}</div>
<div v-if="issue.station_code" class="jr-issue-sub mono">{{ issue.station_code }}</div> <div v-if="issue.station_code" class="jr-issue-sub mono">{{
issue.station_code }}</div>
</td> </td>
<td> <td>
<div class="jr-issue-error">{{ issue.displayError }}</div> <div class="jr-issue-error">{{ issue.displayError }}</div>
</td> </td>
<td class="jr-issue-detail-cell"> <td class="jr-issue-detail-cell">
<div v-if="issue.detailEntries && issue.detailEntries.length" class="jr-issue-detail-card"> <div v-if="issue.detailEntries && issue.detailEntries.length"
class="jr-issue-detail-card">
<div class="jr-issue-detail-grid"> <div class="jr-issue-detail-grid">
<div v-for="entry in issue.detailEntries" :key="`${idx}-${entry.key}`" class="jr-issue-detail-item"> <div v-for="entry in issue.detailEntries"
:key="`${idx}-${entry.key}`" class="jr-issue-detail-item">
<span>{{ entry.label }}</span> <span>{{ entry.label }}</span>
<strong>{{ entry.value }}</strong> <strong>{{ entry.value }}</strong>
</div> </div>
@@ -1007,14 +1117,16 @@
</tbody> </tbody>
</table> </table>
<div v-else class="empty-state"> <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> <i class="fas fa-check-circle"
style="font-size: 48px; margin-bottom: 16px; opacity: 0.3; color: var(--success);"></i>
<p>暂无问题上报记录</p> <p>暂无问题上报记录</p>
</div> </div>
</div> </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>
</footer> </footer>
</div> </div>
+292
View File
@@ -4647,6 +4647,298 @@ body.jr-admin-login-page {
} }
} }
/* ========== Route 页面移动端专项适配 ========== */
@media (max-width: 980px) {
body.jr-admin-route-page {
overflow: auto;
}
body.jr-admin-route-page #app {
min-height: auto;
height: auto;
}
.jr-admin-route-page .jr-admin-app {
min-height: auto;
border-radius: 0;
}
.jr-admin-route-page .jr-admin-main-shell {
width: min(100%, calc(100% - 24px));
padding: 12px 0 24px;
}
.jr-admin-route-page .jr-admin-app {
flex-direction: column;
}
.jr-admin-route-page .sidebar {
width: 86%;
max-width: 340px;
}
.jr-admin-route-page .jr-admin-header-side {
width: auto;
justify-content: flex-end;
}
}
@media (max-width: 768px) {
body.jr-admin-route-page .jr-topbar-inner {
min-height: 52px;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 8px 0;
gap: 6px;
}
body.jr-admin-route-page .jr-top-status {
font-size: 0.8rem;
padding: 5px 10px;
}
body.jr-admin-route-page .jr-brandbar-inner {
min-height: auto;
padding: 14px 0;
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
body.jr-admin-route-page .jr-nav {
width: 100%;
justify-content: flex-start;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
body.jr-admin-route-page .jr-nav a {
min-width: 88px;
padding: 8px 12px;
font-size: 0.85rem;
flex-shrink: 0;
}
.jr-admin-route-page .jr-admin-page .header,
.jr-admin-route-page .jr-admin-page .content {
padding-left: 14px;
padding-right: 14px;
}
.jr-admin-route-page .content {
padding: 14px;
}
.jr-admin-route-page .header {
padding: 12px 14px;
min-height: 60px;
flex-wrap: wrap;
gap: 10px;
}
.jr-admin-route-page .jr-page-intro {
padding: 18px 16px;
}
.jr-admin-route-page .jr-page-intro h1 {
font-size: clamp(1.4rem, 6vw, 2rem);
}
.jr-admin-route-page .jr-page-intro p {
font-size: 0.9rem;
line-height: 1.6;
}
.jr-admin-route-page .card {
padding: 14px;
margin-bottom: 14px;
}
.jr-admin-route-page .management-container {
flex-direction: column;
gap: 14px;
height: auto;
overflow: visible;
}
.jr-admin-route-page .management-sidebar {
width: 100%;
max-height: 380px;
}
.jr-admin-route-page .management-main {
overflow: visible;
min-height: 400px;
}
.jr-admin-route-page .editor-toolbar {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.jr-admin-route-page .editor-toolbar > .flex:first-child {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.jr-admin-route-page .switch-label {
width: 100%;
margin-left: 0 !important;
}
.jr-admin-route-page .hint-text {
margin-left: 0;
width: 100%;
word-break: break-word;
}
.jr-admin-route-page .editor-toolbar > .flex:last-child {
background: transparent !important;
padding: 0 !important;
flex-direction: column;
gap: 8px;
}
.jr-admin-route-page .editor-toolbar > .flex:last-child input,
.jr-admin-route-page .editor-toolbar > .flex:last-child button {
width: 100%;
}
.jr-admin-route-page .visual-line-container {
padding: 16px 10px;
min-height: 280px;
cursor: default;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
}
.jr-admin-route-page .visual-line-container svg {
min-width: 720px;
height: 220px;
}
.jr-admin-route-page .faremap-card .flex.between {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.jr-admin-route-page .faremap-card .flex.between > .flex:last-child {
width: 100%;
flex-wrap: wrap;
}
.jr-admin-route-page .faremap-card .flex.between > .flex:last-child button {
flex: 1;
min-width: 52px;
}
.jr-admin-route-page .faremap-viewport {
max-height: 60vh;
}
.jr-admin-route-page .faremap-canvas {
min-width: 100%;
}
.jr-admin-route-page .ticket-table,
.jr-admin-route-page table {
display: block;
overflow-x: auto;
white-space: nowrap;
font-size: 0.85rem;
}
.jr-admin-route-page .ticket-table th,
.jr-admin-route-page .ticket-table td,
.jr-admin-route-page table th,
.jr-admin-route-page table td {
padding: 10px 12px;
white-space: nowrap;
}
.jr-admin-route-page .modal-card {
width: 96%;
max-width: 96%;
padding: 18px 16px;
max-height: 92vh;
border-radius: 0;
}
.jr-admin-route-page .modal-title {
font-size: 1.05rem;
margin-bottom: 16px;
}
.jr-admin-route-page .modal-actions {
flex-direction: column-reverse;
margin-top: 20px;
gap: 8px;
}
.jr-admin-route-page .modal-actions button {
width: 100%;
min-height: 48px;
}
.jr-admin-route-page select[multiple] {
height: 160px;
font-size: 0.9rem;
}
.jr-admin-route-page button,
.jr-admin-route-page .btn {
min-height: 44px;
padding: 0 14px;
}
.jr-admin-route-page button.sm,
.jr-admin-route-page .btn.sm {
min-height: 36px;
min-width: 36px;
}
.jr-admin-route-page .icon-btn {
width: 44px;
height: 44px;
}
.jr-admin-route-page input,
.jr-admin-route-page select {
min-height: 44px;
font-size: 1rem;
}
.jr-admin-route-page .line-item {
padding: 14px 12px;
min-height: 56px;
}
.jr-admin-route-page .line-item .line-actions {
opacity: 1;
}
.jr-admin-route-page .line-item .line-actions button {
min-height: 36px;
min-width: 36px;
}
.jr-admin-route-page .site-footer {
margin-top: 20px;
padding-top: 12px;
font-size: 11px;
}
}
@media (max-width: 480px) {
.jr-admin-route-page .header h3 {
font-size: 1rem;
}
.jr-admin-route-page .jr-admin-header-pill {
font-size: 11px;
padding: 6px 10px;
}
.jr-admin-route-page .badge {
font-size: 0.7rem;
padding: 2px 5px;
}
.jr-admin-route-page h4 {
font-size: 0.95rem;
margin-bottom: 0.75rem;
}
.jr-admin-route-page h3 {
font-size: 1.1rem;
}
.jr-admin-route-page .jr-brand-copy strong {
font-size: 1rem;
}
.jr-admin-route-page .jr-brand-copy span {
font-size: 0.78rem;
}
.jr-admin-route-page .jr-brand-logo {
width: 40px;
height: 40px;
}
.jr-admin-route-page .faremap-viewport {
max-height: 50vh;
}
.jr-admin-route-page .visual-line-container svg {
min-width: 640px;
}
.jr-admin-route-page #app .ticket-header .flex.between:last-of-type {
flex-direction: column;
align-items: flex-start;
}
}
/* Static Map Overlays */ /* Static Map Overlays */
.map-station-overlay { .map-station-overlay {
z-index: 10; z-index: 10;
+6 -4
View File
@@ -8,7 +8,7 @@
<title>线路规划|FSE铁路运输票务系统</title> <title>线路规划|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png"> <link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=14"> <link rel="stylesheet" href="/style.css?v=16">
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script> <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="/socket.io/socket.io.js"></script> <script src="/socket.io/socket.io.js"></script>
</head> </head>
@@ -239,8 +239,10 @@
<!--车站节点--> <!--车站节点-->
<g v-for="(sCode, index) in selectedLine.stations" :key="sCode" <g v-for="(sCode, index) in selectedLine.stations" :key="sCode"
@mousedown="onStationDragStart(index)" @mouseup="onStationDrop" @mousedown="onStationDragStart(index, $event)"
@mousemove="onStationDragOver(index)" @click="handleStationClick(sCode)" @mouseup="onStationDrop"
@click="handleStationClick(sCode)"
@touchstart.passive="onStationTouchStart(index, $event)"
class="station-node" :class="{ class="station-node" :class="{
'selected': isStationSelected(sCode), 'selected': isStationSelected(sCode),
'fare-source': fareSelection[0] === sCode, 'fare-source': fareSelection[0] === sCode,
@@ -600,7 +602,7 @@
</div> </div>
<script src="/custom-dialog.js?v=11"></script> <script src="/custom-dialog.js?v=11"></script>
<script src="/public-status.js?v=14"></script> <script src="/public-status.js?v=14"></script>
<script src="ticket-route.js?v=3"></script> <script src="ticket-route.js?v=5"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group'); const isDomain = location.hostname.includes('fse-media.group');
+86 -30
View File
@@ -400,24 +400,38 @@ createApp({
selectedTicket.value = null; selectedTicket.value = null;
}; };
/* 拖动调整站序 */ /* 拖动调整站序 - 统一实现(鼠标+触摸) */
const onStationDragStart = (index) => { const dragActiveSvg = ref(null);
if (fareMode.value) return;
draggingStationIndex.value = index; const computeStationIndexFromClientXY = (clientX, clientY) => {
const svgEl = dragActiveSvg.value;
if (!svgEl || !selectedLine.value) return -1;
const pt = svgEl.createSVGPoint();
pt.x = clientX;
pt.y = clientY;
const ctm = svgEl.getScreenCTM();
if (!ctm) return -1;
const loc = pt.matrixTransform(ctm.inverse());
return Math.max(0, Math.min(
(selectedLine.value.stations?.length || 1) - 1,
Math.round((loc.x - 50) / 120)
));
}; };
const onStationDragOver = (index) => { const swapStationByIndex = (targetIndex) => {
if (draggingStationIndex.value === null) return; if (draggingStationIndex.value === null) return false;
if (draggingStationIndex.value === index) return; if (targetIndex < 0) return false;
if (draggingStationIndex.value === targetIndex) return false;
const list = selectedLine.value.stations; const list = selectedLine.value.stations;
if (!list) return false;
const temp = list[draggingStationIndex.value]; const temp = list[draggingStationIndex.value];
list.splice(draggingStationIndex.value, 1); list.splice(draggingStationIndex.value, 1);
list.splice(index, 0, temp); list.splice(targetIndex, 0, temp);
draggingStationIndex.value = index; draggingStationIndex.value = targetIndex;
return true;
}; };
const onStationDrop = async () => { const saveDragResult = async () => {
if (draggingStationIndex.value === null) return; if (draggingStationIndex.value === null) return;
try { try {
await updateLineStations(selectedLine.value.id, selectedLine.value.stations); await updateLineStations(selectedLine.value.id, selectedLine.value.stations);
@@ -426,6 +440,61 @@ createApp({
await fetchData(); await fetchData();
} }
draggingStationIndex.value = null; draggingStationIndex.value = null;
dragActiveSvg.value = null;
};
/* 鼠标事件 */
const onStationDragStart = (index, event) => {
if (fareMode.value) return;
draggingStationIndex.value = index;
const svgEl = (event.currentTarget && event.currentTarget.closest)
? event.currentTarget.closest('svg')
: null;
dragActiveSvg.value = svgEl || document.querySelector('.visual-line-container svg');
};
const onStationDragOver = (index) => {
swapStationByIndex(index);
};
const onStationDrop = async () => {
await saveDragResult();
};
const globalMouseMoveHandler = (e) => {
if (draggingStationIndex.value === null) return;
const targetIndex = computeStationIndexFromClientXY(e.clientX, e.clientY);
swapStationByIndex(targetIndex);
};
const globalMouseUpHandler = async () => {
if (draggingStationIndex.value === null) return;
await saveDragResult();
};
/* 触摸事件 - 移动端拖拽支持 */
const onStationTouchStart = (index, event) => {
if (fareMode.value) return;
if (!event.touches || event.touches.length !== 1) return;
draggingStationIndex.value = index;
const svgEl = (event.currentTarget && event.currentTarget.closest)
? event.currentTarget.closest('svg')
: null;
dragActiveSvg.value = svgEl || document.querySelector('.visual-line-container svg');
};
const globalTouchMoveHandler = (e) => {
if (draggingStationIndex.value === null) return;
if (!e.touches || e.touches.length !== 1) return;
if (e.cancelable) e.preventDefault();
const t = e.touches[0];
const targetIndex = computeStationIndexFromClientXY(t.clientX, t.clientY);
swapStationByIndex(targetIndex);
};
const globalTouchEndHandler = async () => {
if (draggingStationIndex.value === null) return;
await saveDragResult();
}; };
/* 订单管理 */ /* 订单管理 */
@@ -890,28 +959,14 @@ createApp({
watch(currentView, () => { sidebarOpen.value = false; }); watch(currentView, () => { sidebarOpen.value = false; });
/* 失败 */
onMounted(() => { onMounted(() => {
fetchData(); fetchData();
loadFareMap(); loadFareMap();
window.addEventListener('mouseup', async () => { window.addEventListener('mousemove', globalMouseMoveHandler);
if (draggingStationIndex.value !== null) { window.addEventListener('mouseup', globalMouseUpHandler);
if (selectedLine.value) { window.addEventListener('touchmove', globalTouchMoveHandler, { passive: false });
try { window.addEventListener('touchend', globalTouchEndHandler);
await requestJson(`/api/lines/${encodeURIComponent(selectedLine.value.id)}`, { window.addEventListener('touchcancel', globalTouchEndHandler);
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ ...(lines.value.find(l => l.id === selectedLine.value.id) || selectedLine.value), stations: selectedLine.value.stations })
}, { expectOk: true });
await fetchData();
} catch (e) {
alert(`保存站序失败:${e?.message || String(e)}`);
await fetchData();
}
}
draggingStationIndex.value = null;
}
});
}); });
/* 计算 */ /* 计算 */
@@ -976,6 +1031,7 @@ createApp({
isStationInLine, addStationToLine, removeStationFromLine, isStationInLine, addStationToLine, removeStationFromLine,
handleStationClick, isStationSelected, handleStationClick, isStationSelected,
onStationDragStart, onStationDragOver, onStationDrop, draggingStationIndex, onStationDragStart, onStationDragOver, onStationDrop, draggingStationIndex,
onStationTouchStart,
showStationModal, stationForm, stationFormOriginalCode, transferTargets, saveStationSettings, closeStationModal, showStationModal, stationForm, stationFormOriginalCode, transferTargets, saveStationSettings, closeStationModal,
showLineModal, lineForm, openLineModal, saveLineSettings, closeLineModal, showLineModal, lineForm, openLineModal, saveLineSettings, closeLineModal,