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:
2026-06-01 15:29:10 +08:00
co-authored by Claude Opus 4.7
parent cc4c460840
commit 2fef743e83
15 changed files with 1791 additions and 1966 deletions
+91 -256
View File
@@ -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>&nbsp;管理后台</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>