feat(web,installer): 更新下载源、升级资源缓存版本、本地化界面并新增管理功能

- 更新 update_machine.lua 和 installer.lua 中的远程资源下载地址,从旧云存储链接切换为 Gitea 仓库提交镜像地址
- 新增双向闸机专用安装脚本 installer_bi.lua
- 为所有网页HTML文件更新静态资源的缓存版本号,避免浏览器加载过期的静态文件缓存
- 修复登录页面的乱码文本,替换为标准简体中文内容,修正ICP备案标识文本
- 新增管理后台概览板块、快捷操作按钮,优化IC卡管理界面与响应式布局样式
This commit is contained in:
2026-06-21 10:37:25 +08:00
parent 108435e90d
commit 7fea8807b8
19 changed files with 2095 additions and 205 deletions
+180 -60
View File
@@ -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,