diff --git a/package.json b/package.json
index d4d0b3b..2814775 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,8 @@
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.0",
"remark-gfm": "^4.0.1",
+ "sweetalert2": "^11.26.24",
+ "sweetalert2-react-content": "^5.1.2",
"typescript": "^5.9.3",
"zustand": "^5.0.11"
},
diff --git a/src/components/modals/TripEditor.tsx b/src/components/modals/TripEditor.tsx
index 0e84954..10e9ce4 100644
--- a/src/components/modals/TripEditor.tsx
+++ b/src/components/modals/TripEditor.tsx
@@ -1,5 +1,8 @@
+import { useTranslation } from 'react-i18next';
import React, { useState, useEffect } from 'react';
import { Edit2, Plus, X, ListFilter, AlertTriangle, ArrowRightLeft, ArrowDown, Search, Loader2 } from 'lucide-react';
+import { toast } from 'react-hot-toast';
+import { showConfirm } from '../../utils/confirm';
import { useStore, EditorMode } from '../../store';
import { DropZone } from '../DragContext';
import { LineSelector } from './LineSelector';
@@ -46,8 +49,8 @@ export const TripEditor: React.FC = () => {
const onSave = () => {
// Validation logic
const validSegments = form.segments?.filter(s => s.fromId !== s.toId) || [];
- if (validSegments.length === 0) { alert(t("tripEdit.atLeastOne", "至少包含一段有效行程")); return; }
- if (validSegments.some(s => !s.lineKey || !s.fromId || !s.toId)) { alert(t("tripEdit.fillInfo", "请完善信息")); return; }
+ if (validSegments.length === 0) { toast.error(t("tripEdit.atLeastOne", "至少包含一段有效行程")); return; }
+ if (validSegments.some(s => !s.lineKey || !s.fromId || !s.toId)) { toast.error(t("tripEdit.fillInfo", "请完善信息")); return; }
// Resolve 'auto' loops to permanent 'up' or 'down'
const resolvedSegments = validSegments.map(seg => {
@@ -90,7 +93,7 @@ export const TripEditor: React.FC = () => {
setTrips(finalTrips);
if (user) {
- saveData(user.token, finalTrips, pins, folders, badgeSettings).catch((e: any) => alert('云端保存失败: ' + e.message));
+ saveData(user.token, finalTrips, pins, folders, badgeSettings).catch((e: any) => toast.error('云端保存失败: ' + e.message));
}
closeEditor();
};
@@ -125,7 +128,7 @@ export const TripEditor: React.FC = () => {
}
}, [autoRouteEasterEggType, isOpen, autoForm, form.date]);
- const onAutoSearch = (retryWithInfiniteSearch = false) => {
+ const onAutoSearch = async (retryWithInfiniteSearch = false) => {
const isInfinite = retryWithInfiniteSearch === true;
const { startLine, startStation, endLine, endStation } = autoForm;
if (!startLine || !startStation || !endLine || !endStation) return;
@@ -152,20 +155,20 @@ export const TripEditor: React.FC = () => {
if (result.error) {
setIsRouteSearching(false);
if (!isInfinite && result.error.includes("超出最大换乘次数")) {
- setTimeout(() => {
- const wantRetry = window.confirm(t("tripEdit.autoMaxLimit", "自动规划超出6次换乘限制或无解。\n是否继续无限制深度搜索?(这可能需要较长等待时间)"));
+ setTimeout(async () => {
+ const wantRetry = await showConfirm(t('tripEdit.autoMaxLimitTitle', '换乘超限'), t("tripEdit.autoMaxLimit", "自动规划超出6次换乘限制或无解。\n是否继续无限制深度搜索?(这可能需要较长等待时间)"), t);
if (wantRetry) {
onAutoSearch(true);
}
}, 100);
} else {
setTimeout(() => {
- alert(`${t("tripEdit.autoFail", "无法规划: ")}${result.error}`);
+ toast.error(`${t("tripEdit.autoFail", "无法规划: ")}${result.error}`);
}, 100);
}
}
else {
- if (result.segments.length > 20) { setIsRouteSearching(false); alert(t("tripEdit.pathTooLong", "路径过长")); return; }
+ if (result.segments.length > 20) { setIsRouteSearching(false); toast.error(t("tripEdit.pathTooLong", "路径过长")); return; }
setForm({ segments: result.segments });
setEditorMode(EditorMode.Manual);
setTimeout(() => setIsRouteSearching(false), 200);
@@ -254,7 +257,7 @@ export const TripEditor: React.FC = () => {
};
const addSegment = () => {
- if ((form.segments?.length || 0) >= 10) { alert(t("tripEdit.maxSegment", "最多 10 段")); return; }
+ if ((form.segments?.length || 0) >= 10) { toast.error(t("tripEdit.maxSegment", "最多 10 段")); return; }
setForm({ segments: [...(form.segments || []), { id: Date.now().toString(), lineKey: '', fromId: '', toId: '', loopVia: 'auto' }] });
};
@@ -365,11 +368,11 @@ export const TripEditor: React.FC = () => {
{editorMode === EditorMode.Manual && (
-
setForm({ date: e.target.value })} />
+
setForm({ date: e.target.value })} />
- setForm({ cost: parseInt(e.target.value) || 0 })} />
+ setForm({ cost: parseInt(e.target.value) || 0 })} />
@@ -397,7 +400,7 @@ export const TripEditor: React.FC = () => {
return (
{idx + 1}
- {idx > 0 &&
}
+ {idx > 0 &&
}
@@ -434,14 +437,14 @@ export const TripEditor: React.FC = () => {
setForm({ segments: newSegs });
}
}}>
-
-
setAutoForm({ ...autoForm, startStation: e.target.value })}>{autoForm.startLine && railwayData[autoForm.startLine]?.stations.map(s => )}
+
setAutoForm({ ...autoForm, startStation: e.target.value })}>{autoForm.startLine && railwayData[autoForm.startLine]?.stations.map(s => )}
@@ -539,7 +542,7 @@ export const TripEditor: React.FC = () => {
openSelector('autoEnd')} className="flex-1 p-2 text-sm text-left text-gray-700 truncate flex items-center gap-1 hover:bg-gray-50 border-r">{autoForm.endLine ? {autoForm.endLine} : {t('tripEdit.selLine', '选择线路...')}}
openSearch('autoEnd')} className="p-2 bg-gray-50 hover:bg-gray-100 text-gray-500 w-10 shrink-0 flex items-center justify-center">
-
setAutoForm({ ...autoForm, endStation: e.target.value })}>{autoForm.endLine && railwayData[autoForm.endLine]?.stations.map(s => )}
+
setAutoForm({ ...autoForm, endStation: e.target.value })}>{autoForm.endLine && railwayData[autoForm.endLine]?.stations.map(s => )}
diff --git a/src/components/modals/WalkTripEditor.tsx b/src/components/modals/WalkTripEditor.tsx
index d2a25eb..4334d88 100644
--- a/src/components/modals/WalkTripEditor.tsx
+++ b/src/components/modals/WalkTripEditor.tsx
@@ -1,3 +1,6 @@
+import { showConfirm } from '../../utils/confirm';
+import { toast } from 'react-hot-toast';
+import { useTranslation } from 'react-i18next';
import React, { useEffect } from 'react';
import { Edit2, X, AlertTriangle, Save, Trash2 } from 'lucide-react';
import { useStore } from '../../store';
@@ -6,6 +9,7 @@ import { useUserData } from '../../hooks/useUserData';
import * as turf from '@turf/turf';
export const WalkTripEditor: React.FC = () => {
+ const { t } = useTranslation();
const {
isOpen, isEditing, form, railwayData, trips, pins, folders, badgeSettings, user
} = useStore(useShallow(state => ({
@@ -67,7 +71,7 @@ export const WalkTripEditor: React.FC = () => {
const onSave = async () => {
if (!form.date) return;
if (!form.fromId || !form.toId) {
- alert(t('walk.noStartEnd', '缺少起止点'));
+ toast.error(t('walk.noStartEnd', '缺少起止点'));
return;
}
@@ -109,17 +113,18 @@ export const WalkTripEditor: React.FC = () => {
setTrips(finalTrips);
if (user) {
- saveData(user.token, finalTrips, pins, folders, badgeSettings).catch((e: any) => alert(t('walk.saveFail', '云端保存失败: ') + e.message));
+ saveData(user.token, finalTrips, pins, folders, badgeSettings).catch((e: any) => toast.error(t('walk.saveFail', '云端保存失败: ') + e.message));
}
closeEditor();
};
const onDelete = async () => {
- if (!window.confirm(t('walk.delConfirm', "确定要删除这条步行记录吗?"))) return;
+ const isConfirmed = await showConfirm(t('walk.deleteTitle', '删除步行记录'), t('walk.delConfirm', '确定要删除这条步行记录吗?'), t);
+ if (!isConfirmed) return;
const nextTrips = trips.filter(t => t.id !== form.id);
setTrips(nextTrips);
if (user) {
- saveData(user.token, nextTrips, pins, folders, badgeSettings).catch((e: any) => alert(t('walk.delFail', '云端删除失败: ') + e.message));
+ saveData(user.token, nextTrips, pins, folders, badgeSettings).catch((e: any) => toast.error(t('walk.delFail', '云端删除失败: ') + e.message));
}
closeEditor();
};
@@ -157,7 +162,7 @@ export const WalkTripEditor: React.FC = () => {
{isEditing ? t('walk.editTitle', '编辑步行路线') : t('walk.newTitle', '新建步行路线')}
-
+ { e.currentTarget.blur(); closeEditor(); }}>
@@ -181,26 +186,26 @@ export const WalkTripEditor: React.FC = () => {
{isEditing && (
-
+
{t('walk.delete', '删除')}
)}
-
+
{t('walk.save', '保存行程')}
diff --git a/src/pages/TripsPage.tsx b/src/pages/TripsPage.tsx
index 7e6ec84..e1faeff 100644
--- a/src/pages/TripsPage.tsx
+++ b/src/pages/TripsPage.tsx
@@ -34,7 +34,7 @@ const RouteSlice = React.memo(({ segments }: { segments: any[] }) => {
[segments, segmentGeometries, railwayData, geoData]
);
- if (visualPaths.length === 0) return {t('tripsPage.noPreview', '无预览')}
;
+ if (visualPaths.length === 0) return {window.i18n?.t('tripsPage.noPreview', '无预览') || '无预览'}
;
const maxWidth = Math.max(0, containerWidth - 300);
const shouldRotate = isMobile && widthPx > maxWidth && maxWidth > 0;
@@ -66,11 +66,13 @@ const RouteSlice = React.memo(({ segments }: { segments: any[] }) => {
});
});
+import { useTranslation } from 'react-i18next';
import { useUserData } from '../hooks/useUserData';
import { processSuicaCSV } from '../utils/suicaParser';
-import toast from 'react-hot-toast';
+import { toast } from 'react-hot-toast';
export const TripsPage: React.FC = () => {
+ const { t } = useTranslation();
const { trips, railwayData, segmentGeometries, user, pins, folders, badgeSettings } = useStore(useShallow(state => ({
trips: state.trips,
railwayData: state.railwayData,
@@ -106,7 +108,12 @@ export const TripsPage: React.FC = () => {
if (newTrips.length > 0) {
toast.dismiss(toastId);
const skipMsg = skippedCount > 0 ? t('tripsPage.skipMsg', '\n(已跳过 {{count}} 条重复记录)', { count: skippedCount }) : '';
- if (window.confirm(t('tripsPage.parseSuccess', '成功解析 {{count}} 条新行程。是否导入?{{skipMsg}}\n(按 F12 打开控制台查看详细匹配日志)', { count: newTrips.length, skipMsg: skipMsg }))) {
+
+ // Changed to SweetAlert2 Promise
+ const confirmMsg = t('tripsPage.parseSuccess', '成功解析 {{count}} 条新行程。是否导入?{{skipMsg}}\n(按 F12 打开控制台查看详细匹配日志)', { count: newTrips.length, skipMsg: skipMsg });
+ const isConfirmed = await showConfirm(t('tripsPage.importTitle', '导入确认'), confirmMsg, t);
+ if (isConfirmed) {
+
newTrips.forEach(trip => addTrip(trip));
const skipMsgShort = skippedCount > 0 ? t('tripsPage.skipMsgShort', ' (跳过 {{count}} 重复)', { count: skippedCount }) : '';
toast.success(t('tripsPage.importSuccess', '导入了 {{count}} 条行程!{{skipMsg}}', { count: newTrips.length, skipMsg: skipMsgShort }));
@@ -136,8 +143,11 @@ export const TripsPage: React.FC = () => {
event.target.value = '';
};
- const handleDeleteTrip = (id: string | number) => {
- if (confirm(t('tripsPage.deleteConfirm', '确认删除?'))) {
+
+ const handleDeleteTrip = async (id: string | number) => {
+ const isConfirmed = await showConfirm(t('tripsPage.deleteTitle', '删除行程'), t('tripsPage.deleteConfirm', '确认删除?'), t);
+ if (isConfirmed) {
+
removeTrip(id);
if (user) {
const newTrips = trips.filter(t => t.id !== id);
@@ -156,21 +166,21 @@ export const TripsPage: React.FC = () => {
{t('tripsPage.addFirstTrip', '点击下方按钮添加你的第一次乗り鉄')}
{t('tripsPage.addFirstTripNote', '注意: 自定义线路可以导入 company_data 和 geojson')}
) : (
- trips.map(t => {
- const segments = t.segments || [{ lineKey: t.lineKey, fromId: t.fromId, toId: t.toId }];
- const isWalk = t.isWalk;
+ trips.map(trip => {
+ const segments = trip.segments || [{ lineKey: trip.lineKey, fromId: trip.fromId, toId: trip.toId }];
+ const isWalk = trip.isWalk;
if (isWalk) {
- let startName = t.fromId || '';
- let endName = t.toId || '';
+ let startName = trip.fromId || '';
+ let endName = trip.toId || '';
Object.values(railwayData).forEach(line => {
- const s = line.stations.find(st => st.id === t.fromId);
+ const s = line.stations.find(st => st.id === trip.fromId);
if (s) startName = s.name_ja;
- const e = line.stations.find(st => st.id === t.toId);
+ const e = line.stations.find(st => st.id === trip.toId);
if (e) endName = e.name_ja;
});
- const isTree = t.walkType === 'tree';
+ const isTree = trip.walkType === 'tree';
const cls = {
bg: isTree ? 'bg-green-50' : 'bg-purple-50',
border: isTree ? 'border-green-100' : 'border-purple-100',
@@ -188,13 +198,13 @@ export const TripsPage: React.FC = () => {
};
return (
- useStore.getState().startEditingWalkTrip(t)}>
+
useStore.getState().startEditingWalkTrip(trip)}>
-
{t.date}
+
{trip.date}
{t('tripsPage.walk', '步行')}
- { e.stopPropagation(); useStore.getState().startEditingWalkTrip(t); }} className={cls.btnEdit}>
- { e.stopPropagation(); handleDeleteTrip(t.id); }} className={cls.btnDel}>
+ { e.stopPropagation(); useStore.getState().startEditingWalkTrip(trip); }} className={`${cls.btnEdit} active:scale-90 transition-transform`}>
+ { e.currentTarget.blur(); e.stopPropagation(); handleDeleteTrip(trip.id); }} className={`${cls.btnDel} active:scale-90 transition-transform`}>
@@ -208,20 +218,20 @@ export const TripsPage: React.FC = () => {
- {t.memo && {t.memo}
}
+ {trip.memo && {trip.memo}
}
);
}
return (
-
+
-
{t.date}
+
{trip.date}
- {(t.cost || 0) > 0 && ¥{t.cost}}
- setModalState({ addToFolderModalOpen: true, currentTripForFolder: t })} className="text-gray-400 hover:text-yellow-500">
- startEditingTrip(t)} className="text-gray-400 hover:text-blue-500">
- { e.stopPropagation(); handleDeleteTrip(t.id); }} className="text-gray-400 hover:text-red-500">
+ {(trip.cost || 0) > 0 && ¥{trip.cost}}
+ { e.currentTarget.blur(); setModalState({ addToFolderModalOpen: true, currentTripForFolder: trip }); }} className="text-gray-400 hover:text-yellow-500 active:scale-90 transition-transform">
+ { e.currentTarget.blur(); startEditingTrip(trip); }} className="text-gray-400 hover:text-blue-500 active:scale-90 transition-transform">
+ { e.currentTarget.blur(); e.stopPropagation(); handleDeleteTrip(trip.id); }} className="text-gray-400 hover:text-red-500 active:scale-90 transition-transform">
@@ -262,13 +272,14 @@ export const TripsPage: React.FC = () => {
- {t.memo &&
{t.memo}
}
+ {trip.memo &&
{trip.memo}
}
);
})
)}
{
};
import { ArrowUp, ArrowDown } from 'lucide-react';
+import { showConfirm } from '../utils/confirm';
export const FloatingActionButtons: React.FC<{
+ t: any,
fileInputRef: React.RefObject,
handleImportSuica: (event: React.ChangeEvent) => void,
startEditingTrip: (data?: any) => void,
alwaysVisible?: boolean
-}> = ({ fileInputRef, handleImportSuica, startEditingTrip, alwaysVisible = false }) => {
+}> = ({ fileInputRef, handleImportSuica, startEditingTrip, alwaysVisible = false, t }) => {
const [isVisible, setIsVisible] = useState(true);
const [isTutorialActive, setIsTutorialActive] = useState(false);
const [scrollPos, setScrollPos] = useState<'top' | 'middle' | 'bottom'>('top');
@@ -459,12 +472,12 @@ export const FloatingActionButtons: React.FC<{
{/* Scroll Buttons */}
{scrollPos !== 'top' && (
-
+
)}
{scrollPos !== 'bottom' && (
-
+
)}
@@ -486,10 +499,10 @@ export const FloatingActionButtons: React.FC<{
}
}}>
-
startEditingTrip()} className="flex-1 py-3 border-1 border-gray-300 text-gray-500 backdrop-blur-sm shadow-lg rounded-xl hover:bg-emerald-50 hover:text-emerald-600 hover:border-emerald-300 font-bold transition-all duration-300 active:scale-[0.98] group flex items-center justify-center gap-2">
+ { e.currentTarget.blur(); startEditingTrip(); }} className="flex-1 py-3 border-1 border-gray-300 text-gray-500 backdrop-blur-sm shadow-lg rounded-xl hover:bg-emerald-50 hover:text-emerald-600 hover:border-emerald-300 font-bold transition-all duration-300 active:scale-95 group flex items-center justify-center gap-2">
{t('tripsPage.recordNewTrip', '记录新行程')}
- fileInputRef.current?.click()} className="flex-none px-4 py-3 border-1 border-gray-300 text-gray-500 backdrop-blur-sm shadow-lg rounded-xl hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300 font-bold transition-all duration-300 active:scale-[0.98] group flex items-center justify-center gap-2" title={t('tripsPage.importSuica', '导入 Suica CSV')}>
+ { e.currentTarget.blur(); fileInputRef.current?.click(); }} className="flex-none px-4 py-3 border-1 border-gray-300 text-gray-500 backdrop-blur-sm shadow-lg rounded-xl hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300 font-bold transition-all duration-300 active:scale-95 group flex items-center justify-center gap-2" title={t('tripsPage.importSuica', '导入 Suica CSV')}>
diff --git a/src/utils/confirm.ts b/src/utils/confirm.ts
new file mode 100644
index 0000000..d60ec9e
--- /dev/null
+++ b/src/utils/confirm.ts
@@ -0,0 +1,28 @@
+import Swal, { SweetAlertOptions } from 'sweetalert2';
+import withReactContent from 'sweetalert2-react-content';
+import { i18n } from 'i18next';
+
+const MySwal = withReactContent(Swal);
+
+// Utility function to show a confirm dialog
+export const showConfirm = async (
+ title: string,
+ text: string = '',
+ t: any = (key: string, fallback: string) => fallback
+): Promise => {
+ const result = await MySwal.fire({
+ title: title,
+ text: text,
+ icon: 'warning',
+ showCancelButton: true,
+ confirmButtonColor: '#10b981', // emerald-500
+ cancelButtonColor: '#ef4444', // red-500
+ confirmButtonText: t('common.confirm', '确认'),
+ cancelButtonText: t('common.cancel', '取消'),
+ reverseButtons: true, // Typically better UX to have confirm on the right
+ });
+
+ return result.isConfirmed;
+};
+
+export default showConfirm;