feat(web,installer): 更新下载源、升级资源缓存版本、本地化界面并新增管理功能
- 更新 update_machine.lua 和 installer.lua 中的远程资源下载地址,从旧云存储链接切换为 Gitea 仓库提交镜像地址 - 新增双向闸机专用安装脚本 installer_bi.lua - 为所有网页HTML文件更新静态资源的缓存版本号,避免浏览器加载过期的静态文件缓存 - 修复登录页面的乱码文本,替换为标准简体中文内容,修正ICP备案标识文本 - 新增管理后台概览板块、快捷操作按钮,优化IC卡管理界面与响应式布局样式
This commit is contained in:
+180
-60
@@ -63,6 +63,24 @@ createApp({
|
||||
const icCreateForm = reactive({ holder_name: '', balance: 50 });
|
||||
const icDetailForm = reactive({ holder_name: '', status: 'active' });
|
||||
let icCardSyncTimer = null;
|
||||
let icCardSyncBusy = false;
|
||||
let icListRequestSeq = 0;
|
||||
let icDetailRequestSeq = 0;
|
||||
let appMouseupHandler = null;
|
||||
let coreLoaded = false;
|
||||
let ticketDataLoaded = false;
|
||||
let orderDataLoaded = false;
|
||||
let logDataLoaded = false;
|
||||
let assetsLoaded = false;
|
||||
let fareMapLoaded = false;
|
||||
const loadingState = reactive({
|
||||
core: false,
|
||||
tickets: false,
|
||||
orders: false,
|
||||
logs: false,
|
||||
iccards: false
|
||||
});
|
||||
const lastSyncAt = ref(0);
|
||||
|
||||
// UI State
|
||||
const showAddLine = ref(false);
|
||||
@@ -103,6 +121,9 @@ createApp({
|
||||
confirm: (message) => Promise.resolve(window.confirm(message)),
|
||||
prompt: (message, defaultValue) => Promise.resolve(window.prompt(message, defaultValue))
|
||||
};
|
||||
const markSynced = () => {
|
||||
lastSyncAt.value = Date.now();
|
||||
};
|
||||
|
||||
const buildAssetUrl = (name) => {
|
||||
if (!name) return '';
|
||||
@@ -417,6 +438,7 @@ createApp({
|
||||
assetsManifest.updatedAt = data ? (data.updatedAt || null) : null;
|
||||
assetsRouteMapUrl.value = buildAssetUrl(assetsManifest.routeMap);
|
||||
assetsFareTableUrl.value = buildAssetUrl(assetsManifest.fareTable);
|
||||
assetsLoaded = true;
|
||||
|
||||
assetsFarePreview.headers = [];
|
||||
assetsFarePreview.rows = [];
|
||||
@@ -454,6 +476,7 @@ createApp({
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
markSynced();
|
||||
};
|
||||
|
||||
const uploadAssetFile = async (url, file) => {
|
||||
@@ -633,34 +656,58 @@ createApp({
|
||||
|
||||
// --- Order Management ---
|
||||
const fetchOrders = async () => {
|
||||
if (loadingState.orders) return;
|
||||
loadingState.orders = true;
|
||||
try {
|
||||
const res = await requestJson('/api/orders');
|
||||
if (res && res.ok) orders.value = res.orders;
|
||||
} catch (e) { console.error(e); }
|
||||
if (res && res.ok) {
|
||||
orders.value = res.orders || [];
|
||||
orderDataLoaded = true;
|
||||
markSynced();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loadingState.orders = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchIcCards = async (keepSelection = true) => {
|
||||
if (loadingState.iccards) return;
|
||||
loadingState.iccards = true;
|
||||
const requestSeq = ++icListRequestSeq;
|
||||
const sp = new URLSearchParams();
|
||||
if (icCardSearch.value.trim()) sp.set('q', icCardSearch.value.trim());
|
||||
const res = await requestJson(`/api/ic-cards?${sp.toString()}`);
|
||||
icCards.value = res?.cards || [];
|
||||
if (keepSelection && icSelectedId.value && icCards.value.some((card) => card.card_id === icSelectedId.value)) {
|
||||
await loadIcCard(icSelectedId.value);
|
||||
} else if (icSelectedId.value && !icCards.value.some((card) => card.card_id === icSelectedId.value)) {
|
||||
icSelectedId.value = '';
|
||||
icSelectedCard.value = null;
|
||||
icSelectedEvents.value = [];
|
||||
try {
|
||||
const res = await requestJson(`/api/ic-cards?${sp.toString()}`);
|
||||
if (requestSeq !== icListRequestSeq) return;
|
||||
icCards.value = res?.cards || [];
|
||||
if (keepSelection && icSelectedId.value && icCards.value.some((card) => card.card_id === icSelectedId.value)) {
|
||||
await loadIcCard(icSelectedId.value);
|
||||
} else if (icSelectedId.value && !icCards.value.some((card) => card.card_id === icSelectedId.value)) {
|
||||
icSelectedId.value = '';
|
||||
icSelectedCard.value = null;
|
||||
icSelectedEvents.value = [];
|
||||
}
|
||||
markSynced();
|
||||
} finally {
|
||||
if (requestSeq === icListRequestSeq) {
|
||||
loadingState.iccards = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loadIcCard = async (id) => {
|
||||
const requestSeq = ++icDetailRequestSeq;
|
||||
const res = await requestJson(`/api/ic-cards/${encodeURIComponent(id)}`);
|
||||
if (requestSeq !== icDetailRequestSeq) return;
|
||||
const card = res?.card || null;
|
||||
icSelectedId.value = id;
|
||||
icSelectedCard.value = card;
|
||||
icSelectedEvents.value = res?.events || [];
|
||||
icDetailForm.holder_name = card?.holder_name || '';
|
||||
icDetailForm.status = card?.status || 'active';
|
||||
markSynced();
|
||||
};
|
||||
|
||||
const syncSelectedIcCard = async () => {
|
||||
@@ -683,9 +730,15 @@ createApp({
|
||||
stopIcCardSync();
|
||||
if (currentView.value !== 'iccards') return;
|
||||
icCardSyncTimer = setInterval(() => {
|
||||
fetchIcCards(false).catch(console.error);
|
||||
syncSelectedIcCard().catch(console.error);
|
||||
}, 3000);
|
||||
if (document.hidden || icCardSyncBusy) return;
|
||||
icCardSyncBusy = true;
|
||||
Promise.all([
|
||||
fetchIcCards(false).catch(console.error),
|
||||
icSelectedId.value ? syncSelectedIcCard().catch(console.error) : Promise.resolve()
|
||||
]).finally(() => {
|
||||
icCardSyncBusy = false;
|
||||
});
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
const createIcCard = async () => {
|
||||
@@ -775,10 +828,15 @@ createApp({
|
||||
};
|
||||
|
||||
const fetchLogs = async () => {
|
||||
if (logLoading.value) return;
|
||||
logLoading.value = true;
|
||||
try {
|
||||
const res = await requestJson(buildLogsUrl());
|
||||
if (res && res.ok) logs.value = res.logs || [];
|
||||
if (res && res.ok) {
|
||||
logs.value = res.logs || [];
|
||||
logDataLoaded = true;
|
||||
markSynced();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
@@ -823,66 +881,67 @@ createApp({
|
||||
return `¥${reg} / ¥${exp}`;
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
const fetchCoreData = async ({ force = false } = {}) => {
|
||||
if (loadingState.core) return;
|
||||
if (coreLoaded && !force) return;
|
||||
loadingState.core = true;
|
||||
try {
|
||||
const safeFetch = (url, defaultVal) => requestJson(url).catch(e => {
|
||||
const safeFetch = (url, defaultVal) => requestJson(url).catch((e) => {
|
||||
console.error(`Fetch failed for ${url}`, e);
|
||||
lastActionError.value = e?.message || String(e);
|
||||
return defaultVal;
|
||||
});
|
||||
|
||||
const safeFetchList = (url, key) => requestJson(url).then(d => d?.[key] || []).catch(e => {
|
||||
console.error(`Fetch list failed for ${url}`, e);
|
||||
lastActionError.value = e?.message || String(e);
|
||||
return [];
|
||||
});
|
||||
|
||||
const [s, l, f, c, t, lg, st, ord, cards] = await Promise.all([
|
||||
const [s, l, f, c, st] = await Promise.all([
|
||||
safeFetch('/api/stations', []),
|
||||
safeFetch('/api/lines', []),
|
||||
safeFetch('/api/fares', []),
|
||||
safeFetch('/api/config', {}),
|
||||
safeFetchList('/api/tickets', 'tickets'),
|
||||
safeFetchList(buildLogsUrl(), 'logs'),
|
||||
safeFetch('/api/stats/ticket/total', {}).then(d => d.total || {}),
|
||||
safeFetchList('/api/orders', 'orders'),
|
||||
safeFetchList('/api/ic-cards', 'cards')
|
||||
safeFetch('/api/stats/ticket/total', {}).then((d) => d.total || {})
|
||||
]);
|
||||
|
||||
stations.value = s;
|
||||
lines.value = l;
|
||||
fares.value = f;
|
||||
Object.assign(config, c);
|
||||
tickets.value = t;
|
||||
logs.value = lg;
|
||||
Object.assign(stats, st);
|
||||
orders.value = ord;
|
||||
icCards.value = cards;
|
||||
|
||||
// Refresh selected line if it exists
|
||||
if (selectedLine.value) {
|
||||
const found = lines.value.find(l => l.id === selectedLine.value.id);
|
||||
if (found) selectedLine.value = found;
|
||||
const found = lines.value.find((line) => line.id === selectedLine.value.id);
|
||||
selectedLine.value = found || null;
|
||||
}
|
||||
if (icSelectedId.value && icCards.value.some((card) => card.card_id === icSelectedId.value)) {
|
||||
await loadIcCard(icSelectedId.value);
|
||||
}
|
||||
|
||||
loadFareMap();
|
||||
coreLoaded = true;
|
||||
markSynced();
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch data", e);
|
||||
console.error('Failed to fetch core data', e);
|
||||
} finally {
|
||||
loadingState.core = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadFareMap = async () => {
|
||||
const fetchTicketData = async () => {
|
||||
if (loadingState.tickets) return;
|
||||
loadingState.tickets = true;
|
||||
try {
|
||||
const res = await requestJson('/api/tickets');
|
||||
tickets.value = res?.tickets || [];
|
||||
ticketDataLoaded = true;
|
||||
markSynced();
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch tickets', e);
|
||||
} finally {
|
||||
loadingState.tickets = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadFareMap = async ({ force = false } = {}) => {
|
||||
if (fareMapLoading.value) return;
|
||||
if (fareMapLoaded && !force) return;
|
||||
fareMapLoading.value = true;
|
||||
fareMapError.value = '';
|
||||
try {
|
||||
// Change to fetch the SVG text directly from the public API
|
||||
// Add timestamp to prevent caching
|
||||
const r = await fetch(`/api/public/fares/map/light?t=${Date.now()}`);
|
||||
const svg = await r.text();
|
||||
fareMapSvg.value = svg;
|
||||
fareMapLoaded = true;
|
||||
markSynced();
|
||||
} catch (e) {
|
||||
console.error("Failed to load fare map", e);
|
||||
fareMapError.value = '加载失败';
|
||||
@@ -891,6 +950,25 @@ createApp({
|
||||
}
|
||||
};
|
||||
|
||||
const ensureViewData = async (view = currentView.value, { force = false } = {}) => {
|
||||
await fetchCoreData({ force });
|
||||
if (view === 'tickets' && (force || !ticketDataLoaded)) await fetchTicketData();
|
||||
if (view === 'vouchers' && (force || !orderDataLoaded)) await fetchOrders();
|
||||
if (view === 'logs' && (force || !logDataLoaded)) await fetchLogs();
|
||||
if (view === 'iccards') {
|
||||
await fetchIcCards(true);
|
||||
if (icSelectedId.value) {
|
||||
await syncSelectedIcCard().catch(console.error);
|
||||
}
|
||||
}
|
||||
if (view === 'faremap' && (force || !fareMapLoaded)) await loadFareMap({ force });
|
||||
if (view === 'assets' && (!assetsLoaded || force)) await fetchAssetsManifest();
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
await ensureViewData(currentView.value, { force: true });
|
||||
};
|
||||
|
||||
const zoomFareMapIn = () => { fareMapScale.value = Math.min(3, Math.round((fareMapScale.value + 0.1) * 100) / 100); };
|
||||
const zoomFareMapOut = () => { fareMapScale.value = Math.max(0.3, Math.round((fareMapScale.value - 0.1) * 100) / 100); };
|
||||
const zoomFareMapReset = () => { fareMapScale.value = 1; };
|
||||
@@ -1237,11 +1315,15 @@ createApp({
|
||||
socket.on('stations:updated', (data) => {
|
||||
stations.value = data;
|
||||
// Refresh map when stations change
|
||||
loadFareMap();
|
||||
fareMapLoaded = false;
|
||||
if (currentView.value === 'faremap') {
|
||||
loadFareMap({ force: true });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('lines:updated', (data) => {
|
||||
lines.value = data;
|
||||
coreLoaded = true;
|
||||
// Update selectedLine reference if it exists
|
||||
if (selectedLine.value) {
|
||||
const updated = data.find(l => l.id === selectedLine.value.id);
|
||||
@@ -1251,14 +1333,28 @@ createApp({
|
||||
selectedLine.value = null; // Line was deleted
|
||||
}
|
||||
}
|
||||
loadFareMap();
|
||||
fareMapLoaded = false;
|
||||
if (currentView.value === 'faremap') {
|
||||
loadFareMap({ force: true });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('fares:updated', (data) => {
|
||||
fares.value = data;
|
||||
loadFareMap();
|
||||
coreLoaded = true;
|
||||
fareMapLoaded = false;
|
||||
if (currentView.value === 'faremap') {
|
||||
loadFareMap({ force: true });
|
||||
}
|
||||
});
|
||||
socket.on('config:updated', (data) => {
|
||||
Object.assign(config, data);
|
||||
coreLoaded = true;
|
||||
fareMapLoaded = false;
|
||||
if (currentView.value === 'faremap') {
|
||||
loadFareMap({ force: true });
|
||||
}
|
||||
});
|
||||
socket.on('config:updated', (data) => { Object.assign(config, data); loadFareMap(); });
|
||||
|
||||
socket.on('stats:ticket:updated', (item) => {
|
||||
stats.sold_tickets += item.sold_tickets;
|
||||
@@ -1281,15 +1377,12 @@ createApp({
|
||||
|
||||
watch(currentView, (v) => {
|
||||
sidebarOpen.value = false;
|
||||
if (v === 'assets') fetchAssetsManifest();
|
||||
if (v === 'logs') fetchLogs();
|
||||
if (v === 'iccards') {
|
||||
fetchIcCards(true).catch(console.error);
|
||||
syncSelectedIcCard().catch(() => {});
|
||||
startIcCardSync();
|
||||
} else {
|
||||
stopIcCardSync();
|
||||
}
|
||||
ensureViewData(v).catch(console.error);
|
||||
const sp = new URLSearchParams(location.search);
|
||||
if (v === 'dashboard') sp.delete('view');
|
||||
else sp.set('view', v);
|
||||
@@ -1300,13 +1393,11 @@ createApp({
|
||||
|
||||
// Initial Load
|
||||
onMounted(() => {
|
||||
fetchData();
|
||||
fetchAssetsManifest();
|
||||
ensureViewData(currentView.value, { force: true }).catch(console.error);
|
||||
if (currentView.value === 'iccards') {
|
||||
fetchIcCards(true).catch(console.error);
|
||||
startIcCardSync();
|
||||
}
|
||||
window.addEventListener('mouseup', async () => {
|
||||
appMouseupHandler = async () => {
|
||||
if (draggingStationIndex.value !== null) {
|
||||
if (selectedLine.value) {
|
||||
try {
|
||||
@@ -1323,16 +1414,44 @@ createApp({
|
||||
}
|
||||
draggingStationIndex.value = null;
|
||||
}
|
||||
});
|
||||
};
|
||||
window.addEventListener('mouseup', appMouseupHandler);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopIcCardSync();
|
||||
if (appMouseupHandler) {
|
||||
window.removeEventListener('mouseup', appMouseupHandler);
|
||||
}
|
||||
});
|
||||
|
||||
// Computed
|
||||
const recentLogs = computed(() => logs.value);
|
||||
const orderList = computed(() => orders.value);
|
||||
const lastSyncText = computed(() => lastSyncAt.value ? formatTime(lastSyncAt.value) : '尚未同步');
|
||||
const isViewBusy = computed(() => {
|
||||
if (loadingState.core) return true;
|
||||
if (currentView.value === 'tickets') return loadingState.tickets;
|
||||
if (currentView.value === 'vouchers') return loadingState.orders;
|
||||
if (currentView.value === 'logs') return logLoading.value;
|
||||
if (currentView.value === 'iccards') return loadingState.iccards;
|
||||
if (currentView.value === 'faremap') return fareMapLoading.value;
|
||||
return false;
|
||||
});
|
||||
const currentViewSummary = computed(() => {
|
||||
const map = {
|
||||
dashboard: `已同步 ${lines.value.length} 条线路与 ${stations.value.length} 个站点`,
|
||||
management: `当前可编辑 ${lines.value.length} 条线路与 ${stations.value.length} 个站点`,
|
||||
faremap: fareMapLoading.value ? '票价图正在生成中' : '可导出当前铁路票价图',
|
||||
tickets: `已加载 ${ticketList.value.length} 条车票记录`,
|
||||
vouchers: `已加载 ${orders.value.length} 条凭证记录`,
|
||||
iccards: `当前检索到 ${icCards.value.length} 张 IC 卡`,
|
||||
assets: assetsManifest.routeMap ? `已上传线路图 ${assetsManifest.routeMap}` : '尚未上传线路图',
|
||||
settings: '可维护优惠活动与导出数据',
|
||||
logs: `当前筛选结果 ${logs.value.length} 条日志`
|
||||
};
|
||||
return map[currentView.value] || '后台模块已就绪';
|
||||
});
|
||||
const icCardStats = computed(() => ({
|
||||
total: icCards.value.length,
|
||||
pending: icCards.value.filter((card) => card.status === 'pending_pickup').length,
|
||||
@@ -1389,6 +1508,7 @@ createApp({
|
||||
|
||||
return {
|
||||
currentView, viewTitle, connected, sidebarOpen,
|
||||
loadingState, isViewBusy, lastSyncText, currentViewSummary,
|
||||
stations, lines, fares, stats, config, recentLogs, ticketList,
|
||||
logs, logCategory, logTypeFilter, logQuery, logMax, logLoading, fetchLogs,
|
||||
orders, orderList, fetchOrders, deleteOrder,
|
||||
|
||||
Reference in New Issue
Block a user