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
+54
View File
@@ -0,0 +1,54 @@
// ─── Sidebar ───
function initSidebar() {
document.querySelectorAll('.ni[data-pg]').forEach(el => {
el.addEventListener('click', function(e) {
e.preventDefault();
window.location.href = this.getAttribute('href');
});
});
document.querySelectorAll('.nst').forEach(el => {
el.addEventListener('click', function() { this.classList.toggle('on'); });
});
}
initSidebar();
// ─── Global Time Control ───
function openTimeModal() {
const display = document.getElementById('sim-time-display');
if (!display) return;
const modal = document.getElementById('time-modal');
const input = document.getElementById('time-input');
if (modal && input) {
input.value = display.textContent.trim().replace(' ', 'T');
modal.classList.add('open');
}
}
function closeTimeModal() {
const modal = document.getElementById('time-modal');
if (modal) modal.classList.remove('open');
}
async function applyTime() {
const input = document.getElementById('time-input');
if (!input || !input.value) return;
await setSimTimeAndReload(input.value.replace('T', ' ') + ':00');
}
async function shiftTime(days) {
const display = document.getElementById('sim-time-display');
if (!display) return;
const d = new Date(display.textContent.trim().replace(' ', 'T') + 'Z');
d.setUTCDate(d.getUTCDate() + days);
await setSimTimeAndReload(d.toISOString().slice(0, 10) + ' 09:00');
}
async function resetTime() {
await setSimTimeAndReload('2060-03-12 09:00');
}
async function setSimTimeAndReload(timeStr) {
try {
await fetch('/api/v1/sim-time', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({sim_time: timeStr})
});
} catch(e) {}
location.reload();
}
+398 -197
View File
@@ -1,231 +1,432 @@
{% extends "base.html" %}
{% from "_datetime_picker_field.html" import datetime_picker_field %}
{% block content %}
{% include "_mission_ops_tabs.html" %}
{% include "_mission_preview_header.html" %}
<div class="ph-row">
<div class="ph">
<div class="ey">Mission Asset</div>
<h1>{{ asset.name }}</h1>
<p>{{ asset.asset_type }} · {{ 'Retired' if asset.is_retired else 'Active' }}</p>
</div>
<div style="display:flex;gap:6px">
<a class="b" href="{{ url_for('web.asset_edit', asset_id=asset.id
) }}">Edit Asset</a>
<a class="b" href="{{ url_for('web.asset_list'
) }}">← Back</a>
</div>
</div>
<section class="mission-detail-layout top-gap">
<div class="mission-detail-main">
<section class="panel mission-asset-overview">
<div class="mission-asset-head">
<div class="ad-grid">
<div>
<!-- Info cards -->
<div class="mg" style="grid-template-columns:repeat(4,1fr);margin-bottom:12px">
<div class="mc"><div class="l">Type</div><div class="v" style="font-size:.95rem">{{ asset.asset_type }}</div></div>
<div class="mc"><div class="l">Location</div><div class="v" style="font-size:.95rem">{{ asset_summary.current_location or '—' }}</div></div>
<div class="mc"><div class="l">Mission</div><div class="v" style="font-size:.85rem">{{ asset_summary.current_mission or '—' }}</div></div>
<div class="mc"><div class="l">Entries</div><div class="v" style="font-size:.95rem">{{ total_entry_count }}</div></div>
</div>
<!-- Docking section -->
<div class="ad-dock">
<div>
<p class="eyebrow">Asset Summary</p>
<h2>{{ asset.name }}</h2>
<p class="small-muted">{{ asset.asset_type }} · {{ asset.program or 'No program assigned' }}</p>
<div class="stl" style="display:flex;align-items:center;justify-content:space-between">
<span>Docking Target</span>
<button class="b sm ac" type="button" onclick="openDockModal('dock_to_target')">+ Target</button>
</div>
<div class="mission-asset-chip-row">
<span class="chip strong">{{ asset_summary.current_state }}</span>
<span class="chip">{{ asset_summary.current_location }}</span>
{% if asset.home_region %}
<span class="chip">{{ asset.home_region }}</span>
{% endif %}
</div>
</div>
<div class="mission-meta-grid">
<div class="mission-meta-item">
<dt>Current Mission</dt>
<dd>{{ asset_summary.current_mission }}</dd>
</div>
<div class="mission-meta-item">
<dt>Active Window</dt>
<dd>{{ asset_summary.state_window }}</dd>
</div>
<div class="mission-meta-item field-span-two">
<dt>Next Planned Event</dt>
<dd>{{ asset_summary.next_event }}</dd>
</div>
</div>
</section>
<section class="panel mission-future-panel">
<div class="mission-log-top">
<div class="panel-heading">
<p class="eyebrow">未来事件</p>
<h2>可编辑的未来记录</h2>
<p class="small-muted">这里汇总了未来的 Event Point、State Interval,以及当前区间里尚未发生的 State Node。</p>
</div>
<div class="mission-group-count">{{ future_events|length }} future items</div>
</div>
{% if future_events %}
<div class="mission-future-list">
{% for item in future_events %}
<article class="mission-upcoming-item mission-future-item">
<div class="mission-upcoming-head">
<div>
<p class="mission-upcoming-date">{{ item.when }}</p>
<h3>{{ item.title }}</h3>
<p class="small-muted">{{ item.kind_label }} · {{ item.status_label }}</p>
</div>
<a class="table-link mission-future-action" href="{{ item.edit_url }}">{{ item.action_label }}</a>
</div>
<p class="small-muted">{{ item.scope }}</p>
<p class="small-muted mission-future-summary">{{ item.summary }}</p>
{% if item.edit_hint %}
<p class="small-muted">{{ item.edit_hint }}</p>
{% endif %}
{% if item.state_labels or item.mission_labels %}
<div class="mission-asset-chip-row mission-entry-label-row">
{% for label in item.state_labels %}<span class="chip">{{ label }}</span>{% endfor %}
{% for label in item.mission_labels %}<span class="chip">{{ label }}</span>{% endfor %}
</div>
{% endif %}
</article>
{% endfor %}
<div class="ad-dock-box">
{% if asset_summary.docking_target %}
<div class="ad-dock-item">
<span>
<a href="{{ url_for('web.asset_detail', asset_id=asset_summary.docking_target.id
) }}" style="color:var(--accent)">{{ asset_summary.docking_target.name }}</a>
<span class="mt" style="margin-left:6px">{{ asset_summary.docking_target.current_location }}</span>
<span class="mt" style="margin-left:6px;color:var(--muted);font-size:.66rem">since {{ asset_summary.docking_target.docked_at }}</span>
</span>
<a class="ad-undock" onclick="openUndockModal('{{ asset_summary.docking_target.event_id }}', '{{ asset_summary.docking_target.undocked_at or '' }}')">&times; Remove</a>
</div>
{% else %}
<section class="empty-state compact-empty">
<h2>没有未来记录</h2>
<p>当前选定的 Simulation Time 之后还没有排定的事件。</p>
</section>
<div style="padding:6px 10px;color:var(--mut);font-size:.72rem">Not docked to any asset</div>
{% endif %}
</section>
<section class="panel mission-log-panel">
<div class="mission-log-top">
<div class="panel-heading">
<p class="eyebrow">全部日志</p>
<h2>资产日志</h2>
</div>
<div class="mission-group-count">{{ filtered_entry_count }} / {{ total_entry_count }} entries</div>
</div>
<div>
<div class="stl" style="display:flex;align-items:center;justify-content:space-between">
<span>Docked Vehicles</span>
<button class="b sm ac" type="button" onclick="openDockModal('dock_vehicle')">+ Dock</button>
</div>
<div class="ad-dock-box">
{% if asset_summary.docked_vehicles %}
{% for v in asset_summary.docked_vehicles %}
<div class="ad-dock-item">
<span>
{% if v.id %}
<a href="{{ url_for('web.asset_detail', asset_id=v.id
) }}" style="color:var(--accent)">{{ v.name }}</a>
{% else %}
<strong style="color:var(--txt)">{{ v.name }}</strong>
{% endif %}
<span class="mt">since {{ v.docked_at }}</span>
</span>
<a class="ad-undock" onclick="openUndockModal('{{ v.event_id }}', '{{ v.undocked_at or '' }}')">&times; Remove</a>
</div>
{% endfor %}
{% else %}
<div style="padding:6px 10px;color:var(--mut);font-size:.72rem">No vehicles docked</div>
{% endif %}
</div>
</div>
</div>
<form method="get" class="mission-log-filter-grid">
<input type="hidden" name="sim_time" value="{{ simulation_time_input }}">
{{ datetime_picker_field('Start', 'start', entry_filter_start_input, field_id='asset-log-start') }}
{{ datetime_picker_field('End', 'end', entry_filter_end_input, field_id='asset-log-end') }}
<button class="primary-button" type="submit">筛选</button>
<a class="secondary-button" href="{{ url_for('web.asset_detail', asset_id=asset.id, sim_time=simulation_time_input) }}">重置</a>
</form>
{% if entries %}
<div class="table-shell">
<table class="data-table compact-table mission-log-table">
<thead>
<tr>
<th>Time</th>
<th>Entry</th>
<th>Labels</th>
<th>Action</th>
</tr>
</thead>
<!-- Log entries -->
<div class="stl" style="display:flex;align-items:center;justify-content:space-between">
<span>Log Entries</span>
<div style="display:flex;gap:4px">
<button class="b sm ac" onclick="openNewLog('state')">+ State Interval</button>
<button class="b sm" onclick="openNewLog('event')">+ Event Point</button>
</div>
</div>
<div class="fb">
<input type="text" id="ad-lq" placeholder="Search logs...">
<select id="ad-lf">
<option value="">All Types</option>
<option value="state">State Interval</option>
<option value="event">Event Point</option>
</select>
</div>
<table>
<thead><tr><th>Date</th><th>Type</th><th>Title</th><th>Mission Label</th><th>Action</th></tr></thead>
<tbody>
{% for entry in entries %}
<tr>
<td class="two-line-cell">
<strong class="table-title">{{ entry.timestamp }}</strong>
{% if entry.end_timestamp %}
<div class="table-subline">{{ entry.end_timestamp }}</div>
{% endif %}
<div class="table-subline single-line-cell">{{ entry.location }}</div>
</td>
<td class="two-line-cell">
<strong class="table-title">{{ entry.title }}</strong>
<div class="table-subline">{{ entry.mode_label }} · {{ entry.status_label }}</div>
<div class="table-subline clamp-line">{{ entry.summary }}</div>
</td>
<td>
{% if entry.state_labels or entry.mission_labels %}
<div class="mission-asset-chip-row mission-entry-label-row">
{% for label in entry.state_labels %}<span class="chip">{{ label }}</span>{% endfor %}
{% for label in entry.mission_labels %}<span class="chip">{{ label }}</span>{% endfor %}
</div>
{% else %}
<span class="small-muted">-</span>
{% endif %}
</td>
<td>
<div class="table-actions">
<a class="table-link" href="{{ url_for('web.asset_entry_edit', asset_id=asset.id, entry_id=entry.id, sim_time=simulation_time_input) }}">编辑条目</a>
</div>
</td>
<tr data-log-row data-log-type="{{ entry.mode }}" data-log-text="{{ (entry.title ~ ' ' ~ entry.summary ~ ' ' ~ entry.mission_label)|lower|e }}">
<td class="mt">{{ entry.timestamp }}{% if entry.end_timestamp %} → {{ entry.end_timestamp }}{% endif %}</td>
<td><span style="font-size:.64rem;padding:2px 5px;border-radius:3px;background:{{ 'rgba(59,130,246,.1);color:var(--accent)' if entry.mode == 'state' else 'rgba(234,179,8,.08);color:var(--amber)' }}">{{ entry.mode_label }}</span></td>
<td>{{ entry.title }}{% for node in entry.state_nodes %}<div class="mt" style="font-size:.62rem">→ {{ node.title }} ({{ node.at }}{% if node.detail %} · {{ node.detail }}{% endif %})</div>{% endfor %}</td>
<td class="mt">{{ entry.mission_label }}</td>
<td><div class="act-btn">
<a href="{{ entry.edit_url }}">Edit</a>
<form method="post" action="{{ url_for('web.asset_entry_delete', asset_id=asset.id, entry_id=entry.source_entry_id) }}" style="display:inline" onsubmit="return confirm('Delete?')">
<input type="hidden" name="sim_time" value="{{ simulation_time_input }}">
<button class="danger-link" type="submit" style="font-size:.66rem">Del</button>
</form>
</div></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if entry_total_pages > 1 %}
<nav class="pagination-bar" aria-label="Asset timeline pagination">
{% if entry_has_prev %}
<a class="pagination-link" href="{{ entry_prev_url }}">上一页</a>
{% else %}
<span class="pagination-link disabled">上一页</span>
<div class="pg-bar">
{% if entry_has_prev %}<a href="{{ entry_prev_url }}">← Prev</a>{% endif %}
{% for link in entry_page_links %}
{% if link.is_current %}<span class="cur">{{ link.page }}</span>
{% elif link.is_ellipsis %}<span>...</span>
{% else %}<a href="{{ link.url }}">{{ link.page }}</a>{% endif %}
{% endfor %}
{% if entry_has_next %}<a href="{{ entry_next_url }}">Next →</a>{% endif %}
</div>
{% endif %}
</div>
<div class="pagination-pages">
{% for item in entry_page_links %}
{% if item.ellipsis %}
<span class="pagination-ellipsis">...</span>
{% elif item.current %}
<span class="pagination-link current">{{ item.number }}</span>
{% else %}
<a class="pagination-link" href="{{ item.url }}">{{ item.number }}</a>
{% endif %}
<div>
<div class="stl">Upcoming Events</div>
<div class="evl" style="margin-bottom:14px">
{% for ev in future_events %}
<div class="evr"><span class="ed">{{ ev.date }}</span>{{ ev.title }}</div>
{% endfor %}
</div>
<div class="stl">Quick Info</div>
<div style="background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:10px 12px;font-size:.72rem;color:var(--mut)">
<div style="margin-bottom:4px"><strong style="color:var(--txt)">Retired:</strong> {{ 'Yes' if asset.is_retired else 'No' }}</div>
<div style="margin-bottom:4px"><strong style="color:var(--txt)">Program:</strong> {{ asset.program or '—' }}</div>
<div><strong style="color:var(--txt)">Home Region:</strong> {{ asset.home_region or '—' }}</div>
</div>
</div>
</div>
{% if entry_has_next %}
<a class="pagination-link" href="{{ entry_next_url }}">下一页</a>
{% else %}
<span class="pagination-link disabled">下一页</span>
{% endif %}
</nav>
{% endif %}
{% else %}
<section class="empty-state compact-empty">
{% if total_entry_count %}
<h2>没有命中筛选条件的日志条目</h2>
<p>调整起止时间,或者重置筛选后重试。</p>
{% else %}
<h2>还没有日志条目</h2>
<p>先为这个资产新增一个 Event Point 或 State Interval。</p>
{% endif %}
</section>
{% endif %}
</section>
<!-- ═══ DOCK MODAL ═══ -->
<div class="modal-bg" id="dock-modal">
<div class="modal">
<h2 id="dock-modal-title">Dock Vehicle</h2>
<div class="fg">
<input type="hidden" id="dock-mode" value="dock_vehicle">
<label><span id="dock-select-label">Vehicle</span><select id="dock-vehicle">
<option value="">Select existing asset...</option>
{% for a in all_assets %}{% if a.id != asset.id %}
<option value="{{ a.id }}">{{ a.name }}</option>
{% endif %}{% endfor %}
<option value="_custom">Custom name...</option>
</select></label>
<input type="text" id="dock-custom" placeholder="Enter vehicle name..." style="display:none">
<label>Dock Time<input type="datetime-local" id="dock-time" value="{{ simulation_time_input }}"></label>
<label>Note<textarea id="dock-note" rows="2" placeholder="Optional"></textarea></label>
</div>
<div class="btn-row">
<button class="b" onclick="closeModal('dock-modal')">Cancel</button>
<button class="b ac" onclick="saveDock()">Dock</button>
</div>
</div>
</div>
<aside class="mission-detail-side">
<section class="panel mission-current-state-panel">
<div class="panel-heading">
<p class="eyebrow">当前状态</p>
<h2>当前派生状态</h2>
<!-- ═══ UNDOCK MODAL ═══ -->
<div class="modal-bg" id="undock-modal">
<div class="modal">
<h2>Remove Docking</h2>
<div class="fg">
<input type="hidden" id="undock-event-id" value="">
<input type="hidden" id="undock-has-future" value="">
<div id="undock-future-warn" style="display:none;padding:8px 12px;margin-bottom:10px;border-radius:4px;background:rgba(234,179,8,.1);border:1px solid rgba(234,179,8,.3);color:var(--amber);font-size:.78rem">
This docking already has a scheduled undock at <strong id="undock-future-time"></strong>.
</div>
<div class="mission-meta-grid">
<div class="mission-meta-item">
<dt>Status</dt>
<dd>{{ asset_summary.current_state }}</dd>
</div>
<div class="mission-meta-item">
<dt>Location</dt>
<dd>{{ asset_summary.current_location }}</dd>
</div>
<div class="mission-meta-item field-span-two">
<dt>Mission</dt>
<dd>{{ asset_summary.current_mission }}</dd>
<div id="undock-normal-form">
<label>Undock Time<input type="datetime-local" id="undock-time" value="{{ simulation_time_input }}"></label>
<label>Note<textarea id="undock-note" rows="2" placeholder="Optional"></textarea></label>
</div>
</div>
</section>
<div class="btn-row" id="undock-btns-normal" style="display:none">
<button class="b" onclick="closeModal('undock-modal')">Cancel</button>
<button class="b ac" onclick="saveUndock()">Remove</button>
</div>
<div class="btn-row" id="undock-btns-future" style="display:none">
<button class="b" onclick="closeModal('undock-modal')">Cancel</button>
<button class="b" onclick="deleteFutureUndock()">Delete Future Undock</button>
<button class="b ac" onclick="editFutureUndock()">Edit</button>
</div>
</div>
</div>
{% include "_mission_upcoming_events.html" %}
<!-- ═══ LOG MODAL ═══ -->
<div class="modal-bg" id="log-modal">
<div class="modal" style="width:440px;max-height:80vh;overflow-y:auto">
<h2 id="log-modal-title">New Log Entry</h2>
<div class="fg">
<input type="hidden" id="log-type" value="state">
<label>Title<input type="text" id="log-title" placeholder="Entry title"></label>
<label id="log-start-wrap">Start At<input type="datetime-local" id="log-start" step="60"></label>
<label id="log-end-wrap">End At<input type="datetime-local" id="log-end" step="60"></label>
<div id="log-state-wrap">
<label>State<select id="log-state">
<option value=""></option>
<option>Transfer</option><option>Exploration</option><option>Maintenance</option>
<option>Docked</option><option>In Transit</option>
</select></label>
</div>
<label>Location<input type="text" id="log-location" placeholder="LEO / Mars Orbit / etc."></label>
<label>Summary<textarea id="log-summary" rows="2" placeholder="Optional"></textarea></label>
<div id="log-nodes-wrap">
<hr style="border:none;border-top:1px solid var(--border);margin:6px 0">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
<span style="font-size:.7rem;color:var(--mut)">State Nodes (0-N)</span>
<button class="b sm" type="button" onclick="addNodeRow()" style="font-size:.62rem">+ Add Node</button>
</div>
<div id="nodes-list"></div>
</div>
</div>
<div class="btn-row">
<button class="b" onclick="closeModal('log-modal')">Cancel</button>
<button class="b ac" onclick="saveLog()">Save</button>
</div>
</div>
</div>
<section class="panel mission-entry-actions-panel">
<div class="panel-heading">
<p class="eyebrow">操作</p>
<h2>日志操作</h2>
{% endblock %}
{% block scripts %}
<script>
const ASSET_ID = '{{ asset.id }}';
const SIM_TIME = '{{ simulation_time_input }}';
// === DOCKING ===
function openDockModal(mode='dock_vehicle') {
document.getElementById('dock-mode').value = mode;
document.getElementById('dock-modal-title').textContent = mode === 'dock_to_target' ? 'Dock To Target' : 'Dock Vehicle';
document.getElementById('dock-select-label').textContent = mode === 'dock_to_target' ? 'Target Asset' : 'Vehicle';
document.getElementById('dock-vehicle').value = '';
document.getElementById('dock-custom').style.display = 'none';
document.querySelector('#dock-vehicle option[value="_custom"]').style.display = mode === 'dock_to_target' ? 'none' : '';
document.getElementById('dock-note').value = '';
document.getElementById('dock-time').value = SIM_TIME;
openModal('dock-modal');
}
document.getElementById('dock-vehicle').addEventListener('change', function() {
document.getElementById('dock-custom').style.display = this.value === '_custom' && document.getElementById('dock-mode').value !== 'dock_to_target' ? 'block' : 'none';
});
async function saveDock() {
const mode = document.getElementById('dock-mode').value;
const vehicleId = document.getElementById('dock-vehicle').value;
const customName = document.getElementById('dock-custom').value;
const note = document.getElementById('dock-note').value;
const dockTime = document.getElementById('dock-time').value;
if (!vehicleId) return;
const name = vehicleId === '_custom' ? customName : document.getElementById('dock-vehicle').selectedOptions[0]?.text;
if (!name) return;
const resp = await fetch(`/api/v1/assets/${ASSET_ID}/dock`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
mode: mode,
vehicle_id: mode === 'dock_vehicle' && vehicleId !== '_custom' ? vehicleId : null,
target_asset_id: mode === 'dock_to_target' ? vehicleId : null,
vehicle_name: name,
note: note,
dock_time: dockTime,
sim_time: SIM_TIME
})
});
if (resp.ok) { closeModal('dock-modal'); location.reload(); }
}
// === UNDOCKING ===
function openUndockModal(eventId, futureUndockAt) {
document.getElementById('undock-event-id').value = eventId;
document.getElementById('undock-time').value = SIM_TIME;
document.getElementById('undock-note').value = '';
const hasFuture = futureUndockAt && futureUndockAt.trim() !== '';
document.getElementById('undock-has-future').value = hasFuture ? futureUndockAt : '';
if (hasFuture) {
document.getElementById('undock-future-time').textContent = futureUndockAt;
document.getElementById('undock-future-warn').style.display = 'block';
document.getElementById('undock-normal-form').style.display = 'none';
document.getElementById('undock-btns-normal').style.display = 'none';
document.getElementById('undock-btns-future').style.display = 'flex';
} else {
document.getElementById('undock-future-warn').style.display = 'none';
document.getElementById('undock-normal-form').style.display = 'block';
document.getElementById('undock-btns-normal').style.display = 'flex';
document.getElementById('undock-btns-future').style.display = 'none';
}
openModal('undock-modal');
}
async function saveUndock() {
const eventId = document.getElementById('undock-event-id').value;
const undockTime = document.getElementById('undock-time').value;
const note = document.getElementById('undock-note').value;
const hasFuture = document.getElementById('undock-has-future').value;
const resp = await fetch(`/api/v1/assets/${ASSET_ID}/undock`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
event_id: eventId,
action: hasFuture ? 'edit_future' : 'undock',
undock_time: undockTime,
note: note,
sim_time: SIM_TIME
})
});
if (resp.ok) { closeModal('undock-modal'); location.reload(); }
}
async function deleteFutureUndock() {
const eventId = document.getElementById('undock-event-id').value;
const resp = await fetch(`/api/v1/assets/${ASSET_ID}/undock`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
event_id: eventId,
action: 'delete_future',
sim_time: SIM_TIME
})
});
if (resp.ok) { closeModal('undock-modal'); location.reload(); }
}
function editFutureUndock() {
const futureTime = document.getElementById('undock-has-future').value;
document.getElementById('undock-future-warn').style.display = 'none';
document.getElementById('undock-normal-form').style.display = 'block';
document.getElementById('undock-time').value = futureTime;
document.getElementById('undock-btns-future').style.display = 'none';
document.getElementById('undock-btns-normal').style.display = 'flex';
}
// === LOG MODAL (multi-node) ===
let logNodeData = [], editLogId = null;
function escapeAttr(value) {
return String(value || '').replace(/[&<>"']/g, (ch) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]));
}
function addNodeRow(title='', at='', detail='', stateLabel='', previousLocation='', transitLocation='', targetLocation='') {
logNodeData.push({title, at, detail, stateLabel, previousLocation, transitLocation, targetLocation});
renderNodeRows();
}
function removeNodeRow(i) { logNodeData.splice(i, 1); renderNodeRows(); }
function renderNodeRows() {
document.getElementById('nodes-list').innerHTML = logNodeData.length ? logNodeData.map((n, i) =>
`<div style="background:var(--surface2);border:1px solid var(--border);border-radius:4px;padding:6px 8px;margin-bottom:4px">
<div style="display:flex;gap:6px;align-items:center">
<input type="text" value="${escapeAttr(n.title)}" placeholder="Node title" oninput="logNodeData[${i}].title=this.value" style="flex:1;padding:3px 6px;border-radius:3px;background:var(--bg);color:var(--text);border:1px solid var(--border);font-size:.7rem">
<input type="datetime-local" value="${escapeAttr(n.at)}" oninput="logNodeData[${i}].at=this.value" step="60" style="width:140px;padding:3px 6px;border-radius:3px;background:var(--bg);color:var(--text);border:1px solid var(--border);font-size:.7rem">
<button class="b sm" type="button" onclick="removeNodeRow(${i})" style="color:var(--red);font-size:.6rem">✕</button>
</div>
<div class="mission-action-stack">
<a class="primary-button" href="{{ url_for('web.asset_entry_new', asset_id=asset.id, kind='event', sim_time=simulation_time_input) }}">新增事件点</a>
<a class="primary-button" href="{{ url_for('web.asset_entry_new', asset_id=asset.id, kind='state', sim_time=simulation_time_input) }}">新增状态区间</a>
<a class="secondary-button" href="{{ url_for('web.asset_edit', asset_id=asset.id, sim_time=simulation_time_input) }}">编辑资产</a>
<a class="secondary-button" href="{{ url_for('web.asset_list', sim_time=simulation_time_input) }}">返回任务资产</a>
<input type="text" value="${escapeAttr(n.detail)}" placeholder="Detail (optional)" oninput="logNodeData[${i}].detail=this.value" style="width:100%;padding:3px 6px;border-radius:3px;background:var(--bg);color:var(--text);border:1px solid var(--border);font-size:.7rem;margin-top:3px">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;margin-top:4px">
<input type="text" value="${escapeAttr(n.stateLabel)}" placeholder="State" oninput="logNodeData[${i}].stateLabel=this.value" style="padding:3px 6px;border-radius:3px;background:var(--bg);color:var(--text);border:1px solid var(--border);font-size:.7rem">
<input type="text" value="${escapeAttr(n.targetLocation)}" placeholder="Target location" oninput="logNodeData[${i}].targetLocation=this.value" style="padding:3px 6px;border-radius:3px;background:var(--bg);color:var(--text);border:1px solid var(--border);font-size:.7rem">
<input type="text" value="${escapeAttr(n.previousLocation)}" placeholder="Previous location" oninput="logNodeData[${i}].previousLocation=this.value" style="padding:3px 6px;border-radius:3px;background:var(--bg);color:var(--text);border:1px solid var(--border);font-size:.7rem">
<input type="text" value="${escapeAttr(n.transitLocation)}" placeholder="Transit location" oninput="logNodeData[${i}].transitLocation=this.value" style="padding:3px 6px;border-radius:3px;background:var(--bg);color:var(--text);border:1px solid var(--border);font-size:.7rem">
</div>
</section>
</aside>
</section>
</div>`
).join('') : '<div style="font-size:.68rem;color:var(--mut);padding:4px 0">No nodes. Click "+ Add Node" to add.</div>';
}
function openNewLog(type) {
editLogId = null; logNodeData = [];
document.getElementById('log-type').value = type;
document.getElementById('log-title').value = '';
document.getElementById('log-start').value = SIM_TIME;
document.getElementById('log-end').value = '';
document.getElementById('log-state').value = '';
document.getElementById('log-location').value = '';
document.getElementById('log-summary').value = '';
document.getElementById('log-start-wrap').style.display = 'block';
document.getElementById('log-end-wrap').style.display = type === 'state' ? 'block' : 'none';
document.getElementById('log-state-wrap').style.display = type === 'state' ? 'block' : 'none';
document.getElementById('log-nodes-wrap').style.display = type === 'state' ? 'block' : 'none';
renderNodeRows();
document.getElementById('log-modal-title').textContent = 'New Log Entry';
openModal('log-modal');
}
async function saveLog() {
const type = document.getElementById('log-type').value;
const title = document.getElementById('log-title').value;
if (!title) { alert('Title required'); return; }
const stateLabel = document.getElementById('log-state').value || null;
const targetLocation = document.getElementById('log-location').value || null;
const enteredNodes = logNodeData.filter(n => n.title).map(n => ({
title: n.title,
at_time: n.at || null,
detail: n.detail || null,
state_label: n.stateLabel || stateLabel,
previous_location: n.previousLocation || null,
transit_location: n.transitLocation || null,
target_location: n.targetLocation || targetLocation
}));
const stateNodes = type === 'state' && enteredNodes.length === 0 && (stateLabel || targetLocation)
? [{title: title, at_time: document.getElementById('log-start').value || SIM_TIME, detail: null, state_label: stateLabel, target_location: targetLocation}]
: enteredNodes;
const data = {
entry_kind: type, title: title,
start_at: document.getElementById('log-start').value || null,
end_at: type === 'state' ? (document.getElementById('log-end').value || null) : null,
state_label: type === 'state' ? stateLabel : null,
location: targetLocation,
summary: document.getElementById('log-summary').value || null,
sim_time: SIM_TIME,
state_nodes: stateNodes
};
const url = editLogId ? `/assets/${ASSET_ID}/entries/${editLogId}/edit` : `/assets/${ASSET_ID}/entries/new`;
const resp = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
if (resp.ok) { closeModal('log-modal'); location.reload(); }
else { const err = await resp.json(); alert(err.error || 'Save failed'); }
}
function filterAssetLogs() {
const query = (document.getElementById('ad-lq')?.value || '').trim().toLowerCase();
const type = document.getElementById('ad-lf')?.value || '';
document.querySelectorAll('[data-log-row]').forEach((row) => {
const matchesType = !type || row.dataset.logType === type;
const matchesQuery = !query || (row.dataset.logText || '').includes(query);
row.style.display = matchesType && matchesQuery ? '' : 'none';
});
}
document.getElementById('ad-lq')?.addEventListener('input', filterAssetLogs);
document.getElementById('ad-lf')?.addEventListener('change', filterAssetLogs);
</script>
{% endblock %}
+369 -213
View File
@@ -1,270 +1,426 @@
{% extends "base.html" %}
{% from "_datetime_picker_field.html" import datetime_picker_field %}
{% block content %}
{% include "_mission_ops_tabs.html" %}
{% set field_error_map = field_errors|default({}, true) %}
{% macro render_end_at_field(end_at_value, has_error=False) %}
{{ datetime_picker_field('End At', 'end_at', end_at_value, field_id='entry-end-at-input', wrapper_class='field-span-two mission-datetime-field', wrapper_id='entry-end-at-field', error=has_error) }}
{% endmacro %}
{% macro render_state_node_row(node, row_index, field_error_map) %}
{% set title_error = field_error_map.get('state_node_title:' ~ row_index) %}
{% set at_error = field_error_map.get('state_node_at:' ~ row_index) %}
<div class="state-node-row{% if title_error or at_error %} state-node-row-error{% endif %}" data-state-node-row{% if title_error or at_error %} data-field-error="true"{% endif %}>
<input type="hidden" name="state_node_id" value="{{ node.id if node else '' }}">
<style>
.hidden-if-not-transit{display:none}
.combobox{position:relative;display:flex;align-items:center}
.combobox input{flex:1;padding-right:28px}
.combobox-btn{position:absolute;right:4px;top:50%;transform:translateY(-50%);width:22px;height:22px;border:none;background:transparent;cursor:pointer;color:var(--text-muted);font-size:10px;display:flex;align-items:center;justify-content:center;border-radius:3px}
.combobox-btn:hover{background:rgba(255,255,255,.08)}
.combobox-drop{position:absolute;top:100%;left:0;right:0;max-height:200px;overflow-y:auto;background:var(--surface);border:1px solid var(--border);border-radius:4px;z-index:100;display:none;list-style:none;padding:0;margin:2px 0 0}
.combobox-drop.open{display:block}
.combobox-drop li{padding:5px 10px;font-size:.82rem;cursor:pointer;color:var(--text)}
.combobox-drop li:hover,.combobox-drop li.active{background:rgba(59,130,246,.15);color:var(--accent)}
.combobox-drop li.hidden{display:none}
</style>
<label class="{% if title_error %}field-error-shell{% endif %}"{% if title_error %} data-field-error="true"{% endif %}>
<span class="field-label">Node Title</span>
<input class="{% if title_error %}field-error-input{% endif %}" type="text" name="state_node_title" value="{{ node.title if node else '' }}" placeholder="State Node Title">
</label>
{% set is_state_entry = form_values.entry_kind == 'state' %}
{% set node_count = form_values.state_nodes|length %}
{% set all_locations = ['LEO','GEO/MEO','Lunar Orbit','Lunar Surface','Earth Surface','Mercury System','Mercury Orbit','Mercury Surface','Venus System','Venus Orbit','Venus Surface','Earth System','Mars System','Mars Orbit','Mars Surface','Phobos Orbit','Phobos Surface','Deimos Orbit','Deimos Surface','Ceres Orbit','Ceres Surface','Vesta Orbit','Vesta Surface','Jupiter System','Jupiter Orbit','Io Orbit','Io Surface','Europa Orbit','Europa Surface','Ganymede Orbit','Ganymede Surface','Callisto Orbit','Callisto Surface','Saturn System','Saturn Orbit','Mimas Orbit','Mimas Surface','Enceladus Orbit','Enceladus Surface','Tethys Orbit','Tethys Surface','Dione Orbit','Dione Surface','Rhea Orbit','Rhea Surface','Titan Orbit','Titan Surface','Iapetus Orbit','Iapetus Surface','Uranus System','Uranus Orbit','Miranda Orbit','Miranda Surface','Ariel Orbit','Ariel Surface','Umbriel Orbit','Umbriel Surface','Titania Orbit','Titania Surface','Oberon Orbit','Oberon Surface','Neptune System','Neptune Orbit','Triton Orbit','Triton Surface','Pluto System','Pluto Orbit','Pluto Surface','Charon Orbit','Charon Surface','Solar Orbit','Transfer'] %}
{% if not all_states %}{% set all_states = ['start-transit','transit-complete','Exploration','Maintenance','Docked','Construction','Testing','Autonomous Standby','Crew Handoff','Cargo Delivery','Surface Expedition','Planetary Operations','Base Construction','Outpost Construction','Base Expansion','Outpost Expansion'] %}{% endif %}
{{ datetime_picker_field('Node At', 'state_node_at', node.at if node else '', field_id='state-node-at-' ~ row_index, wrapper_class='state-node-datetime', error=at_error) }}
<label class="state-node-detail-field">
<span class="field-label">Node Detail</span>
<textarea name="state_node_detail" rows="2" placeholder="State Node Detail">{{ node.detail if node else '' }}</textarea>
</label>
<button class="secondary-button state-node-remove-button" type="button" data-state-node-remove>移除</button>
</div>
{% endmacro %}
<section class="hero-panel compact page-header-compact">
<div class="hero-copy">
<p class="eyebrow">资产日志</p>
<h1>{{ heading }}</h1>
<section class="asset-entry-hero">
<div class="asset-entry-title-block">
<div class="asset-entry-crumbs">
<span>任务资产</span>
<span>/</span>
<span>{{ asset.name }}</span>
<span>/</span>
<span>{{ '编辑日志' if delete_url else '新增日志' }}</span>
</div>
<h1>{{ form_values.title or heading }}</h1>
<p>{{ asset.asset_type }}{% if asset.program %} · {{ asset.program }}{% endif %}</p>
</div>
<div class="asset-entry-status-grid">
<div class="asset-entry-stat active">
<span>Entry Type</span>
<strong>{{ 'State Interval' if is_state_entry else 'Event Point' }}</strong>
</div>
<div class="asset-entry-stat">
<span>Simulation Time</span>
<strong>{{ simulation_time_input }}</strong>
</div>
<div class="asset-entry-stat">
<span>State Window</span>
<strong>{{ form_values.start_at or '—' }}{% if form_values.end_at %} → {{ form_values.end_at }}{% endif %}</strong>
</div>
<div class="asset-entry-stat">
<span>State Nodes</span>
<strong>{{ node_count }} node{{ '' if node_count == 1 else 's' }}</strong>
</div>
</div>
</section>
<form method="post" class="edit-layout">
<form method="post" id="asset-entry-form" class="asset-entry-layout">
<input type="hidden" name="sim_time" value="{{ simulation_time_input }}">
<section class="panel panel-wide">
{% set is_event_kind = form_values.entry_kind == 'event' %}
<div class="form-grid resource-form-grid">
<label>
<aside class="asset-entry-side">
<section class="asset-entry-panel">
<div class="asset-entry-panel-head">
<div>
<p class="asset-entry-kicker">Entry</p>
<h2>日志信息</h2>
<p>这些字段会决定资产在状态板、时间线和地点板上的当前状态。</p>
</div>
</div>
<div class="asset-entry-panel-body">
<div class="asset-entry-context">
<div><span>Asset</span><strong>{{ asset.name }}</strong></div>
<div><span>Asset Type</span><strong>{{ asset.asset_type }}</strong></div>
<div><span>Program</span><strong>{{ asset.program or '—' }}</strong></div>
</div>
<label class="asset-entry-field">
<span class="field-label">Asset</span>
<input type="text" value="{{ asset.name }}" disabled>
</label>
<label class="{% if field_error_map.get('entry_kind') %}field-error-shell{% endif %}"{% if field_error_map.get('entry_kind') %} data-field-error="true"{% endif %}>
{% set entry_kind_error = field_error_map.get('entry_kind') %}
<label class="asset-entry-field{% if entry_kind_error %} field-error-shell{% endif %}"{% if entry_kind_error %} data-field-error="true"{% endif %}>
<span class="field-label">Entry Type</span>
<select id="entry-kind-select" name="entry_kind" class="{% if field_error_map.get('entry_kind') %}field-error-input{% endif %}">
{% for option in kind_options %}
<option value="{{ option.value }}" {% if option.value == form_values.entry_kind %}selected{% endif %}>{{ option.label }}</option>
<select name="entry_kind" id="entry-kind-select" class="{% if entry_kind_error %}field-error-input{% endif %}">
{% for opt in kind_options %}
<option value="{{ opt.value }}" {% if form_values.entry_kind == opt.value %}selected{% endif %}>{{ opt.label }}</option>
{% endfor %}
</select>
</label>
<label class="{% if field_error_map.get('location') %}field-error-shell{% endif %}"{% if field_error_map.get('location') %} data-field-error="true"{% endif %}>
<span class="field-label">Location</span>
<input class="{% if field_error_map.get('location') %}field-error-input{% endif %}" type="text" name="location" value="{{ form_values.location }}">
</label>
<div class="asset-entry-type-switch" aria-hidden="true">
<button type="button" class="asset-entry-type-option{% if is_state_entry %} active{% endif %}" data-entry-kind-pill="state">State Interval</button>
<button type="button" class="asset-entry-type-option{% if not is_state_entry %} active{% endif %}" data-entry-kind-pill="event">Event Point</button>
</div>
<label class="field-span-three{% if field_error_map.get('title') %} field-error-shell{% endif %}"{% if field_error_map.get('title') %} data-field-error="true"{% endif %}>
{% set title_error = field_error_map.get('title') %}
<label class="asset-entry-field{% if title_error %} field-error-shell{% endif %}"{% if title_error %} data-field-error="true"{% endif %}>
<span class="field-label">Title</span>
<input class="{% if field_error_map.get('title') %}field-error-input{% endif %}" type="text" name="title" value="{{ form_values.title }}" required>
<input class="{% if title_error %}field-error-input{% endif %}" type="text" name="title" value="{{ form_values.title }}" placeholder="Entry title">
</label>
<label class="{% if field_error_map.get('state_label') %}field-error-shell{% endif %}"{% if field_error_map.get('state_label') %} data-field-error="true"{% endif %}>
<span class="field-label">State Label</span>
<input class="{% if field_error_map.get('state_label') %}field-error-input{% endif %}" type="text" name="state_label" value="{{ form_values.state_label }}">
<small class="field-hint">可单独设置显示标签;多个标签用分号分隔。State Interval 留空时使用 Title。</small>
<div class="asset-entry-two-col">
{% set start_error = field_error_map.get('start_at') %}
<label class="asset-entry-field{% if start_error %} field-error-shell{% endif %}"{% if start_error %} data-field-error="true"{% endif %}>
<span class="field-label">Start At</span>
<input class="{% if start_error %}field-error-input{% endif %}" type="datetime-local" name="start_at" value="{{ form_values.start_at }}" step="60">
</label>
<label class="field-span-two{% if field_error_map.get('mission_label') %} field-error-shell{% endif %}"{% if field_error_map.get('mission_label') %} data-field-error="true"{% endif %}>
<span class="field-label">Mission Label</span>
<input class="{% if field_error_map.get('mission_label') %}field-error-input{% endif %}" type="text" name="mission_label" value="{{ form_values.mission_label }}">
<small class="field-hint">多个 Mission Label 可用分号分隔。</small>
{% set end_error = field_error_map.get('end_at') %}
<label id="end-at-wrap" class="asset-entry-field{% if end_error %} field-error-shell{% endif %}" {% if not is_state_entry %}style="display:none"{% endif %}{% if end_error %} data-field-error="true"{% endif %}>
<span class="field-label">End At</span>
<input class="{% if end_error %}field-error-input{% endif %}" type="datetime-local" name="end_at" value="{{ form_values.end_at }}" step="60">
</label>
</div>
{{ datetime_picker_field('At' if is_event_kind else 'Start At', 'start_at', form_values.start_at, field_id='entry-start-at-input', required=True, wrapper_class='field-span-two mission-datetime-field', wrapper_id='entry-start-at-field', label_id='entry-start-at-label', error=field_error_map.get('start_at')) }}
{% if not is_event_kind %}
{{ render_end_at_field(form_values.end_at, field_error_map.get('end_at')) }}
{% if not is_state_entry %}
{% set loc_error = field_error_map.get('location') %}
<label class="asset-entry-field{% if loc_error %} field-error-shell{% endif %}"{% if loc_error %} data-field-error="true"{% endif %}>
<span class="field-label">Location</span>
<select class="{% if loc_error %}field-error-input{% endif %}" name="location">
<option value=""></option>
{% for loc in all_locations %}
<option value="{{ loc }}" {% if form_values.location == loc %}selected{% endif %}>{{ loc }}</option>
{% endfor %}
</select>
</label>
{% endif %}
<div class="field-span-three state-node-panel{% if field_error_map.get('state_nodes') %} field-error-shell{% endif %}" id="state-node-panel" {% if is_event_kind %}hidden{% endif %}{% if field_error_map.get('state_nodes') %} data-field-error="true"{% endif %}>
<div class="state-node-panel-header">
<div>
<span class="field-label">State Nodes</span>
<p class="field-hint">每个 State Node 包含时间、标题和 Node Detail,并会在 Timeline 的 Event Point 泳道中显示。</p>
<p class="field-hint">State Node 时间必须落在当前 State Interval 的 Start At 和 End At 之间。</p>
</div>
<button class="secondary-button" type="button" data-state-node-add>新增 State Node</button>
</div>
{% set mission_error = field_error_map.get('mission_label') %}
<label class="asset-entry-field{% if mission_error %} field-error-shell{% endif %}"{% if mission_error %} data-field-error="true"{% endif %}>
<span class="field-label">Mission Label</span>
<input class="{% if mission_error %}field-error-input{% endif %}" type="text" name="mission_label" value="{{ form_values.mission_label }}" placeholder="Mission name">
</label>
<div class="state-node-list" data-state-node-list>
{% for node in form_values.state_nodes %}
{{ render_state_node_row(node, loop.index0, field_error_map) }}
{% endfor %}
</div>
<p class="small-muted state-node-empty" data-state-node-empty {% if form_values.state_nodes %}hidden{% endif %}>当前没有 State Node,可按需添加。</p>
</div>
<label class="field-span-three{% if field_error_map.get('summary') %} field-error-shell{% endif %}"{% if field_error_map.get('summary') %} data-field-error="true"{% endif %}>
{% set summary_error = field_error_map.get('summary') %}
<label class="asset-entry-field{% if summary_error %} field-error-shell{% endif %}"{% if summary_error %} data-field-error="true"{% endif %}>
<span class="field-label">Summary</span>
<textarea class="{% if field_error_map.get('summary') %}field-error-input{% endif %}" name="summary" rows="4">{{ form_values.summary }}</textarea>
<textarea class="{% if summary_error %}field-error-input{% endif %}" name="summary" rows="4" placeholder="Optional">{{ form_values.summary }}</textarea>
</label>
<label class="field-span-three{% if field_error_map.get('note') %} field-error-shell{% endif %}"{% if field_error_map.get('note') %} data-field-error="true"{% endif %}>
{% set note_error = field_error_map.get('note') %}
<label class="asset-entry-field{% if note_error %} field-error-shell{% endif %}"{% if note_error %} data-field-error="true"{% endif %}>
<span class="field-label">Note</span>
<textarea class="{% if field_error_map.get('note') %}field-error-input{% endif %}" name="note" rows="4">{{ form_values.note }}</textarea>
<textarea class="{% if note_error %}field-error-input{% endif %}" name="note" rows="4" placeholder="Optional">{{ form_values.note }}</textarea>
</label>
</div>
</section>
</aside>
<div class="button-row top-gap">
<button class="primary-button" type="submit">{{ submit_label }}</button>
<a class="secondary-button" href="{{ cancel_url }}">返回</a>
<section id="state-node-section" class="asset-entry-panel asset-entry-node-panel" {% if not is_state_entry %}style="display:none"{% endif %}>
<div class="asset-entry-panel-head">
<div>
<p class="asset-entry-kicker">State Nodes</p>
<h2>状态节点时间轴</h2>
<p>每个节点代表这段状态中的一个具体时刻。时间、事件和备注会一起保存。</p>
</div>
<div class="asset-entry-node-actions">
<span class="asset-entry-chip">按 Node At 升序</span>
<button class="b sm" type="button" onclick="addNodeRow()">+ Add Node</button>
</div>
</div>
<div class="asset-entry-node-summary">
<span class="asset-entry-node-pill start">Start</span>
<span class="asset-entry-node-pill mid">Course</span>
<span class="asset-entry-node-pill watch">Watch</span>
<span class="asset-entry-node-pill end">End</span>
<span class="asset-entry-node-count" data-node-count>{{ node_count }} node{{ '' if node_count == 1 else 's' }}</span>
</div>
{% set node_section_error = field_error_map.get('state_nodes') %}
<div id="nodes-list" class="asset-entry-rail{% if node_section_error %} field-error-shell{% endif %}"{% if node_section_error %} data-field-error="true"{% endif %}>
{% if not form_values.state_nodes %}
<div class="asset-entry-empty-nodes" data-empty-node-state>
<strong>还没有 State Node</strong>
<span>添加节点后,这里会按时间显示“什么时间发生了什么”。</span>
<button class="b sm" type="button" onclick="addNodeRow()">+ Add First Node</button>
</div>
{% endif %}
{% for node in form_values.state_nodes %}
{% set node_title_error = field_error_map.get('state_node_title:' ~ loop.index0) %}
{% set node_at_error = field_error_map.get('state_node_at:' ~ loop.index0) %}
<article class="state-node-row asset-entry-rail-node{% if node_title_error or node_at_error %} field-error-shell{% endif %}"{% if node_title_error or node_at_error %} data-field-error="true"{% endif %}>
<div class="asset-entry-rail-time" data-node-time-label>{{ node.at or '未设置时间' }}</div>
<div class="asset-entry-rail-line"><span></span></div>
<div class="asset-entry-rail-copy">
<div class="asset-entry-node-card">
<div class="asset-entry-node-card-head">
<span class="asset-entry-time-badge" data-node-badge>{{ 'Node ' ~ loop.index }}</span>
<div class="asset-entry-node-title">
<strong data-node-title-label>{{ node.title or 'Untitled node' }}</strong>
<span data-node-detail-label>{{ node.detail or 'No detail yet.' }}</span>
</div>
<button class="b sm dg" type="button" onclick="removeNodeRow(this)">Delete</button>
</div>
<div class="asset-entry-node-fields">
<input type="hidden" name="state_node_id" value="{{ node.id }}">
<label class="asset-entry-field{% if node_title_error %} field-error-shell{% endif %}"{% if node_title_error %} data-field-error="true"{% endif %}>
<span class="field-label">Node Title</span>
<input class="{% if node_title_error %}field-error-input{% endif %}" type="text" name="state_node_title" value="{{ node.title }}" placeholder="Node title" oninput="syncNodePreview(this)">
</label>
<label class="asset-entry-field{% if node_at_error %} field-error-shell{% endif %}"{% if node_at_error %} data-field-error="true"{% endif %}>
<span class="field-label">Node At</span>
<input class="{% if node_at_error %}field-error-input{% endif %}" type="datetime-local" name="state_node_at" value="{{ node.at }}" step="60" oninput="syncNodePreview(this)">
</label>
<label class="asset-entry-field asset-entry-node-detail">
<span class="field-label">Detail</span>
<textarea name="state_node_detail" rows="2" placeholder="Detail (optional)" oninput="syncNodePreview(this)">{{ node.detail }}</textarea>
</label>
{% macro combobox_location(name, value, placeholder) %}
<div class="combobox"><input type="text" name="{{ name }}" value="{{ value }}" placeholder="{{ placeholder }}" autocomplete="off" onfocus="openCombobox(this)" oninput="filterCombobox(this)"><button type="button" class="combobox-btn" onclick="toggleCombobox(this)">&#9662;</button><ul class="combobox-drop">{% for loc in all_locations %}<li onclick="selectCombobox(this)">{{ loc }}</li>{% endfor %}</ul></div>
{% endmacro %}
{% macro combobox_state(name, value) %}
<div class="combobox"><input type="text" name="{{ name }}" value="{{ value }}" placeholder="Search state..." autocomplete="off" onfocus="openCombobox(this)" oninput="filterCombobox(this)" onchange="toggleTransitFields(this)"><button type="button" class="combobox-btn" onclick="toggleCombobox(this)">&#9662;</button><ul class="combobox-drop">{% for s in all_states %}<li onclick="selectCombobox(this)">{{ s }}</li>{% endfor %}</ul></div>
{% endmacro %}
<label class="asset-entry-field">
<span class="field-label">State</span>
{{ combobox_state('state_node_state_label', node.state_label) }}
</label>
<label class="asset-entry-field hidden-if-not-transit" data-transit-fields>
<span class="field-label">Previous Location</span>
{{ combobox_location('state_node_previous_location', node.previous_location, 'Search location...') }}
</label>
<label class="asset-entry-field hidden-if-not-transit" data-transit-fields>
<span class="field-label">Transit Location</span>
{{ combobox_location('state_node_transit_location', node.transit_location, 'Search location...') }}
</label>
<label class="asset-entry-field">
<span class="field-label">Target Location</span>
{{ combobox_location('state_node_target_location', node.target_location, 'Search location...') }}
</label>
</div>
</div>
</div>
</article>
{% endfor %}
</div>
</section>
</form>
{% if delete_url %}
<section class="panel danger-panel top-gap">
<p class="small-muted">删除后不可恢复,请谨慎操作。</p>
<form method="post" action="{{ delete_url }}" class="inline-form" onsubmit="return confirm('确认删除这个日志条目?')">
<form method="post" action="{{ delete_url }}" id="asset-entry-delete-form" onsubmit="return confirm('确认删除?')">
<input type="hidden" name="sim_time" value="{{ simulation_time_input }}">
<button class="danger-link" type="submit">删除条目</button>
</form>
</section>
</form>
{% endif %}
<template id="entry-end-at-template">
{{ render_end_at_field(form_values.end_at, false) }}
</template>
<div class="asset-entry-footer">
<button class="b ac" type="submit" form="asset-entry-form">{{ submit_label }}</button>
<a class="b" href="{{ cancel_url }}">返回</a>
{% if delete_url %}
<span class="asset-entry-footer-spacer"></span>
<button class="b dg" type="submit" form="asset-entry-delete-form">删除条目</button>
{% endif %}
</div>
<template id="state-node-row-template">
{{ render_state_node_row(none, '__INDEX__', {}) }}
<template id="node-template">
<article class="state-node-row asset-entry-rail-node">
<div class="asset-entry-rail-time" data-node-time-label>未设置时间</div>
<div class="asset-entry-rail-line"><span></span></div>
<div class="asset-entry-rail-copy">
<div class="asset-entry-node-card">
<div class="asset-entry-node-card-head">
<span class="asset-entry-time-badge" data-node-badge>New</span>
<div class="asset-entry-node-title">
<strong data-node-title-label>Untitled node</strong>
<span data-node-detail-label>No detail yet.</span>
</div>
<button class="b sm dg" type="button" onclick="removeNodeRow(this)">Delete</button>
</div>
<div class="asset-entry-node-fields">
<input type="hidden" name="state_node_id" value="">
<label class="asset-entry-field">
<span class="field-label">Node Title</span>
<input type="text" name="state_node_title" placeholder="Node title" oninput="syncNodePreview(this)">
</label>
<label class="asset-entry-field">
<span class="field-label">Node At</span>
<input type="datetime-local" name="state_node_at" step="60" oninput="syncNodePreview(this)">
</label>
<label class="asset-entry-field asset-entry-node-detail">
<span class="field-label">Detail</span>
<textarea name="state_node_detail" rows="2" placeholder="Detail (optional)" oninput="syncNodePreview(this)"></textarea>
</label>
<label class="asset-entry-field">
<span class="field-label">State</span>
<div class="combobox"><input type="text" name="state_node_state_label" placeholder="Search state..." autocomplete="off" onfocus="openCombobox(this)" oninput="filterCombobox(this)" onchange="toggleTransitFields(this)"><button type="button" class="combobox-btn" onclick="toggleCombobox(this)">&#9662;</button><ul class="combobox-drop">{% for s in all_states %}<li onclick="selectCombobox(this)">{{ s }}</li>{% endfor %}</ul></div>
</label>
<label class="asset-entry-field hidden-if-not-transit" data-transit-fields>
<span class="field-label">Previous Location</span>
<div class="combobox"><input type="text" name="state_node_previous_location" placeholder="Search location..." autocomplete="off" onfocus="openCombobox(this)" oninput="filterCombobox(this)"><button type="button" class="combobox-btn" onclick="toggleCombobox(this)">&#9662;</button><ul class="combobox-drop">{% for loc in all_locations %}<li onclick="selectCombobox(this)">{{ loc }}</li>{% endfor %}</ul></div>
</label>
<label class="asset-entry-field hidden-if-not-transit" data-transit-fields>
<span class="field-label">Transit Location</span>
<div class="combobox"><input type="text" name="state_node_transit_location" placeholder="Search location..." autocomplete="off" onfocus="openCombobox(this)" oninput="filterCombobox(this)"><button type="button" class="combobox-btn" onclick="toggleCombobox(this)">&#9662;</button><ul class="combobox-drop">{% for loc in all_locations %}<li onclick="selectCombobox(this)">{{ loc }}</li>{% endfor %}</ul></div>
</label>
<label class="asset-entry-field">
<span class="field-label">Target Location</span>
<div class="combobox"><input type="text" name="state_node_target_location" placeholder="Search location..." autocomplete="off" onfocus="openCombobox(this)" oninput="filterCombobox(this)"><button type="button" class="combobox-btn" onclick="toggleCombobox(this)">&#9662;</button><ul class="combobox-drop">{% for loc in all_locations %}<li onclick="selectCombobox(this)">{{ loc }}</li>{% endfor %}</ul></div>
</label>
</div>
</div>
</div>
</article>
</template>
{% include "_validation_dialog.html" %}
<script>
(() => {
const kindField = document.getElementById("entry-kind-select");
const startLabel = document.getElementById("entry-start-at-label");
const startField = document.getElementById("entry-start-at-field");
const endFieldTemplate = document.getElementById("entry-end-at-template");
const stateNodePanel = document.getElementById("state-node-panel");
const stateNodeList = stateNodePanel?.querySelector("[data-state-node-list]");
const stateNodeEmpty = stateNodePanel?.querySelector("[data-state-node-empty]");
const stateNodeAddButton = stateNodePanel?.querySelector("[data-state-node-add]");
const stateNodeRowTemplate = document.getElementById("state-node-row-template");
let endField = document.getElementById("entry-end-at-field");
let endInput = document.getElementById("entry-end-at-input");
let cachedEndValue = endInput?.value || "";
let stateNodeIndex = stateNodeList?.querySelectorAll("[data-state-node-row]").length || 0;
function formatNodeTime(value) {
return value ? value.replace("T", " ") : "未设置时间";
}
if (!kindField || !startLabel || !startField || !endFieldTemplate) {
return;
}
function findNodeRow(control) {
return control.closest(".asset-entry-rail-node");
}
const refreshEndField = () => {
endField = document.getElementById("entry-end-at-field");
endInput = document.getElementById("entry-end-at-input");
};
function syncNodePreview(control) {
const row = findNodeRow(control);
if (!row) return;
const title = row.querySelector('[name="state_node_title"]')?.value?.trim();
const at = row.querySelector('[name="state_node_at"]')?.value;
const detail = row.querySelector('[name="state_node_detail"]')?.value?.trim();
const titleLabel = row.querySelector("[data-node-title-label]");
const detailLabel = row.querySelector("[data-node-detail-label]");
const timeLabel = row.querySelector("[data-node-time-label]");
if (titleLabel) titleLabel.textContent = title || "Untitled node";
if (detailLabel) detailLabel.textContent = detail || "No detail yet.";
if (timeLabel) timeLabel.textContent = formatNodeTime(at);
}
const rememberEndValue = () => {
cachedEndValue = endInput?.value || "";
};
const restoreEndValue = () => {
if (!endInput) {
return;
}
endInput.value = cachedEndValue;
};
const ensureEndField = () => {
if (endField) {
restoreEndValue();
return;
}
const templateField = endFieldTemplate.content.firstElementChild;
if (!templateField) {
return;
}
startField.insertAdjacentElement("afterend", templateField.cloneNode(true));
refreshEndField();
restoreEndValue();
window.bindDatetimePickerTriggers?.(endField);
};
const removeEndField = () => {
if (!endField) {
return;
}
rememberEndValue();
endField.remove();
refreshEndField();
};
const stateNodeRows = () => Array.from(stateNodeList?.querySelectorAll("[data-state-node-row]") || []);
const updateStateNodeEmpty = () => {
if (!stateNodeEmpty) {
return;
}
stateNodeEmpty.hidden = stateNodeRows().length > 0;
};
const setStateNodePanelVisible = (visible) => {
if (!stateNodePanel) {
return;
}
stateNodePanel.hidden = !visible;
for (const field of stateNodePanel.querySelectorAll("input[name], textarea[name], select[name]")) {
field.disabled = !visible;
}
if (stateNodeAddButton) {
stateNodeAddButton.disabled = !visible;
}
for (const button of stateNodePanel.querySelectorAll("[data-state-node-remove]")) {
button.disabled = !visible;
}
};
const addStateNodeRow = () => {
if (!stateNodeList || !stateNodeRowTemplate) {
return;
}
const rowMarkup = stateNodeRowTemplate.innerHTML.replaceAll("__INDEX__", String(stateNodeIndex));
stateNodeIndex += 1;
stateNodeList.insertAdjacentHTML("beforeend", rowMarkup);
const row = stateNodeList.lastElementChild;
if (row) {
window.bindDatetimePickerTriggers?.(row);
row.querySelector('input[name="state_node_title"]')?.focus();
}
updateStateNodeEmpty();
};
stateNodeAddButton?.addEventListener("click", addStateNodeRow);
stateNodeList?.addEventListener("click", (event) => {
const removeButton = event.target.closest("[data-state-node-remove]");
if (!removeButton) {
return;
}
removeButton.closest("[data-state-node-row]")?.remove();
updateStateNodeEmpty();
function updateNodeCount() {
const count = document.querySelectorAll("#nodes-list .asset-entry-rail-node").length;
document.querySelectorAll("[data-node-count]").forEach((el) => {
el.textContent = `${count} node${count === 1 ? "" : "s"}`;
});
const syncEntryKind = () => {
const isEvent = kindField.value === "event";
startLabel.textContent = isEvent ? "At" : "Start At";
if (isEvent) {
removeEndField();
setStateNodePanelVisible(false);
return;
const emptyState = document.querySelector("[data-empty-node-state]");
if (emptyState) {
emptyState.style.display = count ? "none" : "";
}
ensureEndField();
setStateNodePanelVisible(true);
};
}
kindField.addEventListener("change", syncEntryKind);
updateStateNodeEmpty();
syncEntryKind();
})();
function removeNodeRow(button) {
const row = findNodeRow(button);
if (row) row.remove();
updateNodeCount();
}
function addNodeRow() {
const tmpl = document.getElementById("node-template");
const clone = tmpl.content.cloneNode(true);
document.getElementById("nodes-list").appendChild(clone);
updateNodeCount();
}
document.getElementById("entry-kind-select").addEventListener("change", function() {
const isState = this.value === "state";
document.getElementById("end-at-wrap").style.display = isState ? "" : "none";
document.getElementById("state-wrap")?.style.setProperty("display", isState ? "" : "none");
document.getElementById("state-node-section").style.display = isState ? "" : "none";
document.querySelectorAll("[data-entry-kind-pill]").forEach((pill) => {
pill.classList.toggle("active", pill.dataset.entryKindPill === this.value);
});
});
document.querySelectorAll("[data-entry-kind-pill]").forEach((pill) => {
pill.addEventListener("click", () => {
const select = document.getElementById("entry-kind-select");
select.value = pill.dataset.entryKindPill;
select.dispatchEvent(new Event("change", {bubbles: true}));
});
});
function toggleTransitFields(sel) {
const row = sel.closest('.asset-entry-node-card') || sel.closest('[data-state-node-row]');
if (!row) return;
const fields = row.querySelectorAll('[data-transit-fields]');
const show = sel.value === 'start-transit';
fields.forEach(function(f) { f.style.display = show ? 'block' : 'none'; });
}
document.querySelectorAll('[name="state_node_state_label"]').forEach(function(s) { toggleTransitFields(s); });
// ── Combobox ──
function filterCombobox(input) {
var drop = input.parentElement.querySelector('.combobox-drop');
if (!drop) return;
var val = input.value.toLowerCase();
var items = drop.querySelectorAll('li');
var hasVisible = false;
items.forEach(function(li) {
if (val === '' || li.textContent.toLowerCase().indexOf(val) !== -1) {
li.classList.remove('hidden');
hasVisible = true;
} else {
li.classList.add('hidden');
}
});
if (hasVisible) drop.classList.add('open');
}
function toggleCombobox(btn) {
var drop = btn.parentElement.querySelector('.combobox-drop');
if (!drop) return;
drop.classList.toggle('open');
if (drop.classList.contains('open')) {
var input = btn.parentElement.querySelector('input');
if (input) { input.focus(); filterCombobox(input); }
}
}
function openCombobox(input) {
var drop = input.parentElement.querySelector('.combobox-drop');
if (drop) { drop.classList.add('open'); filterCombobox(input); }
}
function selectCombobox(li) {
var drop = li.parentElement;
var combobox = drop.parentElement;
var input = combobox.querySelector('input');
if (input) {
input.value = li.textContent;
input.dispatchEvent(new Event('change', {bubbles: true}));
}
drop.classList.remove('open');
}
document.addEventListener('click', function(e) {
if (!e.target.closest('.combobox')) {
document.querySelectorAll('.combobox-drop.open').forEach(function(d) { d.classList.remove('open'); });
}
});
</script>
{% include "_validation_dialog.html" %}
{% endblock %}
+45 -91
View File
@@ -1,114 +1,68 @@
{% extends "base.html" %}
{% from "_datetime_picker_field.html" import datetime_picker_field %}
{% block content %}
{% include "_mission_ops_tabs.html" %}
{% include "_mission_preview_header.html" %}
<section class="panel catalog-actions-panel top-gap">
<div>
<p class="eyebrow">Actions</p>
<h2>新增内容</h2>
<div class="ph-row fi">
<div class="ph">
<div class="ey">Mission Operations</div>
<h1>任务资产</h1>
</div>
<div class="button-row">
<a class="primary-button" href="{{ url_for('web.asset_new', sim_time=simulation_time_input) }}">新增任务资产</a>
<form method="post" action="{{ url_for('web.asset_import_log_book') }}" class="inline-form">
<a class="b ac" href="{{ url_for('web.asset_new'
) }}">+ New Asset</a>
</div>
<div class="fb fi">
<form method="get" action="{{ url_for('web.asset_list') }}" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="hidden" name="sim_time" value="{{ simulation_time_input }}">
<button class="secondary-button" type="submit">导入 Log Book</button>
</form>
</div>
</section>
<section class="panel panel-wide top-gap">
<form class="search-form filter-row" method="get">
<input type="search" name="q" value="{{ search_term }}" placeholder="Asset / Type / Program / Region">
<select name="asset_type">
<option value="">全部类型</option>
{% for option in asset_type_options %}
<option value="{{ option }}" {% if option == asset_type_filter %}selected{% endif %}>{{ option }}</option>
{% endfor %}
<input type="text" name="q" value="{{ search_term }}" placeholder="Search assets...">
<select name="asset_type" onchange="this.form.submit()">
<option value="">All Types</option>
{% for t in asset_type_options %}<option value="{{ t }}" {% if t == asset_type_filter %}selected{% endif %}>{{ t }}</option>{% endfor %}
</select>
<select name="sort">
{% for option in sort_options %}
<option value="{{ option.value }}" {% if option.value == sort_option %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
<select name="sort" onchange="this.form.submit()">
{% for s in sort_options %}<option value="{{ s.value }}" {% if s.value == sort_option %}selected{% endif %}>{{ s.label }}</option>{% endfor %}
</select>
<button class="primary-button" type="submit">应用</button>
<a class="secondary-button" href="{{ url_for('web.asset_list') }}">重置</a>
<button class="b" type="submit">Filter</button>
</form>
</div>
{% if rows %}
<div class="table-shell top-gap">
<table class="data-table compact-table">
<thead>
<tr>
<th>Asset</th>
<table class="fi">
<thead><tr>
<th>Name</th>
<th>Type</th>
<th>Program</th>
<th>Location</th>
<th>Current Event</th>
<th>Next Event</th>
<th>Entries</th>
<th>Action</th>
</tr>
</thead>
</tr></thead>
<tbody>
{% for row in rows %}
<tr>
<td>
<strong><a class="table-link" href="{{ url_for('web.asset_detail', asset_id=row.asset.id, sim_time=simulation_time_input) }}">{{ row.asset.name }}</a></strong>
</td>
<td>{{ row.asset.asset_type }}</td>
<td>{{ row.asset.program or '-' }}</td>
<td>{{ row.current_event }}</td>
<td>{{ row.next_event }}</td>
<td><a href="{{ url_for('web.asset_detail', asset_id=row.asset.id
) }}">{{ row.asset.name }}</a></td>
<td>{{ row.asset.asset_type or '—' }}</td>
<td class="mt">{{ row.current_location or '—' }}</td>
<td>{{ row.current_event or '' }}</td>
<td>{{ row.entry_count }}</td>
<td>
<div class="table-actions">
<a class="table-link" href="{{ url_for('web.asset_edit', asset_id=row.asset.id, sim_time=simulation_time_input) }}">编辑</a>
<a class="table-link" href="{{ url_for('web.asset_entry_new', asset_id=row.asset.id, sim_time=simulation_time_input) }}">加条目</a>
</div>
</td>
<td><div class="act-btn">
<a href="{{ url_for('web.asset_edit', asset_id=row.asset.id
) }}">Edit</a>
<a href="{{ url_for('web.asset_entry_new', asset_id=row.asset.id
) }}">+Entry</a>
</div></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</table>
{% if total_pages > 1 %}
<nav class="pagination-bar" aria-label="Asset pagination">
{% if has_prev %}
<a class="pagination-link" href="{{ prev_url }}">上一页</a>
{% else %}
<span class="pagination-link disabled">上一页</span>
{% endif %}
<div class="pagination-pages">
{% for item in page_links %}
{% if item.ellipsis %}
<span class="pagination-ellipsis">...</span>
{% elif item.current %}
<span class="pagination-link current">{{ item.number }}</span>
{% else %}
<a class="pagination-link" href="{{ item.url }}">{{ item.number }}</a>
{% endif %}
{% if total_pages > 1 %}
<div class="pg-bar fi">
{% if has_prev %}<a href="{{ prev_url }}">← Prev</a>{% endif %}
{% for link in page_links %}
{% if link.is_current %}<span class="cur">{{ link.page }}</span>
{% elif link.is_ellipsis %}<span>...</span>
{% else %}<a href="{{ link.url }}">{{ link.page }}</a>{% endif %}
{% endfor %}
</div>
{% if has_next %}
<a class="pagination-link" href="{{ next_url }}">下一页</a>
{% else %}
<span class="pagination-link disabled">下一页</span>
{% endif %}
</nav>
{% endif %}
{% else %}
<section class="empty-state compact-empty">
<h2>没有命中结果</h2>
<p>调整筛选条件或重置后重试。</p>
</section>
{% endif %}
</section>
{% if has_next %}<a href="{{ next_url }}">Next →</a>{% endif %}
</div>
{% endif %}
{% endblock %}
+88 -253
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) => {
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;
};
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,
};
})();
</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>
<!-- ═══ 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>
</header>
<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>
+33 -131
View File
@@ -1,148 +1,50 @@
{% extends "base.html" %}
{% block content %}
<section class="catalog-header panel catalog-header-tight catalog-page-header">
<div class="catalog-header-copy">
<p class="eyebrow">Communication</p>
<h1>通信部件目录</h1>
<p class="hero-text">支持分页、筛选、排序和快速编辑。</p>
<div class="ph-row fi">
<div class="ph">
<div class="ey">Reference Data</div>
<h1>通信部件</h1>
</div>
<div class="catalog-header-side">
<div class="catalog-metrics">
<div class="summary-card compact">
<span class="summary-label">总记录</span>
<strong>{{ total_catalog_count }}</strong>
</div>
<div class="summary-card compact">
<span class="summary-label">当前结果</span>
<strong>{{ total_count }}</strong>
</div>
<div class="summary-card compact">
<span class="summary-label">当前页</span>
<strong>{{ page }} / {{ total_pages }}</strong>
</div>
</div>
</div>
</section>
<a class="b ac" href="{{ url_for('web.communication_new') }}">+ New</a>
</div>
<section class="panel catalog-actions-panel top-gap">
<div>
<p class="eyebrow">Actions</p>
<h2>新增内容</h2>
</div>
<a class="primary-button" href="{{ url_for('web.communication_new') }}">新增通信部件</a>
</section>
<section class="panel panel-wide top-gap">
<form class="search-form filter-row" method="get">
<input type="search" name="q" value="{{ search_term }}" placeholder="Part / Display / Source / Type">
<select name="source">
<option value="">全部来源</option>
{% for option in source_options %}
<option value="{{ option }}" {% if option == source_filter %}selected{% endif %}>{{ option }}</option>
{% endfor %}
<div class="fb fi">
<form method="get" action="{{ url_for('web.communication_list') }}" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="text" name="q" value="{{ search_term }}" placeholder="Search...">
<select name="source" onchange="this.form.submit()">
<option value="">All Sources</option>
{% for s in source_options %}<option value="{{ s }}" {% if s == source_filter %}selected{% endif %}>{{ s }}</option>{% endfor %}
</select>
<select name="antenna_type">
<option value="">全部类型</option>
{% for option in type_options %}
<option value="{{ option }}" {% if option == antenna_type_filter %}selected{% endif %}>{{ option }}</option>
{% endfor %}
<select name="antenna_type" onchange="this.form.submit()">
<option value="">All Types</option>
{% for t in type_options %}<option value="{{ t }}" {% if t == antenna_type_filter %}selected{% endif %}>{{ t }}</option>{% endfor %}
</select>
<select name="sort">
{% for option in sort_options %}
<option value="{{ option.value }}" {% if option.value == sort_option %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
<button class="primary-button" type="submit">应用</button>
<a class="secondary-button" href="{{ url_for('web.communication_list') }}">重置</a>
<button class="b" type="submit">Search</button>
</form>
</div>
{% if rows %}
<div class="table-shell top-gap">
<table class="data-table compact-table">
<thead>
<tr>
<th>Part</th>
<th>Display</th>
<th>Type</th>
<th>Range (km)</th>
<th>Mass (t)</th>
<th>Idle (W)</th>
<th>Source</th>
<th>Flags</th>
<th>Action</th>
</tr>
</thead>
<table class="fi">
<thead><tr><th>Name</th><th>Range</th><th>Mass</th><th>Type</th><th>Source</th><th>Action</th></tr></thead>
<tbody>
{% for row in rows %}
<tr>
<td><strong>{{ row.part_name }}</strong></td>
<td>{{ row.display_name or '-' }}</td>
<td>{{ row.antenna_type or '-' }}</td>
<td>{{ row.range_km if row.range_km is not none else '-' }}</td>
<td>{{ row.mass_t if row.mass_t is not none else '-' }}</td>
<td>{{ row.idle_power_watt if row.idle_power_watt is not none else '-' }}</td>
<td>{{ row.source or '-' }}</td>
<td>
{% if row.is_active %}Active{% else %}-{% endif %}
{% if row.is_deployable %} / Deployable{% endif %}
{% if row.is_feeder %} / Feeder{% endif %}
</td>
<td>
<div class="table-actions">
<a class="table-link" href="{{ url_for('web.communication_edit', part_id=row.id) }}">编辑</a>
<form
method="post"
action="{{ url_for('web.communication_delete', part_id=row.id) }}"
class="inline-form"
onsubmit="return confirm('确认删除 {{ row.part_name }} ?')"
>
<button class="danger-link" type="submit">删除</button>
</form>
</div>
</td>
<td><a href="{{ url_for('web.communication_edit', part_id=row.id) }}">{{ row.part_name }}</a></td>
<td>{{ row.display_name or '' }}</td>
<td>{{ row.mass_t or '' }}t</td>
<td>{{ row.antenna_type or '' }}</td>
<td class="mt">{{ row.source or '' }}</td>
<td><div class="act-btn"><a href="{{ url_for('web.communication_edit', part_id=row.id) }}">Edit</a></div></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</table>
{% if total_pages > 1 %}
<nav class="pagination-bar" aria-label="Communication pagination">
{% if has_prev %}
<a class="pagination-link" href="{{ prev_url }}">上一页</a>
{% else %}
<span class="pagination-link disabled">上一页</span>
{% endif %}
<div class="pagination-pages">
{% for item in page_links %}
{% if item.ellipsis %}
<span class="pagination-ellipsis">...</span>
{% elif item.current %}
<span class="pagination-link current">{{ item.number }}</span>
{% else %}
<a class="pagination-link" href="{{ item.url }}">{{ item.number }}</a>
{% endif %}
{% endfor %}
</div>
{% if has_next %}
<a class="pagination-link" href="{{ next_url }}">下一页</a>
{% else %}
<span class="pagination-link disabled">下一页</span>
{% endif %}
</nav>
{% endif %}
{% else %}
<section class="empty-state compact-empty">
<h2>没有命中结果</h2>
<p>调整筛选条件或重置后重试。</p>
</section>
{% endif %}
</section>
{% if total_pages > 1 %}
<div class="pg-bar fi">
{% if has_prev %}<a href="{{ prev_url }}">← Prev</a>{% endif %}
{% for link in page_links %}{% if link.is_current %}<span class="cur">{{ link.page }}</span>{% elif link.is_ellipsis %}<span>...</span>{% else %}<a href="{{ link.url }}">{{ link.page }}</a>{% endif %}{% endfor %}
{% if has_next %}<a href="{{ next_url }}">Next →</a>{% endif %}
</div>
{% endif %}
{% endblock %}
+35 -70
View File
@@ -1,80 +1,45 @@
{% extends "base.html" %}
{% block content %}
<section class="hero-panel">
<div class="hero-copy">
<p class="eyebrow">项目起步版本</p>
<h1>先把数据模型和导入边界定死,再做 CRUD。</h1>
<p class="hero-text">
这个骨架现在已经提供 Flask 应用、规范化数据库模型、Docker 运行基线、Excel 巡检脚本,
以及第一块可直接使用的 Fuel Chart 页面工具。
</p>
</div>
<div class="hero-callout">
<p class="callout-label">关键风险</p>
<p class="callout-number">{{ duplicate_conflicts }}</p>
<p class="callout-text">组 Engine + Config Name 在原始工作簿中存在重复,不能作为数据库硬唯一键。</p>
<p class="callout-subtext">即便补上 Fuel Type,仍然还有 {{ duplicate_conflicts_with_fuel }} 组冲突。</p>
</div>
</section>
<div class="ph fi">
<div class="ey">Mission Operations</div>
<h1>任务总览</h1>
<p>Simulation: <strong>{{ simulation_time_display }}</strong> · All metrics derived from asset logs</p>
</div>
<section class="metric-grid">
{% for metric in metrics %}
<article class="metric-card">
<span class="metric-value">{{ metric.value }}</span>
<span class="metric-label">{{ metric.label }}</span>
</article>
{% endfor %}
</section>
<div class="mg fi">
<div class="mc ac"><div class="l">Tracked Assets</div><div class="v">{{ total_assets }}</div><div class="s">{{ mothership_count }} Motherships · {{ station_count }} Stations</div></div>
<div class="mc gn"><div class="l">Active Missions</div><div class="v">{{ active_missions }}</div></div>
<div class="mc am"><div class="l">Upcoming (30d)</div><div class="v">{{ upcoming_count }}</div><div class="s">{{ upcoming_summary }}</div></div>
</div>
<section class="content-grid">
<article class="panel panel-wide">
<div class="panel-heading">
<p class="eyebrow">建模决策</p>
<h2>当前推荐的数据库拆分</h2>
<div class="stl fi">Status Board</div>
<div class="sgr fi">
{% for group in status_groups %}
<div class="sgi">
<div class="sgh">
<span class="sdt {{ group.css_class }}"></span>{{ group.label }}
<span class="scc">{{ group.assets|length }}</span>
</div>
<div class="decision-grid">
<div>
<h3>engine_families</h3>
<p>只放经 Excel 校验后稳定的字段:Engine、Name、Cycle、Size、entry Cost。</p>
</div>
<div>
<h3>engine_variants</h3>
<p>放所有配置级字段:Fuel Type、Work Env、ISP、推力、TVC、价格、点火次数、Tech、备注。</p>
</div>
<div>
<h3>代理主键</h3>
<p>所有核心表使用 UUID 主键,导入时保留原始自然键,并单独输出重复告警。</p>
</div>
<div>
<h3>Fuel Chart</h3>
<p>不先入库,先作为常量配置和 API 小工具,后面再看是否要抽成字典表。</p>
</div>
</div>
</article>
<article class="panel">
<div class="panel-heading">
<p class="eyebrow">模块状态</p>
<h2>第一批交付</h2>
</div>
<div class="module-list">
{% for module in modules %}
<div class="module-card {{ module.status }}">
<div class="module-meta">
<span class="module-status">{{ module.status }}</span>
<h3>{{ module.title }}</h3>
</div>
<p>{{ module.description }}</p>
{% if module.endpoint %}
<a class="module-link" href="{{ url_for(module.endpoint) }}">立即打开</a>
{% else %}
<span class="module-link muted">下一阶段实现</span>
{% endif %}
{% for asset in group.assets %}
<div class="srw">
<a class="an" href="{{ url_for('web.asset_detail', asset_id=asset.id
) }}">{{ asset.name }}</a>
<span class="am">{{ asset.mission }}</span>
<span class="al">{{ asset.location }}</span>
</div>
{% endfor %}
</div>
</article>
</section>
{% endfor %}
</div>
<div class="stl fi">Upcoming Events</div>
<div class="evl fi">
{% for ev in upcoming_events %}
<div class="evr">
<span class="ed">{{ ev.date }}</span>{{ ev.title }}
{% if ev.within_30d %}<span class="eb">30d</span>{% endif %}
</div>
{% endfor %}
</div>
{% endblock %}
+42 -242
View File
@@ -1,262 +1,62 @@
{% extends "base.html" %}
{% block content %}
<section class="catalog-header panel catalog-header-tight catalog-page-header engine-catalog-hero">
<div class="catalog-header-copy engine-catalog-copy">
<p class="eyebrow">Engine Catalog</p>
<div class="ph-row fi">
<div class="ph">
<div class="ey">Reference Data</div>
<h1>引擎目录</h1>
<p class="hero-text">宽屏高密度视图,支持分页、燃料/循环/Mod 筛选,并按推力或比冲排序。</p>
</div>
<div class="catalog-header-side">
<div class="catalog-metrics">
<div class="summary-card compact">
<span class="summary-label">总引擎家族</span>
<strong>{{ total_catalog_count }}</strong>
</div>
<div class="summary-card compact">
<span class="summary-label">当前结果</span>
<strong>{{ total_count }}</strong>
</div>
<div class="summary-card compact">
<span class="summary-label">当前页</span>
<strong>{{ page }} / {{ total_pages }}</strong>
</div>
</div>
</div>
</section>
<a class="b ac" href="{{ url_for('web.engine_new') }}">+ New Engine</a>
</div>
<section class="catalog-layout">
<div class="catalog-sidebar">
<aside class="panel filter-panel filter-panel-tight">
<div class="panel-heading">
<p class="eyebrow">Filters</p>
<h2>筛选和排序</h2>
</div>
<form class="filter-form" method="get">
<label>
<span class="field-label">搜索</span>
<input type="search" name="q" value="{{ search_term }}" placeholder="Engine / Part / Config">
</label>
<label>
<span class="field-label">燃料种类</span>
<select name="fuel_type">
<option value="">全部</option>
{% for option in fuel_options %}
<option value="{{ option }}" {% if option == fuel_filter %}selected{% endif %}>{{ option }}</option>
{% endfor %}
<div class="fb fi">
<form method="get" action="{{ url_for('web.engine_list') }}" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="text" name="q" value="{{ search_term }}" placeholder="Search engines...">
<select name="fuel_type" onchange="this.form.submit()">
<option value="">All Fuel</option>
{% for f in fuel_options %}<option value="{{ f }}" {% if f == fuel_filter %}selected{% endif %}>{{ f }}</option>{% endfor %}
</select>
</label>
<label>
<span class="field-label">循环类型</span>
<select name="cycle">
<option value="">全部</option>
{% for option in cycle_options %}
<option value="{{ option }}" {% if option == cycle_filter %}selected{% endif %}>{{ option }}</option>
{% endfor %}
<select name="cycle" onchange="this.form.submit()">
<option value="">All Cycles</option>
{% for c in cycle_options %}<option value="{{ c }}" {% if c == cycle_filter %}selected{% endif %}>{{ c }}</option>{% endfor %}
</select>
</label>
<label>
<span class="field-label">Mod 来源</span>
<select name="mod_source">
<option value="">全部</option>
{% for option in mod_source_options %}
<option value="{{ option }}" {% if option == mod_source_filter %}selected{% endif %}>{{ option }}</option>
{% endfor %}
<select name="mod_source" onchange="this.form.submit()">
<option value="">All Mods</option>
{% for m in mod_source_options %}<option value="{{ m }}" {% if m == mod_source_filter %}selected{% endif %}>{{ m }}</option>{% endfor %}
</select>
</label>
<div class="filter-actions">
<button class="primary-button" type="submit">应用</button>
<a class="secondary-button" href="{{ url_for('web.engine_list') }}">重置</a>
</div>
<button class="b" type="submit">Search</button>
</form>
</aside>
</div>
<section class="panel catalog-actions-panel">
<div>
<p class="eyebrow">Actions</p>
<h2>新增内容</h2>
<p class="small-muted">创建新的引擎家族和初始配置。</p>
</div>
<a class="primary-button" href="{{ url_for('web.engine_new') }}">新增引擎</a>
</section>
</div>
<section class="panel panel-wide catalog-panel">
<div class="results-header">
<div>
<p class="eyebrow">Results</p>
<h2>结果列表</h2>
<p class="small-muted">每页 30 条,单行压缩并补充海平面/真空核心参数。</p>
</div>
</div>
{% if catalog_rows %}
<div class="table-shell">
<table class="data-table compact-table">
<colgroup>
<col class="engine-name-col">
<col class="cycle-col">
<col class="fuel-col">
<col class="mod-col">
<col class="count-col">
<col class="numeric-col">
<col class="numeric-col">
<col class="numeric-col">
<col class="numeric-col">
<col class="numeric-col">
<col class="numeric-col">
<col class="action-col">
</colgroup>
<thead>
<tr>
<th class="engine-name-col">
<div class="column-sort-head">
<span>Engine</span>
{% for column in sort_columns if column.key == 'engine_name' %}
<span class="sort-chip-group">
<a class="sort-chip {% if column.asc_active %}active{% endif %}" href="{{ column.asc_url }}"></a>
<a class="sort-chip {% if column.desc_active %}active{% endif %}" href="{{ column.desc_url }}"></a>
</span>
{% endfor %}
</div>
</th>
<th class="cycle-col">Cycle</th>
<th class="fuel-col">Fuel</th>
<th class="mod-col">Mod</th>
<th class="count-col">Configs</th>
<th class="numeric-col">
<div class="column-sort-head numeric-head">
<span>Mass</span>
{% for column in sort_columns if column.key == 'mass_t' %}
<span class="sort-chip-group">
<a class="sort-chip {% if column.asc_active %}active{% endif %}" href="{{ column.asc_url }}"></a>
<a class="sort-chip {% if column.desc_active %}active{% endif %}" href="{{ column.desc_url }}"></a>
</span>
{% endfor %}
</div>
</th>
<th class="numeric-col">
<div class="column-sort-head numeric-head">
<span>SL Thrust</span>
{% for column in sort_columns if column.key == 'sl_thrust' %}
<span class="sort-chip-group">
<a class="sort-chip {% if column.asc_active %}active{% endif %}" href="{{ column.asc_url }}"></a>
<a class="sort-chip {% if column.desc_active %}active{% endif %}" href="{{ column.desc_url }}"></a>
</span>
{% endfor %}
</div>
</th>
<th class="numeric-col">
<div class="column-sort-head numeric-head">
<span>Vac Thrust</span>
{% for column in sort_columns if column.key == 'vac_thrust' %}
<span class="sort-chip-group">
<a class="sort-chip {% if column.asc_active %}active{% endif %}" href="{{ column.asc_url }}"></a>
<a class="sort-chip {% if column.desc_active %}active{% endif %}" href="{{ column.desc_url }}"></a>
</span>
{% endfor %}
</div>
</th>
<th class="numeric-col">
<div class="column-sort-head numeric-head">
<span>SL ISP</span>
{% for column in sort_columns if column.key == 'sl_isp' %}
<span class="sort-chip-group">
<a class="sort-chip {% if column.asc_active %}active{% endif %}" href="{{ column.asc_url }}"></a>
<a class="sort-chip {% if column.desc_active %}active{% endif %}" href="{{ column.desc_url }}"></a>
</span>
{% endfor %}
</div>
</th>
<th class="numeric-col">
<div class="column-sort-head numeric-head">
<span>Vac ISP</span>
{% for column in sort_columns if column.key == 'vac_isp' %}
<span class="sort-chip-group">
<a class="sort-chip {% if column.asc_active %}active{% endif %}" href="{{ column.asc_url }}"></a>
<a class="sort-chip {% if column.desc_active %}active{% endif %}" href="{{ column.desc_url }}"></a>
</span>
{% endfor %}
</div>
</th>
<th class="numeric-col">TWR</th>
<th class="action-col">Action</th>
</tr>
</thead>
<table class="fi">
<thead><tr>
<th>Name</th><th>Cycle</th><th>Mass</th><th>Vac Thrust</th><th>Vac ISP</th><th>Fuel</th><th>Mod</th><th>Action</th>
</tr></thead>
<tbody>
{% for row in catalog_rows %}
<tr>
<td class="engine-cell two-line-cell engine-name-col">
<strong class="table-title">{{ row.family.engine_name }}</strong>
<div class="table-subline clamp-line">{{ row.family.part_name or "No part name" }}</div>
</td>
<td class="single-line-cell cycle-col">{{ row.family.cycle or "-" }}</td>
<td class="single-line-cell fuel-col">{{ row.fuel_types|join(', ') if row.fuel_types else "-" }}</td>
<td class="single-line-cell mod-col">{{ row.mod_sources|join(', ') if row.mod_sources else "-" }}</td>
<td class="count-col">{{ row.config_count }}</td>
<td class="numeric-col">{{ row.mass_t|fmt_decimal }}</td>
<td class="numeric-col">{{ row.sl_thrust_kn|fmt_decimal }}</td>
<td class="numeric-col">{{ row.vac_thrust_kn|fmt_decimal }}</td>
<td class="numeric-col">{{ row.sl_isp|fmt_decimal }}</td>
<td class="numeric-col">{{ row.vac_isp|fmt_decimal }}</td>
<td class="numeric-col">{{ row.twr|fmt_decimal }}</td>
<td class="action-col">
<div class="table-actions">
<a class="table-link" href="{{ url_for('web.engine_detail', family_id=row.family.id) }}">详情</a>
<a class="table-link" href="{{ url_for('web.engine_edit', family_id=row.family.id) }}">编辑</a>
<form
method="post"
action="{{ url_for('web.engine_delete', family_id=row.family.id) }}"
class="inline-form"
onsubmit="return confirm('确认删除 {{ row.family.engine_name }} ?')"
>
<button class="danger-link" type="submit">删除</button>
</form>
</div>
</td>
<td><a href="{{ url_for('web.engine_detail', family_id=row.family.id) }}">{{ row.family.engine_name }}</a></td>
<td>{{ row.family.cycle or '—' }}</td>
<td>{{ '%.2f'|format(row.max_mass_t) if row.max_mass_t else '—' }}t</td>
<td>{{ '%.0f'|format(row.max_vac_thrust_kn) if row.max_vac_thrust_kn else '—' }}kN</td>
<td>{{ '%.0f'|format(row.max_vac_isp) if row.max_vac_isp else '—' }}s</td>
<td>{{ row.fuel_types or '—' }}</td>
<td class="mt">{{ row.mod_source or '—' }}</td>
<td><div class="act-btn"><a href="{{ url_for('web.engine_edit', family_id=row.family.id) }}">Edit</a></div></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</table>
{% if total_pages > 1 %}
<nav class="pagination-bar" aria-label="Engine pagination">
{% if has_prev %}
<a class="pagination-link" href="{{ prev_url }}">上一页</a>
{% else %}
<span class="pagination-link disabled">上一页</span>
{% endif %}
<div class="pagination-pages">
{% for item in page_links %}
{% if item.ellipsis %}
<span class="pagination-ellipsis">...</span>
{% elif item.current %}
<span class="pagination-link current">{{ item.number }}</span>
{% else %}
<a class="pagination-link" href="{{ item.url }}">{{ item.number }}</a>
{% endif %}
{% if total_pages > 1 %}
<div class="pg-bar fi">
{% if has_prev %}<a href="{{ prev_url }}">← Prev</a>{% endif %}
{% for link in page_links %}
{% if link.is_current %}<span class="cur">{{ link.page }}</span>
{% elif link.is_ellipsis %}<span>...</span>
{% else %}<a href="{{ link.url }}">{{ link.page }}</a>{% endif %}
{% endfor %}
</div>
{% if has_next %}
<a class="pagination-link" href="{{ next_url }}">下一页</a>
{% else %}
<span class="pagination-link disabled">下一页</span>
{% endif %}
</nav>
{% endif %}
{% else %}
<section class="empty-state compact-empty">
<h2>没有命中结果</h2>
<p>调整筛选条件,或者重置后重新查看全部引擎。</p>
</section>
{% endif %}
</section>
</section>
{% if has_next %}<a href="{{ next_url }}">Next →</a>{% endif %}
</div>
{% endif %}
{% endblock %}
+38 -97
View File
@@ -1,109 +1,50 @@
{% extends "base.html" %}
{% block content %}
<section class="hero-panel compact page-header-compact">
<div class="hero-copy">
<p class="eyebrow">Fuel Chart 工具化</p>
<h1>燃料体积 / 质量快速换算</h1>
<p class="hero-text">
系数直接来自工作簿第二行。输入升数可换算吨位,输入吨位可反推不同燃料对应的体积。
</p>
</div>
</section>
<div class="ph fi">
<div class="ey">Tools</div>
<h1>燃料换算</h1>
</div>
<section class="content-grid fuel-layout">
<article class="panel">
<div class="panel-heading">
<p class="eyebrow">换算输入</p>
<h2>输入一个值</h2>
</div>
<form id="converter-form" class="converter-form">
<label class="field-label" for="mode">换算方向</label>
<select id="mode" name="mode">
<option value="volume">体积 L -> 质量 t</option>
<option value="mass">质量 t -> 体积 L</option>
</select>
<label class="field-label" for="value">数值</label>
<input id="value" name="value" type="number" min="0.000001" step="any" value="75" required>
<button class="primary-button" type="submit">执行换算</button>
<p id="form-error" class="form-error" hidden></p>
</form>
</article>
<article class="panel panel-wide">
<div class="panel-heading">
<p class="eyebrow">换算结果</p>
<h2>输出列表</h2>
</div>
<div id="result-summary" class="result-summary">输入 75 t 或 75 L 后,这里会显示结果。</div>
<div id="result-table" class="result-table"></div>
</article>
</section>
<section class="panel">
<div class="panel-heading">
<p class="eyebrow">当前系数</p>
<h2>每升对应吨位</h2>
</div>
<div class="factor-grid">
{% for factor in fuel_factors %}
<article class="factor-card">
<h3>{{ factor.fuel }}</h3>
<p>{{ factor.tonnes_per_liter }} t / L</p>
</article>
<div class="cvw fi">
<form id="conv-form">
<label>Value<input type="number" id="cv-val" value="1000" step="1"></label>
<label>Mode<select id="cv-mode"><option value="L2t">Liters → Tons</option><option value="t2L">Tons → Liters</option></select></label>
<label>Fuel<select id="cv-fuel">
{% for f in fuel_factors %}
<option value="{{ f.factor }}">{{ f.fuel_type }} ({{ f.factor }} kg/L)</option>
{% endfor %}
</select></label>
<button type="submit" class="b ac">Convert</button>
</form>
<div class="res">
<table>
<thead><tr><th>Fuel</th><th>Density (kg/L)</th><th>Result</th></tr></thead>
<tbody id="cv-res">
{% for f in fuel_factors %}
<tr><td>{{ f.fuel_type }}</td><td>{{ f.factor }}</td><td id="cv-{{ loop.index }}"></td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
</div>
<script>
const form = document.getElementById("converter-form");
const modeField = document.getElementById("mode");
const valueField = document.getElementById("value");
const resultSummary = document.getElementById("result-summary");
const resultTable = document.getElementById("result-table");
const formError = document.getElementById("form-error");
const endpoint = "{{ url_for('api.convert_fuel') }}";
async function renderResults(mode, value) {
formError.hidden = true;
const query = new URLSearchParams({ mode, value }).toString();
const response = await fetch(`${endpoint}?${query}`);
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || "换算失败");
document.getElementById('conv-form').addEventListener('submit', function(e) {
e.preventDefault();
const val = parseFloat(document.getElementById('cv-val').value) || 0;
const mode = document.getElementById('cv-mode').value;
const factors = {{ fuel_factors|tojson }};
factors.forEach(function(f, i) {
const cell = document.getElementById('cv-' + (i+1));
if (cell) {
cell.textContent = mode === 'L2t'
? (val * f.factor / 1000).toFixed(4) + ' t'
: (val * 1000 / f.factor).toFixed(0) + ' L';
cell.style.color = 'var(--accent)';
cell.style.fontFamily = 'var(--font-data)';
}
const outputUnit = payload.results[0]?.unit || "";
resultSummary.textContent = `输入 ${payload.input_value} ${payload.input_unit},输出单位 ${outputUnit}`;
resultTable.innerHTML = payload.results
.map((item) => `
<div class="result-row">
<span>${item.fuel}</span>
<strong>${item.value}</strong>
<span>${item.unit}</span>
</div>
`)
.join("");
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
try {
await renderResults(modeField.value, valueField.value);
} catch (error) {
formError.hidden = false;
formError.textContent = error.message;
}
});
renderResults(modeField.value, valueField.value).catch((error) => {
formError.hidden = false;
formError.textContent = error.message;
});
});
</script>
{% endblock %}
+8 -4
View File
@@ -46,7 +46,8 @@
{{ datetime_picker_field('开始', 'start', range_start_input, field_id='mission-list-start') }}
{{ datetime_picker_field('结束', 'end', range_end_input, field_id='mission-list-end') }}
<button class="primary-button" type="submit">应用</button>
<a class="secondary-button" href="{{ url_for('web.mission_list', sim_time=simulation_time_input) }}">重置</a>
<a class="secondary-button" href="{{ url_for('web.mission_list'
) }}">重置</a>
</div>
</div>
</form>
@@ -100,7 +101,8 @@
<div class="table-subline">{{ entry.status_label }}</div>
</td>
<td class="two-line-cell">
<strong><a class="table-link" href="{{ url_for('web.asset_detail', asset_id=entry.asset.id, sim_time=simulation_time_input) }}">{{ entry.asset_name }}</a></strong>
<strong><a class="table-link" href="{{ url_for('web.asset_detail', asset_id=entry.asset.id
) }}">{{ entry.asset_name }}</a></strong>
<div class="table-subline">{{ entry.asset_type }}</div>
</td>
<td class="two-line-cell">
@@ -117,8 +119,10 @@
<td>{{ entry.location }}</td>
<td>
<div class="table-actions">
<a class="table-link" href="{{ url_for('web.asset_detail', asset_id=entry.asset.id, sim_time=simulation_time_input) }}">查看资产</a>
<a class="table-link" href="{{ url_for('web.asset_entry_edit', asset_id=entry.asset.id, entry_id=entry.entry_id, sim_time=simulation_time_input) }}">编辑条目</a>
<a class="table-link" href="{{ url_for('web.asset_detail', asset_id=entry.asset.id
) }}">查看资产</a>
<a class="table-link" href="{{ url_for('web.asset_entry_edit', asset_id=entry.asset.id, entry_id=entry.entry_id
) }}">编辑条目</a>
</div>
</td>
</tr>
@@ -1,106 +1,79 @@
{% extends "base.html" %}
{% from "_datetime_picker_field.html" import datetime_picker_field %}
{% block content %}
{% include "_mission_ops_tabs.html" %}
{% include "_mission_preview_header.html" %}
<section class="panel mission-control-panel top-gap">
<form method="get" action="{{ url_for('web.mission_location_board_preview') }}" class="mission-toolbar-grid">
<div class="mission-toolbar-block">
<span class="field-label">面板筛选</span>
<div class="mission-filter-grid">
<label>
<span class="field-label">资产类型</span>
<select name="asset_type">
<option value="">全部类型</option>
{% for option in asset_type_options %}
<option value="{{ option }}" {% if option == asset_type_filter %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
</label>
<label>
<span class="field-label">地点</span>
<select name="location">
<option value="">全部地点</option>
{% for option in location_options %}
<option value="{{ option }}" {% if option == location_filter %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
</label>
<label>
<span class="field-label">记录范围</span>
<select name="record_scope">
{% for option in record_scope_options %}
<option value="{{ option.value }}" {% if option.value == record_scope %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</label>
<button class="primary-button" type="submit">应用</button>
<a class="secondary-button" href="{{ reset_url }}">重置</a>
</div>
</div>
</form>
</section>
<section class="mission-board-shell top-gap">
<div class="mission-board-main">
{% if location_groups %}
{% for group in location_groups %}
<section class="panel mission-group-panel">
<div class="mission-group-header">
<div>
<p class="eyebrow">地点分组</p>
<h2>{{ group.title }}</h2>
<p class="mission-group-subtitle">{{ group.subtitle }}</p>
</div>
<span class="mission-group-count">{{ group['items']|length }} 项资产</span>
<div style="display:flex;flex:1;overflow:hidden;position:absolute;inset:0;top:var(--topbar-h);left:var(--sidebar-w)">
<!-- Canvas Map -->
<div id="map-ct" style="flex:1;position:relative;display:flex;align-items:center;justify-content:center;background:#0B0B10;cursor:grab;overflow:hidden">
<canvas id="map-cv"></canvas>
<button id="bk-btn" onclick="mapGoBack()">← Back</button>
<span id="scope-ind">Solar System</span>
</div>
<div class="mission-card-grid">
{% for item in group['items'] %}
<article class="mission-asset-card mission-tone-soft">
<div class="mission-card-top">
<div>
<h3>{{ item.name }}</h3>
<p class="small-muted mission-type">{{ item.asset_type }}</p>
</div>
<span class="mission-status-pill">{{ item.state }}</span>
<!-- Info Panel -->
<div id="map-info" style="width:280px;background:var(--surface);border-left:1px solid var(--border);padding:16px;overflow-y:auto;display:flex;flex-direction:column;gap:14px;flex-shrink:0">
<h2 id="mi-title" style="font-family:var(--font-data);font-size:.85rem;font-weight:600;padding-bottom:5px;border-bottom:1px solid var(--border)">Solar System</h2>
<div><h3 style="font-family:var(--font-data);font-size:.6rem;text-transform:uppercase;color:var(--muted);letter-spacing:.05em;margin-bottom:3px">Scope</h3><div id="mi-scope" style="font-size:.88rem;font-weight:500">Solar System</div></div>
<div><h3 style="font-family:var(--font-data);font-size:.6rem;text-transform:uppercase;color:var(--muted);letter-spacing:.05em;margin-bottom:3px">Assets in Scope</h3><div id="mi-assets"></div></div>
<div><h3 style="font-family:var(--font-data);font-size:.6rem;text-transform:uppercase;color:var(--muted);letter-spacing:.05em;margin-bottom:3px">Ongoing Missions</h3><div style="font-family:var(--font-data);font-size:1.3rem;font-weight:600;color:var(--accent)" id="mi-miss">{{ ongoing_count }}</div></div>
<div><h3 style="font-family:var(--font-data);font-size:.6rem;text-transform:uppercase;color:var(--muted);letter-spacing:.05em;margin-bottom:3px">Upcoming Events</h3><div id="mi-events"></div></div>
</div>
</div>
<div class="mission-meta-grid">
<div class="mission-meta-item">
<dt>Location</dt>
<dd>{{ item.location }}</dd>
</div>
<div class="mission-meta-item">
<dt>Mission</dt>
<dd>{{ item.mission }}</dd>
</div>
<div class="mission-meta-item field-span-two">
<dt>Next Event</dt>
<dd>{{ item.next_event }}</dd>
</div>
</div>
<script>
// Pass asset data from backend to JS
const LOC_ASSETS = {{ asset_positions_json|safe }};
const LOC_EVENTS = {{ upcoming_events|tojson|safe }};
</script>
{% endblock %}
<div class="mission-card-foot">
<a class="table-link" href="{{ url_for('web.asset_list', q=item.name, sim_time=simulation_time_input) }}">查看任务资产</a>
</div>
</article>
{% endfor %}
</div>
</section>
{% endfor %}
{% else %}
<section class="panel empty-state compact-empty">
<h2>当前筛选条件下没有资产</h2>
</section>
{% endif %}
</div>
{% block scripts %}
<script>
// ═══ Keplerian Orbital Mechanics ═══
const J2000_JD=2451545.0,DAY_MS=86400000;
const ORB={
mercury:{a:.387098,e:.205630,i:7.005,O:48.331,p:77.456,L:252.251,P:87.969},
venus:{a:.723332,e:.006772,i:3.395,O:76.680,p:131.563,L:181.980,P:224.701},
earth:{a:1.000003,e:.016709,i:.000,O:.000,p:102.938,L:100.464,P:365.256},
mars:{a:1.523679,e:.093401,i:1.850,O:49.558,p:336.060,L:355.453,P:686.980},
ceres:{a:2.767500,e:.075823,i:10.593,O:80.306,p:72.716,L:73.598,P:1680},
vesta:{a:2.361000,e:.088740,i:7.141,O:103.852,p:150.047,L:18.900,P:1325},
jupiter:{a:5.203363,e:.048393,i:1.305,O:100.556,p:14.754,L:34.404,P:4332.589},
saturn:{a:9.537070,e:.054151,i:2.485,O:113.715,p:92.432,L:49.944,P:10759.22},
uranus:{a:19.19126,e:.047168,i:.770,O:74.230,p:170.964,L:313.232,P:30685.4},
neptune:{a:30.06904,e:.008606,i:1.770,O:131.723,p:44.971,L:304.880,P:60189},
pluto:{a:39.48212,e:.248828,i:17.140,O:110.304,p:224.069,L:238.929,P:90560}
};
function jd(d){return d.getTime()/DAY_MS+2440587.5}
function kepl(M,e,eps=1e-8){let E=M+e*Math.sin(M)*(1+e*Math.cos(M));for(let i=0;i<20;i++){let d=(M-E+e*Math.sin(E))/(1-e*Math.cos(E));E+=d;if(Math.abs(d)<eps)break}return E}
function helio(k,jv){const o=ORB[k];if(!o)return{x:0,y:0};const D=Math.PI/180,n=2*Math.PI/o.P,ds=jv-J2000_JD;let M=(o.L-o.p)*D+n*ds;M=((M%(2*Math.PI))+2*Math.PI)%(2*Math.PI);const E=kepl(M,o.e),s=Math.sin(E),c=Math.cos(E),r=o.a*(1-o.e*c),sv=Math.sqrt(1-o.e*o.e)*s/(1-o.e*c),cv=(c-o.e)/(1-o.e*c);const w=(o.p-o.O)*D,O=o.O*D,I=o.i*D,cO=Math.cos(O),sO=Math.sin(O),cI=Math.cos(I),sI=Math.sin(I);const xo=r*cv,yo=r*sv,cw=Math.cos(w),sw=Math.sin(w);const xe=xo*(cO*cw-sO*sw*cI)-yo*(cO*sw+sO*cw*cI),ye=xo*(sO*cw+cO*sw*cI)+yo*(cO*cw*cI-sO*sw);const dist=Math.sqrt(xe*xe+ye*ye),dd=Math.pow(dist,.4)*100,sc=dist>.001?dd/dist:0;return{x:xe*sc,y:ye*sc}}
<div class="mission-board-side">
{% include "_mission_upcoming_events.html" %}
</div>
</section>
// ═══ Star Map Data ═══
const BODIES={sun:{n:'Sun',t:'star',r:20,fr:52,c:'#FDB813',c2:'#ff6b00'},mercury:{n:'Mercury',t:'p',r:5.5,fr:38,c:'#b5b5b5',c2:'#8a8a8a'},venus:{n:'Venus',t:'p',r:8,fr:44,c:'#e6c229',c2:'#c4952a'},earth:{n:'Earth',t:'p',r:9,fr:46,c:'#4da6ff',c2:'#1a56db',mo:{moon:{n:'Moon',r:2.8,c:'#ccc',c2:'#999',orb:40,P:27.3}}},mars:{n:'Mars',t:'p',r:7,fr:40,c:'#c1440e',c2:'#7a1f0a',mo:{phobos:{n:'Phobos',r:1.6,c:'#998',c2:'#665',orb:26,P:.32},deimos:{n:'Deimos',r:1.4,c:'#887',c2:'#554',orb:35,P:1.26}}},ceres:{n:'Ceres',t:'d',r:4,fr:32,c:'#8b8b8b',c2:'#6b6b6b'},vesta:{n:'Vesta',t:'d',r:3,fr:28,c:'#a0a0a0',c2:'#808080'},jupiter:{n:'Jupiter',t:'p',r:16,fr:60,c:'#c9b07c',c2:'#8b6914',mo:{io:{n:'Io',r:2.8,c:'#e8d44d',c2:'#b8a420',orb:50,P:1.77},europa:{n:'Europa',r:2.5,c:'#d4c9a8',c2:'#a09060',orb:62,P:3.55},ganymede:{n:'Ganymede',r:3.2,c:'#aaa',c2:'#777',orb:76,P:7.15},callisto:{n:'Callisto',r:2.8,c:'#666',c2:'#444',orb:90,P:16.7}}},saturn:{n:'Saturn',t:'p',r:13.5,fr:54,c:'#e0c97f',c2:'#b8952a',mo:{mimas:{n:'Mimas',r:1.8,c:'#bbb',c2:'#999',orb:44,P:.94},titan:{n:'Titan',r:3.5,c:'#d4a85c',c2:'#a07830',orb:90,P:15.95}}},uranus:{n:'Uranus',t:'p',r:11,fr:46,c:'#7ec8e3',c2:'#4a90b0',mo:{titania:{n:'Titania',r:3,c:'#bbb',c2:'#999',orb:64,P:8.71}}},neptune:{n:'Neptune',t:'p',r:10,fr:44,c:'#3f54ba',c2:'#1a2a80',mo:{triton:{n:'Triton',r:2.8,c:'#b8d4e3',c2:'#80a0b0',orb:44,P:5.88}}},pluto:{n:'Pluto',t:'d',r:3,fr:28,c:'#d4c9a8',c2:'#a09070',mo:{charon:{n:'Charon',r:2.2,c:'#bbb',c2:'#999',orb:28,P:6.39}}}};
const SO=['mercury','venus','earth','mars','ceres','vesta','jupiter','saturn','uranus','neptune','pluto'];
for(const bk of Object.keys(BODIES)){const b=BODIES[bk];if(b.mo)for(const[mk,mb]of Object.entries(b.mo))BODIES[mk]=mb}
// ═══ State ═══
let mapVS=[{tp:'solar',ck:'sun'}],mapHv=null,mapDg=false,mapDS=null,mapDL=null,mapDD=false,mapVO={x:0,y:0},mapZm=1;
let mapCtx,mapCv,mapStars=null;
function initMap(){
mapCv=document.getElementById('map-cv');mapCtx=mapCv.getContext('2d');
function rsz(){const r=document.getElementById('map-ct').getBoundingClientRect(),d=devicePixelRatio||1;if(r.width===0||r.height===0)return;mapCv.width=r.width*d;mapCv.height=r.height*d;mapCv.style.width=r.width+'px';mapCv.style.height=r.height+'px';mapStars=null;renderMap()}
window.addEventListener('resize',rsz);setTimeout(rsz,100);
mapCv.addEventListener('mousedown',e=>{mapDg=true;mapDS={x:e.clientX,y:e.clientY};mapDL={x:e.clientX,y:e.clientY};mapDD=false});
window.addEventListener('mousemove',e=>{if(!mapDg||!mapDL)return;if(Math.abs(e.clientX-mapDS.x)>6||Math.abs(e.clientY-mapDS.y)>6)mapDD=true;if(mapDD){mapVO.x+=e.clientX-mapDL.x;mapVO.y+=e.clientY-mapDL.y;mapDL={x:e.clientX,y:e.clientY};renderMap()}});
window.addEventListener('mouseup',()=>{mapDg=false;mapDS=null;mapDL=null});
mapCv.addEventListener('wheel',e=>{e.preventDefault();mapZm=Math.max(.3,Math.min(4,mapZm*(e.deltaY>0?.9:1.1)));renderMap()},{passive:false});
mapCv.addEventListener('click',e=>{if(mapDD)return;const r=mapCv.getBoundingClientRect(),t=mapHit(e.clientX-r.left,e.clientY-r.top);if(!t)return;const v=mapVS[mapVS.length-1],b=BODIES[t];if(v.tp==='solar')mapVS.push({tp:b&&b.mo?'planet_system':'single',ck:t});else if(v.tp==='planet_system')mapVS.push({tp:'single',ck:t});mapVO={x:0,y:0};mapZm=1;updateMapBack();renderMap();rMapInfo()});
mapCv.addEventListener('mousemove',e=>{if(mapDD)return;const r=mapCv.getBoundingClientRect(),t=mapHit(e.clientX-r.left,e.clientY-r.top);if(t!==mapHv){mapHv=t;mapCv.style.cursor=t?'pointer':'grab';renderMap()}});
}
function mapFV(k){if(BODIES[k])return BODIES[k];for(const bk of Object.keys(BODIES)){const b=BODIES[bk];if(b.mo&&b.mo[k])return b.mo[k]}return null}
function mapHit(mx,my){const v=mapVS[mapVS.length-1],cs=[];if(v.tp==='solar'){cs.push({k:'sun'});for(const k of SO)cs.push({k})}else if(v.tp==='planet_system'){const c=BODIES[v.ck];if(c){cs.push({k:v.ck});if(c.mo)for(const mk of Object.keys(c.mo))cs.push({k:mk})}}else{const b=BODIES[v.ck];if(b)cs.push({k:v.ck})}for(const{k}of cs){const b=mapFV(k);if(!b||b._x==null)continue;const dx=mx-b._x,dy=my-b._y,hr=(b._r||4)+6;if(dx*dx+dy*dy<=hr*hr)return k}return null}
function mapDraw(ox,oy,rad,key,lb){const b=BODIES[key]||mapFV(key);if(!b)return;const isStar=b.t==='star',bc=b.c||'#888';mapCtx.save();mapCtx.beginPath();mapCtx.arc(ox,oy,rad,0,Math.PI*2);mapCtx.clip();const g=mapCtx.createRadialGradient(ox-rad*.25,oy-rad*.3,0,ox,oy,rad);if(isStar){g.addColorStop(0,'#fffff8');g.addColorStop(.3,'#ffcc44');g.addColorStop(.7,'#ff6600');g.addColorStop(1,'#330800')}else{g.addColorStop(0,'#fff');g.addColorStop(.2,bc);g.addColorStop(.8,b.c2||'#333');g.addColorStop(1,'#111')}mapCtx.fillStyle=g;mapCtx.beginPath();mapCtx.arc(ox,oy,rad,0,Math.PI*2);mapCtx.fill();mapCtx.restore();if(!isStar){mapCtx.save();mapCtx.beginPath();mapCtx.arc(ox,oy,rad,0,Math.PI*2);mapCtx.clip();const sh=mapCtx.createRadialGradient(ox-rad*.3,oy-rad*.3,rad*.05,ox+rad*.15,oy+rad*.15,rad*1.1);sh.addColorStop(0,'rgba(255,255,255,.1)');sh.addColorStop(.4,'rgba(0,0,0,0)');sh.addColorStop(.8,'rgba(0,0,0,.5)');mapCtx.fillStyle=sh;mapCtx.fillRect(ox-rad,oy-rad,rad*2,rad*2);mapCtx.restore()}mapCtx.fillStyle='#e2e8f0';mapCtx.font=`${Math.max(8,rad*.45)}px system-ui`;mapCtx.textAlign='center';mapCtx.fillText(lb,ox,oy-rad-4);b._x=ox;b._y=oy;b._r=rad}
function renderMap(){if(!mapCv||mapCv.width===0||mapCv.height===0)return;const dpr=devicePixelRatio||1,W=mapCv.width/dpr,H=mapCv.height/dpr;mapCtx.save();mapCtx.setTransform(dpr,0,0,dpr,0,0);if(!mapStars||mapStars.width!==mapCv.width){const sc=document.createElement('canvas');sc.width=W;sc.height=H;const sx=sc.getContext('2d');sx.fillStyle='#0B0B10';sx.fillRect(0,0,W,H);for(let i=0;i<600;i++){sx.fillStyle=`rgba(255,255,255,${.2+Math.random()*.6})`;sx.beginPath();sx.arc(Math.random()*W,Math.random()*H,Math.random()*1.2+.1,0,Math.PI*2);sx.fill()}mapStars=sc}mapCtx.drawImage(mapStars,0,0,W,H);const v=mapVS[mapVS.length-1],cx=W/2+mapVO.x,cy=H/2+mapVO.y,jv=jd(new Date('{{ simulation_time_display }}Z'));if(v.tp==='solar'){for(const k of SO){mapCtx.beginPath();mapCtx.arc(cx,cy,Math.pow(ORB[k].a,.4)*100*mapZm,0,Math.PI*2);mapCtx.strokeStyle='rgba(148,163,184,.1)';mapCtx.lineWidth=.5;mapCtx.setLineDash([3,6]);mapCtx.stroke();mapCtx.setLineDash([])}mapDraw(cx,cy,BODIES.sun.r*mapZm,'sun','Sun');for(const k of SO){const b=BODIES[k],p=helio(k,jv);mapDraw(cx+p.x*mapZm,cy-p.y*mapZm,b.r*mapZm,k,b.n)}}else if(v.tp==='planet_system'){const c=BODIES[v.ck];if(!c)return;mapDraw(cx,cy,c.fr*mapZm,v.ck,c.n);if(c.mo)for(const[mk,mb]of Object.entries(c.mo)){const od=mb.orb*mapZm*1.8;mapCtx.beginPath();mapCtx.arc(cx,cy,od,0,Math.PI*2);mapCtx.strokeStyle='rgba(148,163,184,.1)';mapCtx.lineWidth=.5;mapCtx.setLineDash([3,6]);mapCtx.stroke();mapCtx.setLineDash([]);const an=(2*Math.PI*((jv-J2000_JD)%mb.P))/mb.P;mapDraw(cx+od*Math.cos(an),cy-od*Math.sin(an),mb.r*2.2*mapZm,mk,mb.n)}}else{const b=BODIES[v.ck];if(!b)return;const mx=Math.min(cx,cy)*.5,rr=Math.min((b.fr?b.fr*3:(b.r||4)*15)*mapZm,mx);mapDraw(cx,cy,rr,v.ck,b.n);if(v.ck==='saturn'&&rr>6){mapCtx.save();mapCtx.strokeStyle='rgba(200,180,140,.3)';mapCtx.lineWidth=Math.max(1,rr*.06);mapCtx.beginPath();mapCtx.ellipse(cx,cy,rr*1.5,rr*.18,0,0,Math.PI*2);mapCtx.stroke();mapCtx.strokeStyle='rgba(200,180,140,.15)';mapCtx.lineWidth=Math.max(1,rr*.04);mapCtx.beginPath();mapCtx.ellipse(cx,cy,rr*1.8,rr*.28,0,0,Math.PI*2);mapCtx.stroke();mapCtx.restore()}}if(mapHv){const b=mapFV(mapHv);if(b&&b._x!=null){mapCtx.beginPath();mapCtx.arc(mapHv?b._x:0,mapHv?b._y:0,(b._r||4)+5,0,Math.PI*2);mapCtx.strokeStyle='rgba(59,130,246,.7)';mapCtx.lineWidth=2;mapCtx.stroke()}}mapCtx.restore();rMapInfo()}
function rMapInfo(){const v=mapVS[mapVS.length-1];let sk,sn;if(v.tp==='solar'){sk='solar_system';sn='Solar System'}else{sk=v.ck;const b=BODIES[v.ck];sn=b?b.n+(v.tp==='planet_system'?' System':''):v.ck}document.getElementById('mi-title').textContent=sn;document.getElementById('mi-scope').textContent=sn;const as=LOC_ASSETS.filter(a=>{if(sk==='solar_system')return true;const loc=a.location.toLowerCase();if(sk==='earth')return loc.includes('leo')||loc.includes('earth')||loc.includes('lunar');if(sk==='mars')return loc.includes('mars');if(sk==='jupiter')return loc.includes('jupiter')||loc.includes('europa');if(sk==='saturn')return loc.includes('saturn');if(sk==='uranus')return loc.includes('uranus');if(sk==='neptune')return loc.includes('neptune');if(sk==='mercury')return loc.includes('mercury');if(sk==='venus')return loc.includes('venus');if(sk==='ceres')return loc.includes('ceres');if(sk==='vesta')return loc.includes('vesta');if(sk==='pluto')return loc.includes('pluto');return loc.includes(sk)});document.getElementById('mi-assets').innerHTML=as.length?as.map(a=>`<div style="display:flex;justify-content:space-between;padding:3px 0;border-bottom:1px solid var(--border);font-size:.74rem"><span style="color:var(--accent);cursor:pointer">${a.name}</span><span class="mt" style="font-family:var(--font-data);font-size:.66rem">${a.location}</span></div>`).join(''):'<div style="color:var(--muted);font-size:.72rem">No assets</div>'}
function updateMapBack(){document.getElementById('bk-btn').classList.toggle('vis',mapVS.length>1)}
function mapGoBack(){if(mapVS.length>1)mapVS.pop();mapVO={x:0,y:0};mapZm=1;updateMapBack();renderMap();rMapInfo()}
initMap();
</script>
{% endblock %}
@@ -90,7 +90,8 @@
<p class="mission-summary-text">{{ item.summary }}</p>
<div class="mission-card-foot">
<a class="table-link" href="{{ url_for('web.asset_list', q=item.name, sim_time=simulation_time_input) }}">查看任务资产</a>
<a class="table-link" href="{{ url_for('web.asset_list', q=item.name
) }}">查看任务资产</a>
</div>
</article>
{% endfor %}
+441 -223
View File
@@ -1,240 +1,458 @@
{% extends "base.html" %}
{% from "_datetime_picker_field.html" import datetime_picker_field %}
{% block content %}
{% include "_mission_ops_tabs.html" %}
{% include "_mission_preview_header.html" %}
<div class="ph fi">
<div class="ey">Mission Operations</div>
<h1>时间线</h1>
</div>
<section class="panel mission-control-panel top-gap">
<form method="get" action="{{ url_for('web.mission_timeline_preview') }}" class="mission-toolbar-grid">
<input type="hidden" name="range_mode" value="{{ timeline_range_mode }}" id="timeline-range-mode">
<div class="mission-toolbar-block">
<span class="field-label">时间范围</span>
<div class="mission-toolbar-inline">
{{ datetime_picker_field('开始', 'start', timeline_range_start, field_id='timeline-start') }}
{{ datetime_picker_field('结束', 'end', timeline_range_end, field_id='timeline-end') }}
<label>
<span class="field-label">尺度</span>
<select name="scale">
{% for option in timeline_scale_options %}
<option value="{{ option.value }}" {% if option.value == timeline_scale_mode %}selected{% endif %}>{{ option.label }}</option>
<!-- Filter bar row 1: search + presets + scale -->
<div class="fb fi">
<form id="tl-form" method="get" action="{{ url_for('web.mission_timeline_preview') }}" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;flex:1">
<input type="hidden" name="sim_time" value="{{ simulation_time_input }}">
<input type="hidden" name="start" id="tl-from" value="{{ timeline_range_start[:10] if timeline_range_start else '' }}">
<input type="hidden" name="end" id="tl-to" value="{{ timeline_range_end[:10] if timeline_range_end else '' }}">
<input type="text" name="q" id="tl-q" placeholder="Search assets..." style="width:150px" value="{{ search_term|default('',true) }}">
<div style="display:flex;gap:3px">
<button class="b sm{% if record_scope == 'active' %} ac{% endif %}" type="submit" name="record_scope" value="active">Active</button>
<button class="b sm" type="submit" name="record_scope" value="all">All</button>
</div>
<label style="font-size:.66rem;color:var(--muted)">Type:</label>
<select name="asset_type" onchange="resetAssetsAndSubmit()" style="padding:3px 6px;border-radius:4px;background:var(--surface2);color:var(--text);border:1px solid var(--border);font-size:.66rem">
<option value="">All</option>
{% for t in asset_type_options|default([],true) %}<option value="{{ t }}" {% if t == asset_type_filter %}selected{% endif %}>{{ t }}</option>{% endfor %}
</select>
<label style="font-size:.66rem;color:var(--muted)">Location:</label>
<select name="location" onchange="resetAssetsAndSubmit()" style="padding:3px 6px;border-radius:4px;background:var(--surface2);color:var(--text);border:1px solid var(--border);font-size:.66rem">
<option value="">All</option>
{% for l in location_options|default([],true) %}<option value="{{ l }}" {% if l == location_filter %}selected{% endif %}>{{ l }}</option>{% endfor %}
</select>
<label style="font-size:.66rem;color:var(--muted)">State:</label>
<select name="state" onchange="resetAssetsAndSubmit()" style="padding:3px 6px;border-radius:4px;background:var(--surface2);color:var(--text);border:1px solid var(--border);font-size:.66rem">
<option value="">All</option>
{% for s in ['start-transit','transit-complete','Exploration','Maintenance','Docked','Construction','Testing','Crew Handoff','Surface Expedition','Planetary Operations'] %}
<option value="{{ s }}" {% if state_filter == s %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
</label>
</div>
</div>
<label style="font-size:.66rem;color:var(--muted)">Assets:</label>
<button type="button" id="tl-asset-btn" class="b sm" style="padding:3px 8px;font-size:.66rem;min-width:100px;text-align:left">{% if selected_asset_ids %}{{ selected_asset_count|default(0) }} selected{% else %}All assets{% endif %} ▾</button>
<style>
#tl-asset-dd{display:none;position:fixed;z-index:99999;background:#1e2030;border:1px solid #3b4261;border-radius:6px;max-height:260px;overflow-y:auto;min-width:180px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,.7);color:#e2e8f0;font-size:.66rem}
#tl-asset-dd.open{display:block}
#tl-asset-dd label{display:flex;align-items:center;gap:4px;padding:2px 6px;cursor:pointer;white-space:nowrap}
#tl-asset-dd label:hover{background:rgba(255,255,255,.06);border-radius:3px}
</style>
<script>
// Build dropdown dynamically and inject into <body>
(function(){
var dd = document.createElement('div');
dd.id = 'tl-asset-dd';
dd.innerHTML =
'<label><input type="checkbox" id="asset-select-all" style="margin:0"> Select All</label>' +
'<label><input type="checkbox" id="asset-clear" style="margin:0"> Clear</label>' +
'<div style="border-top:1px solid #3b4261;margin:4px 0"></div>' +
'{% for opt in timeline_asset_options|default([],true) %}' +
'<label><input type="checkbox" class="asset-cb" name="asset_ids" value="{{ opt.value }}" {% if opt.selected or not selected_asset_ids %}checked{% endif %}>{{ opt.label }}</label>' +
'{% endfor %}';
document.body.appendChild(dd);
<div class="mission-toolbar-block">
<span class="field-label">时间线筛选</span>
<div class="mission-filter-grid">
<label>
<span class="field-label">资产类型</span>
<select name="asset_type">
<option value="">全部类型</option>
{% for option in asset_type_options %}
<option value="{{ option }}" {% if option == asset_type_filter %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
</label>
<label>
<span class="field-label">地点</span>
<select name="location">
<option value="">全部地点</option>
{% for option in location_options %}
<option value="{{ option }}" {% if option == location_filter %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
</label>
<label>
<span class="field-label">记录范围</span>
<select name="record_scope">
{% for option in record_scope_options %}
<option value="{{ option.value }}" {% if option.value == record_scope %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</label>
<button class="primary-button" type="submit">应用</button>
<a class="secondary-button" href="{{ reset_url }}">重置</a>
</div>
</div>
<div class="mission-toolbar-block mission-toolbar-span-two">
<span class="field-label">显示资产</span>
{% if timeline_asset_options %}
<div class="mission-asset-picker" data-asset-picker>
<div class="mission-asset-picker-toolbar">
<label class="mission-asset-picker-search">
<span class="field-label">按名称搜索</span>
<input type="search" placeholder="搜索资产名称" data-asset-picker-search>
</label>
<div class="mission-asset-picker-actions">
<button class="secondary-button" type="button" data-asset-picker-select-all>全选可见</button>
<button class="secondary-button" type="button" data-asset-picker-clear>清空选择</button>
<span class="small-muted" data-asset-picker-summary></span>
</div>
</div>
<div class="mission-asset-picker-grid">
{% for option in timeline_asset_options %}
<label class="mission-asset-picker-item" data-asset-picker-item data-asset-picker-name="{{ option.label|lower }}">
<input type="checkbox" name="asset_ids" value="{{ option.value }}" data-asset-picker-checkbox {% if option.selected %}checked{% endif %}>
<span>
<strong>{{ option.label }}</strong>
<small>{{ option.asset_type }}</small>
</span>
</label>
{% endfor %}
</div>
</div>
<p class="field-hint">未勾选任何 Asset 时,默认显示当前筛选命中的全部资产。</p>
{% else %}
<p class="small-muted">当前筛选条件下没有可选资产。</p>
{% endif %}
</div>
</form>
</section>
<section class="panel mission-timeline-panel top-gap">
<div class="mission-timeline-toolbar">
<div>
<p class="eyebrow">Timeline</p>
<h2>多资产时间线</h2>
</div>
<div class="mission-legend">
<span class="legend-item"><span class="legend-swatch warning"></span>转移段</span>
<span class="legend-item"><span class="legend-swatch primary"></span>持续运行</span>
<span class="legend-item"><span class="legend-swatch neutral"></span>停泊 / 地表 / 待命</span>
<span class="legend-item"><span class="legend-swatch current"></span>当前活动状态</span>
</div>
</div>
<div class="mission-filter-pill-row">
<span class="mission-filter-pill"><strong>范围:</strong> {{ timeline_range_display }}</span>
<span class="mission-filter-pill"><strong>尺度:</strong> {{ timeline_scale_mode|title }}</span>
<span class="mission-filter-pill"><strong>资产:</strong> {% if selected_asset_count %}已选 {{ selected_asset_count }} 个{% else %}显示 {{ timeline_rows|length }} 条{% endif %}</span>
<span class="mission-filter-pill"><strong>模式:</strong> {{ record_scope_options|selectattr('value', 'equalto', record_scope)|map(attribute='label')|first }}</span>
</div>
{% if timeline_rows %}
<div class="mission-timeline-scroll">
<div class="mission-timeline-frame" style="min-width: {{ 320 + (timeline_scale|length * timeline_column_width) }}px;">
<div class="mission-timeline-scale">
<div class="mission-timeline-corner"></div>
<div class="mission-scale-labels" style="--timeline-columns: {{ timeline_scale|length }}; --timeline-column-width: {{ timeline_column_width }}px;">
{% for label in timeline_scale %}
<span>{{ label }}</span>
{% endfor %}
</div>
</div>
<div class="mission-timeline-rows">
{% for row in timeline_rows %}
<div class="mission-timeline-row">
<a class="mission-timeline-label mission-timeline-label-link" href="{{ url_for('web.asset_detail', asset_id=row.asset_id, sim_time=simulation_time_input) }}">
<h3>{{ row.name }}</h3>
<p class="small-muted mission-timeline-meta">{{ row.asset_type }}</p>
<p class="small-muted mission-timeline-status">Current Status: {{ row.current_state }}</p>
</a>
<div class="mission-timeline-track-stack">
<div class="mission-timeline-lane mission-timeline-state-lane {% if timeline_scale_mode == 'year' %}mission-timeline-lane-year{% endif %}" style="--timeline-columns: {{ timeline_scale|length }}; --timeline-column-width: {{ timeline_column_width }}px; --timeline-track-count: {{ row.state_track_count }};">
{% for segment in row.segments %}
<a class="mission-segment mission-timeline-link {% if segment.compact %}mission-segment-compact {% endif %}{% for tone in segment.tone.split() %}mission-tone-{{ tone }} {% endfor %}" style="left: {{ segment.left_pct }}; width: {{ segment.track_width_pct }}; --timeline-track-index: {{ segment.track }};" title="{{ segment.label }}" data-full-label="{{ segment.label }}" href="{{ segment.url }}">
<span class="mission-segment-body" style="width: {{ segment.body_width_pct }};"></span>
<span class="mission-segment-text">{{ segment.label }}</span>
</a>
{% endfor %}
</div>
<div class="mission-timeline-lane mission-timeline-event-lane {% if timeline_scale_mode == 'year' %}mission-timeline-lane-year{% endif %}" style="--timeline-columns: {{ timeline_scale|length }}; --timeline-column-width: {{ timeline_column_width }}px; --timeline-track-count: {{ row.event_track_count }};">
{% for event in row.events %}
<a class="mission-event-node mission-timeline-link {% if event.state_node %}mission-event-node-state{% endif %}{% if event.label_side == 'left' %} mission-event-node-label-left{% endif %}" style="left: {{ event.left_pct }}; --timeline-track-index: {{ event.track }}; --event-label-width: {{ event.label_width_px }}px;{% if event.connector_height_px %} --state-node-connector-height: {{ event.connector_height_px }};{% endif %}" title="{{ event.label }}" data-full-label="{{ event.full_label }}" href="{{ event.url }}">
{% if event.state_node %}
<span class="mission-state-node-connector" aria-hidden="true"></span>
{% endif %}
<span class="mission-event-node-label">{{ event.label }}</span>
</a>
{% endfor %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% else %}
<section class="empty-state compact-empty">
<h2>当前时间范围内没有时间线条目</h2>
</section>
{% endif %}
</section>
<script>
(() => {
const picker = document.querySelector('[data-asset-picker]');
if (!picker) {
return;
}
const searchInput = picker.querySelector('[data-asset-picker-search]');
const summary = picker.querySelector('[data-asset-picker-summary]');
const items = Array.from(picker.querySelectorAll('[data-asset-picker-item]'));
const rangeModeField = document.getElementById('timeline-range-mode');
const rangeInputs = [document.getElementById('timeline-start'), document.getElementById('timeline-end')].filter(Boolean);
const checkboxFor = (item) => item.querySelector('[data-asset-picker-checkbox]');
const updateSummary = () => {
const visibleCount = items.filter((item) => !item.hidden).length;
const selectedCount = items.filter((item) => checkboxFor(item)?.checked).length;
summary.textContent = `已选 ${selectedCount} 个 · 可见 ${visibleCount}`;
};
const setRangeModeManual = () => {
if (rangeModeField) {
rangeModeField.value = 'manual';
}
};
const applySearch = () => {
const term = (searchInput?.value || '').trim().toLowerCase();
for (const item of items) {
const assetName = item.dataset.assetPickerName || '';
item.hidden = Boolean(term) && !assetName.includes(term);
}
updateSummary();
};
picker.querySelector('[data-asset-picker-select-all]')?.addEventListener('click', () => {
for (const item of items) {
const checkbox = checkboxFor(item);
if (!item.hidden && checkbox) {
checkbox.checked = true;
}
}
updateSummary();
document.getElementById('asset-select-all').addEventListener('change', function(){
dd.querySelectorAll('.asset-cb').forEach(function(cb){cb.checked = true});
updateAssetCount();
});
document.getElementById('asset-clear').addEventListener('change', function(){
dd.querySelectorAll('.asset-cb').forEach(function(cb){cb.checked = false});
updateAssetCount();
});
dd.querySelectorAll('.asset-cb').forEach(function(cb){
cb.addEventListener('change', function(){ updateAssetCount(); });
});
picker.querySelector('[data-asset-picker-clear]')?.addEventListener('click', () => {
for (const item of items) {
const checkbox = checkboxFor(item);
if (checkbox) {
checkbox.checked = false;
var btn = document.getElementById('tl-asset-btn');
btn.addEventListener('click', function(e){
e.stopPropagation();
var r = btn.getBoundingClientRect();
dd.style.left = Math.max(4, r.left) + 'px';
var top = r.bottom + 4;
dd.style.top = top + 'px';
dd.classList.toggle('open');
if (dd.classList.contains('open')) {
var maxB = window.innerHeight - 8;
if (dd.getBoundingClientRect().bottom > maxB) {
dd.style.top = (r.top - dd.getBoundingClientRect().height - 4) + 'px';
}
}
updateSummary();
});
searchInput?.addEventListener('input', applySearch);
for (const item of items) {
checkboxFor(item)?.addEventListener('change', updateSummary);
var hasExplicit = {{ 'true' if selected_asset_ids else 'false' }};
if (!hasExplicit) {
dd.querySelectorAll('.asset-cb').forEach(function(cb){cb.checked = true});
updateAssetCount();
}
for (const input of rangeInputs) {
input.addEventListener('input', setRangeModeManual);
input.addEventListener('change', setRangeModeManual);
}
applySearch();
})();
function updateAssetCount() {
var dd = document.getElementById('tl-asset-dd');
var cbs = dd.querySelectorAll('.asset-cb');
var total = cbs.length;
var checked = Array.from(cbs).filter(function(cb){return cb.checked}).length;
var btn = document.getElementById('tl-asset-btn');
btn.textContent = (checked === total ? 'All assets' : checked + ' selected') + ' ▾';
}
function syncAssetIdsToForm() {
var form = document.getElementById('tl-form');
form.querySelectorAll('input[name=asset_ids]').forEach(function(el){el.remove()});
document.querySelectorAll('#tl-asset-dd .asset-cb:checked').forEach(function(cb){
var h = document.createElement('input');
h.type = 'hidden'; h.name = 'asset_ids'; h.value = cb.value;
form.appendChild(h);
});
}
function resetAssetsAndSubmit() {
document.querySelectorAll('#tl-asset-dd .asset-cb').forEach(function(cb){cb.checked = true;});
syncAssetIdsToForm();
document.getElementById('tl-form').submit();
}
document.getElementById('tl-form').addEventListener('submit', function(){ syncAssetIdsToForm(); });
document.addEventListener('click', function(e) {
var dd = document.getElementById('tl-asset-dd');
if (dd && !dd.contains(e.target) && e.target.id !== 'tl-asset-btn') {
dd.classList.remove('open');
}
});
</script>
<div style="margin-left:auto;display:flex;gap:4px;align-items:center">
<label style="font-size:.66rem;color:var(--muted)">Scale:</label>
<select name="scale" id="tl-scale" onchange="this.form.submit()" style="padding:3px 6px;border-radius:4px;background:var(--surface2);color:var(--text);border:1px solid var(--border);font-size:.66rem">
<option value="">Auto</option>
<option value="year" {% if timeline_scale_mode == 'year' %}selected{% endif %}>Year</option>
<option value="month" {% if timeline_scale_mode == 'month' %}selected{% endif %}>Month</option>
<option value="day" {% if timeline_scale_mode == 'day' %}selected{% endif %}>Day</option>
</select>
</div>
<button class="b" type="submit" style="font-size:.66rem">Apply</button>
</form>
</div>
<!-- Timeline: single scrollable container with sticky names -->
<div id="tl-viewport" style="flex:1;overflow:auto;min-height:calc(100vh - 180px);cursor:grab;user-select:none">
<div id="tl-canvas">
<!-- Scale labels row -->
<div style="display:flex;height:26px;border-bottom:1px solid var(--border)">
<div style="width:130px;flex-shrink:0;position:sticky;left:0;background:var(--bg);z-index:2;border-right:1px solid var(--border)"></div>
<div style="flex:1;display:flex;font-size:.58rem;color:var(--muted);text-align:center;align-items:center">
{% for label in timeline_scale %}<div style="flex:1">{{ label }}</div>{% endfor %}
</div>
</div>
<!-- Timeline rows -->
{% for row in timeline_rows %}
<div style="display:flex;height:26px;border-bottom:1px solid rgba(148,163,184,.04)">
<!-- Fixed asset name -->
<div style="width:130px;flex-shrink:0;display:flex;align-items:center;padding:0 8px;font-size:.68rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500;position:sticky;left:0;background:var(--bg);z-index:2;border-right:1px solid var(--border)"><a href="{{ row.asset_url }}" style="color:var(--accent);text-decoration:none;overflow:hidden;text-overflow:ellipsis" title="{{ row.name }}">{{ row.name }}</a></div>
<!-- Scrollable bars -->
<div style="flex:1;position:relative;height:26px;overflow:visible">
{% for seg in row.segments %}
<div class="tl-fl" style="position:absolute;left:{{ seg.left_pct }};width:{{ seg.track_width_pct }};top:3px;height:20px"><div style="width:{{ seg.body_width_pct }};height:100%;cursor:pointer;display:flex;align-items:center;justify-content:space-between;padding:0 4px;font-size:.56rem;color:rgba(255,255,255,.85);overflow:hidden;white-space:nowrap;border-radius:2px;background:{{ seg.body_color }};opacity:.85"
data-url="{{ seg.url|default('#',true) }}"
data-label="{{ seg.label }}"
data-start="{{ seg.start_at }}"
data-end="{{ seg.end_at }}"
data-nodes="{{ seg.state_node_count }}"
title="{{ seg.label }}&#10;{{ seg.start_at }} → {{ seg.end_at }}&#10;{{ seg.state_node_count }} state node(s)&#10;Click to edit"
onclick="if(this.dataset.url&&this.dataset.url!=='#') window.location.href=this.dataset.url">
<span style="overflow:hidden;text-overflow:ellipsis;flex-shrink:1">{{ seg.label }}</span>
{% if seg.state_node_count > 0 %}
<span style="flex-shrink:0;margin-left:2px;font-size:.5rem;opacity:.7">●{{ seg.state_node_count }}</span>
{% endif %}
</div></div>
{% endfor %}
{% for evt in row.events %}
<div class="tl-evt" style="position:absolute;left:{{ evt.left_pct }};top:3px;height:20px;display:flex;align-items:flex-start;justify-content:center;cursor:pointer"
data-url="{{ evt.url|default('#',true) }}"
data-label="{{ evt.label }}"
data-full="{{ evt.full_label|default(evt.label,true) }}"
title="{{ evt.full_label|default(evt.label,true) }}&#10;Click to edit"
onclick="if(this.dataset.url&&this.dataset.url!=='#') window.location.href=this.dataset.url">
<div style="width:7px;height:7px;border-radius:50%;background:{{ 'rgba(59,130,246,.95)' if evt.state_node else 'rgba(248,250,252,.85)' }};box-shadow:{{ '0 0 6px rgba(59,130,246,.6)' if evt.state_node else '0 0 4px rgba(248,250,252,.4)' }};flex-shrink:0;border:1px solid rgba(255,255,255,.2)"></div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- Zoom controls -->
<div style="display:flex;align-items:center;gap:6px;margin-top:8px">
<button class="b sm" onclick="tlZoom(1.3)">Zoom +</button>
<button class="b sm" onclick="tlZoom(0.7)">Zoom </button>
<button class="b sm" onclick="tlResetView()">Reset View</button>
<span style="font-size:.64rem;color:var(--muted);margin-left:8px">Scroll to zoom · Drag to pan</span>
</div>
{% endblock %}
{% block scripts %}
<script id="tl-json" type="application/json">{{ timeline_json|safe }}</script>
<script>
(function(){
const viewport = document.getElementById('tl-viewport');
if (!viewport) return;
const jsonEl = document.getElementById('tl-json');
let tlData = jsonEl ? JSON.parse(jsonEl.textContent) : null;
if (!tlData || !tlData.rows) return;
const DAY_MS = 86400000;
const MIN_BUCKETS = 3, MAX_BUCKETS = 200;
function parseISO(s) { if(!s) return null; return new Date(s.replace(' ','T')+(s.includes('T')?'':':00')); }
function fmtDate(d) { return d.toISOString().slice(0,10); }
function fmtDisplay(d) { const y=d.getFullYear(); const m=String(d.getMonth()+1).padStart(2,'0'); const dd=String(d.getDate()).padStart(2,'0'); return y+'-'+m+'-'+dd; }
// Determine scale mode from range span
function autoScale(from, to) {
const days = (to - from) / DAY_MS;
if (days <= 60) return 'day';
if (days <= 730) return 'month';
return 'year';
}
// Build scale labels
function buildScaleLabels(from, to, scale) {
let labels = [];
if (scale === 'day') {
let d = new Date(from); d.setHours(0,0,0,0);
while (d <= to) { labels.push(fmtDisplay(d)); d.setDate(d.getDate()+1); }
} else if (scale === 'month') {
let d = new Date(from.getFullYear(), from.getMonth(), 1);
while (d <= to) {
labels.push(d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0'));
d.setMonth(d.getMonth()+1);
}
} else {
let y = from.getFullYear();
while (y <= to.getFullYear()) { labels.push(String(y)); y++; }
}
return { labels: labels, count: labels.length };
}
// Client-side position recalculation from ISO timestamps
function calcPosition(isoStr, from, to, bucketCount) {
if (!isoStr) return 0;
const t = parseISO(isoStr);
if (!t || t <= from) return 0;
if (t >= to) return bucketCount;
return ((t - from) / (to - from)) * bucketCount;
}
function bucketW(scale) {
if (scale === 'day') return 50;
if (scale === 'month') return 60;
return 250;
}
const SEG_H = 18, SEG_GAP = 2;
function assignLanes(segments, from, to, bucketCount) {
const items = segments.map(function(s) {
const sp = calcPosition(s.start_iso, from, to, bucketCount);
const ep = s.end_iso ? calcPosition(s.end_iso, from, to, bucketCount) : bucketCount;
return { seg: s, sp: sp, ep: Math.max(ep, sp + 0.05) };
}).sort(function(a, b) { return a.sp - b.sp || b.ep - a.ep; });
const lanes = [];
for (let i = 0; i < items.length; i++) {
var placed = false;
for (let l = 0; l < lanes.length; l++) {
if (items[i].sp >= lanes[l]) {
lanes[l] = items[i].ep;
items[i].lane = l;
placed = true;
break;
}
}
if (!placed) {
items[i].lane = lanes.length;
lanes.push(items[i].ep);
}
}
return { items: items, laneCount: lanes.length };
}
function buildHTML(data) {
const from = parseISO(data.range_start);
const to = parseISO(data.range_end);
const scale = autoScale(from, to);
const sl = buildScaleLabels(from, to, scale);
const bucketCount = sl.count;
const EVT_H = 12;
let html = '';
html += '<div style="display:flex;height:36px;border-bottom:1px solid var(--border);font-size:.6rem;color:var(--muted);align-items:flex-end;padding-bottom:4px">';
html += '<div style="width:130px;flex-shrink:0;position:sticky;left:0;background:var(--bg);z-index:2;border-right:1px solid var(--border);padding:0 8px">' + scale.toUpperCase() + '</div>';
html += '<div style="flex:1;position:relative;min-width:0">';
for (let i = 0; i < bucketCount; i++) {
const leftPct = (i / bucketCount * 100).toFixed(4);
const widthPct = (1 / bucketCount * 100).toFixed(4);
html += '<div style="position:absolute;left:' + leftPct + '%;width:' + widthPct + '%;text-align:center;white-space:nowrap;overflow:hidden">' + sl.labels[i] + '</div>';
}
html += '</div></div>';
for (const row of data.rows) {
const visSegs = row.segments.filter(function(s) {
const sp = calcPosition(s.start_iso, from, to, bucketCount);
const ep = s.end_iso ? calcPosition(s.end_iso, from, to, bucketCount) : bucketCount;
const vis = Math.min(ep, bucketCount) - Math.max(sp, 0);
return ep > 0 && sp < bucketCount;
});
const result = assignLanes(visSegs, from, to, bucketCount);
const maxLane = Math.max(result.laneCount, 1);
const segAreaH = maxLane * (SEG_H + SEG_GAP) + 4;
const hasEvt = visSegs.length > 0 && row.events && row.events.length > 0;
const rowH = segAreaH + (hasEvt ? EVT_H : 0);
html += '<div style="display:flex;border-bottom:1px solid rgba(148,163,184,.06)">';
html += '<div style="width:130px;flex-shrink:0;display:flex;align-items:center;padding:0 8px;font-size:.68rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500;position:sticky;left:0;background:var(--bg);z-index:2;border-right:1px solid var(--border);height:' + rowH + 'px"><a href="' + row.asset_url + '" style="color:var(--accent);text-decoration:none;overflow:hidden;text-overflow:ellipsis" title="' + row.asset_name + '">' + row.asset_name + '</a></div>';
html += '<div style="flex:1;position:relative;height:' + rowH + 'px;min-width:0">';
for (const item of result.items) {
const visSp = Math.max(item.sp, 0);
const visEp = Math.min(item.ep, bucketCount);
const leftPct = (visSp / bucketCount * 100).toFixed(4);
const widthPct = ((visEp - visSp) / bucketCount * 100).toFixed(4);
const w = Math.max(parseFloat(widthPct), 0.3);
const top = 10 + item.lane * (SEG_H + SEG_GAP);
const seg = item.seg;
html += '<div class="tl-fl" style="position:absolute;left:' + leftPct + '%;width:' + w.toFixed(4) + '%;top:' + top + 'px;height:' + SEG_H + 'px"><div style="width:100%;height:100%;cursor:pointer;display:flex;align-items:center;padding:0 4px;font-size:.56rem;color:rgba(255,255,255,.9);overflow:hidden;white-space:nowrap;border-radius:3px;background:' + seg.body_color + ';opacity:.9" data-url="' + seg.url + '" data-label="' + seg.label + '" data-start="' + seg.start_at + '" data-end="' + seg.end_at + '" title="' + seg.label + '&#10;' + seg.start_at + ' → ' + seg.end_at + '&#10;Click to edit" onclick="if(this.dataset.url)window.location.href=this.dataset.url">' + seg.label + '</div></div>';
}
if (hasEvt) {
const evtY = segAreaH + 8;
const seen = new Set();
for (const evt of row.events) {
const ep = calcPosition(evt.at_iso, from, to, bucketCount);
const key = evt.label + '|' + evt.at_iso;
if (seen.has(key)) continue;
seen.add(key);
const cls = evt.is_state_node ? 'tl-sn' : 'tl-ev';
const shape = evt.is_state_node
? '<div style="width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;border-bottom:6px solid var(--accent)"></div>'
: '<div style="width:5px;height:5px;background:var(--warning);transform:rotate(45deg)"></div>';
const epPct = (ep / bucketCount * 100).toFixed(4);
html += '<div class="' + cls + '" style="position:absolute;left:calc(' + epPct + '% - 3px);top:' + evtY + 'px;width:7px;height:' + EVT_H + 'px;display:flex;align-items:center;justify-content:center;cursor:pointer;z-index:3" data-url="' + evt.url + '" data-label="' + evt.label + '" data-full="' + (evt.full_label||evt.label) + '" title="' + (evt.full_label||evt.label) + '&#10;Click to edit" onclick="if(this.dataset.url)window.location.href=this.dataset.url">' + shape + '</div>';
}
}
html += '</div></div>';
}
return { html: html, buckets: bucketCount };
}
function renderAll() {
const canvas = document.getElementById('tl-canvas');
if (!canvas) return;
const result = buildHTML(tlData);
canvas.innerHTML = result.html;
const vp = document.getElementById('tl-viewport');
const vpW = vp ? vp.offsetWidth - 130 : 1200;
const scale = autoScale(parseISO(tlData.range_start), parseISO(tlData.range_end));
const naturalW = result.buckets * bucketW(scale);
canvas.style.width = Math.max(vpW, naturalW) + 'px';
const fromInput = document.getElementById('tl-from');
const toInput = document.getElementById('tl-to');
if (fromInput) fromInput.value = tlData.range_start.slice(0,10);
if (toInput) toInput.value = tlData.range_end.slice(0,10);
}
renderAll();
// === Zoom functions ===
const ZOOM_MIN = new Date(2020, 0, 1).getTime();
const ZOOM_MAX = new Date(2150, 0, 1).getTime();
function tlZoom(factor, mouseX) {
const from = parseISO(tlData.range_start);
const to = parseISO(tlData.range_end);
const span = to - from;
const vp = document.getElementById('tl-viewport');
const canvas = document.getElementById('tl-canvas');
let anchorRatio = 0.5;
if (mouseX !== undefined && vp && canvas) {
const nameW = 130;
const trackScreenLeft = vp.getBoundingClientRect().left + nameW;
const trackW = canvas.offsetWidth - nameW;
const mouseInTrack = mouseX - trackScreenLeft + vp.scrollLeft;
anchorRatio = Math.max(0, Math.min(1, mouseInTrack / trackW));
}
const anchor = from.getTime() + span * anchorRatio;
const newSpan = Math.max(DAY_MS * 7, Math.min(DAY_MS * 40000, span * factor));
let start = anchor - newSpan * anchorRatio;
let end = anchor + newSpan * (1 - anchorRatio);
if (start < ZOOM_MIN) { start = ZOOM_MIN; end = start + newSpan; }
if (end > ZOOM_MAX) { end = ZOOM_MAX; start = end - newSpan; }
if (start < ZOOM_MIN) start = ZOOM_MIN;
tlData.range_start = fmtDate(new Date(start)) + ' 00:00';
tlData.range_end = fmtDate(new Date(end)) + ' 00:00';
renderAll();
if (mouseX !== undefined && vp && canvas) {
const nameW = 130;
const newTrackW = canvas.offsetWidth - nameW;
const anchorPx = newTrackW * anchorRatio;
vp.scrollLeft = Math.max(0, anchorPx - (mouseX - vp.getBoundingClientRect().left - nameW));
}
}
function tlResetView() {
location.href = location.pathname;
}
window.tlZoom = tlZoom;
window.tlResetView = tlResetView;
// === Wheel zoom ===
let wheelRAF = null;
viewport.addEventListener('wheel', function(e) {
e.preventDefault();
if (wheelRAF) return;
const mx = e.clientX;
wheelRAF = requestAnimationFrame(function() {
wheelRAF = null;
tlZoom(e.deltaY < 0 ? 0.85 : 1 / 0.85, mx);
});
}, {passive: false});
// === Drag pan ===
let dragging = false, dragX = 0, scrollX = 0;
viewport.addEventListener('mousedown', function(e) {
if (e.target.closest('.tl-fl') || e.target.closest('.tl-evt') || e.target.closest('.tl-sn') || e.target.closest('a')) return;
dragging = true; dragX = e.clientX; scrollX = viewport.scrollLeft; viewport.style.cursor = 'grabbing';
viewport.style.userSelect = 'none';
document.body.style.userSelect = 'none';
e.preventDefault();
});
window.addEventListener('mousemove', function(e) { if (dragging) { e.preventDefault(); viewport.scrollLeft = scrollX - (e.clientX - dragX); } });
function endDrag() { dragging = false; viewport.style.cursor = 'grab'; viewport.style.userSelect = ''; document.body.style.userSelect = ''; }
window.addEventListener('mouseup', endDrag);
viewport.addEventListener('mouseleave', function() { if (dragging) endDrag(); });
// === Tooltip ===
let tip = null;
function ensureTip() {
if (!tip) { tip = document.createElement('div'); tip.style.cssText = 'position:fixed;z-index:100;background:#1a1a22;border:1px solid rgba(248,250,252,.15);border-radius:6px;padding:8px 12px;font-size:.74rem;pointer-events:none;display:none;max-width:350px;box-shadow:0 8px 24px rgba(0,0,0,.5)'; document.body.appendChild(tip); }
}
document.getElementById('tl-canvas').addEventListener('mouseover', function(e) {
const seg = e.target.closest('.tl-fl div');
const evt = e.target.closest('.tl-ev,.tl-sn');
ensureTip();
if (seg && seg.dataset.url) {
seg.style.filter = 'brightness(1.3)';
tip.innerHTML = '<strong>' + (seg.dataset.label||'') + '</strong><br><span style="color:#94A3B8;font-size:.66rem">' + (seg.dataset.start||'') + ' → ' + (seg.dataset.end||'') + '</span><br><span style="color:#94A3B8;font-size:.66rem">Click to edit</span>';
tip.style.display = 'block';
} else if (evt) {
tip.innerHTML = '<strong>' + (evt.dataset.label||'') + '</strong><br><span style="color:#94A3B8;font-size:.66rem">' + (evt.dataset.full||evt.dataset.label||'') + '</span><br><span style="color:#94A3B8;font-size:.66rem">Click to edit</span>';
tip.style.display = 'block';
}
});
document.getElementById('tl-canvas').addEventListener('mousemove', function(e) { if (tip && tip.style.display==='block') { tip.style.left=(e.clientX+12)+'px'; tip.style.top=(e.clientY-30)+'px'; }});
document.getElementById('tl-canvas').addEventListener('mouseout', function(e) { if (e.target.closest('.tl-fl div')) e.target.closest('.tl-fl div').style.filter=''; if (tip) tip.style.display='none'; });
})();
</script>
{% endblock %}
+33 -129
View File
@@ -1,146 +1,50 @@
{% extends "base.html" %}
{% block content %}
<section class="catalog-header panel catalog-header-tight catalog-page-header">
<div class="catalog-header-copy">
<p class="eyebrow">Tank</p>
<h1>燃料箱目录</h1>
<p class="hero-text">支持分页、筛选、排序和快速编辑。</p>
<div class="ph-row fi">
<div class="ph">
<div class="ey">Reference Data</div>
<h1>燃料箱规格</h1>
</div>
<div class="catalog-header-side">
<div class="catalog-metrics">
<div class="summary-card compact">
<span class="summary-label">总记录</span>
<strong>{{ total_catalog_count }}</strong>
</div>
<div class="summary-card compact">
<span class="summary-label">当前结果</span>
<strong>{{ total_count }}</strong>
</div>
<div class="summary-card compact">
<span class="summary-label">当前页</span>
<strong>{{ page }} / {{ total_pages }}</strong>
</div>
</div>
</div>
</section>
<a class="b ac" href="{{ url_for('web.tank_new') }}">+ New</a>
</div>
<section class="panel catalog-actions-panel top-gap">
<div>
<p class="eyebrow">Actions</p>
<h2>新增内容</h2>
</div>
<a class="primary-button" href="{{ url_for('web.tank_new') }}">新增燃料箱</a>
</section>
<section class="panel panel-wide top-gap">
<form class="search-form filter-row" method="get">
<input type="search" name="q" value="{{ search_term }}" placeholder="Tank / Vehicle / Fuel / Source">
<select name="fuel_type">
<option value="">全部燃料</option>
{% for option in fuel_options %}
<option value="{{ option }}" {% if option == fuel_filter %}selected{% endif %}>{{ option }}</option>
{% endfor %}
<div class="fb fi">
<form method="get" action="{{ url_for('web.tank_list') }}" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="text" name="q" value="{{ search_term }}" placeholder="Search...">
<select name="fuel_type" onchange="this.form.submit()">
<option value="">All Fuels</option>
{% for f in fuel_options %}<option value="{{ f }}" {% if f == fuel_filter %}selected{% endif %}>{{ f }}</option>{% endfor %}
</select>
<select name="source">
<option value="">全部来源</option>
{% for option in source_options %}
<option value="{{ option }}" {% if option == source_filter %}selected{% endif %}>{{ option }}</option>
{% endfor %}
<select name="source" onchange="this.form.submit()">
<option value="">All Sources</option>
{% for s in source_options %}<option value="{{ s }}" {% if s == source_filter %}selected{% endif %}>{{ s }}</option>{% endfor %}
</select>
<select name="sort">
{% for option in sort_options %}
<option value="{{ option.value }}" {% if option.value == sort_option %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
<button class="primary-button" type="submit">应用</button>
<a class="secondary-button" href="{{ url_for('web.tank_list') }}">重置</a>
<button class="b" type="submit">Search</button>
</form>
</div>
{% if rows %}
<div class="table-shell top-gap">
<table class="data-table compact-table">
<thead>
<tr>
<th>Tank</th>
<th>Fuel</th>
<th>Vehicle</th>
<th>Volume (L)</th>
<th>Dry (t)</th>
<th>Fuel (t)</th>
<th>Wet (t)</th>
<th>Ratio</th>
<th>Source</th>
<th>Action</th>
</tr>
</thead>
<table class="fi">
<thead><tr><th>Name</th><th>Volume</th><th>Dry Mass</th><th>Fuel</th><th>Source</th><th>Action</th></tr></thead>
<tbody>
{% for row in rows %}
<tr>
<td><strong>{{ row.tank_name }}</strong></td>
<td>{{ row.fuel_type or '-' }}</td>
<td>{{ row.vehicle_name or '-' }}</td>
<td>{{ row.tank_volume_l if row.tank_volume_l is not none else '-' }}</td>
<td>{{ row.dry_mass_t if row.dry_mass_t is not none else '-' }}</td>
<td>{{ row.fuel_mass_t if row.fuel_mass_t is not none else '-' }}</td>
<td>{{ row.wet_mass_t if row.wet_mass_t is not none else '-' }}</td>
<td>{{ row.mass_ratio if row.mass_ratio is not none else '-' }}</td>
<td>{{ row.source or '-' }}</td>
<td>
<div class="table-actions">
<a class="table-link" href="{{ url_for('web.tank_edit', tank_id=row.id) }}">编辑</a>
<form
method="post"
action="{{ url_for('web.tank_delete', tank_id=row.id) }}"
class="inline-form"
onsubmit="return confirm('确认删除 {{ row.tank_name }} ?')"
>
<button class="danger-link" type="submit">删除</button>
</form>
</div>
</td>
<td><a href="{{ url_for('web.tank_edit', tank_id=row.id) }}">{{ row.tank_name }}</a></td>
<td>{{ row.total_volume_l or '' }}L</td>
<td>{{ row.dry_mass_t or '' }}t</td>
<td>{{ row.fuel_type or '' }}</td>
<td class="mt">{{ row.source or '' }}</td>
<td><div class="act-btn"><a href="{{ url_for('web.tank_edit', tank_id=row.id) }}">Edit</a></div></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</table>
{% if total_pages > 1 %}
<nav class="pagination-bar" aria-label="Tank pagination">
{% if has_prev %}
<a class="pagination-link" href="{{ prev_url }}">上一页</a>
{% else %}
<span class="pagination-link disabled">上一页</span>
{% endif %}
<div class="pagination-pages">
{% for item in page_links %}
{% if item.ellipsis %}
<span class="pagination-ellipsis">...</span>
{% elif item.current %}
<span class="pagination-link current">{{ item.number }}</span>
{% else %}
<a class="pagination-link" href="{{ item.url }}">{{ item.number }}</a>
{% endif %}
{% endfor %}
</div>
{% if has_next %}
<a class="pagination-link" href="{{ next_url }}">下一页</a>
{% else %}
<span class="pagination-link disabled">下一页</span>
{% endif %}
</nav>
{% endif %}
{% else %}
<section class="empty-state compact-empty">
<h2>没有命中结果</h2>
<p>调整筛选条件或重置后重试。</p>
</section>
{% endif %}
</section>
{% if total_pages > 1 %}
<div class="pg-bar fi">
{% if has_prev %}<a href="{{ prev_url }}">← Prev</a>{% endif %}
{% for link in page_links %}{% if link.is_current %}<span class="cur">{{ link.page }}</span>{% elif link.is_ellipsis %}<span>...</span>{% else %}<a href="{{ link.url }}">{{ link.page }}</a>{% endif %}{% endfor %}
{% if has_next %}<a href="{{ next_url }}">Next →</a>{% endif %}
</div>
{% endif %}
{% endblock %}
+28 -111
View File
@@ -1,127 +1,44 @@
{% extends "base.html" %}
{% block content %}
<section class="catalog-header panel catalog-header-tight catalog-page-header">
<div class="catalog-header-copy">
<p class="eyebrow">Vehicle</p>
<h1>载具成本目录</h1>
<p class="hero-text">支持分页、来源筛选、成本排序与编辑。</p>
<div class="ph-row fi">
<div class="ph">
<div class="ey">Reference Data</div>
<h1>载具成本</h1>
</div>
<div class="catalog-header-side">
<div class="catalog-metrics">
<div class="summary-card compact">
<span class="summary-label">总记录</span>
<strong>{{ total_catalog_count }}</strong>
</div>
<div class="summary-card compact">
<span class="summary-label">当前结果</span>
<strong>{{ total_count }}</strong>
</div>
<div class="summary-card compact">
<span class="summary-label">当前页</span>
<strong>{{ page }} / {{ total_pages }}</strong>
</div>
</div>
</div>
</section>
<a class="b ac" href="{{ url_for('web.vehicle_new') }}">+ New</a>
</div>
<section class="panel catalog-actions-panel top-gap">
<div>
<p class="eyebrow">Actions</p>
<h2>新增内容</h2>
</div>
<a class="primary-button" href="{{ url_for('web.vehicle_new') }}">新增载具成本</a>
</section>
<section class="panel panel-wide top-gap">
<form class="search-form filter-row" method="get">
<input type="search" name="q" value="{{ search_term }}" placeholder="Vehicle / Source">
<select name="source">
<option value="">全部来源</option>
{% for option in source_options %}
<option value="{{ option }}" {% if option == source_filter %}selected{% endif %}>{{ option }}</option>
{% endfor %}
<div class="fb fi">
<form method="get" action="{{ url_for('web.vehicle_list') }}" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="text" name="q" value="{{ search_term }}" placeholder="Search...">
<select name="source" onchange="this.form.submit()">
<option value="">All Sources</option>
{% for s in source_options %}<option value="{{ s }}" {% if s == source_filter %}selected{% endif %}>{{ s }}</option>{% endfor %}
</select>
<select name="sort">
{% for option in sort_options %}
<option value="{{ option.value }}" {% if option.value == sort_option %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
<button class="primary-button" type="submit">应用</button>
<a class="secondary-button" href="{{ url_for('web.vehicle_list') }}">重置</a>
<button class="b" type="submit">Search</button>
</form>
</div>
{% if rows %}
<div class="table-shell top-gap">
<table class="data-table compact-table">
<thead>
<tr>
<th>Vehicle</th>
<th>Launch Price</th>
<th>Source</th>
<th>Action</th>
</tr>
</thead>
<table class="fi">
<thead><tr><th>Name</th><th>Launch Cost</th><th>Source</th><th>Action</th></tr></thead>
<tbody>
{% for row in rows %}
<tr>
<td><strong>{{ row.vehicle_name }}</strong></td>
<td>{{ row.launch_price if row.launch_price is not none else '-' }}</td>
<td>{{ row.source or '-' }}</td>
<td>
<div class="table-actions">
<a class="table-link" href="{{ url_for('web.vehicle_edit', vehicle_id=row.id) }}">编辑</a>
<form
method="post"
action="{{ url_for('web.vehicle_delete', vehicle_id=row.id) }}"
class="inline-form"
onsubmit="return confirm('确认删除 {{ row.vehicle_name }} ?')"
>
<button class="danger-link" type="submit">删除</button>
</form>
</div>
</td>
<td><a href="{{ url_for('web.vehicle_edit', vehicle_id=row.id) }}">{{ row.vehicle_name }}</a></td>
<td>{{ row.launch_cost_funds or '' }} funds</td>
<td class="mt">{{ row.source or '' }}</td>
<td><div class="act-btn"><a href="{{ url_for('web.vehicle_edit', vehicle_id=row.id) }}">Edit</a></div></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</table>
{% if total_pages > 1 %}
<nav class="pagination-bar" aria-label="Vehicle pagination">
{% if has_prev %}
<a class="pagination-link" href="{{ prev_url }}">上一页</a>
{% else %}
<span class="pagination-link disabled">上一页</span>
{% endif %}
<div class="pagination-pages">
{% for item in page_links %}
{% if item.ellipsis %}
<span class="pagination-ellipsis">...</span>
{% elif item.current %}
<span class="pagination-link current">{{ item.number }}</span>
{% else %}
<a class="pagination-link" href="{{ item.url }}">{{ item.number }}</a>
{% endif %}
{% endfor %}
</div>
{% if has_next %}
<a class="pagination-link" href="{{ next_url }}">下一页</a>
{% else %}
<span class="pagination-link disabled">下一页</span>
{% endif %}
</nav>
{% endif %}
{% else %}
<section class="empty-state compact-empty">
<h2>没有命中结果</h2>
<p>调整筛选条件或重置后重试。</p>
</section>
{% endif %}
</section>
{% if total_pages > 1 %}
<div class="pg-bar fi">
{% if has_prev %}<a href="{{ prev_url }}">← Prev</a>{% endif %}
{% for link in page_links %}{% if link.is_current %}<span class="cur">{{ link.page }}</span>{% elif link.is_ellipsis %}<span>...</span>{% else %}<a href="{{ link.url }}">{{ link.page }}</a>{% endif %}{% endfor %}
{% if has_next %}<a href="{{ next_url }}">Next →</a>{% endif %}
</div>
{% endif %}
{% endblock %}