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:
@@ -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();
|
||||||
|
}
|
||||||
+423
-222
@@ -1,231 +1,432 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% from "_datetime_picker_field.html" import datetime_picker_field %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include "_mission_ops_tabs.html" %}
|
<div class="ph-row">
|
||||||
{% include "_mission_preview_header.html" %}
|
<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="ad-grid">
|
||||||
<div class="mission-detail-main">
|
<div>
|
||||||
<section class="panel mission-asset-overview">
|
<!-- Info cards -->
|
||||||
<div class="mission-asset-head">
|
<div class="mg" style="grid-template-columns:repeat(4,1fr);margin-bottom:12px">
|
||||||
<div>
|
<div class="mc"><div class="l">Type</div><div class="v" style="font-size:.95rem">{{ asset.asset_type }}</div></div>
|
||||||
<p class="eyebrow">Asset Summary</p>
|
<div class="mc"><div class="l">Location</div><div class="v" style="font-size:.95rem">{{ asset_summary.current_location or '—' }}</div></div>
|
||||||
<h2>{{ asset.name }}</h2>
|
<div class="mc"><div class="l">Mission</div><div class="v" style="font-size:.85rem">{{ asset_summary.current_mission or '—' }}</div></div>
|
||||||
<p class="small-muted">{{ asset.asset_type }} · {{ asset.program or 'No program assigned' }}</p>
|
<div class="mc"><div class="l">Entries</div><div class="v" style="font-size:.95rem">{{ total_entry_count }}</div></div>
|
||||||
</div>
|
</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">
|
<!-- Docking section -->
|
||||||
<div class="mission-meta-item">
|
<div class="ad-dock">
|
||||||
<dt>Current Mission</dt>
|
<div>
|
||||||
<dd>{{ asset_summary.current_mission }}</dd>
|
<div class="stl" style="display:flex;align-items:center;justify-content:space-between">
|
||||||
</div>
|
<span>Docking Target</span>
|
||||||
<div class="mission-meta-item">
|
<button class="b sm ac" type="button" onclick="openDockModal('dock_to_target')">+ Target</button>
|
||||||
<dt>Active Window</dt>
|
</div>
|
||||||
<dd>{{ asset_summary.state_window }}</dd>
|
<div class="ad-dock-box">
|
||||||
</div>
|
{% if asset_summary.docking_target %}
|
||||||
<div class="mission-meta-item field-span-two">
|
<div class="ad-dock-item">
|
||||||
<dt>Next Planned Event</dt>
|
<span>
|
||||||
<dd>{{ asset_summary.next_event }}</dd>
|
<a href="{{ url_for('web.asset_detail', asset_id=asset_summary.docking_target.id
|
||||||
</div>
|
) }}" style="color:var(--accent)">{{ asset_summary.docking_target.name }}</a>
|
||||||
</div>
|
<span class="mt" style="margin-left:6px">{{ asset_summary.docking_target.current_location }}</span>
|
||||||
</section>
|
<span class="mt" style="margin-left:6px;color:var(--muted);font-size:.66rem">since {{ asset_summary.docking_target.docked_at }}</span>
|
||||||
|
</span>
|
||||||
<section class="panel mission-future-panel">
|
<a class="ad-undock" onclick="openUndockModal('{{ asset_summary.docking_target.event_id }}', '{{ asset_summary.docking_target.undocked_at or '' }}')">× Remove</a>
|
||||||
<div class="mission-log-top">
|
</div>
|
||||||
<div class="panel-heading">
|
{% else %}
|
||||||
<p class="eyebrow">未来事件</p>
|
<div style="padding:6px 10px;color:var(--mut);font-size:.72rem">Not docked to any asset</div>
|
||||||
<h2>可编辑的未来记录</h2>
|
{% endif %}
|
||||||
<p class="small-muted">这里汇总了未来的 Event Point、State Interval,以及当前区间里尚未发生的 State Node。</p>
|
</div>
|
||||||
</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>
|
|
||||||
{% else %}
|
|
||||||
<section class="empty-state compact-empty">
|
|
||||||
<h2>没有未来记录</h2>
|
|
||||||
<p>当前选定的 Simulation Time 之后还没有排定的事件。</p>
|
|
||||||
</section>
|
|
||||||
{% 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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
{% 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>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<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 %}
|
|
||||||
{% endfor %}
|
|
||||||
</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>
|
|
||||||
</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 '' }}')">× Remove</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div style="padding:6px 10px;color:var(--mut);font-size:.72rem">No vehicles docked</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<aside class="mission-detail-side">
|
<!-- Log entries -->
|
||||||
<section class="panel mission-current-state-panel">
|
<div class="stl" style="display:flex;align-items:center;justify-content:space-between">
|
||||||
<div class="panel-heading">
|
<span>Log Entries</span>
|
||||||
<p class="eyebrow">当前状态</p>
|
<div style="display:flex;gap:4px">
|
||||||
<h2>当前派生状态</h2>
|
<button class="b sm ac" onclick="openNewLog('state')">+ State Interval</button>
|
||||||
</div>
|
<button class="b sm" onclick="openNewLog('event')">+ Event Point</button>
|
||||||
<div class="mission-meta-grid">
|
</div>
|
||||||
<div class="mission-meta-item">
|
</div>
|
||||||
<dt>Status</dt>
|
<div class="fb">
|
||||||
<dd>{{ asset_summary.current_state }}</dd>
|
<input type="text" id="ad-lq" placeholder="Search logs...">
|
||||||
</div>
|
<select id="ad-lf">
|
||||||
<div class="mission-meta-item">
|
<option value="">All Types</option>
|
||||||
<dt>Location</dt>
|
<option value="state">State Interval</option>
|
||||||
<dd>{{ asset_summary.current_location }}</dd>
|
<option value="event">Event Point</option>
|
||||||
</div>
|
</select>
|
||||||
<div class="mission-meta-item field-span-two">
|
</div>
|
||||||
<dt>Mission</dt>
|
<table>
|
||||||
<dd>{{ asset_summary.current_mission }}</dd>
|
<thead><tr><th>Date</th><th>Type</th><th>Title</th><th>Mission Label</th><th>Action</th></tr></thead>
|
||||||
</div>
|
<tbody>
|
||||||
</div>
|
{% for entry in entries %}
|
||||||
</section>
|
<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>
|
||||||
|
{% if entry_total_pages > 1 %}
|
||||||
|
<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>
|
||||||
|
|
||||||
{% include "_mission_upcoming_events.html" %}
|
<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>
|
||||||
|
|
||||||
<section class="panel mission-entry-actions-panel">
|
<!-- ═══ DOCK MODAL ═══ -->
|
||||||
<div class="panel-heading">
|
<div class="modal-bg" id="dock-modal">
|
||||||
<p class="eyebrow">操作</p>
|
<div class="modal">
|
||||||
<h2>日志操作</h2>
|
<h2 id="dock-modal-title">Dock Vehicle</h2>
|
||||||
</div>
|
<div class="fg">
|
||||||
<div class="mission-action-stack">
|
<input type="hidden" id="dock-mode" value="dock_vehicle">
|
||||||
<a class="primary-button" href="{{ url_for('web.asset_entry_new', asset_id=asset.id, kind='event', sim_time=simulation_time_input) }}">新增事件点</a>
|
<label><span id="dock-select-label">Vehicle</span><select id="dock-vehicle">
|
||||||
<a class="primary-button" href="{{ url_for('web.asset_entry_new', asset_id=asset.id, kind='state', sim_time=simulation_time_input) }}">新增状态区间</a>
|
<option value="">Select existing asset...</option>
|
||||||
<a class="secondary-button" href="{{ url_for('web.asset_edit', asset_id=asset.id, sim_time=simulation_time_input) }}">编辑资产</a>
|
{% for a in all_assets %}{% if a.id != asset.id %}
|
||||||
<a class="secondary-button" href="{{ url_for('web.asset_list', sim_time=simulation_time_input) }}">返回任务资产</a>
|
<option value="{{ a.id }}">{{ a.name }}</option>
|
||||||
</div>
|
{% endif %}{% endfor %}
|
||||||
</section>
|
<option value="_custom">Custom name...</option>
|
||||||
</aside>
|
</select></label>
|
||||||
</section>
|
<input type="text" id="dock-custom" placeholder="Enter vehicle name..." style="display:none">
|
||||||
{% endblock %}
|
<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>
|
||||||
|
|
||||||
|
<!-- ═══ 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 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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- ═══ 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>
|
||||||
|
|
||||||
|
{% 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) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
||||||
|
<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>
|
||||||
|
</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 %}
|
||||||
|
|||||||
+392
-236
@@ -1,270 +1,426 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% from "_datetime_picker_field.html" import datetime_picker_field %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include "_mission_ops_tabs.html" %}
|
|
||||||
{% set field_error_map = field_errors|default({}, true) %}
|
{% set field_error_map = field_errors|default({}, true) %}
|
||||||
{% macro render_end_at_field(end_at_value, has_error=False) %}
|
<style>
|
||||||
{{ 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) }}
|
.hidden-if-not-transit{display:none}
|
||||||
{% endmacro %}
|
.combobox{position:relative;display:flex;align-items:center}
|
||||||
{% macro render_state_node_row(node, row_index, field_error_map) %}
|
.combobox input{flex:1;padding-right:28px}
|
||||||
{% set title_error = field_error_map.get('state_node_title:' ~ row_index) %}
|
.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}
|
||||||
{% set at_error = field_error_map.get('state_node_at:' ~ row_index) %}
|
.combobox-btn:hover{background:rgba(255,255,255,.08)}
|
||||||
<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 %}>
|
.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}
|
||||||
<input type="hidden" name="state_node_id" value="{{ node.id if node else '' }}">
|
.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 %}>
|
{% set is_state_entry = form_values.entry_kind == 'state' %}
|
||||||
<span class="field-label">Node Title</span>
|
{% set node_count = form_values.state_nodes|length %}
|
||||||
<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">
|
{% 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'] %}
|
||||||
</label>
|
{% 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) }}
|
<section class="asset-entry-hero">
|
||||||
|
<div class="asset-entry-title-block">
|
||||||
<label class="state-node-detail-field">
|
<div class="asset-entry-crumbs">
|
||||||
<span class="field-label">Node Detail</span>
|
<span>任务资产</span>
|
||||||
<textarea name="state_node_detail" rows="2" placeholder="State Node Detail">{{ node.detail if node else '' }}</textarea>
|
<span>/</span>
|
||||||
</label>
|
<span>{{ asset.name }}</span>
|
||||||
|
<span>/</span>
|
||||||
<button class="secondary-button state-node-remove-button" type="button" data-state-node-remove>移除</button>
|
<span>{{ '编辑日志' if delete_url else '新增日志' }}</span>
|
||||||
</div>
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
<section class="hero-panel compact page-header-compact">
|
|
||||||
<div class="hero-copy">
|
|
||||||
<p class="eyebrow">资产日志</p>
|
|
||||||
<h1>{{ heading }}</h1>
|
|
||||||
</div>
|
</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>
|
</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 }}">
|
<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>
|
|
||||||
<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 %}>
|
<aside class="asset-entry-side">
|
||||||
<span class="field-label">Entry Type</span>
|
<section class="asset-entry-panel">
|
||||||
<select id="entry-kind-select" name="entry_kind" class="{% if field_error_map.get('entry_kind') %}field-error-input{% endif %}">
|
<div class="asset-entry-panel-head">
|
||||||
{% for option in kind_options %}
|
<div>
|
||||||
<option value="{{ option.value }}" {% if option.value == form_values.entry_kind %}selected{% endif %}>{{ option.label }}</option>
|
<p class="asset-entry-kicker">Entry</p>
|
||||||
{% endfor %}
|
<h2>日志信息</h2>
|
||||||
</select>
|
<p>这些字段会决定资产在状态板、时间线和地点板上的当前状态。</p>
|
||||||
</label>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label class="{% if field_error_map.get('location') %}field-error-shell{% endif %}"{% if field_error_map.get('location') %} data-field-error="true"{% endif %}>
|
<div class="asset-entry-panel-body">
|
||||||
<span class="field-label">Location</span>
|
<div class="asset-entry-context">
|
||||||
<input class="{% if field_error_map.get('location') %}field-error-input{% endif %}" type="text" name="location" value="{{ form_values.location }}">
|
<div><span>Asset</span><strong>{{ asset.name }}</strong></div>
|
||||||
</label>
|
<div><span>Asset Type</span><strong>{{ asset.asset_type }}</strong></div>
|
||||||
|
<div><span>Program</span><strong>{{ asset.program or '—' }}</strong></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 %}>
|
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
</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>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{{ 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')) }}
|
|
||||||
{% 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>
|
|
||||||
|
|
||||||
<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 %}>
|
|
||||||
<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>
|
|
||||||
</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 %}>
|
|
||||||
<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>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="button-row top-gap">
|
<label class="asset-entry-field">
|
||||||
<button class="primary-button" type="submit">{{ submit_label }}</button>
|
<span class="field-label">Asset</span>
|
||||||
<a class="secondary-button" href="{{ cancel_url }}">返回</a>
|
<input type="text" value="{{ asset.name }}" disabled>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{% 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 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>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
|
{% 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 title_error %}field-error-input{% endif %}" type="text" name="title" value="{{ form_values.title }}" placeholder="Entry title">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{% 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>
|
||||||
|
|
||||||
|
{% 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 %}
|
||||||
|
|
||||||
|
{% 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>
|
||||||
|
|
||||||
|
{% 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 summary_error %}field-error-input{% endif %}" name="summary" rows="4" placeholder="Optional">{{ form_values.summary }}</textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{% 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 note_error %}field-error-input{% endif %}" name="note" rows="4" placeholder="Optional">{{ form_values.note }}</textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<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)">▾</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)">▾</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>
|
</form>
|
||||||
|
|
||||||
{% if delete_url %}
|
{% if delete_url %}
|
||||||
<section class="panel danger-panel top-gap">
|
<form method="post" action="{{ delete_url }}" id="asset-entry-delete-form" onsubmit="return confirm('确认删除?')">
|
||||||
<p class="small-muted">删除后不可恢复,请谨慎操作。</p>
|
<input type="hidden" name="sim_time" value="{{ simulation_time_input }}">
|
||||||
<form method="post" action="{{ delete_url }}" class="inline-form" onsubmit="return confirm('确认删除这个日志条目?')">
|
</form>
|
||||||
<input type="hidden" name="sim_time" value="{{ simulation_time_input }}">
|
|
||||||
<button class="danger-link" type="submit">删除条目</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<template id="entry-end-at-template">
|
<div class="asset-entry-footer">
|
||||||
{{ render_end_at_field(form_values.end_at, false) }}
|
<button class="b ac" type="submit" form="asset-entry-form">{{ submit_label }}</button>
|
||||||
</template>
|
<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">
|
<template id="node-template">
|
||||||
{{ render_state_node_row(none, '__INDEX__', {}) }}
|
<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)">▾</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)">▾</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)">▾</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)">▾</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>
|
</template>
|
||||||
|
|
||||||
{% include "_validation_dialog.html" %}
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
function formatNodeTime(value) {
|
||||||
const kindField = document.getElementById("entry-kind-select");
|
return value ? value.replace("T", " ") : "未设置时间";
|
||||||
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;
|
|
||||||
|
|
||||||
if (!kindField || !startLabel || !startField || !endFieldTemplate) {
|
function findNodeRow(control) {
|
||||||
return;
|
return control.closest(".asset-entry-rail-node");
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshEndField = () => {
|
function syncNodePreview(control) {
|
||||||
endField = document.getElementById("entry-end-at-field");
|
const row = findNodeRow(control);
|
||||||
endInput = document.getElementById("entry-end-at-input");
|
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 = () => {
|
function updateNodeCount() {
|
||||||
cachedEndValue = endInput?.value || "";
|
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 emptyState = document.querySelector("[data-empty-node-state]");
|
||||||
|
if (emptyState) {
|
||||||
|
emptyState.style.display = count ? "none" : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const restoreEndValue = () => {
|
function removeNodeRow(button) {
|
||||||
if (!endInput) {
|
const row = findNodeRow(button);
|
||||||
return;
|
if (row) row.remove();
|
||||||
}
|
updateNodeCount();
|
||||||
endInput.value = cachedEndValue;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const ensureEndField = () => {
|
function addNodeRow() {
|
||||||
if (endField) {
|
const tmpl = document.getElementById("node-template");
|
||||||
restoreEndValue();
|
const clone = tmpl.content.cloneNode(true);
|
||||||
return;
|
document.getElementById("nodes-list").appendChild(clone);
|
||||||
}
|
updateNodeCount();
|
||||||
const templateField = endFieldTemplate.content.firstElementChild;
|
}
|
||||||
if (!templateField) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
startField.insertAdjacentElement("afterend", templateField.cloneNode(true));
|
|
||||||
refreshEndField();
|
|
||||||
restoreEndValue();
|
|
||||||
window.bindDatetimePickerTriggers?.(endField);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeEndField = () => {
|
document.getElementById("entry-kind-select").addEventListener("change", function() {
|
||||||
if (!endField) {
|
const isState = this.value === "state";
|
||||||
return;
|
document.getElementById("end-at-wrap").style.display = isState ? "" : "none";
|
||||||
}
|
document.getElementById("state-wrap")?.style.setProperty("display", isState ? "" : "none");
|
||||||
rememberEndValue();
|
document.getElementById("state-node-section").style.display = isState ? "" : "none";
|
||||||
endField.remove();
|
document.querySelectorAll("[data-entry-kind-pill]").forEach((pill) => {
|
||||||
refreshEndField();
|
pill.classList.toggle("active", pill.dataset.entryKindPill === this.value);
|
||||||
};
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const stateNodeRows = () => Array.from(stateNodeList?.querySelectorAll("[data-state-node-row]") || []);
|
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}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const updateStateNodeEmpty = () => {
|
function toggleTransitFields(sel) {
|
||||||
if (!stateNodeEmpty) {
|
const row = sel.closest('.asset-entry-node-card') || sel.closest('[data-state-node-row]');
|
||||||
return;
|
if (!row) return;
|
||||||
}
|
const fields = row.querySelectorAll('[data-transit-fields]');
|
||||||
stateNodeEmpty.hidden = stateNodeRows().length > 0;
|
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); });
|
||||||
|
|
||||||
const setStateNodePanelVisible = (visible) => {
|
// ── Combobox ──
|
||||||
if (!stateNodePanel) {
|
function filterCombobox(input) {
|
||||||
return;
|
var drop = input.parentElement.querySelector('.combobox-drop');
|
||||||
}
|
if (!drop) return;
|
||||||
stateNodePanel.hidden = !visible;
|
var val = input.value.toLowerCase();
|
||||||
for (const field of stateNodePanel.querySelectorAll("input[name], textarea[name], select[name]")) {
|
var items = drop.querySelectorAll('li');
|
||||||
field.disabled = !visible;
|
var hasVisible = false;
|
||||||
}
|
items.forEach(function(li) {
|
||||||
if (stateNodeAddButton) {
|
if (val === '' || li.textContent.toLowerCase().indexOf(val) !== -1) {
|
||||||
stateNodeAddButton.disabled = !visible;
|
li.classList.remove('hidden');
|
||||||
}
|
hasVisible = true;
|
||||||
for (const button of stateNodePanel.querySelectorAll("[data-state-node-remove]")) {
|
} else {
|
||||||
button.disabled = !visible;
|
li.classList.add('hidden');
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
if (hasVisible) drop.classList.add('open');
|
||||||
const addStateNodeRow = () => {
|
}
|
||||||
if (!stateNodeList || !stateNodeRowTemplate) {
|
function toggleCombobox(btn) {
|
||||||
return;
|
var drop = btn.parentElement.querySelector('.combobox-drop');
|
||||||
}
|
if (!drop) return;
|
||||||
const rowMarkup = stateNodeRowTemplate.innerHTML.replaceAll("__INDEX__", String(stateNodeIndex));
|
drop.classList.toggle('open');
|
||||||
stateNodeIndex += 1;
|
if (drop.classList.contains('open')) {
|
||||||
stateNodeList.insertAdjacentHTML("beforeend", rowMarkup);
|
var input = btn.parentElement.querySelector('input');
|
||||||
const row = stateNodeList.lastElementChild;
|
if (input) { input.focus(); filterCombobox(input); }
|
||||||
if (row) {
|
}
|
||||||
window.bindDatetimePickerTriggers?.(row);
|
}
|
||||||
row.querySelector('input[name="state_node_title"]')?.focus();
|
function openCombobox(input) {
|
||||||
}
|
var drop = input.parentElement.querySelector('.combobox-drop');
|
||||||
updateStateNodeEmpty();
|
if (drop) { drop.classList.add('open'); filterCombobox(input); }
|
||||||
};
|
}
|
||||||
|
function selectCombobox(li) {
|
||||||
stateNodeAddButton?.addEventListener("click", addStateNodeRow);
|
var drop = li.parentElement;
|
||||||
stateNodeList?.addEventListener("click", (event) => {
|
var combobox = drop.parentElement;
|
||||||
const removeButton = event.target.closest("[data-state-node-remove]");
|
var input = combobox.querySelector('input');
|
||||||
if (!removeButton) {
|
if (input) {
|
||||||
return;
|
input.value = li.textContent;
|
||||||
}
|
input.dispatchEvent(new Event('change', {bubbles: true}));
|
||||||
removeButton.closest("[data-state-node-row]")?.remove();
|
}
|
||||||
updateStateNodeEmpty();
|
drop.classList.remove('open');
|
||||||
});
|
}
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
const syncEntryKind = () => {
|
if (!e.target.closest('.combobox')) {
|
||||||
const isEvent = kindField.value === "event";
|
document.querySelectorAll('.combobox-drop.open').forEach(function(d) { d.classList.remove('open'); });
|
||||||
startLabel.textContent = isEvent ? "At" : "Start At";
|
}
|
||||||
if (isEvent) {
|
});
|
||||||
removeEndField();
|
|
||||||
setStateNodePanelVisible(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ensureEndField();
|
|
||||||
setStateNodePanelVisible(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
kindField.addEventListener("change", syncEntryKind);
|
|
||||||
updateStateNodeEmpty();
|
|
||||||
syncEntryKind();
|
|
||||||
})();
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% include "_validation_dialog.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
+62
-108
@@ -1,114 +1,68 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% from "_datetime_picker_field.html" import datetime_picker_field %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include "_mission_ops_tabs.html" %}
|
<div class="ph-row fi">
|
||||||
{% include "_mission_preview_header.html" %}
|
<div class="ph">
|
||||||
|
<div class="ey">Mission Operations</div>
|
||||||
|
<h1>任务资产</h1>
|
||||||
|
</div>
|
||||||
|
<a class="b ac" href="{{ url_for('web.asset_new'
|
||||||
|
) }}">+ New Asset</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="panel catalog-actions-panel top-gap">
|
<div class="fb fi">
|
||||||
<div>
|
<form method="get" action="{{ url_for('web.asset_list') }}" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||||
<p class="eyebrow">Actions</p>
|
<input type="hidden" name="sim_time" value="{{ simulation_time_input }}">
|
||||||
<h2>新增内容</h2>
|
<input type="text" name="q" value="{{ search_term }}" placeholder="Search assets...">
|
||||||
</div>
|
<select name="asset_type" onchange="this.form.submit()">
|
||||||
<div class="button-row">
|
<option value="">All Types</option>
|
||||||
<a class="primary-button" href="{{ url_for('web.asset_new', sim_time=simulation_time_input) }}">新增任务资产</a>
|
{% for t in asset_type_options %}<option value="{{ t }}" {% if t == asset_type_filter %}selected{% endif %}>{{ t }}</option>{% endfor %}
|
||||||
<form method="post" action="{{ url_for('web.asset_import_log_book') }}" class="inline-form">
|
</select>
|
||||||
<input type="hidden" name="sim_time" value="{{ simulation_time_input }}">
|
<select name="sort" onchange="this.form.submit()">
|
||||||
<button class="secondary-button" type="submit">导入 Log Book</button>
|
{% for s in sort_options %}<option value="{{ s.value }}" {% if s.value == sort_option %}selected{% endif %}>{{ s.label }}</option>{% endfor %}
|
||||||
</form>
|
</select>
|
||||||
</div>
|
<button class="b" type="submit">Filter</button>
|
||||||
</section>
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="panel panel-wide top-gap">
|
<table class="fi">
|
||||||
<form class="search-form filter-row" method="get">
|
<thead><tr>
|
||||||
<input type="search" name="q" value="{{ search_term }}" placeholder="Asset / Type / Program / Region">
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Location</th>
|
||||||
|
<th>Current Event</th>
|
||||||
|
<th>Entries</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in rows %}
|
||||||
|
<tr>
|
||||||
|
<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="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>
|
||||||
|
|
||||||
<select name="asset_type">
|
{% if total_pages > 1 %}
|
||||||
<option value="">全部类型</option>
|
<div class="pg-bar fi">
|
||||||
{% for option in asset_type_options %}
|
{% if has_prev %}<a href="{{ prev_url }}">← Prev</a>{% endif %}
|
||||||
<option value="{{ option }}" {% if option == asset_type_filter %}selected{% endif %}>{{ option }}</option>
|
{% for link in page_links %}
|
||||||
{% endfor %}
|
{% if link.is_current %}<span class="cur">{{ link.page }}</span>
|
||||||
</select>
|
{% elif link.is_ellipsis %}<span>...</span>
|
||||||
|
{% else %}<a href="{{ link.url }}">{{ link.page }}</a>{% endif %}
|
||||||
<select name="sort">
|
{% endfor %}
|
||||||
{% for option in sort_options %}
|
{% if has_next %}<a href="{{ next_url }}">Next →</a>{% endif %}
|
||||||
<option value="{{ option.value }}" {% if option.value == sort_option %}selected{% endif %}>{{ option.label }}</option>
|
</div>
|
||||||
{% endfor %}
|
{% endif %}
|
||||||
</select>
|
{% endblock %}
|
||||||
|
|
||||||
<button class="primary-button" type="submit">应用</button>
|
|
||||||
<a class="secondary-button" href="{{ url_for('web.asset_list') }}">重置</a>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{% if rows %}
|
|
||||||
<div class="table-shell top-gap">
|
|
||||||
<table class="data-table compact-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Asset</th>
|
|
||||||
<th>Type</th>
|
|
||||||
<th>Program</th>
|
|
||||||
<th>Current Event</th>
|
|
||||||
<th>Next Event</th>
|
|
||||||
<th>Entries</th>
|
|
||||||
<th>Action</th>
|
|
||||||
</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>{{ 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>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% 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 %}
|
|
||||||
{% 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>
|
|
||||||
{% endblock %}
|
|
||||||
|
|||||||
+91
-256
@@ -1,87 +1,91 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN" data-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="color-scheme" content="light dark">
|
|
||||||
<title>{{ title }} · {{ config["PROJECT_TITLE"] }}</title>
|
<title>{{ title }} · {{ config["PROJECT_TITLE"] }}</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
|
||||||
<script>
|
<script>
|
||||||
|
// Inline theme init to prevent FOUC
|
||||||
(() => {
|
(() => {
|
||||||
const storageKey = 'ksp-theme';
|
const k = 'ksp-theme', c = 'ksp_theme';
|
||||||
const cookieKey = 'ksp_theme';
|
const saved = (() => { try { return localStorage.getItem(k); } catch(e) { return null; } })()
|
||||||
const readSavedTheme = () => {
|
|| (document.cookie.match(/(?:^|; )ksp_theme=(dark|light)(?:;|$)/) || [])[1];
|
||||||
try {
|
const theme = saved || 'dark';
|
||||||
const savedTheme = localStorage.getItem(storageKey);
|
document.documentElement.dataset.theme = theme;
|
||||||
if (savedTheme === 'dark' || savedTheme === 'light') {
|
document.documentElement.style.colorScheme = theme;
|
||||||
return savedTheme;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Ignore storage access failures and fall back to cookies/system preference.
|
|
||||||
}
|
|
||||||
|
|
||||||
const cookieMatch = document.cookie.match(/(?:^|; )ksp_theme=(dark|light)(?:;|$)/);
|
|
||||||
return cookieMatch ? cookieMatch[1] : null;
|
|
||||||
};
|
|
||||||
const persistTheme = (theme) => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(storageKey, theme);
|
|
||||||
} catch (error) {
|
|
||||||
// Ignore storage access failures and rely on cookies for persistence.
|
|
||||||
}
|
|
||||||
|
|
||||||
document.cookie = `${cookieKey}=${theme}; path=/; max-age=31536000; SameSite=Lax`;
|
|
||||||
};
|
|
||||||
const applyDocumentTheme = (theme) => {
|
|
||||||
document.documentElement.dataset.theme = theme;
|
|
||||||
document.documentElement.style.colorScheme = theme;
|
|
||||||
};
|
|
||||||
const savedTheme = readSavedTheme();
|
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
const resolvedTheme = savedTheme === 'dark' || savedTheme === 'light'
|
|
||||||
? savedTheme
|
|
||||||
: (prefersDark ? 'dark' : 'light');
|
|
||||||
|
|
||||||
applyDocumentTheme(resolvedTheme);
|
|
||||||
window.__kspTheme = {
|
|
||||||
storageKey,
|
|
||||||
readSavedTheme,
|
|
||||||
persistTheme,
|
|
||||||
applyDocumentTheme,
|
|
||||||
};
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="page-shell">
|
<!-- ═══ TOPBAR ═══ -->
|
||||||
<header class="site-header">
|
<div id="topbar">
|
||||||
<a class="brand" href="{{ url_for('web.dashboard') }}">
|
<div class="brand"><b>KSP</b> 管理后台</div>
|
||||||
<span class="brand-name">KSP Operation Hangar</span>
|
<div class="tb-sp"></div>
|
||||||
</a>
|
<div class="tb-time">
|
||||||
<div class="site-header-tools">
|
<span class="l">Sim Time</span>
|
||||||
<nav class="site-nav">
|
<span class="td" id="sim-time-display" onclick="openTimeModal()">{{ simulation_time_display or '2060-03-12 09:00' }}</span>
|
||||||
<a href="{{ url_for('web.dashboard') }}">控制台</a>
|
<button class="b" onclick="shiftTime(-1)">−1d</button>
|
||||||
<a href="{{ url_for('web.engine_list') }}">引擎目录</a>
|
<button class="b" onclick="shiftTime(1)">+1d</button>
|
||||||
<a href="{{ url_for('web.communication_list') }}">通信部件</a>
|
<button class="b" onclick="shiftTime(7)">+7d</button>
|
||||||
<a href="{{ url_for('web.tank_list') }}">燃料箱</a>
|
</div>
|
||||||
<a href="{{ url_for('web.vehicle_list') }}">载具成本</a>
|
<button class="b" onclick="toggleTheme()" title="Toggle theme" style="margin-left:4px">◐</button>
|
||||||
<a href="{{ url_for('web.mission_preview_home') }}">任务运营</a>
|
</div>
|
||||||
<a href="{{ url_for('web.fuel_converter') }}">燃料换算</a>
|
|
||||||
<a href="{{ url_for('api.health') }}">API 健康检查</a>
|
|
||||||
</nav>
|
|
||||||
<button class="theme-toggle" type="button" data-theme-toggle aria-label="切换深浅色模式" aria-pressed="false">
|
|
||||||
<span class="theme-toggle-track" aria-hidden="true">
|
|
||||||
<span class="theme-toggle-thumb"></span>
|
|
||||||
</span>
|
|
||||||
<span class="theme-toggle-copy">
|
|
||||||
<span class="theme-toggle-label">主题</span>
|
|
||||||
<span class="theme-toggle-value" data-theme-toggle-label>浅色</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<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) %}
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
<div class="flash-stack">
|
<div class="flash-stack">
|
||||||
@@ -95,190 +99,21 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<!-- ═══ TIME MODAL ═══ -->
|
||||||
(() => {
|
<div class="modal-bg" id="time-modal">
|
||||||
const themeManager = window.__kspTheme || null;
|
<div class="modal">
|
||||||
const themeStorageKey = themeManager?.storageKey || 'ksp-theme';
|
<h2>Set Simulation Time</h2>
|
||||||
const themeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
<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 = () => {
|
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||||
const savedTheme = themeManager?.readSavedTheme?.();
|
{% block scripts %}{% endblock %}
|
||||||
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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,148 +1,50 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="catalog-header panel catalog-header-tight catalog-page-header">
|
<div class="ph-row fi">
|
||||||
<div class="catalog-header-copy">
|
<div class="ph">
|
||||||
<p class="eyebrow">Communication</p>
|
<div class="ey">Reference Data</div>
|
||||||
<h1>通信部件目录</h1>
|
<h1>通信部件</h1>
|
||||||
<p class="hero-text">支持分页、筛选、排序和快速编辑。</p>
|
</div>
|
||||||
</div>
|
<a class="b ac" href="{{ url_for('web.communication_new') }}">+ New</a>
|
||||||
<div class="catalog-header-side">
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<section class="panel catalog-actions-panel top-gap">
|
<div class="fb fi">
|
||||||
<div>
|
<form method="get" action="{{ url_for('web.communication_list') }}" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||||
<p class="eyebrow">Actions</p>
|
<input type="text" name="q" value="{{ search_term }}" placeholder="Search...">
|
||||||
<h2>新增内容</h2>
|
<select name="source" onchange="this.form.submit()">
|
||||||
</div>
|
<option value="">All Sources</option>
|
||||||
<a class="primary-button" href="{{ url_for('web.communication_new') }}">新增通信部件</a>
|
{% for s in source_options %}<option value="{{ s }}" {% if s == source_filter %}selected{% endif %}>{{ s }}</option>{% endfor %}
|
||||||
</section>
|
</select>
|
||||||
|
<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>
|
||||||
|
<button class="b" type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="panel panel-wide top-gap">
|
<table class="fi">
|
||||||
<form class="search-form filter-row" method="get">
|
<thead><tr><th>Name</th><th>Range</th><th>Mass</th><th>Type</th><th>Source</th><th>Action</th></tr></thead>
|
||||||
<input type="search" name="q" value="{{ search_term }}" placeholder="Part / Display / Source / Type">
|
<tbody>
|
||||||
|
{% for row in rows %}
|
||||||
|
<tr>
|
||||||
|
<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>
|
||||||
|
|
||||||
<select name="source">
|
{% if total_pages > 1 %}
|
||||||
<option value="">全部来源</option>
|
<div class="pg-bar fi">
|
||||||
{% for option in source_options %}
|
{% if has_prev %}<a href="{{ prev_url }}">← Prev</a>{% endif %}
|
||||||
<option value="{{ option }}" {% if option == source_filter %}selected{% endif %}>{{ option }}</option>
|
{% 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 %}
|
||||||
{% endfor %}
|
{% if has_next %}<a href="{{ next_url }}">Next →</a>{% endif %}
|
||||||
</select>
|
</div>
|
||||||
|
{% endif %}
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{% 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>
|
|
||||||
<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>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% 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>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,80 +1,45 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="hero-panel">
|
<div class="ph fi">
|
||||||
<div class="hero-copy">
|
<div class="ey">Mission Operations</div>
|
||||||
<p class="eyebrow">项目起步版本</p>
|
<h1>任务总览</h1>
|
||||||
<h1>先把数据模型和导入边界定死,再做 CRUD。</h1>
|
<p>Simulation: <strong>{{ simulation_time_display }}</strong> · All metrics derived from asset logs</p>
|
||||||
<p class="hero-text">
|
</div>
|
||||||
这个骨架现在已经提供 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>
|
|
||||||
|
|
||||||
<section class="metric-grid">
|
<div class="mg fi">
|
||||||
{% for metric in metrics %}
|
<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>
|
||||||
<article class="metric-card">
|
<div class="mc gn"><div class="l">Active Missions</div><div class="v">{{ active_missions }}</div></div>
|
||||||
<span class="metric-value">{{ metric.value }}</span>
|
<div class="mc am"><div class="l">Upcoming (30d)</div><div class="v">{{ upcoming_count }}</div><div class="s">{{ upcoming_summary }}</div></div>
|
||||||
<span class="metric-label">{{ metric.label }}</span>
|
</div>
|
||||||
</article>
|
|
||||||
|
<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>
|
||||||
|
{% 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 %}
|
{% endfor %}
|
||||||
</section>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="content-grid">
|
<div class="stl fi">Upcoming Events</div>
|
||||||
<article class="panel panel-wide">
|
<div class="evl fi">
|
||||||
<div class="panel-heading">
|
{% for ev in upcoming_events %}
|
||||||
<p class="eyebrow">建模决策</p>
|
<div class="evr">
|
||||||
<h2>当前推荐的数据库拆分</h2>
|
<span class="ed">{{ ev.date }}</span>{{ ev.title }}
|
||||||
</div>
|
{% if ev.within_30d %}<span class="eb">30d</span>{% endif %}
|
||||||
<div class="decision-grid">
|
</div>
|
||||||
<div>
|
{% endfor %}
|
||||||
<h3>engine_families</h3>
|
</div>
|
||||||
<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 %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
+55
-255
@@ -1,262 +1,62 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="catalog-header panel catalog-header-tight catalog-page-header engine-catalog-hero">
|
<div class="ph-row fi">
|
||||||
<div class="catalog-header-copy engine-catalog-copy">
|
<div class="ph">
|
||||||
<p class="eyebrow">Engine Catalog</p>
|
<div class="ey">Reference Data</div>
|
||||||
<h1>引擎目录</h1>
|
<h1>引擎目录</h1>
|
||||||
<p class="hero-text">宽屏高密度视图,支持分页、燃料/循环/Mod 筛选,并按推力或比冲排序。</p>
|
</div>
|
||||||
</div>
|
<a class="b ac" href="{{ url_for('web.engine_new') }}">+ New Engine</a>
|
||||||
<div class="catalog-header-side">
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<section class="catalog-layout">
|
<div class="fb fi">
|
||||||
<div class="catalog-sidebar">
|
<form method="get" action="{{ url_for('web.engine_list') }}" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||||
<aside class="panel filter-panel filter-panel-tight">
|
<input type="text" name="q" value="{{ search_term }}" placeholder="Search engines...">
|
||||||
<div class="panel-heading">
|
<select name="fuel_type" onchange="this.form.submit()">
|
||||||
<p class="eyebrow">Filters</p>
|
<option value="">All Fuel</option>
|
||||||
<h2>筛选和排序</h2>
|
{% for f in fuel_options %}<option value="{{ f }}" {% if f == fuel_filter %}selected{% endif %}>{{ f }}</option>{% endfor %}
|
||||||
</div>
|
</select>
|
||||||
<form class="filter-form" method="get">
|
<select name="cycle" onchange="this.form.submit()">
|
||||||
<label>
|
<option value="">All Cycles</option>
|
||||||
<span class="field-label">搜索</span>
|
{% for c in cycle_options %}<option value="{{ c }}" {% if c == cycle_filter %}selected{% endif %}>{{ c }}</option>{% endfor %}
|
||||||
<input type="search" name="q" value="{{ search_term }}" placeholder="Engine / Part / Config">
|
</select>
|
||||||
</label>
|
<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>
|
||||||
|
<button class="b" type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label>
|
<table class="fi">
|
||||||
<span class="field-label">燃料种类</span>
|
<thead><tr>
|
||||||
<select name="fuel_type">
|
<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>
|
||||||
<option value="">全部</option>
|
</tr></thead>
|
||||||
{% for option in fuel_options %}
|
<tbody>
|
||||||
<option value="{{ option }}" {% if option == fuel_filter %}selected{% endif %}>{{ option }}</option>
|
{% for row in catalog_rows %}
|
||||||
{% endfor %}
|
<tr>
|
||||||
</select>
|
<td><a href="{{ url_for('web.engine_detail', family_id=row.family.id) }}">{{ row.family.engine_name }}</a></td>
|
||||||
</label>
|
<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>
|
||||||
|
|
||||||
<label>
|
{% if total_pages > 1 %}
|
||||||
<span class="field-label">循环类型</span>
|
<div class="pg-bar fi">
|
||||||
<select name="cycle">
|
{% if has_prev %}<a href="{{ prev_url }}">← Prev</a>{% endif %}
|
||||||
<option value="">全部</option>
|
{% for link in page_links %}
|
||||||
{% for option in cycle_options %}
|
{% if link.is_current %}<span class="cur">{{ link.page }}</span>
|
||||||
<option value="{{ option }}" {% if option == cycle_filter %}selected{% endif %}>{{ option }}</option>
|
{% elif link.is_ellipsis %}<span>...</span>
|
||||||
{% endfor %}
|
{% else %}<a href="{{ link.url }}">{{ link.page }}</a>{% endif %}
|
||||||
</select>
|
{% endfor %}
|
||||||
</label>
|
{% if has_next %}<a href="{{ next_url }}">Next →</a>{% endif %}
|
||||||
|
</div>
|
||||||
<label>
|
{% endif %}
|
||||||
<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>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="filter-actions">
|
|
||||||
<button class="primary-button" type="submit">应用</button>
|
|
||||||
<a class="secondary-button" href="{{ url_for('web.engine_list') }}">重置</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% 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 %}
|
|
||||||
{% 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>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,109 +1,50 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="hero-panel compact page-header-compact">
|
<div class="ph fi">
|
||||||
<div class="hero-copy">
|
<div class="ey">Tools</div>
|
||||||
<p class="eyebrow">Fuel Chart 工具化</p>
|
<h1>燃料换算</h1>
|
||||||
<h1>燃料体积 / 质量快速换算</h1>
|
</div>
|
||||||
<p class="hero-text">
|
|
||||||
系数直接来自工作簿第二行。输入升数可换算吨位,输入吨位可反推不同燃料对应的体积。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="content-grid fuel-layout">
|
<div class="cvw fi">
|
||||||
<article class="panel">
|
<form id="conv-form">
|
||||||
<div class="panel-heading">
|
<label>Value<input type="number" id="cv-val" value="1000" step="1"></label>
|
||||||
<p class="eyebrow">换算输入</p>
|
<label>Mode<select id="cv-mode"><option value="L2t">Liters → Tons</option><option value="t2L">Tons → Liters</option></select></label>
|
||||||
<h2>输入一个值</h2>
|
<label>Fuel<select id="cv-fuel">
|
||||||
</div>
|
{% for f in fuel_factors %}
|
||||||
|
<option value="{{ f.factor }}">{{ f.fuel_type }} ({{ f.factor }} kg/L)</option>
|
||||||
<form id="converter-form" class="converter-form">
|
{% endfor %}
|
||||||
<label class="field-label" for="mode">换算方向</label>
|
</select></label>
|
||||||
<select id="mode" name="mode">
|
<button type="submit" class="b ac">Convert</button>
|
||||||
<option value="volume">体积 L -> 质量 t</option>
|
</form>
|
||||||
<option value="mass">质量 t -> 体积 L</option>
|
<div class="res">
|
||||||
</select>
|
<table>
|
||||||
|
<thead><tr><th>Fuel</th><th>Density (kg/L)</th><th>Result</th></tr></thead>
|
||||||
<label class="field-label" for="value">数值</label>
|
<tbody id="cv-res">
|
||||||
<input id="value" name="value" type="number" min="0.000001" step="any" value="75" required>
|
{% for f in fuel_factors %}
|
||||||
|
<tr><td>{{ f.fuel_type }}</td><td>{{ f.factor }}</td><td id="cv-{{ loop.index }}">—</td></tr>
|
||||||
<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>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</tbody>
|
||||||
</section>
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const form = document.getElementById("converter-form");
|
document.getElementById('conv-form').addEventListener('submit', function(e) {
|
||||||
const modeField = document.getElementById("mode");
|
e.preventDefault();
|
||||||
const valueField = document.getElementById("value");
|
const val = parseFloat(document.getElementById('cv-val').value) || 0;
|
||||||
const resultSummary = document.getElementById("result-summary");
|
const mode = document.getElementById('cv-mode').value;
|
||||||
const resultTable = document.getElementById("result-table");
|
const factors = {{ fuel_factors|tojson }};
|
||||||
const formError = document.getElementById("form-error");
|
factors.forEach(function(f, i) {
|
||||||
const endpoint = "{{ url_for('api.convert_fuel') }}";
|
const cell = document.getElementById('cv-' + (i+1));
|
||||||
|
if (cell) {
|
||||||
async function renderResults(mode, value) {
|
cell.textContent = mode === 'L2t'
|
||||||
formError.hidden = true;
|
? (val * f.factor / 1000).toFixed(4) + ' t'
|
||||||
const query = new URLSearchParams({ mode, value }).toString();
|
: (val * 1000 / f.factor).toFixed(0) + ' L';
|
||||||
const response = await fetch(`${endpoint}?${query}`);
|
cell.style.color = 'var(--accent)';
|
||||||
const payload = await response.json();
|
cell.style.fontFamily = 'var(--font-data)';
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(payload.error || "换算失败");
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -46,7 +46,8 @@
|
|||||||
{{ datetime_picker_field('开始', 'start', range_start_input, field_id='mission-list-start') }}
|
{{ datetime_picker_field('开始', 'start', range_start_input, field_id='mission-list-start') }}
|
||||||
{{ datetime_picker_field('结束', 'end', range_end_input, field_id='mission-list-end') }}
|
{{ datetime_picker_field('结束', 'end', range_end_input, field_id='mission-list-end') }}
|
||||||
<button class="primary-button" type="submit">应用</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -100,7 +101,8 @@
|
|||||||
<div class="table-subline">{{ entry.status_label }}</div>
|
<div class="table-subline">{{ entry.status_label }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="two-line-cell">
|
<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>
|
<div class="table-subline">{{ entry.asset_type }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="two-line-cell">
|
<td class="two-line-cell">
|
||||||
@@ -117,8 +119,10 @@
|
|||||||
<td>{{ entry.location }}</td>
|
<td>{{ entry.location }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="table-actions">
|
<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_detail', asset_id=entry.asset.id
|
||||||
<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>
|
||||||
|
<a class="table-link" href="{{ url_for('web.asset_entry_edit', asset_id=entry.asset.id, entry_id=entry.entry_id
|
||||||
|
) }}">编辑条目</a>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,106 +1,79 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% from "_datetime_picker_field.html" import datetime_picker_field %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include "_mission_ops_tabs.html" %}
|
<div style="display:flex;flex:1;overflow:hidden;position:absolute;inset:0;top:var(--topbar-h);left:var(--sidebar-w)">
|
||||||
{% include "_mission_preview_header.html" %}
|
<!-- 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>
|
||||||
|
|
||||||
<section class="panel mission-control-panel top-gap">
|
<!-- Info Panel -->
|
||||||
<form method="get" action="{{ url_for('web.mission_location_board_preview') }}" class="mission-toolbar-grid">
|
<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">
|
||||||
<div class="mission-toolbar-block">
|
<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>
|
||||||
<span class="field-label">面板筛选</span>
|
<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 class="mission-filter-grid">
|
<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>
|
||||||
<label>
|
<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>
|
||||||
<span class="field-label">资产类型</span>
|
<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>
|
||||||
<select name="asset_type">
|
</div>
|
||||||
<option value="">全部类型</option>
|
</div>
|
||||||
{% 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>
|
<script>
|
||||||
|
// Pass asset data from backend to JS
|
||||||
|
const LOC_ASSETS = {{ asset_positions_json|safe }};
|
||||||
|
const LOC_EVENTS = {{ upcoming_events|tojson|safe }};
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
<section class="mission-board-shell top-gap">
|
{% block scripts %}
|
||||||
<div class="mission-board-main">
|
<script>
|
||||||
{% if location_groups %}
|
// ═══ Keplerian Orbital Mechanics ═══
|
||||||
{% for group in location_groups %}
|
const J2000_JD=2451545.0,DAY_MS=86400000;
|
||||||
<section class="panel mission-group-panel">
|
const ORB={
|
||||||
<div class="mission-group-header">
|
mercury:{a:.387098,e:.205630,i:7.005,O:48.331,p:77.456,L:252.251,P:87.969},
|
||||||
<div>
|
venus:{a:.723332,e:.006772,i:3.395,O:76.680,p:131.563,L:181.980,P:224.701},
|
||||||
<p class="eyebrow">地点分组</p>
|
earth:{a:1.000003,e:.016709,i:.000,O:.000,p:102.938,L:100.464,P:365.256},
|
||||||
<h2>{{ group.title }}</h2>
|
mars:{a:1.523679,e:.093401,i:1.850,O:49.558,p:336.060,L:355.453,P:686.980},
|
||||||
<p class="mission-group-subtitle">{{ group.subtitle }}</p>
|
ceres:{a:2.767500,e:.075823,i:10.593,O:80.306,p:72.716,L:73.598,P:1680},
|
||||||
</div>
|
vesta:{a:2.361000,e:.088740,i:7.141,O:103.852,p:150.047,L:18.900,P:1325},
|
||||||
<span class="mission-group-count">{{ group['items']|length }} 项资产</span>
|
jupiter:{a:5.203363,e:.048393,i:1.305,O:100.556,p:14.754,L:34.404,P:4332.589},
|
||||||
</div>
|
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-card-grid">
|
// ═══ Star Map Data ═══
|
||||||
{% for item in group['items'] %}
|
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}}}};
|
||||||
<article class="mission-asset-card mission-tone-soft">
|
const SO=['mercury','venus','earth','mars','ceres','vesta','jupiter','saturn','uranus','neptune','pluto'];
|
||||||
<div class="mission-card-top">
|
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}
|
||||||
<div>
|
|
||||||
<h3>{{ item.name }}</h3>
|
|
||||||
<p class="small-muted mission-type">{{ item.asset_type }}</p>
|
|
||||||
</div>
|
|
||||||
<span class="mission-status-pill">{{ item.state }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mission-meta-grid">
|
// ═══ State ═══
|
||||||
<div class="mission-meta-item">
|
let mapVS=[{tp:'solar',ck:'sun'}],mapHv=null,mapDg=false,mapDS=null,mapDL=null,mapDD=false,mapVO={x:0,y:0},mapZm=1;
|
||||||
<dt>Location</dt>
|
let mapCtx,mapCv,mapStars=null;
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="mission-card-foot">
|
function initMap(){
|
||||||
<a class="table-link" href="{{ url_for('web.asset_list', q=item.name, sim_time=simulation_time_input) }}">查看任务资产</a>
|
mapCv=document.getElementById('map-cv');mapCtx=mapCv.getContext('2d');
|
||||||
</div>
|
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()}
|
||||||
</article>
|
window.addEventListener('resize',rsz);setTimeout(rsz,100);
|
||||||
{% endfor %}
|
mapCv.addEventListener('mousedown',e=>{mapDg=true;mapDS={x:e.clientX,y:e.clientY};mapDL={x:e.clientX,y:e.clientY};mapDD=false});
|
||||||
</div>
|
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()}});
|
||||||
</section>
|
window.addEventListener('mouseup',()=>{mapDg=false;mapDS=null;mapDL=null});
|
||||||
{% endfor %}
|
mapCv.addEventListener('wheel',e=>{e.preventDefault();mapZm=Math.max(.3,Math.min(4,mapZm*(e.deltaY>0?.9:1.1)));renderMap()},{passive:false});
|
||||||
{% else %}
|
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()});
|
||||||
<section class="panel empty-state compact-empty">
|
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()}});
|
||||||
<h2>当前筛选条件下没有资产</h2>
|
}
|
||||||
</section>
|
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}
|
||||||
{% endif %}
|
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}
|
||||||
</div>
|
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()}
|
||||||
<div class="mission-board-side">
|
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>'}
|
||||||
{% include "_mission_upcoming_events.html" %}
|
function updateMapBack(){document.getElementById('bk-btn').classList.toggle('vis',mapVS.length>1)}
|
||||||
</div>
|
function mapGoBack(){if(mapVS.length>1)mapVS.pop();mapVO={x:0,y:0};mapZm=1;updateMapBack();renderMap();rMapInfo()}
|
||||||
</section>
|
initMap();
|
||||||
{% endblock %}
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -90,7 +90,8 @@
|
|||||||
<p class="mission-summary-text">{{ item.summary }}</p>
|
<p class="mission-summary-text">{{ item.summary }}</p>
|
||||||
|
|
||||||
<div class="mission-card-foot">
|
<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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -1,240 +1,458 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% from "_datetime_picker_field.html" import datetime_picker_field %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include "_mission_ops_tabs.html" %}
|
<div class="ph fi">
|
||||||
{% include "_mission_preview_header.html" %}
|
<div class="ey">Mission Operations</div>
|
||||||
|
<h1>时间线</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="panel mission-control-panel top-gap">
|
<!-- Filter bar row 1: search + presets + scale -->
|
||||||
<form method="get" action="{{ url_for('web.mission_timeline_preview') }}" class="mission-toolbar-grid">
|
<div class="fb fi">
|
||||||
<input type="hidden" name="range_mode" value="{{ timeline_range_mode }}" id="timeline-range-mode">
|
<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">
|
||||||
<div class="mission-toolbar-block">
|
<input type="hidden" name="sim_time" value="{{ simulation_time_input }}">
|
||||||
<span class="field-label">时间范围</span>
|
<input type="hidden" name="start" id="tl-from" value="{{ timeline_range_start[:10] if timeline_range_start else '' }}">
|
||||||
<div class="mission-toolbar-inline">
|
<input type="hidden" name="end" id="tl-to" value="{{ timeline_range_end[:10] if timeline_range_end else '' }}">
|
||||||
{{ datetime_picker_field('开始', 'start', timeline_range_start, field_id='timeline-start') }}
|
<input type="text" name="q" id="tl-q" placeholder="Search assets..." style="width:150px" value="{{ search_term|default('',true) }}">
|
||||||
{{ datetime_picker_field('结束', 'end', timeline_range_end, field_id='timeline-end') }}
|
<div style="display:flex;gap:3px">
|
||||||
<label>
|
<button class="b sm{% if record_scope == 'active' %} ac{% endif %}" type="submit" name="record_scope" value="active">Active</button>
|
||||||
<span class="field-label">尺度</span>
|
<button class="b sm" type="submit" name="record_scope" value="all">All</button>
|
||||||
<select name="scale">
|
</div>
|
||||||
{% for option in timeline_scale_options %}
|
<label style="font-size:.66rem;color:var(--muted)">Type:</label>
|
||||||
<option value="{{ option.value }}" {% if option.value == timeline_scale_mode %}selected{% endif %}>{{ option.label }}</option>
|
<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">
|
||||||
{% endfor %}
|
<option value="">All</option>
|
||||||
</select>
|
{% for t in asset_type_options|default([],true) %}<option value="{{ t }}" {% if t == asset_type_filter %}selected{% endif %}>{{ t }}</option>{% endfor %}
|
||||||
</label>
|
</select>
|
||||||
</div>
|
<label style="font-size:.66rem;color:var(--muted)">Location:</label>
|
||||||
</div>
|
<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 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">
|
document.getElementById('asset-select-all').addEventListener('change', function(){
|
||||||
<span class="field-label">时间线筛选</span>
|
dd.querySelectorAll('.asset-cb').forEach(function(cb){cb.checked = true});
|
||||||
<div class="mission-filter-grid">
|
updateAssetCount();
|
||||||
<label>
|
});
|
||||||
<span class="field-label">资产类型</span>
|
document.getElementById('asset-clear').addEventListener('change', function(){
|
||||||
<select name="asset_type">
|
dd.querySelectorAll('.asset-cb').forEach(function(cb){cb.checked = false});
|
||||||
<option value="">全部类型</option>
|
updateAssetCount();
|
||||||
{% for option in asset_type_options %}
|
});
|
||||||
<option value="{{ option }}" {% if option == asset_type_filter %}selected{% endif %}>{{ option }}</option>
|
dd.querySelectorAll('.asset-cb').forEach(function(cb){
|
||||||
{% endfor %}
|
cb.addEventListener('change', function(){ updateAssetCount(); });
|
||||||
</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">
|
var btn = document.getElementById('tl-asset-btn');
|
||||||
<span class="field-label">显示资产</span>
|
btn.addEventListener('click', function(e){
|
||||||
{% if timeline_asset_options %}
|
e.stopPropagation();
|
||||||
<div class="mission-asset-picker" data-asset-picker>
|
var r = btn.getBoundingClientRect();
|
||||||
<div class="mission-asset-picker-toolbar">
|
dd.style.left = Math.max(4, r.left) + 'px';
|
||||||
<label class="mission-asset-picker-search">
|
var top = r.bottom + 4;
|
||||||
<span class="field-label">按名称搜索</span>
|
dd.style.top = top + 'px';
|
||||||
<input type="search" placeholder="搜索资产名称" data-asset-picker-search>
|
dd.classList.toggle('open');
|
||||||
</label>
|
if (dd.classList.contains('open')) {
|
||||||
<div class="mission-asset-picker-actions">
|
var maxB = window.innerHeight - 8;
|
||||||
<button class="secondary-button" type="button" data-asset-picker-select-all>全选可见</button>
|
if (dd.getBoundingClientRect().bottom > maxB) {
|
||||||
<button class="secondary-button" type="button" data-asset-picker-clear>清空选择</button>
|
dd.style.top = (r.top - dd.getBoundingClientRect().height - 4) + 'px';
|
||||||
<span class="small-muted" data-asset-picker-summary></span>
|
}
|
||||||
</div>
|
}
|
||||||
</div>
|
});
|
||||||
|
|
||||||
<div class="mission-asset-picker-grid">
|
var hasExplicit = {{ 'true' if selected_asset_ids else 'false' }};
|
||||||
{% for option in timeline_asset_options %}
|
if (!hasExplicit) {
|
||||||
<label class="mission-asset-picker-item" data-asset-picker-item data-asset-picker-name="{{ option.label|lower }}">
|
dd.querySelectorAll('.asset-cb').forEach(function(cb){cb.checked = true});
|
||||||
<input type="checkbox" name="asset_ids" value="{{ option.value }}" data-asset-picker-checkbox {% if option.selected %}checked{% endif %}>
|
updateAssetCount();
|
||||||
<span>
|
}
|
||||||
<strong>{{ option.label }}</strong>
|
})();
|
||||||
<small>{{ option.asset_type }}</small>
|
|
||||||
</span>
|
function updateAssetCount() {
|
||||||
</label>
|
var dd = document.getElementById('tl-asset-dd');
|
||||||
{% endfor %}
|
var cbs = dd.querySelectorAll('.asset-cb');
|
||||||
</div>
|
var total = cbs.length;
|
||||||
</div>
|
var checked = Array.from(cbs).filter(function(cb){return cb.checked}).length;
|
||||||
<p class="field-hint">未勾选任何 Asset 时,默认显示当前筛选命中的全部资产。</p>
|
var btn = document.getElementById('tl-asset-btn');
|
||||||
{% else %}
|
btn.textContent = (checked === total ? 'All assets' : checked + ' selected') + ' ▾';
|
||||||
<p class="small-muted">当前筛选条件下没有可选资产。</p>
|
}
|
||||||
|
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 }} {{ seg.start_at }} → {{ seg.end_at }} {{ seg.state_node_count }} state node(s) 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 %}
|
{% 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) }} 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>
|
||||||
</form>
|
</div>
|
||||||
</section>
|
{% endfor %}
|
||||||
|
|
||||||
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mission-filter-pill-row">
|
<!-- Zoom controls -->
|
||||||
<span class="mission-filter-pill"><strong>范围:</strong> {{ timeline_range_display }}</span>
|
<div style="display:flex;align-items:center;gap:6px;margin-top:8px">
|
||||||
<span class="mission-filter-pill"><strong>尺度:</strong> {{ timeline_scale_mode|title }}</span>
|
<button class="b sm" onclick="tlZoom(1.3)">Zoom +</button>
|
||||||
<span class="mission-filter-pill"><strong>资产:</strong> {% if selected_asset_count %}已选 {{ selected_asset_count }} 个{% else %}显示 {{ timeline_rows|length }} 条{% endif %}</span>
|
<button class="b sm" onclick="tlZoom(0.7)">Zoom −</button>
|
||||||
<span class="mission-filter-pill"><strong>模式:</strong> {{ record_scope_options|selectattr('value', 'equalto', record_scope)|map(attribute='label')|first }}</span>
|
<button class="b sm" onclick="tlResetView()">Reset View</button>
|
||||||
</div>
|
<span style="font-size:.64rem;color:var(--muted);margin-left:8px">Scroll to zoom · Drag to pan</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if timeline_rows %}
|
{% endblock %}
|
||||||
<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>
|
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script id="tl-json" type="application/json">{{ timeline_json|safe }}</script>
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
(function(){
|
||||||
const picker = document.querySelector('[data-asset-picker]');
|
const viewport = document.getElementById('tl-viewport');
|
||||||
if (!picker) {
|
if (!viewport) return;
|
||||||
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 };
|
||||||
|
}
|
||||||
|
|
||||||
const searchInput = picker.querySelector('[data-asset-picker-search]');
|
function buildHTML(data) {
|
||||||
const summary = picker.querySelector('[data-asset-picker-summary]');
|
const from = parseISO(data.range_start);
|
||||||
const items = Array.from(picker.querySelectorAll('[data-asset-picker-item]'));
|
const to = parseISO(data.range_end);
|
||||||
const rangeModeField = document.getElementById('timeline-range-mode');
|
const scale = autoScale(from, to);
|
||||||
const rangeInputs = [document.getElementById('timeline-start'), document.getElementById('timeline-end')].filter(Boolean);
|
const sl = buildScaleLabels(from, to, scale);
|
||||||
|
const bucketCount = sl.count;
|
||||||
|
const EVT_H = 12;
|
||||||
|
|
||||||
const checkboxFor = (item) => item.querySelector('[data-asset-picker-checkbox]');
|
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>';
|
||||||
|
|
||||||
const updateSummary = () => {
|
for (const row of data.rows) {
|
||||||
const visibleCount = items.filter((item) => !item.hidden).length;
|
const visSegs = row.segments.filter(function(s) {
|
||||||
const selectedCount = items.filter((item) => checkboxFor(item)?.checked).length;
|
const sp = calcPosition(s.start_iso, from, to, bucketCount);
|
||||||
summary.textContent = `已选 ${selectedCount} 个 · 可见 ${visibleCount} 个`;
|
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);
|
||||||
|
|
||||||
const setRangeModeManual = () => {
|
html += '<div style="display:flex;border-bottom:1px solid rgba(148,163,184,.06)">';
|
||||||
if (rangeModeField) {
|
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>';
|
||||||
rangeModeField.value = 'manual';
|
html += '<div style="flex:1;position:relative;height:' + rowH + 'px;min-width:0">';
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const applySearch = () => {
|
for (const item of result.items) {
|
||||||
const term = (searchInput?.value || '').trim().toLowerCase();
|
const visSp = Math.max(item.sp, 0);
|
||||||
for (const item of items) {
|
const visEp = Math.min(item.ep, bucketCount);
|
||||||
const assetName = item.dataset.assetPickerName || '';
|
const leftPct = (visSp / bucketCount * 100).toFixed(4);
|
||||||
item.hidden = Boolean(term) && !assetName.includes(term);
|
const widthPct = ((visEp - visSp) / bucketCount * 100).toFixed(4);
|
||||||
}
|
const w = Math.max(parseFloat(widthPct), 0.3);
|
||||||
updateSummary();
|
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 + ' ' + seg.start_at + ' → ' + seg.end_at + ' Click to edit" onclick="if(this.dataset.url)window.location.href=this.dataset.url">' + seg.label + '</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
picker.querySelector('[data-asset-picker-select-all]')?.addEventListener('click', () => {
|
if (hasEvt) {
|
||||||
for (const item of items) {
|
const evtY = segAreaH + 8;
|
||||||
const checkbox = checkboxFor(item);
|
const seen = new Set();
|
||||||
if (!item.hidden && checkbox) {
|
for (const evt of row.events) {
|
||||||
checkbox.checked = true;
|
const ep = calcPosition(evt.at_iso, from, to, bucketCount);
|
||||||
}
|
const key = evt.label + '|' + evt.at_iso;
|
||||||
}
|
if (seen.has(key)) continue;
|
||||||
updateSummary();
|
seen.add(key);
|
||||||
});
|
const cls = evt.is_state_node ? 'tl-sn' : 'tl-ev';
|
||||||
|
const shape = evt.is_state_node
|
||||||
picker.querySelector('[data-asset-picker-clear]')?.addEventListener('click', () => {
|
? '<div style="width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;border-bottom:6px solid var(--accent)"></div>'
|
||||||
for (const item of items) {
|
: '<div style="width:5px;height:5px;background:var(--warning);transform:rotate(45deg)"></div>';
|
||||||
const checkbox = checkboxFor(item);
|
const epPct = (ep / bucketCount * 100).toFixed(4);
|
||||||
if (checkbox) {
|
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) + ' Click to edit" onclick="if(this.dataset.url)window.location.href=this.dataset.url">' + shape + '</div>';
|
||||||
checkbox.checked = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateSummary();
|
|
||||||
});
|
|
||||||
|
|
||||||
searchInput?.addEventListener('input', applySearch);
|
|
||||||
for (const item of items) {
|
|
||||||
checkboxFor(item)?.addEventListener('change', updateSummary);
|
|
||||||
}
|
|
||||||
for (const input of rangeInputs) {
|
|
||||||
input.addEventListener('input', setRangeModeManual);
|
|
||||||
input.addEventListener('change', setRangeModeManual);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
applySearch();
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+43
-139
@@ -1,146 +1,50 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="catalog-header panel catalog-header-tight catalog-page-header">
|
<div class="ph-row fi">
|
||||||
<div class="catalog-header-copy">
|
<div class="ph">
|
||||||
<p class="eyebrow">Tank</p>
|
<div class="ey">Reference Data</div>
|
||||||
<h1>燃料箱目录</h1>
|
<h1>燃料箱规格</h1>
|
||||||
<p class="hero-text">支持分页、筛选、排序和快速编辑。</p>
|
</div>
|
||||||
</div>
|
<a class="b ac" href="{{ url_for('web.tank_new') }}">+ New</a>
|
||||||
<div class="catalog-header-side">
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<section class="panel catalog-actions-panel top-gap">
|
<div class="fb fi">
|
||||||
<div>
|
<form method="get" action="{{ url_for('web.tank_list') }}" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||||
<p class="eyebrow">Actions</p>
|
<input type="text" name="q" value="{{ search_term }}" placeholder="Search...">
|
||||||
<h2>新增内容</h2>
|
<select name="fuel_type" onchange="this.form.submit()">
|
||||||
</div>
|
<option value="">All Fuels</option>
|
||||||
<a class="primary-button" href="{{ url_for('web.tank_new') }}">新增燃料箱</a>
|
{% for f in fuel_options %}<option value="{{ f }}" {% if f == fuel_filter %}selected{% endif %}>{{ f }}</option>{% endfor %}
|
||||||
</section>
|
</select>
|
||||||
|
<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>
|
||||||
|
<button class="b" type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="panel panel-wide top-gap">
|
<table class="fi">
|
||||||
<form class="search-form filter-row" method="get">
|
<thead><tr><th>Name</th><th>Volume</th><th>Dry Mass</th><th>Fuel</th><th>Source</th><th>Action</th></tr></thead>
|
||||||
<input type="search" name="q" value="{{ search_term }}" placeholder="Tank / Vehicle / Fuel / Source">
|
<tbody>
|
||||||
|
{% for row in rows %}
|
||||||
|
<tr>
|
||||||
|
<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>
|
||||||
|
|
||||||
<select name="fuel_type">
|
{% if total_pages > 1 %}
|
||||||
<option value="">全部燃料</option>
|
<div class="pg-bar fi">
|
||||||
{% for option in fuel_options %}
|
{% if has_prev %}<a href="{{ prev_url }}">← Prev</a>{% endif %}
|
||||||
<option value="{{ option }}" {% if option == fuel_filter %}selected{% endif %}>{{ option }}</option>
|
{% 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 %}
|
||||||
{% endfor %}
|
{% if has_next %}<a href="{{ next_url }}">Next →</a>{% endif %}
|
||||||
</select>
|
</div>
|
||||||
|
{% endif %}
|
||||||
<select name="source">
|
|
||||||
<option value="">全部来源</option>
|
|
||||||
{% for option in source_options %}
|
|
||||||
<option value="{{ option }}" {% if option == source_filter %}selected{% endif %}>{{ option }}</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>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{% 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>
|
|
||||||
<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>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% 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>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+37
-120
@@ -1,127 +1,44 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="catalog-header panel catalog-header-tight catalog-page-header">
|
<div class="ph-row fi">
|
||||||
<div class="catalog-header-copy">
|
<div class="ph">
|
||||||
<p class="eyebrow">Vehicle</p>
|
<div class="ey">Reference Data</div>
|
||||||
<h1>载具成本目录</h1>
|
<h1>载具成本</h1>
|
||||||
<p class="hero-text">支持分页、来源筛选、成本排序与编辑。</p>
|
</div>
|
||||||
</div>
|
<a class="b ac" href="{{ url_for('web.vehicle_new') }}">+ New</a>
|
||||||
<div class="catalog-header-side">
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<section class="panel catalog-actions-panel top-gap">
|
<div class="fb fi">
|
||||||
<div>
|
<form method="get" action="{{ url_for('web.vehicle_list') }}" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||||
<p class="eyebrow">Actions</p>
|
<input type="text" name="q" value="{{ search_term }}" placeholder="Search...">
|
||||||
<h2>新增内容</h2>
|
<select name="source" onchange="this.form.submit()">
|
||||||
</div>
|
<option value="">All Sources</option>
|
||||||
<a class="primary-button" href="{{ url_for('web.vehicle_new') }}">新增载具成本</a>
|
{% for s in source_options %}<option value="{{ s }}" {% if s == source_filter %}selected{% endif %}>{{ s }}</option>{% endfor %}
|
||||||
</section>
|
</select>
|
||||||
|
<button class="b" type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="panel panel-wide top-gap">
|
<table class="fi">
|
||||||
<form class="search-form filter-row" method="get">
|
<thead><tr><th>Name</th><th>Launch Cost</th><th>Source</th><th>Action</th></tr></thead>
|
||||||
<input type="search" name="q" value="{{ search_term }}" placeholder="Vehicle / Source">
|
<tbody>
|
||||||
|
{% for row in rows %}
|
||||||
|
<tr>
|
||||||
|
<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>
|
||||||
|
|
||||||
<select name="source">
|
{% if total_pages > 1 %}
|
||||||
<option value="">全部来源</option>
|
<div class="pg-bar fi">
|
||||||
{% for option in source_options %}
|
{% if has_prev %}<a href="{{ prev_url }}">← Prev</a>{% endif %}
|
||||||
<option value="{{ option }}" {% if option == source_filter %}selected{% endif %}>{{ option }}</option>
|
{% 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 %}
|
||||||
{% endfor %}
|
{% if has_next %}<a href="{{ next_url }}">Next →</a>{% endif %}
|
||||||
</select>
|
</div>
|
||||||
|
{% endif %}
|
||||||
<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>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{% 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>
|
|
||||||
<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>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% 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>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user