feat(web/route): 新增全局站点管理页面 (Station Management)

1. 在左侧侧边栏新增「站点管理」导航项,并支持在 /route 内无缝切换视图。
2. 实现了全局的站点列表:
   - 支持显示所有已入库站点的编号、中英文名称。
   - 动态计算并展示每个站点目前被关联到了哪些线路(所属线路)。
   - 支持通过站点编号或中英文名称进行快速搜索过滤。
3. 提供了独立于线路外的全局新增、编辑和删除站点功能:
   - 新增:不再强制需要选中线路,直接在库中创建。
   - 编辑:复用原有的站点编辑弹窗。
   - 删除:可从库中全局删除无用站点。
4. 移动端和电脑端均已应用自适应表格和工具栏布局。
This commit is contained in:
2026-08-07 23:56:40 +08:00
parent 0a37c0cba2
commit 0e3f1a6e86
2 changed files with 109 additions and 10 deletions
+70 -2
View File
@@ -8,7 +8,7 @@
<title>线路规划|FSE铁路运输票务系统</title>
<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="/style.css?v=17">
<link rel="stylesheet" href="/style.css?v=18">
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="/socket.io/socket.io.js"></script>
</head>
@@ -64,6 +64,10 @@
@click="currentView = 'management'">
<span class="nav-icon"><i class="fas fa-network-wired"></i></span> 线路规划
</div>
<div class="nav-item" :class="{active: currentView === 'stations'}"
@click="currentView = 'stations'">
<span class="nav-icon"><i class="fas fa-map-marker-alt"></i></span> 站点管理
</div>
<div class="nav-item" :class="{active: currentView === 'faremap'}" @click="currentView = 'faremap'">
<span class="nav-icon"><i class="fas fa-map"></i></span> 票价地图
</div>
@@ -392,6 +396,70 @@
</div>
</div>
<div v-if="currentView === 'stations'" class="card">
<div class="flex between mb-4" style="flex-wrap: wrap; gap: 12px;">
<h4 style="margin: 0;">站点总览 ({{ stations.length }})</h4>
<div class="flex" style="gap: 12px; flex-wrap: wrap;">
<div class="search-box">
<i class="fas fa-search"></i>
<input v-model="stationSearch" placeholder="搜索站点编号/名称..." style="width: 200px;">
</div>
<button @click="showAddStation = true"><i class="fas fa-plus"></i> 新增站点</button>
</div>
</div>
<div v-if="showAddStation" class="mb-4" style="background: rgba(255,255,255,0.05); padding: 15px; border-radius: 8px;">
<h5 style="margin-top:0; margin-bottom:12px;">创建新站点</h5>
<div class="flex" style="flex-wrap: wrap; gap: 12px;">
<input v-model="newStation.code" placeholder="站点编号 (必填)" style="flex: 1; min-width: 120px;">
<input v-model="newStation.name" placeholder="中文名称 (必填)" style="flex: 1; min-width: 120px;">
<input v-model="newStation.en_name" placeholder="英文名称" style="flex: 1; min-width: 120px;">
<button @click="addStationToLine" :disabled="!newStation.code || !newStation.name" style="min-width: 80px;">创建</button>
<button class="danger" @click="showAddStation = false" style="min-width: 80px;">取消</button>
</div>
<p class="text-muted mt-2" style="font-size: 0.85rem; margin-bottom: 0;"><i class="fas fa-info-circle"></i> 在此创建的站点将被保存到数据库中,可以在线路规划中添加到任意线路。</p>
</div>
<div class="table-responsive">
<table class="ticket-table" style="width: 100%;">
<thead>
<tr>
<th>站点编号</th>
<th>中文名称</th>
<th>英文名称</th>
<th>所属线路</th>
<th>换乘支持</th>
<th style="text-align: right;">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="s in stationList" :key="s.code">
<td class="mono" style="font-weight:bold;">{{ s.code }}</td>
<td>{{ s.name || s.cn_name || '-' }}</td>
<td>{{ s.en_name || s.enName || '-' }}</td>
<td>
<div class="flex" style="gap: 4px; flex-wrap: wrap;">
<span v-for="l in getStationLines(s.code)" :key="l.id" class="badge" :style="{background: l.color, color: '#fff', border: 'none'}">{{ l.id }}</span>
<span v-if="getStationLines(s.code).length === 0" class="text-muted" style="font-size: 0.85em;">暂无线路</span>
</div>
</td>
<td>
<span v-if="s.transfer_enabled" class="badge" style="background: #10b981; color: white;">是</span>
<span v-else class="text-muted">-</span>
</td>
<td style="text-align: right;">
<button class="sm" @click="editStationGlobal(s.code)" title="编辑"><i class="fas fa-pen"></i></button>
<button class="danger sm" @click="deleteStationGlobal(s.code)" title="删除"><i class="fas fa-trash"></i></button>
</td>
</tr>
<tr v-if="stationList.length === 0">
<td colspan="6" class="text-center text-muted" style="padding: 30px;">无匹配站点</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 票价地图 -->
<div v-if="currentView === 'faremap'">
<div class="card faremap-card">
@@ -608,7 +676,7 @@
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/public-status.js?v=14"></script>
<script src="ticket-route.js?v=6"></script>
<script src="ticket-route.js?v=7"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
+39 -8
View File
@@ -17,8 +17,10 @@ createApp({
const map = {
dashboard: '仪表盘',
management: '线路与票价管理',
stations: '站点管理',
tickets: '车票记录',
settings: '系统设置'
settings: '系统设置',
faremap: '票价地图'
};
return map[currentView.value] || '线路规划系统';
});
@@ -691,7 +693,6 @@ createApp({
};
const addStationToLine = async () => {
if (!selectedLine.value) return;
const { code, name, en_name } = newStation;
if (!code || !name) return alert('请填写编号和名称');
@@ -706,12 +707,18 @@ createApp({
}, { expectOk: true });
}
if (isStationInLine(code)) throw new Error('该站点已在此线路中');
const newStations = [...(selectedLine.value.stations || [])];
newStations.push(code);
await updateLineStations(selectedLine.value.id, newStations, { skipFetchData: true });
if (selectedLine.value) {
if (isStationInLine(code)) throw new Error('该站点已在此线路中');
const newStations = [...(selectedLine.value.stations || [])];
newStations.push(code);
await updateLineStations(selectedLine.value.id, newStations, { skipFetchData: true });
}
Object.assign(newStation, { code: '', name: '', en_name: '' });
if (currentView.value === 'stations') {
showAddStation.value = false;
await fetchData();
}
});
};
@@ -999,7 +1006,30 @@ createApp({
/* 计算 */
const recentLogs = computed(() => logs.value);
const orderList = computed(() => orders.value);
const ticketList = computed(() => {
const stationSearch = ref('');
const stationList = computed(() => {
if (!stationSearch.value) return stations.value;
const q = stationSearch.value.toLowerCase();
return stations.value.filter(s =>
(s.code && s.code.toLowerCase().includes(q)) ||
(s.name && s.name.toLowerCase().includes(q)) ||
(s.en_name && s.en_name.toLowerCase().includes(q))
);
});
const getStationLines = (code) => {
return lines.value.filter(l => l.stations && l.stations.includes(code));
};
const editStationGlobal = (code) => {
stationEditMode.value = true;
openStationModal(code);
// reset mode when modal closes is already handled? Wait, we need to ensure stationEditMode state isn't breaking anything, but openStationModal is generic.
};
const deleteStationGlobal = async (code) => {
await deleteStation(code);
};
if (!ticketSearch.value) return tickets.value.slice(0, 50);
const q = ticketSearch.value.toLowerCase();
return tickets.value.filter(t =>
@@ -1063,6 +1093,7 @@ createApp({
showStationModal, stationForm, stationFormOriginalCode, transferTargets, saveStationSettings, closeStationModal,
showLineModal, lineForm, openLineModal, saveLineSettings, closeLineModal,
stationSearch, stationList, getStationLines, editStationGlobal, deleteStationGlobal,
/* 订单 */
fetchOrders, deleteOrder,
showTicketModal, selectedTicket, viewTicketDetails, closeTicketModal, formatTicketStatus, formatTicketEvent, formatTicketEventLocation, formatTicketEventUnified, formatLogType, formatTrainType,