feat: timeline refactor, docking UI, location/state dropdowns, sim_time cleanup
Templates: - mission_timeline_preview: client-side rendering with lane stacking, wheel zoom, drag pan, percentage-based layout, event dedup, scale labels - asset_detail: dock/undock modals with time selection, future event warning, Mission Label column replacing Location/State - asset_entry_form: all_locations expanded to 72 items (draft board hierarchy), all_states dynamic from DB, event point dropdown uses same list - Remove sim_time from all template url_for links - base.html: remove Today button JS: - app.js: sim_time controls use POST /api/v1/sim-time API instead of URL param Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+91
-256
@@ -1,87 +1,91 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>{{ title }} · {{ config["PROJECT_TITLE"] }}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
|
||||
<script>
|
||||
// Inline theme init to prevent FOUC
|
||||
(() => {
|
||||
const storageKey = 'ksp-theme';
|
||||
const cookieKey = 'ksp_theme';
|
||||
const readSavedTheme = () => {
|
||||
try {
|
||||
const savedTheme = localStorage.getItem(storageKey);
|
||||
if (savedTheme === 'dark' || savedTheme === 'light') {
|
||||
return savedTheme;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore storage access failures and fall back to cookies/system preference.
|
||||
}
|
||||
|
||||
const cookieMatch = document.cookie.match(/(?:^|; )ksp_theme=(dark|light)(?:;|$)/);
|
||||
return cookieMatch ? cookieMatch[1] : null;
|
||||
};
|
||||
const persistTheme = (theme) => {
|
||||
try {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
} catch (error) {
|
||||
// Ignore storage access failures and rely on cookies for persistence.
|
||||
}
|
||||
|
||||
document.cookie = `${cookieKey}=${theme}; path=/; max-age=31536000; SameSite=Lax`;
|
||||
};
|
||||
const applyDocumentTheme = (theme) => {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
};
|
||||
const savedTheme = readSavedTheme();
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const resolvedTheme = savedTheme === 'dark' || savedTheme === 'light'
|
||||
? savedTheme
|
||||
: (prefersDark ? 'dark' : 'light');
|
||||
|
||||
applyDocumentTheme(resolvedTheme);
|
||||
window.__kspTheme = {
|
||||
storageKey,
|
||||
readSavedTheme,
|
||||
persistTheme,
|
||||
applyDocumentTheme,
|
||||
};
|
||||
const k = 'ksp-theme', c = 'ksp_theme';
|
||||
const saved = (() => { try { return localStorage.getItem(k); } catch(e) { return null; } })()
|
||||
|| (document.cookie.match(/(?:^|; )ksp_theme=(dark|light)(?:;|$)/) || [])[1];
|
||||
const theme = saved || 'dark';
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-shell">
|
||||
<header class="site-header">
|
||||
<a class="brand" href="{{ url_for('web.dashboard') }}">
|
||||
<span class="brand-name">KSP Operation Hangar</span>
|
||||
</a>
|
||||
<div class="site-header-tools">
|
||||
<nav class="site-nav">
|
||||
<a href="{{ url_for('web.dashboard') }}">控制台</a>
|
||||
<a href="{{ url_for('web.engine_list') }}">引擎目录</a>
|
||||
<a href="{{ url_for('web.communication_list') }}">通信部件</a>
|
||||
<a href="{{ url_for('web.tank_list') }}">燃料箱</a>
|
||||
<a href="{{ url_for('web.vehicle_list') }}">载具成本</a>
|
||||
<a href="{{ url_for('web.mission_preview_home') }}">任务运营</a>
|
||||
<a href="{{ url_for('web.fuel_converter') }}">燃料换算</a>
|
||||
<a href="{{ url_for('api.health') }}">API 健康检查</a>
|
||||
</nav>
|
||||
<button class="theme-toggle" type="button" data-theme-toggle aria-label="切换深浅色模式" aria-pressed="false">
|
||||
<span class="theme-toggle-track" aria-hidden="true">
|
||||
<span class="theme-toggle-thumb"></span>
|
||||
</span>
|
||||
<span class="theme-toggle-copy">
|
||||
<span class="theme-toggle-label">主题</span>
|
||||
<span class="theme-toggle-value" data-theme-toggle-label>浅色</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<!-- ═══ TOPBAR ═══ -->
|
||||
<div id="topbar">
|
||||
<div class="brand"><b>KSP</b> 管理后台</div>
|
||||
<div class="tb-sp"></div>
|
||||
<div class="tb-time">
|
||||
<span class="l">Sim Time</span>
|
||||
<span class="td" id="sim-time-display" onclick="openTimeModal()">{{ simulation_time_display or '2060-03-12 09:00' }}</span>
|
||||
<button class="b" onclick="shiftTime(-1)">−1d</button>
|
||||
<button class="b" onclick="shiftTime(1)">+1d</button>
|
||||
<button class="b" onclick="shiftTime(7)">+7d</button>
|
||||
</div>
|
||||
<button class="b" onclick="toggleTheme()" title="Toggle theme" style="margin-left:4px">◐</button>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<div id="layout">
|
||||
<!-- ═══ SIDEBAR ═══ -->
|
||||
<nav id="sidebar">
|
||||
<a class="ni{% if active_page == 'overview' %} act{% endif %}" href="{{ url_for('web.dashboard') }}">
|
||||
<span class="ic">◉</span>任务总览
|
||||
</a>
|
||||
|
||||
<div class="nst" onclick="this.classList.toggle('on')">
|
||||
<span class="ar">▾</span>任务运营
|
||||
</div>
|
||||
<div class="niw">
|
||||
<a class="ni su{% if active_page == 'assets' %} act{% endif %}" href="{{ url_for('web.asset_list') }}">
|
||||
<span class="ic">◆</span>任务资产
|
||||
</a>
|
||||
<a class="ni su{% if active_page == 'missions' %} act{% endif %}" href="{{ url_for('web.mission_list') }}">
|
||||
<span class="ic">◈</span>任务列表
|
||||
</a>
|
||||
<a class="ni su{% if active_page == 'timeline' %} act{% endif %}" href="{{ url_for('web.mission_timeline_preview') }}">
|
||||
<span class="ic">◫</span>时间线
|
||||
</a>
|
||||
<a class="ni su{% if active_page == 'location' %} act{% endif %}" href="{{ url_for('web.mission_location_board_preview') }}">
|
||||
<span class="ic">◎</span>地点板
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="nst" onclick="this.classList.toggle('on')">
|
||||
<span class="ar">▾</span>数据中心
|
||||
</div>
|
||||
<div class="niw">
|
||||
<a class="ni su{% if active_page == 'engines' %} act{% endif %}" href="{{ url_for('web.engine_list') }}">
|
||||
<span class="ic">⚙</span>引擎目录
|
||||
</a>
|
||||
<a class="ni su{% if active_page == 'comms' %} act{% endif %}" href="{{ url_for('web.communication_list') }}">
|
||||
<span class="ic">◎</span>通信部件
|
||||
</a>
|
||||
<a class="ni su{% if active_page == 'tanks' %} act{% endif %}" href="{{ url_for('web.tank_list') }}">
|
||||
<span class="ic">⬡</span>燃料箱规格
|
||||
</a>
|
||||
<a class="ni su{% if active_page == 'vehicles' %} act{% endif %}" href="{{ url_for('web.vehicle_list') }}">
|
||||
<span class="ic">⇧</span>载具成本
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<a class="ni{% if active_page == 'converter' %} act{% endif %}" href="{{ url_for('web.fuel_converter') }}">
|
||||
<span class="ic">⇄</span>燃料换算
|
||||
</a>
|
||||
|
||||
<a class="ni" href="{{ url_for('api.health') }}">
|
||||
<span class="ic">📡</span>API 健康检查
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- ═══ CONTENT ═══ -->
|
||||
<main id="content">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-stack">
|
||||
@@ -95,190 +99,21 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const themeManager = window.__kspTheme || null;
|
||||
const themeStorageKey = themeManager?.storageKey || 'ksp-theme';
|
||||
const themeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
<!-- ═══ TIME MODAL ═══ -->
|
||||
<div class="modal-bg" id="time-modal">
|
||||
<div class="modal">
|
||||
<h2>Set Simulation Time</h2>
|
||||
<div class="fg">
|
||||
<label>Time<input type="datetime-local" id="time-input" step="60"></label>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="b" onclick="closeTimeModal()">Cancel</button>
|
||||
<button class="b ac" onclick="applyTime()">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
const resolveTheme = () => {
|
||||
const savedTheme = themeManager?.readSavedTheme?.();
|
||||
if (savedTheme === 'dark' || savedTheme === 'light') {
|
||||
return savedTheme;
|
||||
}
|
||||
return themeMediaQuery.matches ? 'dark' : 'light';
|
||||
};
|
||||
|
||||
const applyTheme = (theme) => {
|
||||
const resolvedTheme = theme === 'dark' ? 'dark' : 'light';
|
||||
if (themeManager?.applyDocumentTheme) {
|
||||
themeManager.applyDocumentTheme(resolvedTheme);
|
||||
} else {
|
||||
document.documentElement.dataset.theme = resolvedTheme;
|
||||
document.documentElement.style.colorScheme = resolvedTheme;
|
||||
}
|
||||
|
||||
for (const button of document.querySelectorAll('[data-theme-toggle]')) {
|
||||
button.setAttribute('aria-pressed', String(resolvedTheme === 'dark'));
|
||||
}
|
||||
|
||||
for (const label of document.querySelectorAll('[data-theme-toggle-label]')) {
|
||||
label.textContent = resolvedTheme === 'dark' ? '深色' : '浅色';
|
||||
}
|
||||
};
|
||||
|
||||
const bindThemeToggle = (root = document) => {
|
||||
for (const button of root.querySelectorAll('[data-theme-toggle]')) {
|
||||
button.onclick = () => {
|
||||
const nextTheme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||
if (themeManager?.persistTheme) {
|
||||
themeManager.persistTheme(nextTheme);
|
||||
} else {
|
||||
localStorage.setItem(themeStorageKey, nextTheme);
|
||||
}
|
||||
applyTheme(nextTheme);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const formatDisplayDatetime = (isoValue) => {
|
||||
if (!isoValue) {
|
||||
return '';
|
||||
}
|
||||
return isoValue.slice(0, 16).replace(/-/g, '/').replace('T', ' ');
|
||||
};
|
||||
|
||||
const parseDisplayDatetime = (rawValue) => {
|
||||
const cleaned = (rawValue || '').trim();
|
||||
if (!cleaned) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const normalized = cleaned
|
||||
.replace(/[年.]/g, '/')
|
||||
.replace(/月/g, '/')
|
||||
.replace(/日/g, ' ')
|
||||
.replace(/T/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const match = normalized.match(/^(\d{4})[\/-](\d{1,2})[\/-](\d{1,2})\s+(\d{1,2}):(\d{1,2})$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, yearValue, monthValue, dayValue, hourValue, minuteValue] = match;
|
||||
const year = Number(yearValue);
|
||||
const month = Number(monthValue);
|
||||
const day = Number(dayValue);
|
||||
const hour = Number(hourValue);
|
||||
const minute = Number(minuteValue);
|
||||
const isoValue = `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}T${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
|
||||
const parsedDate = new Date(`${isoValue}:00`);
|
||||
if (Number.isNaN(parsedDate.getTime())) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
parsedDate.getFullYear() !== year
|
||||
|| parsedDate.getMonth() + 1 !== month
|
||||
|| parsedDate.getDate() !== day
|
||||
|| parsedDate.getHours() !== hour
|
||||
|| parsedDate.getMinutes() !== minute
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return isoValue;
|
||||
};
|
||||
|
||||
const bindDatetimePickerTriggers = (root = document) => {
|
||||
const controls = root.querySelectorAll('[data-datetime-control]');
|
||||
for (const control of controls) {
|
||||
if (control.dataset.datetimeControlBound === 'true') {
|
||||
continue;
|
||||
}
|
||||
control.dataset.datetimeControlBound = 'true';
|
||||
|
||||
const displayInput = control.querySelector('.mission-datetime-display-input');
|
||||
const button = control.querySelector('[data-datetime-picker-target]');
|
||||
if (!displayInput || !button) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pickerInput = document.getElementById(button.dataset.datetimePickerTarget || '');
|
||||
if (!pickerInput) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const syncPickerFromDisplay = () => {
|
||||
const parsedValue = parseDisplayDatetime(displayInput.value);
|
||||
if (parsedValue === '') {
|
||||
pickerInput.value = '';
|
||||
return '';
|
||||
}
|
||||
if (parsedValue === null) {
|
||||
return null;
|
||||
}
|
||||
pickerInput.value = parsedValue;
|
||||
displayInput.value = formatDisplayDatetime(parsedValue);
|
||||
return parsedValue;
|
||||
};
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
syncPickerFromDisplay();
|
||||
if (typeof pickerInput.showPicker === 'function') {
|
||||
pickerInput.showPicker();
|
||||
return;
|
||||
}
|
||||
pickerInput.focus();
|
||||
pickerInput.click();
|
||||
});
|
||||
|
||||
pickerInput.addEventListener('change', () => {
|
||||
displayInput.value = formatDisplayDatetime(pickerInput.value);
|
||||
});
|
||||
|
||||
displayInput.addEventListener('blur', () => {
|
||||
syncPickerFromDisplay();
|
||||
});
|
||||
|
||||
displayInput.addEventListener('input', () => {
|
||||
if (!displayInput.value.trim()) {
|
||||
pickerInput.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
if (pickerInput.value && !displayInput.value.trim()) {
|
||||
displayInput.value = formatDisplayDatetime(pickerInput.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.bindDatetimePickerTriggers = bindDatetimePickerTriggers;
|
||||
window.bindThemeToggle = bindThemeToggle;
|
||||
bindThemeToggle();
|
||||
applyTheme(resolveTheme());
|
||||
|
||||
const handleThemeMediaChange = (event) => {
|
||||
if (localStorage.getItem(themeStorageKey)) {
|
||||
return;
|
||||
}
|
||||
applyTheme(event.matches ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
if (typeof themeMediaQuery.addEventListener === 'function') {
|
||||
themeMediaQuery.addEventListener('change', handleThemeMediaChange);
|
||||
} else if (typeof themeMediaQuery.addListener === 'function') {
|
||||
themeMediaQuery.addListener(handleThemeMediaChange);
|
||||
}
|
||||
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key && event.key !== themeStorageKey) {
|
||||
return;
|
||||
}
|
||||
applyTheme(resolveTheme());
|
||||
});
|
||||
|
||||
bindDatetimePickerTriggers();
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user