feat: 支持编辑线路图署名并固定 CC BY 4.0

This commit is contained in:
2026-07-19 00:16:20 +08:00
parent 6195ebb6be
commit 847957be61
6 changed files with 106 additions and 12 deletions
+22 -2
View File
@@ -8,6 +8,7 @@ const router = express.Router();
const ASSETS_DIR = path.join(__dirname, '../../Assets');
const MANIFEST_PATH = path.join(ASSETS_DIR, 'manifest.json');
const ROUTE_MAP_LICENSE = 'CC BY 4.0';
const getIp = (req) => (req.headers['x-forwarded-for'] || '').toString().split(',')[0].trim() || req.ip || req.connection?.remoteAddress || '';
@@ -48,6 +49,8 @@ const writeManifest = (m) => {
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(out, null, 2), 'utf8');
};
const normalizeCredit = (value) => String(value || '').trim().slice(0, 200);
const removeByPrefix = (prefix) => {
ensureDir(ASSETS_DIR);
const list = fs.readdirSync(ASSETS_DIR);
@@ -97,6 +100,8 @@ router.get('/manifest', (req, res) => {
res.json({
ok: true,
routeMap,
routeMapCredit: normalizeCredit(m.routeMapCredit || ''),
routeMapLicense: ROUTE_MAP_LICENSE,
fareTable,
updatedAt: m.updatedAt || null
});
@@ -106,10 +111,24 @@ router.post('/route-map', uploadRouteMap.single('file'), (req, res) => {
if (!req.file) return res.status(400).json({ ok: false, error: '缺少文件' });
const m = readManifest();
m.routeMap = path.basename(req.file.filename);
if (req.body && Object.prototype.hasOwnProperty.call(req.body, 'credit')) {
m.routeMapCredit = normalizeCredit(req.body.credit);
} else if (typeof m.routeMapCredit !== 'string') {
m.routeMapCredit = '';
}
m.updatedAt = Date.now();
writeManifest(m);
appendReqLog(req, { category: 'admin', type: 'asset_upload_route_map', detail: { file: m.routeMap } });
res.json({ ok: true, routeMap: m.routeMap, updatedAt: m.updatedAt });
appendReqLog(req, { category: 'admin', type: 'asset_upload_route_map', detail: { file: m.routeMap, credit: m.routeMapCredit || '' } });
res.json({ ok: true, routeMap: m.routeMap, routeMapCredit: m.routeMapCredit || '', routeMapLicense: ROUTE_MAP_LICENSE, updatedAt: m.updatedAt });
});
router.post('/route-map/credit', (req, res) => {
const m = readManifest();
m.routeMapCredit = normalizeCredit(req.body?.credit);
m.updatedAt = Date.now();
writeManifest(m);
appendReqLog(req, { category: 'admin', type: 'asset_update_route_map_credit', detail: { credit: m.routeMapCredit || '' } });
res.json({ ok: true, routeMapCredit: m.routeMapCredit || '', routeMapLicense: ROUTE_MAP_LICENSE, updatedAt: m.updatedAt });
});
router.post('/fare-table', uploadFareTable.single('file'), (req, res) => {
@@ -129,6 +148,7 @@ router.delete('/route-map', (req, res) => {
}
removeByPrefix('route-map');
m.routeMap = null;
m.routeMapCredit = '';
m.updatedAt = Date.now();
writeManifest(m);
appendReqLog(req, { category: 'admin', type: 'asset_delete_route_map', detail: { ok: true } });
+22 -2
View File
@@ -184,6 +184,26 @@
<script src="/custom-dialog.js?v=11"></script>
<script src="/public-status.js?v=14"></script>
<script>
const createRouteMapCredit = (credit, license) => {
const wrap = document.createElement('div');
wrap.className = 'jr-asset-credit';
const creditText = document.createElement('span');
creditText.textContent = `线路图署名:${credit || '未填写'}`;
wrap.appendChild(creditText);
wrap.appendChild(document.createTextNode(' 许可协议:'));
const licenseLink = document.createElement('a');
licenseLink.href = 'https://creativecommons.org/licenses/by/4.0/';
licenseLink.target = '_blank';
licenseLink.rel = 'noopener noreferrer';
licenseLink.textContent = license || 'CC BY 4.0';
wrap.appendChild(licenseLink);
return wrap;
};
const loadPublicAssets = async () => {
const fareMapBox = document.getElementById('fareMapBox');
const routeMapBox = document.getElementById('routeMapBox');
@@ -207,8 +227,9 @@
const img = document.createElement('img');
img.alt = '线路图';
img.src = `/assets/${encodeURIComponent(m.routeMap)}?v=${encodeURIComponent(mv)}`;
routeMapBox.innerHTML = '<div class="jr-asset-frame"></div><div class="jr-asset-credit">线路图署名:CC-BY 4.0</div>';
routeMapBox.innerHTML = '<div class="jr-asset-frame"></div>';
routeMapBox.firstElementChild.appendChild(img);
routeMapBox.appendChild(createRouteMapCredit(m.routeMapCredit, m.routeMapLicense));
} else {
routeMapBox.innerHTML = '<div class="text-muted">暂无线路图</div>';
}
@@ -245,4 +266,3 @@
</html>
+19 -2
View File
@@ -820,8 +820,22 @@
<div>建议上传横向长图,便于首页和购票页展示。</div>
<div>再次上传会自动替换旧文件。</div>
</div>
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); display: grid; gap: 10px;">
<label style="display: grid; gap: 6px;">
<span style="font-weight: 700;">线路图署名</span>
<input v-model="assetsForm.routeMapCredit" class="input" type="text" maxlength="200" placeholder="例如:OpenStreetMap contributors / 作者名 / 来源名">
</label>
<div class="text-muted">许可协议固定为 <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener noreferrer">CC BY 4.0</a></div>
<div class="flex" style="gap: 8px; flex-wrap: wrap;">
<button class="btn" @click="saveRouteMapCredit" :disabled="mutationBusy">
<i class="fas fa-pen"></i> 保存署名
</button>
</div>
</div>
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); display: grid; gap: 8px;">
<div><strong>当前文件:</strong> {{ assetsManifest.routeMap || '未上传' }}</div>
<div><strong>当前署名:</strong> {{ assetsManifest.routeMapCredit || '未填写' }}</div>
<div><strong>许可协议:</strong> <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener noreferrer">{{ assetsManifest.routeMapLicense }}</a></div>
<div><strong>更新时间:</strong> {{ assetsManifest.updatedAt ? formatTime(assetsManifest.updatedAt) : '暂无' }}</div>
</div>
</div>
@@ -830,7 +844,11 @@
<div style="width: 100%;">
<template v-if="assetsRouteMapUrl">
<img :src="assetsRouteMapUrl" alt="线路图预览" style="display: block; max-width: 100%; height: auto; background: #ffffff; margin: 0 auto;">
<div class="jr-asset-credit" style="color: #cbd5e1;">线路图署名:CC-BY 4.0</div>
<div class="jr-asset-credit" style="color: #cbd5e1;">
<span>线路图署名:{{ assetsManifest.routeMapCredit || '未填写' }}</span>
<span>许可协议:</span>
<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener noreferrer" style="color: #93c5fd;">{{ assetsManifest.routeMapLicense }}</a>
</div>
</template>
<div v-else class="text-muted" style="padding: 24px; text-align: center;">
暂无线路图,请先上传一张线路图。
@@ -966,4 +984,3 @@
</body>
</html>
+21 -5
View File
@@ -56,10 +56,11 @@ createApp({
const logMax = ref(200);
const logLoading = ref(false);
const orders = ref([]);
const assetsManifest = reactive({ routeMap: null, fareTable: null, updatedAt: null });
const assetsManifest = reactive({ routeMap: null, routeMapCredit: '', routeMapLicense: 'CC BY 4.0', fareTable: null, updatedAt: null });
const assetsFarePreview = reactive({ headers: [], rows: [] });
const assetsRouteMapUrl = ref('');
const assetsFareTableUrl = ref('');
const assetsForm = reactive({ routeMapCredit: '' });
const icCards = ref([]);
const icCardSearch = ref('');
const icSelectedId = ref('');
@@ -577,8 +578,11 @@ createApp({
const fetchAssetsManifest = async () => {
const data = await requestJson('/api/assets/manifest');
assetsManifest.routeMap = data ? (data.routeMap || null) : null;
assetsManifest.routeMapCredit = data ? String(data.routeMapCredit || '') : '';
assetsManifest.routeMapLicense = data ? String(data.routeMapLicense || 'CC BY 4.0') : 'CC BY 4.0';
assetsManifest.fareTable = data ? (data.fareTable || null) : null;
assetsManifest.updatedAt = data ? (data.updatedAt || null) : null;
assetsForm.routeMapCredit = assetsManifest.routeMapCredit;
assetsRouteMapUrl.value = buildAssetUrl(assetsManifest.routeMap);
assetsFareTableUrl.value = buildAssetUrl(assetsManifest.fareTable);
assetsLoaded = true;
@@ -622,9 +626,12 @@ createApp({
markSynced();
};
const uploadAssetFile = async (url, file) => {
const uploadAssetFile = async (url, file, extraFields = {}) => {
const fd = new FormData();
fd.append('file', file);
for (const [key, value] of Object.entries(extraFields || {})) {
fd.append(key, value == null ? '' : String(value));
}
await requestJson(url, { method: 'POST', body: fd }, { expectOk: true });
await fetchAssetsManifest();
};
@@ -633,7 +640,7 @@ createApp({
const f = ev && ev.target && ev.target.files && ev.target.files[0];
if (!f) return;
ev.target.value = '';
await uploadAssetFile('/api/assets/route-map', f);
await uploadAssetFile('/api/assets/route-map', f, { credit: assetsForm.routeMapCredit });
};
const uploadFareTable = async (ev) => {
@@ -648,6 +655,15 @@ createApp({
await fetchAssetsManifest();
};
const saveRouteMapCredit = async () => {
await requestJson('/api/assets/route-map/credit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credit: assetsForm.routeMapCredit })
}, { expectOk: true });
await fetchAssetsManifest();
};
const deleteFareTable = async () => {
await requestJson('/api/assets/fare-table', { method: 'DELETE' }, { expectOk: true });
await fetchAssetsManifest();
@@ -2000,8 +2016,8 @@ createApp({
icCards, icCardSearch, icSelectedId, icSelectedCard, icSelectedEvents, icCreateForm, icDetailForm, icCardStats,
createIcCard, loadIcCard, saveIcCard, topupIcCard, deleteIcCard, icStatusInfo, icStatusColor, icEventTitle, formatIcEventDetail, cardOrderCode, displayIcCardId, formatMoney,
showAddLine, showAddStation, newLine, newStation, fareMapSvg, ticketSearch,
assetsManifest, assetsFarePreview, assetsRouteMapUrl, assetsFareTableUrl,
uploadRouteMap, uploadFareTable, deleteRouteMap, deleteFareTable,
assetsManifest, assetsForm, assetsFarePreview, assetsRouteMapUrl, assetsFareTableUrl,
uploadRouteMap, uploadFareTable, deleteRouteMap, deleteFareTable, saveRouteMapCredit,
// Management
selectedLine, fareMode, stationEditMode, fareSelection, showFareModal, currentFare, availableStations,
+3
View File
@@ -2746,6 +2746,9 @@ body.jr-public-page {
.jr-asset-credit {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 4px;
font-size: 0.82rem;
line-height: 1.5;
color: #6a786d;
+19 -1
View File
@@ -43,6 +43,8 @@
let lineIdxsByCanonical = {};
let activeLineIdx = -1;
let routeMapUrl = '';
let routeMapCredit = '';
let routeMapLicense = 'CC BY 4.0';
const piePath = (cx, cy, r, a0, a1) => {
const x0 = cx + r * Math.cos(a0);
@@ -84,6 +86,20 @@
return `/assets/${encodeURIComponent(name)}?v=${encodeURIComponent(version)}`;
};
const escapeHtml = (value) => String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const buildCreditHtml = (credit, license, linkColor = '') => {
const safeCredit = escapeHtml(credit || '未填写');
const safeLicense = escapeHtml(license || 'CC BY 4.0');
const colorStyle = linkColor ? ` style="color: ${linkColor};"` : '';
return `<div class="jr-asset-credit"><span>线路图署名:${safeCredit}</span><span>许可协议:</span><a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener noreferrer"${colorStyle}>${safeLicense}</a></div>`;
};
function renderStationNode(x, y, code, i, forceColor = null) {
const canonical = stationCanonicalByCode[code] || code;
const codes = stationCodesByCanonical[canonical] || [canonical];
@@ -183,7 +199,7 @@
const li = lineStops[activeLineIdx];
if (!li) {
if (routeMapUrl) {
container.innerHTML = `<div class="tvm-overview tvm-animate"><div class="tvm-overview-frame"><img src="${routeMapUrl}" alt="线路图总览"><div class="jr-asset-credit">线路图署名:CC-BY 4.0</div></div></div>`;
container.innerHTML = `<div class="tvm-overview tvm-animate"><div class="tvm-overview-frame"><img src="${routeMapUrl}" alt="线路图总览">${buildCreditHtml(routeMapCredit, routeMapLicense)}</div></div>`;
} else {
container.innerHTML = '<div class="tvm-overview-empty">暂未选择线路,且暂无已上传线路图。</div>';
}
@@ -388,6 +404,8 @@
}
routeMapUrl = (assetsData && assetsData.routeMap) ? buildAssetUrl(assetsData.routeMap, assetsData.updatedAt) : '';
routeMapCredit = assetsData ? String(assetsData.routeMapCredit || '') : '';
routeMapLicense = assetsData ? String(assetsData.routeMapLicense || 'CC BY 4.0') : 'CC BY 4.0';
const fadeStyle = `<style>
@keyframes tvmFadeIn {