feat: 支持编辑线路图署名并固定 CC BY 4.0
This commit is contained in:
+22
-2
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user