Skip to content

Commit 2705f70

Browse files
committed
feat: support mobile lightweight editing with bottom sheet
On mobile (<=768px), reuse detail panels as bottom sheets so users can edit date notes/expenses and delete markers, instead of being pure read-only: - date detail (notes + expenses) opens as a bottom sheet with overlay - delete entry added to the read-only marker popup - drop the outdated "read-only mode" title suffix and alert Implemented via a MutationObserver-based sheet controller without touching existing show/hide methods; also fix a stacking-context bug where the .right-panel z-index trapped detail panels under the overlay. Add E2E coverage (test/tests/mobile-edit.spec.mjs) and allow overriding the test server port/baseURL via E2E_PORT/E2E_BASE_URL to avoid local port clashes.
1 parent 4219bf8 commit 2705f70

6 files changed

Lines changed: 231 additions & 13 deletions

File tree

static/app_map.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,13 +286,29 @@ RoadbookApp.prototype.createMarkerEntity = function(lat, lng, title = null, id =
286286
marker.on('click', (e) => {
287287
L.DomEvent.stopPropagation(e);
288288
if (this.isMobileDevice()) {
289-
const popupContent = this.generateMarkerPopupContent(markerData);
289+
const popupContent = this.generateMarkerPopupContent(markerData, { withDelete: true });
290290
marker.bindPopup(popupContent).openPopup();
291291
} else {
292292
this.showMarkerDetail(markerData);
293293
}
294294
});
295295

296+
// 移动端:标记气泡内提供删除入口(极轻量编辑)
297+
marker.on('popupopen', () => {
298+
const popup = marker.getPopup();
299+
const popupEl = popup && popup.getElement();
300+
if (!popupEl) return;
301+
const delBtn = popupEl.querySelector('.popup-delete-marker');
302+
if (!delBtn) return;
303+
delBtn.onclick = async () => {
304+
const result = await this.showSwalConfirm('删除确认', `确定要删除标记点"${markerData.title}"吗?`, '删除', '取消');
305+
if (result.isConfirmed) {
306+
marker.closePopup();
307+
this.removeMarker(markerData);
308+
}
309+
};
310+
});
311+
296312
// 添加悬浮事件显示标注信息
297313
marker.on('mouseover', (e) => {
298314
if (e.target.getElement()) {

static/app_utils.js

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,52 @@ RoadbookApp.prototype.isMobileDevice = function() {
88
(window.innerWidth <= 768); // 小屏幕设备也视为移动设备
99
};
1010

11+
// 移动端:将详情面板(标记/连接线/日期)以底部抽屉形式呈现,并统一管理遮罩与背景滚动锁
12+
// 仅监听面板 display 变化,不改动各 show/hide 业务方法,降低耦合与回归风险
13+
RoadbookApp.prototype.initMobileDetailSheet = function() {
14+
const panelIds = ['markerDetailPanel', 'connectionDetailPanel', 'dateDetailPanel'];
15+
const panels = panelIds.map(id => document.getElementById(id)).filter(Boolean);
16+
if (panels.length === 0) {
17+
return;
18+
}
19+
20+
// 遮罩层:点击空白处复用已有的关闭逻辑(保证实时保存等副作用一致)
21+
let overlay = document.getElementById('mobileSheetOverlay');
22+
if (!overlay) {
23+
overlay = document.createElement('div');
24+
overlay.id = 'mobileSheetOverlay';
25+
overlay.className = 'mobile-sheet-overlay';
26+
document.body.appendChild(overlay);
27+
overlay.addEventListener('click', () => {
28+
if (typeof this.hideMarkerDetail === 'function') this.hideMarkerDetail();
29+
if (typeof this.hideConnectionDetail === 'function') this.hideConnectionDetail();
30+
if (typeof this.closeDateDetail === 'function') this.closeDateDetail();
31+
});
32+
}
33+
34+
// 同步遮罩/滚动锁状态:仅在窄屏(与 CSS @media 768 对齐)且有面板打开时启用抽屉态
35+
const sync = () => {
36+
const isNarrow = window.innerWidth <= 768;
37+
const anyOpen = isNarrow && panels.some(p => p.style.display && p.style.display !== 'none');
38+
if (anyOpen) {
39+
overlay.classList.add('active');
40+
document.body.classList.add('sheet-open');
41+
// 避免与右侧日程抽屉叠加
42+
const rightPanel = document.querySelector('.right-panel');
43+
if (rightPanel) rightPanel.classList.remove('active');
44+
} else {
45+
overlay.classList.remove('active');
46+
document.body.classList.remove('sheet-open');
47+
}
48+
};
49+
50+
const observer = new MutationObserver(sync);
51+
panels.forEach(p => observer.observe(p, { attributes: true, attributeFilter: ['style'] }));
52+
window.addEventListener('resize', sync);
53+
this.syncMobileDetailSheet = sync;
54+
sync();
55+
};
56+
1157
RoadbookApp.prototype.getIconForName = function(name) {
1258
const lowerCaseName = name.toLowerCase();
1359
// 交通类
@@ -120,7 +166,7 @@ RoadbookApp.prototype.convertMarkdownLinksToHtml = function(text) {
120166
};
121167

122168
// 生成标记点弹窗内容 (只读模式)
123-
RoadbookApp.prototype.generateMarkerPopupContent = function(markerData) {
169+
RoadbookApp.prototype.generateMarkerPopupContent = function(markerData, options = {}) {
124170
let content = '<div class="popup-content">';
125171
content += '<h3>' + markerData.title + '</h3>';
126172

@@ -137,6 +183,11 @@ RoadbookApp.prototype.generateMarkerPopupContent = function(markerData) {
137183
}
138184

139185
content += '<p><strong>坐标:</strong> ' + markerData.position[1].toFixed(6) + ', ' + markerData.position[0].toFixed(6) + '</p>';
186+
187+
// 移动端轻量编辑:在只读气泡底部提供删除入口
188+
if (options.withDelete) {
189+
content += '<div class="popup-actions"><button type="button" class="popup-delete-marker">🗑️ 删除此标记点</button></div>';
190+
}
140191
content += '</div>';
141192

142193
return content;

static/script.js

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ class RoadbookApp {
123123

124124
// 初始化移动端适配
125125
this.initMobileFeatures();
126+
this.initMobileDetailSheet();
126127
this.preventBrowserZoom();
127128

128129
// 先尝试从本地存储加载设置,以获取保存的地图源和搜索方式
@@ -1331,14 +1332,7 @@ class RoadbookApp {
13311332
return; // 非移动设备不执行移动端适配
13321333
}
13331334

1334-
// 修改标题为只读模式
1335-
const titleElement = document.querySelector('header h1');
1336-
if (titleElement) {
1337-
titleElement.textContent = `${titleElement.textContent} (只读模式)`;
1338-
}
1339-
1340-
// 显示进入提示
1341-
alert('当前为移动端只读模式,如需编辑请使用电脑访问。');
1335+
// 移动端支持查看与轻量编辑(日期备注 / 花销 / 删除标记点),完整行程编辑建议使用电脑
13421336

13431337
// 初始化移动端菜单功能
13441338
this.initMobileMenu();

static/style.css

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2482,6 +2482,38 @@ body.dark-mode .leaflet-tile {
24822482
filter: invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%);
24832483
}
24842484

2485+
/* 移动端底部抽屉:遮罩、动画与气泡删除按钮(详情面板复用为底部抽屉) */
2486+
.mobile-sheet-overlay {
2487+
display: none;
2488+
}
2489+
2490+
@keyframes sheetSlideUp {
2491+
from { transform: translateY(100%); }
2492+
to { transform: translateY(0); }
2493+
}
2494+
2495+
/* 标记气泡内的删除按钮(移动端轻量编辑入口) */
2496+
.popup-actions {
2497+
margin-top: 10px;
2498+
text-align: center;
2499+
}
2500+
2501+
.popup-delete-marker {
2502+
width: 100%;
2503+
padding: 8px 12px;
2504+
border: none;
2505+
border-radius: 8px;
2506+
background: #e74c3c;
2507+
color: #fff;
2508+
font-size: 14px;
2509+
font-weight: 600;
2510+
cursor: pointer;
2511+
}
2512+
2513+
.popup-delete-marker:active {
2514+
background: #c0392b;
2515+
}
2516+
24852517
/* 移动端适配样式 */
24862518
@media (max-width: 768px) {
24872519
/* 移动端菜单切换按钮 */
@@ -2650,9 +2682,63 @@ body.dark-mode .leaflet-tile {
26502682
min-width: 120px;
26512683
}
26522684

2653-
/* 移动端详情面板样式 */
2685+
/* 移动端详情面板:底部抽屉呈现(查看 + 轻量编辑) */
26542686
.detail-panel {
26552687
font-size: 0.9rem;
2688+
position: fixed;
2689+
left: 0;
2690+
right: 0;
2691+
bottom: 0;
2692+
top: auto;
2693+
width: 100%;
2694+
height: auto;
2695+
max-height: 85vh;
2696+
overflow-y: auto;
2697+
z-index: 2000;
2698+
border-left: none;
2699+
border-radius: 18px 18px 0 0;
2700+
box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.28);
2701+
-webkit-overflow-scrolling: touch;
2702+
animation: sheetSlideUp 0.28s cubic-bezier(0.4, 0, 0.2, 1);
2703+
}
2704+
2705+
/* 抽屉标题栏吸顶,便于随时关闭 */
2706+
.detail-panel .detail-header {
2707+
position: sticky;
2708+
top: 0;
2709+
background: var(--bg-secondary);
2710+
z-index: 1;
2711+
}
2712+
2713+
/* 底部抽屉遮罩层 */
2714+
.mobile-sheet-overlay {
2715+
display: block;
2716+
position: fixed;
2717+
top: 0;
2718+
left: 0;
2719+
right: 0;
2720+
bottom: 0;
2721+
background: rgba(0, 0, 0, 0.45);
2722+
z-index: 1500;
2723+
opacity: 0;
2724+
pointer-events: none;
2725+
transition: opacity 0.3s ease;
2726+
}
2727+
2728+
.mobile-sheet-overlay.active {
2729+
opacity: 1;
2730+
pointer-events: auto;
2731+
}
2732+
2733+
/* 抽屉打开时锁定背景滚动 */
2734+
body.sheet-open {
2735+
overflow: hidden;
2736+
}
2737+
2738+
/* 抽屉打开时,让详情面板所在容器整体盖过遮罩,保证面板内可交互
2739+
(.right-panel 自身 z-index 会形成 stacking context,需高于遮罩) */
2740+
body.sheet-open .right-panel {
2741+
z-index: 2000;
26562742
}
26572743

26582744
.detail-header h3 {

test/playwright.config.mjs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ const isCI = !!process.env.CI;
55
// 默认:本地复用系统 Chrome(不下载 Playwright 浏览器);CI 用 Playwright 的 Chromium(由 setup:ci 显式安装)
66
const useSystemChrome = !isCI && process.env.USE_SYSTEM_CHROME !== '0';
77

8+
// 端口/基址可经环境变量覆盖,避免本地默认端口被其它服务占用时 reuseExistingServer 复用到错误服务
9+
const port = Number(process.env.E2E_PORT || 4173);
10+
const baseURL = process.env.E2E_BASE_URL || `http://127.0.0.1:${port}`;
11+
812
export default defineConfig({
913
testDir: './tests',
1014
timeout: 60_000,
@@ -16,15 +20,15 @@ export default defineConfig({
1620
? [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]]
1721
: [['list']],
1822
use: {
19-
baseURL: process.env.E2E_BASE_URL || 'http://127.0.0.1:4173',
23+
baseURL,
2024
headless: isCI,
2125
acceptDownloads: true,
2226
actionTimeout: 15_000,
2327
navigationTimeout: 30_000
2428
},
2529
webServer: {
2630
command: 'node ./server.mjs',
27-
port: 4173,
31+
url: baseURL,
2832
reuseExistingServer: !isCI
2933
},
3034
projects: [

test/tests/mobile-edit.spec.mjs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { test, expect } from '@playwright/test';
2+
import { prepareApp, confirmSwal } from './helpers.mjs';
3+
4+
// 移动端视口(<=768,与 CSS @media 及 isMobileDevice() 对齐,触发底部抽屉态)
5+
test.use({ viewport: { width: 390, height: 740 } });
6+
7+
test.describe('移动端轻量编辑', () => {
8+
test('点击标记弹出只读气泡,并可删除标记点', async ({ page }) => {
9+
await prepareApp(page);
10+
11+
// 直接通过 API 在当前地图中心加一个标记点(移动端禁用地图点击加点,故不走 UI 点击)
12+
await page.evaluate(() => window.app.addMarker(window.app.map.getCenter()));
13+
await page.waitForFunction(() => window.app.markers.length === 1);
14+
15+
// 关闭加点后弹出的详情抽屉,露出地图上的标记
16+
await page.locator('#closeMarkerDetailBtn').click();
17+
await expect(page.locator('#markerDetailPanel')).toBeHidden();
18+
19+
// 点击地图标记 → 移动端弹出只读气泡(含删除入口)。
20+
// 用 leaflet 事件触发(与项目其它用例一致,比对 divIcon 派发 DOM 点击更可靠)
21+
await page.evaluate(() => {
22+
const m = window.app.markers[0];
23+
m.marker.fire('click', { latlng: m.marker.getLatLng() });
24+
});
25+
const delBtn = page.locator('.popup-delete-marker');
26+
await expect(delBtn).toBeVisible();
27+
28+
// 删除并确认 → 标记被移除
29+
await delBtn.click();
30+
await confirmSwal(page);
31+
await page.waitForFunction(() => window.app.markers.length === 0);
32+
});
33+
34+
test('日期详情以底部抽屉呈现,可编辑备注/花销并通过遮罩关闭', async ({ page }) => {
35+
await prepareApp(page);
36+
37+
// 移动端已支持轻量编辑,标题不再标注“只读模式”
38+
await expect(page.locator('#mainTitle')).not.toContainText('只读模式');
39+
40+
const date = '2030-01-01';
41+
await page.evaluate((d) => window.app.showDateDetail(d), date);
42+
43+
// 底部抽屉态:面板可见 + 遮罩激活 + 背景滚动锁
44+
await expect(page.locator('#dateDetailPanel')).toBeVisible();
45+
await expect(page.locator('#mobileSheetOverlay')).toHaveClass(/active/);
46+
await expect(page.locator('body')).toHaveClass(/sheet-open/);
47+
48+
// 轻量编辑:日期备注(实时保存到 dateNotes)
49+
await page.fill('#dateNotesInput', '移动端备注测试');
50+
await expect
51+
.poll(() => page.evaluate((d) => window.app.getDateNotes(d), date))
52+
.toBe('移动端备注测试');
53+
54+
// 轻量编辑:记一笔花销
55+
await page.fill('#expenseCostInput', '128');
56+
await page.fill('#expenseRemarkInput', '午餐');
57+
await page.click('#addExpenseBtn');
58+
await expect
59+
.poll(() => page.evaluate((d) => window.app.getDateExpenses(d).length, date))
60+
.toBe(1);
61+
62+
// 点击遮罩(顶部空白处,避开底部抽屉)关闭
63+
await page.locator('#mobileSheetOverlay').click({ position: { x: 5, y: 5 } });
64+
await expect(page.locator('#dateDetailPanel')).toBeHidden();
65+
await expect(page.locator('#mobileSheetOverlay')).not.toHaveClass(/active/);
66+
});
67+
});

0 commit comments

Comments
 (0)