Files
KSP_project/app/templates/asset_entry_form.html
ArmorandClaude Opus 4.7 2fef743e83 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>
2026-06-01 15:29:10 +08:00

427 lines
24 KiB
HTML

{% extends "base.html" %}
{% block content %}
{% set field_error_map = field_errors|default({}, true) %}
<style>
.hidden-if-not-transit{display:none}
.combobox{position:relative;display:flex;align-items:center}
.combobox input{flex:1;padding-right:28px}
.combobox-btn{position:absolute;right:4px;top:50%;transform:translateY(-50%);width:22px;height:22px;border:none;background:transparent;cursor:pointer;color:var(--text-muted);font-size:10px;display:flex;align-items:center;justify-content:center;border-radius:3px}
.combobox-btn:hover{background:rgba(255,255,255,.08)}
.combobox-drop{position:absolute;top:100%;left:0;right:0;max-height:200px;overflow-y:auto;background:var(--surface);border:1px solid var(--border);border-radius:4px;z-index:100;display:none;list-style:none;padding:0;margin:2px 0 0}
.combobox-drop.open{display:block}
.combobox-drop li{padding:5px 10px;font-size:.82rem;cursor:pointer;color:var(--text)}
.combobox-drop li:hover,.combobox-drop li.active{background:rgba(59,130,246,.15);color:var(--accent)}
.combobox-drop li.hidden{display:none}
</style>
{% set is_state_entry = form_values.entry_kind == 'state' %}
{% set node_count = form_values.state_nodes|length %}
{% set all_locations = ['LEO','GEO/MEO','Lunar Orbit','Lunar Surface','Earth Surface','Mercury System','Mercury Orbit','Mercury Surface','Venus System','Venus Orbit','Venus Surface','Earth System','Mars System','Mars Orbit','Mars Surface','Phobos Orbit','Phobos Surface','Deimos Orbit','Deimos Surface','Ceres Orbit','Ceres Surface','Vesta Orbit','Vesta Surface','Jupiter System','Jupiter Orbit','Io Orbit','Io Surface','Europa Orbit','Europa Surface','Ganymede Orbit','Ganymede Surface','Callisto Orbit','Callisto Surface','Saturn System','Saturn Orbit','Mimas Orbit','Mimas Surface','Enceladus Orbit','Enceladus Surface','Tethys Orbit','Tethys Surface','Dione Orbit','Dione Surface','Rhea Orbit','Rhea Surface','Titan Orbit','Titan Surface','Iapetus Orbit','Iapetus Surface','Uranus System','Uranus Orbit','Miranda Orbit','Miranda Surface','Ariel Orbit','Ariel Surface','Umbriel Orbit','Umbriel Surface','Titania Orbit','Titania Surface','Oberon Orbit','Oberon Surface','Neptune System','Neptune Orbit','Triton Orbit','Triton Surface','Pluto System','Pluto Orbit','Pluto Surface','Charon Orbit','Charon Surface','Solar Orbit','Transfer'] %}
{% if not all_states %}{% set all_states = ['start-transit','transit-complete','Exploration','Maintenance','Docked','Construction','Testing','Autonomous Standby','Crew Handoff','Cargo Delivery','Surface Expedition','Planetary Operations','Base Construction','Outpost Construction','Base Expansion','Outpost Expansion'] %}{% endif %}
<section class="asset-entry-hero">
<div class="asset-entry-title-block">
<div class="asset-entry-crumbs">
<span>任务资产</span>
<span>/</span>
<span>{{ asset.name }}</span>
<span>/</span>
<span>{{ '编辑日志' if delete_url else '新增日志' }}</span>
</div>
<h1>{{ form_values.title or heading }}</h1>
<p>{{ asset.asset_type }}{% if asset.program %} · {{ asset.program }}{% endif %}</p>
</div>
<div class="asset-entry-status-grid">
<div class="asset-entry-stat active">
<span>Entry Type</span>
<strong>{{ 'State Interval' if is_state_entry else 'Event Point' }}</strong>
</div>
<div class="asset-entry-stat">
<span>Simulation Time</span>
<strong>{{ simulation_time_input }}</strong>
</div>
<div class="asset-entry-stat">
<span>State Window</span>
<strong>{{ form_values.start_at or '—' }}{% if form_values.end_at %} → {{ form_values.end_at }}{% endif %}</strong>
</div>
<div class="asset-entry-stat">
<span>State Nodes</span>
<strong>{{ node_count }} node{{ '' if node_count == 1 else 's' }}</strong>
</div>
</div>
</section>
<form method="post" id="asset-entry-form" class="asset-entry-layout">
<input type="hidden" name="sim_time" value="{{ simulation_time_input }}">
<aside class="asset-entry-side">
<section class="asset-entry-panel">
<div class="asset-entry-panel-head">
<div>
<p class="asset-entry-kicker">Entry</p>
<h2>日志信息</h2>
<p>这些字段会决定资产在状态板、时间线和地点板上的当前状态。</p>
</div>
</div>
<div class="asset-entry-panel-body">
<div class="asset-entry-context">
<div><span>Asset</span><strong>{{ asset.name }}</strong></div>
<div><span>Asset Type</span><strong>{{ asset.asset_type }}</strong></div>
<div><span>Program</span><strong>{{ asset.program or '—' }}</strong></div>
</div>
<label class="asset-entry-field">
<span class="field-label">Asset</span>
<input type="text" value="{{ asset.name }}" disabled>
</label>
{% 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>
{% 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>
</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)">&#9662;</button><ul class="combobox-drop">{% for loc in all_locations %}<li onclick="selectCombobox(this)">{{ loc }}</li>{% endfor %}</ul></div>
{% endmacro %}
{% macro combobox_state(name, value) %}
<div class="combobox"><input type="text" name="{{ name }}" value="{{ value }}" placeholder="Search state..." autocomplete="off" onfocus="openCombobox(this)" oninput="filterCombobox(this)" onchange="toggleTransitFields(this)"><button type="button" class="combobox-btn" onclick="toggleCombobox(this)">&#9662;</button><ul class="combobox-drop">{% for s in all_states %}<li onclick="selectCombobox(this)">{{ s }}</li>{% endfor %}</ul></div>
{% endmacro %}
<label class="asset-entry-field">
<span class="field-label">State</span>
{{ combobox_state('state_node_state_label', node.state_label) }}
</label>
<label class="asset-entry-field hidden-if-not-transit" data-transit-fields>
<span class="field-label">Previous Location</span>
{{ combobox_location('state_node_previous_location', node.previous_location, 'Search location...') }}
</label>
<label class="asset-entry-field hidden-if-not-transit" data-transit-fields>
<span class="field-label">Transit Location</span>
{{ combobox_location('state_node_transit_location', node.transit_location, 'Search location...') }}
</label>
<label class="asset-entry-field">
<span class="field-label">Target Location</span>
{{ combobox_location('state_node_target_location', node.target_location, 'Search location...') }}
</label>
</div>
</div>
</div>
</article>
{% endfor %}
</div>
</section>
</form>
{% if delete_url %}
<form method="post" action="{{ delete_url }}" id="asset-entry-delete-form" onsubmit="return confirm('确认删除?')">
<input type="hidden" name="sim_time" value="{{ simulation_time_input }}">
</form>
{% endif %}
<div class="asset-entry-footer">
<button class="b ac" type="submit" form="asset-entry-form">{{ submit_label }}</button>
<a class="b" href="{{ cancel_url }}">返回</a>
{% if delete_url %}
<span class="asset-entry-footer-spacer"></span>
<button class="b dg" type="submit" form="asset-entry-delete-form">删除条目</button>
{% endif %}
</div>
<template id="node-template">
<article class="state-node-row asset-entry-rail-node">
<div class="asset-entry-rail-time" data-node-time-label>未设置时间</div>
<div class="asset-entry-rail-line"><span></span></div>
<div class="asset-entry-rail-copy">
<div class="asset-entry-node-card">
<div class="asset-entry-node-card-head">
<span class="asset-entry-time-badge" data-node-badge>New</span>
<div class="asset-entry-node-title">
<strong data-node-title-label>Untitled node</strong>
<span data-node-detail-label>No detail yet.</span>
</div>
<button class="b sm dg" type="button" onclick="removeNodeRow(this)">Delete</button>
</div>
<div class="asset-entry-node-fields">
<input type="hidden" name="state_node_id" value="">
<label class="asset-entry-field">
<span class="field-label">Node Title</span>
<input type="text" name="state_node_title" placeholder="Node title" oninput="syncNodePreview(this)">
</label>
<label class="asset-entry-field">
<span class="field-label">Node At</span>
<input type="datetime-local" name="state_node_at" step="60" oninput="syncNodePreview(this)">
</label>
<label class="asset-entry-field asset-entry-node-detail">
<span class="field-label">Detail</span>
<textarea name="state_node_detail" rows="2" placeholder="Detail (optional)" oninput="syncNodePreview(this)"></textarea>
</label>
<label class="asset-entry-field">
<span class="field-label">State</span>
<div class="combobox"><input type="text" name="state_node_state_label" placeholder="Search state..." autocomplete="off" onfocus="openCombobox(this)" oninput="filterCombobox(this)" onchange="toggleTransitFields(this)"><button type="button" class="combobox-btn" onclick="toggleCombobox(this)">&#9662;</button><ul class="combobox-drop">{% for s in all_states %}<li onclick="selectCombobox(this)">{{ s }}</li>{% endfor %}</ul></div>
</label>
<label class="asset-entry-field hidden-if-not-transit" data-transit-fields>
<span class="field-label">Previous Location</span>
<div class="combobox"><input type="text" name="state_node_previous_location" placeholder="Search location..." autocomplete="off" onfocus="openCombobox(this)" oninput="filterCombobox(this)"><button type="button" class="combobox-btn" onclick="toggleCombobox(this)">&#9662;</button><ul class="combobox-drop">{% for loc in all_locations %}<li onclick="selectCombobox(this)">{{ loc }}</li>{% endfor %}</ul></div>
</label>
<label class="asset-entry-field hidden-if-not-transit" data-transit-fields>
<span class="field-label">Transit Location</span>
<div class="combobox"><input type="text" name="state_node_transit_location" placeholder="Search location..." autocomplete="off" onfocus="openCombobox(this)" oninput="filterCombobox(this)"><button type="button" class="combobox-btn" onclick="toggleCombobox(this)">&#9662;</button><ul class="combobox-drop">{% for loc in all_locations %}<li onclick="selectCombobox(this)">{{ loc }}</li>{% endfor %}</ul></div>
</label>
<label class="asset-entry-field">
<span class="field-label">Target Location</span>
<div class="combobox"><input type="text" name="state_node_target_location" placeholder="Search location..." autocomplete="off" onfocus="openCombobox(this)" oninput="filterCombobox(this)"><button type="button" class="combobox-btn" onclick="toggleCombobox(this)">&#9662;</button><ul class="combobox-drop">{% for loc in all_locations %}<li onclick="selectCombobox(this)">{{ loc }}</li>{% endfor %}</ul></div>
</label>
</div>
</div>
</div>
</article>
</template>
<script>
function formatNodeTime(value) {
return value ? value.replace("T", " ") : "未设置时间";
}
function findNodeRow(control) {
return control.closest(".asset-entry-rail-node");
}
function syncNodePreview(control) {
const row = findNodeRow(control);
if (!row) return;
const title = row.querySelector('[name="state_node_title"]')?.value?.trim();
const at = row.querySelector('[name="state_node_at"]')?.value;
const detail = row.querySelector('[name="state_node_detail"]')?.value?.trim();
const titleLabel = row.querySelector("[data-node-title-label]");
const detailLabel = row.querySelector("[data-node-detail-label]");
const timeLabel = row.querySelector("[data-node-time-label]");
if (titleLabel) titleLabel.textContent = title || "Untitled node";
if (detailLabel) detailLabel.textContent = detail || "No detail yet.";
if (timeLabel) timeLabel.textContent = formatNodeTime(at);
}
function updateNodeCount() {
const count = document.querySelectorAll("#nodes-list .asset-entry-rail-node").length;
document.querySelectorAll("[data-node-count]").forEach((el) => {
el.textContent = `${count} node${count === 1 ? "" : "s"}`;
});
const emptyState = document.querySelector("[data-empty-node-state]");
if (emptyState) {
emptyState.style.display = count ? "none" : "";
}
}
function removeNodeRow(button) {
const row = findNodeRow(button);
if (row) row.remove();
updateNodeCount();
}
function addNodeRow() {
const tmpl = document.getElementById("node-template");
const clone = tmpl.content.cloneNode(true);
document.getElementById("nodes-list").appendChild(clone);
updateNodeCount();
}
document.getElementById("entry-kind-select").addEventListener("change", function() {
const isState = this.value === "state";
document.getElementById("end-at-wrap").style.display = isState ? "" : "none";
document.getElementById("state-wrap")?.style.setProperty("display", isState ? "" : "none");
document.getElementById("state-node-section").style.display = isState ? "" : "none";
document.querySelectorAll("[data-entry-kind-pill]").forEach((pill) => {
pill.classList.toggle("active", pill.dataset.entryKindPill === this.value);
});
});
document.querySelectorAll("[data-entry-kind-pill]").forEach((pill) => {
pill.addEventListener("click", () => {
const select = document.getElementById("entry-kind-select");
select.value = pill.dataset.entryKindPill;
select.dispatchEvent(new Event("change", {bubbles: true}));
});
});
function toggleTransitFields(sel) {
const row = sel.closest('.asset-entry-node-card') || sel.closest('[data-state-node-row]');
if (!row) return;
const fields = row.querySelectorAll('[data-transit-fields]');
const show = sel.value === 'start-transit';
fields.forEach(function(f) { f.style.display = show ? 'block' : 'none'; });
}
document.querySelectorAll('[name="state_node_state_label"]').forEach(function(s) { toggleTransitFields(s); });
// ── Combobox ──
function filterCombobox(input) {
var drop = input.parentElement.querySelector('.combobox-drop');
if (!drop) return;
var val = input.value.toLowerCase();
var items = drop.querySelectorAll('li');
var hasVisible = false;
items.forEach(function(li) {
if (val === '' || li.textContent.toLowerCase().indexOf(val) !== -1) {
li.classList.remove('hidden');
hasVisible = true;
} else {
li.classList.add('hidden');
}
});
if (hasVisible) drop.classList.add('open');
}
function toggleCombobox(btn) {
var drop = btn.parentElement.querySelector('.combobox-drop');
if (!drop) return;
drop.classList.toggle('open');
if (drop.classList.contains('open')) {
var input = btn.parentElement.querySelector('input');
if (input) { input.focus(); filterCombobox(input); }
}
}
function openCombobox(input) {
var drop = input.parentElement.querySelector('.combobox-drop');
if (drop) { drop.classList.add('open'); filterCombobox(input); }
}
function selectCombobox(li) {
var drop = li.parentElement;
var combobox = drop.parentElement;
var input = combobox.querySelector('input');
if (input) {
input.value = li.textContent;
input.dispatchEvent(new Event('change', {bubbles: true}));
}
drop.classList.remove('open');
}
document.addEventListener('click', function(e) {
if (!e.target.closest('.combobox')) {
document.querySelectorAll('.combobox-drop.open').forEach(function(d) { d.classList.remove('open'); });
}
});
</script>
{% include "_validation_dialog.html" %}
{% endblock %}