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
+90 -34
View File
@@ -400,24 +400,38 @@ createApp({
selectedTicket.value = null;
};
/* 拖动调整站序 */
const onStationDragStart = (index) => {
if (fareMode.value) return;
draggingStationIndex.value = index;
/* 拖动调整站序 - 统一实现(鼠标+触摸) */
const dragActiveSvg = ref(null);
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) => {
if (draggingStationIndex.value === null) return;
if (draggingStationIndex.value === index) return;
const list = selectedLine.value.stations;
const temp = list[draggingStationIndex.value];
list.splice(draggingStationIndex.value, 1);
list.splice(index, 0, temp);
draggingStationIndex.value = index;
const swapStationByIndex = (targetIndex) => {
if (draggingStationIndex.value === null) return false;
if (targetIndex < 0) return false;
if (draggingStationIndex.value === targetIndex) return false;
const list = selectedLine.value.stations;
if (!list) return false;
const temp = list[draggingStationIndex.value];
list.splice(draggingStationIndex.value, 1);
list.splice(targetIndex, 0, temp);
draggingStationIndex.value = targetIndex;
return true;
};
const onStationDrop = async () => {
const saveDragResult = async () => {
if (draggingStationIndex.value === null) return;
try {
await updateLineStations(selectedLine.value.id, selectedLine.value.stations);
@@ -426,6 +440,61 @@ createApp({
await fetchData();
}
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();
};
/* 订单管理 */
@@ -889,29 +958,15 @@ createApp({
});
watch(currentView, () => { sidebarOpen.value = false; });
/* 失败 */
onMounted(() => {
fetchData();
loadFareMap();
window.addEventListener('mouseup', async () => {
if (draggingStationIndex.value !== null) {
if (selectedLine.value) {
try {
await requestJson(`/api/lines/${encodeURIComponent(selectedLine.value.id)}`, {
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;
}
});
window.addEventListener('mousemove', globalMouseMoveHandler);
window.addEventListener('mouseup', globalMouseUpHandler);
window.addEventListener('touchmove', globalTouchMoveHandler, { passive: false });
window.addEventListener('touchend', globalTouchEndHandler);
window.addEventListener('touchcancel', globalTouchEndHandler);
});
/* 计算 */
@@ -976,6 +1031,7 @@ createApp({
isStationInLine, addStationToLine, removeStationFromLine,
handleStationClick, isStationSelected,
onStationDragStart, onStationDragOver, onStationDrop, draggingStationIndex,
onStationTouchStart,
showStationModal, stationForm, stationFormOriginalCode, transferTargets, saveStationSettings, closeStationModal,
showLineModal, lineForm, openLineModal, saveLineSettings, closeLineModal,