feat: timeline refactor, docking UI, location/state dropdowns, sim_time cleanup

Templates:
- mission_timeline_preview: client-side rendering with lane stacking, wheel zoom,
  drag pan, percentage-based layout, event dedup, scale labels
- asset_detail: dock/undock modals with time selection, future event warning,
  Mission Label column replacing Location/State
- asset_entry_form: all_locations expanded to 72 items (draft board hierarchy),
  all_states dynamic from DB, event point dropdown uses same list
- Remove sim_time from all template url_for links
- base.html: remove Today button

JS:
- app.js: sim_time controls use POST /api/v1/sim-time API instead of URL param

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 15:29:10 +08:00
co-authored by Claude Opus 4.7
parent cc4c460840
commit 2fef743e83
15 changed files with 1791 additions and 1966 deletions
+392 -236
View File
@@ -1,270 +1,426 @@
{% extends "base.html" %}
{% from "_datetime_picker_field.html" import datetime_picker_field %}
{% block content %}
{% include "_mission_ops_tabs.html" %}
{% set field_error_map = field_errors|default({}, true) %}
{% macro render_end_at_field(end_at_value, has_error=False) %}
{{ datetime_picker_field('End At', 'end_at', end_at_value, field_id='entry-end-at-input', wrapper_class='field-span-two mission-datetime-field', wrapper_id='entry-end-at-field', error=has_error) }}
{% endmacro %}
{% macro render_state_node_row(node, row_index, field_error_map) %}
{% set title_error = field_error_map.get('state_node_title:' ~ row_index) %}
{% set at_error = field_error_map.get('state_node_at:' ~ row_index) %}
<div class="state-node-row{% if title_error or at_error %} state-node-row-error{% endif %}" data-state-node-row{% if title_error or at_error %} data-field-error="true"{% endif %}>
<input type="hidden" name="state_node_id" value="{{ node.id if node else '' }}">
<style>
.hidden-if-not-transit{display:none}
.combobox{position:relative;display:flex;align-items:center}
.combobox input{flex:1;padding-right:28px}
.combobox-btn{position:absolute;right:4px;top:50%;transform:translateY(-50%);width:22px;height:22px;border:none;background:transparent;cursor:pointer;color:var(--text-muted);font-size:10px;display:flex;align-items:center;justify-content:center;border-radius:3px}
.combobox-btn:hover{background:rgba(255,255,255,.08)}
.combobox-drop{position:absolute;top:100%;left:0;right:0;max-height:200px;overflow-y:auto;background:var(--surface);border:1px solid var(--border);border-radius:4px;z-index:100;display:none;list-style:none;padding:0;margin:2px 0 0}
.combobox-drop.open{display:block}
.combobox-drop li{padding:5px 10px;font-size:.82rem;cursor:pointer;color:var(--text)}
.combobox-drop li:hover,.combobox-drop li.active{background:rgba(59,130,246,.15);color:var(--accent)}
.combobox-drop li.hidden{display:none}
</style>
<label class="{% if title_error %}field-error-shell{% endif %}"{% if title_error %} data-field-error="true"{% endif %}>
<span class="field-label">Node Title</span>
<input class="{% if title_error %}field-error-input{% endif %}" type="text" name="state_node_title" value="{{ node.title if node else '' }}" placeholder="State Node Title">
</label>
{% set is_state_entry = form_values.entry_kind == 'state' %}
{% set node_count = form_values.state_nodes|length %}
{% set all_locations = ['LEO','GEO/MEO','Lunar Orbit','Lunar Surface','Earth Surface','Mercury System','Mercury Orbit','Mercury Surface','Venus System','Venus Orbit','Venus Surface','Earth System','Mars System','Mars Orbit','Mars Surface','Phobos Orbit','Phobos Surface','Deimos Orbit','Deimos Surface','Ceres Orbit','Ceres Surface','Vesta Orbit','Vesta Surface','Jupiter System','Jupiter Orbit','Io Orbit','Io Surface','Europa Orbit','Europa Surface','Ganymede Orbit','Ganymede Surface','Callisto Orbit','Callisto Surface','Saturn System','Saturn Orbit','Mimas Orbit','Mimas Surface','Enceladus Orbit','Enceladus Surface','Tethys Orbit','Tethys Surface','Dione Orbit','Dione Surface','Rhea Orbit','Rhea Surface','Titan Orbit','Titan Surface','Iapetus Orbit','Iapetus Surface','Uranus System','Uranus Orbit','Miranda Orbit','Miranda Surface','Ariel Orbit','Ariel Surface','Umbriel Orbit','Umbriel Surface','Titania Orbit','Titania Surface','Oberon Orbit','Oberon Surface','Neptune System','Neptune Orbit','Triton Orbit','Triton Surface','Pluto System','Pluto Orbit','Pluto Surface','Charon Orbit','Charon Surface','Solar Orbit','Transfer'] %}
{% if not all_states %}{% set all_states = ['start-transit','transit-complete','Exploration','Maintenance','Docked','Construction','Testing','Autonomous Standby','Crew Handoff','Cargo Delivery','Surface Expedition','Planetary Operations','Base Construction','Outpost Construction','Base Expansion','Outpost Expansion'] %}{% endif %}
{{ datetime_picker_field('Node At', 'state_node_at', node.at if node else '', field_id='state-node-at-' ~ row_index, wrapper_class='state-node-datetime', error=at_error) }}
<label class="state-node-detail-field">
<span class="field-label">Node Detail</span>
<textarea name="state_node_detail" rows="2" placeholder="State Node Detail">{{ node.detail if node else '' }}</textarea>
</label>
<button class="secondary-button state-node-remove-button" type="button" data-state-node-remove>移除</button>
</div>
{% endmacro %}
<section class="hero-panel compact page-header-compact">
<div class="hero-copy">
<p class="eyebrow">资产日志</p>
<h1>{{ heading }}</h1>
<section class="asset-entry-hero">
<div class="asset-entry-title-block">
<div class="asset-entry-crumbs">
<span>任务资产</span>
<span>/</span>
<span>{{ asset.name }}</span>
<span>/</span>
<span>{{ '编辑日志' if delete_url else '新增日志' }}</span>
</div>
<h1>{{ form_values.title or heading }}</h1>
<p>{{ asset.asset_type }}{% if asset.program %} · {{ asset.program }}{% endif %}</p>
</div>
<div class="asset-entry-status-grid">
<div class="asset-entry-stat active">
<span>Entry Type</span>
<strong>{{ 'State Interval' if is_state_entry else 'Event Point' }}</strong>
</div>
<div class="asset-entry-stat">
<span>Simulation Time</span>
<strong>{{ simulation_time_input }}</strong>
</div>
<div class="asset-entry-stat">
<span>State Window</span>
<strong>{{ form_values.start_at or '—' }}{% if form_values.end_at %} → {{ form_values.end_at }}{% endif %}</strong>
</div>
<div class="asset-entry-stat">
<span>State Nodes</span>
<strong>{{ node_count }} node{{ '' if node_count == 1 else 's' }}</strong>
</div>
</div>
</section>
<form method="post" class="edit-layout">
<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>
<form method="post" id="asset-entry-form" class="asset-entry-layout">
<input type="hidden" name="sim_time" value="{{ simulation_time_input }}">
<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 %}>
<span class="field-label">Entry Type</span>
<select id="entry-kind-select" name="entry_kind" class="{% if field_error_map.get('entry_kind') %}field-error-input{% endif %}">
{% for option in kind_options %}
<option value="{{ option.value }}" {% if option.value == form_values.entry_kind %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</label>
<aside class="asset-entry-side">
<section class="asset-entry-panel">
<div class="asset-entry-panel-head">
<div>
<p class="asset-entry-kicker">Entry</p>
<h2>日志信息</h2>
<p>这些字段会决定资产在状态板、时间线和地点板上的当前状态。</p>
</div>
</div>
<label class="{% if field_error_map.get('location') %}field-error-shell{% endif %}"{% if field_error_map.get('location') %} data-field-error="true"{% endif %}>
<span class="field-label">Location</span>
<input class="{% if field_error_map.get('location') %}field-error-input{% endif %}" type="text" name="location" value="{{ form_values.location }}">
</label>
<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 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>
<div class="button-row top-gap">
<button class="primary-button" type="submit">{{ submit_label }}</button>
<a class="secondary-button" href="{{ cancel_url }}">返回</a>
<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 %}
<section class="panel danger-panel top-gap">
<p class="small-muted">删除后不可恢复,请谨慎操作。</p>
<form method="post" action="{{ delete_url }}" class="inline-form" onsubmit="return confirm('确认删除这个日志条目?')">
<input type="hidden" name="sim_time" value="{{ simulation_time_input }}">
<button class="danger-link" type="submit">删除条目</button>
</form>
</section>
<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 %}
<template id="entry-end-at-template">
{{ render_end_at_field(form_values.end_at, false) }}
</template>
<div class="asset-entry-footer">
<button class="b ac" type="submit" form="asset-entry-form">{{ submit_label }}</button>
<a class="b" href="{{ cancel_url }}">返回</a>
{% if delete_url %}
<span class="asset-entry-footer-spacer"></span>
<button class="b dg" type="submit" form="asset-entry-delete-form">删除条目</button>
{% endif %}
</div>
<template id="state-node-row-template">
{{ render_state_node_row(none, '__INDEX__', {}) }}
<template id="node-template">
<article class="state-node-row asset-entry-rail-node">
<div class="asset-entry-rail-time" data-node-time-label>未设置时间</div>
<div class="asset-entry-rail-line"><span></span></div>
<div class="asset-entry-rail-copy">
<div class="asset-entry-node-card">
<div class="asset-entry-node-card-head">
<span class="asset-entry-time-badge" data-node-badge>New</span>
<div class="asset-entry-node-title">
<strong data-node-title-label>Untitled node</strong>
<span data-node-detail-label>No detail yet.</span>
</div>
<button class="b sm dg" type="button" onclick="removeNodeRow(this)">Delete</button>
</div>
<div class="asset-entry-node-fields">
<input type="hidden" name="state_node_id" value="">
<label class="asset-entry-field">
<span class="field-label">Node Title</span>
<input type="text" name="state_node_title" placeholder="Node title" oninput="syncNodePreview(this)">
</label>
<label class="asset-entry-field">
<span class="field-label">Node At</span>
<input type="datetime-local" name="state_node_at" step="60" oninput="syncNodePreview(this)">
</label>
<label class="asset-entry-field asset-entry-node-detail">
<span class="field-label">Detail</span>
<textarea name="state_node_detail" rows="2" placeholder="Detail (optional)" oninput="syncNodePreview(this)"></textarea>
</label>
<label class="asset-entry-field">
<span class="field-label">State</span>
<div class="combobox"><input type="text" name="state_node_state_label" placeholder="Search state..." autocomplete="off" onfocus="openCombobox(this)" oninput="filterCombobox(this)" onchange="toggleTransitFields(this)"><button type="button" class="combobox-btn" onclick="toggleCombobox(this)">&#9662;</button><ul class="combobox-drop">{% for s in all_states %}<li onclick="selectCombobox(this)">{{ s }}</li>{% endfor %}</ul></div>
</label>
<label class="asset-entry-field hidden-if-not-transit" data-transit-fields>
<span class="field-label">Previous Location</span>
<div class="combobox"><input type="text" name="state_node_previous_location" placeholder="Search location..." autocomplete="off" onfocus="openCombobox(this)" oninput="filterCombobox(this)"><button type="button" class="combobox-btn" onclick="toggleCombobox(this)">&#9662;</button><ul class="combobox-drop">{% for loc in all_locations %}<li onclick="selectCombobox(this)">{{ loc }}</li>{% endfor %}</ul></div>
</label>
<label class="asset-entry-field hidden-if-not-transit" data-transit-fields>
<span class="field-label">Transit Location</span>
<div class="combobox"><input type="text" name="state_node_transit_location" placeholder="Search location..." autocomplete="off" onfocus="openCombobox(this)" oninput="filterCombobox(this)"><button type="button" class="combobox-btn" onclick="toggleCombobox(this)">&#9662;</button><ul class="combobox-drop">{% for loc in all_locations %}<li onclick="selectCombobox(this)">{{ loc }}</li>{% endfor %}</ul></div>
</label>
<label class="asset-entry-field">
<span class="field-label">Target Location</span>
<div class="combobox"><input type="text" name="state_node_target_location" placeholder="Search location..." autocomplete="off" onfocus="openCombobox(this)" oninput="filterCombobox(this)"><button type="button" class="combobox-btn" onclick="toggleCombobox(this)">&#9662;</button><ul class="combobox-drop">{% for loc in all_locations %}<li onclick="selectCombobox(this)">{{ loc }}</li>{% endfor %}</ul></div>
</label>
</div>
</div>
</div>
</article>
</template>
{% include "_validation_dialog.html" %}
<script>
(() => {
const kindField = document.getElementById("entry-kind-select");
const startLabel = document.getElementById("entry-start-at-label");
const startField = document.getElementById("entry-start-at-field");
const endFieldTemplate = document.getElementById("entry-end-at-template");
const stateNodePanel = document.getElementById("state-node-panel");
const stateNodeList = stateNodePanel?.querySelector("[data-state-node-list]");
const stateNodeEmpty = stateNodePanel?.querySelector("[data-state-node-empty]");
const stateNodeAddButton = stateNodePanel?.querySelector("[data-state-node-add]");
const stateNodeRowTemplate = document.getElementById("state-node-row-template");
let endField = document.getElementById("entry-end-at-field");
let endInput = document.getElementById("entry-end-at-input");
let cachedEndValue = endInput?.value || "";
let stateNodeIndex = stateNodeList?.querySelectorAll("[data-state-node-row]").length || 0;
function formatNodeTime(value) {
return value ? value.replace("T", " ") : "未设置时间";
}
if (!kindField || !startLabel || !startField || !endFieldTemplate) {
return;
}
function findNodeRow(control) {
return control.closest(".asset-entry-rail-node");
}
const refreshEndField = () => {
endField = document.getElementById("entry-end-at-field");
endInput = document.getElementById("entry-end-at-input");
};
function syncNodePreview(control) {
const row = findNodeRow(control);
if (!row) return;
const title = row.querySelector('[name="state_node_title"]')?.value?.trim();
const at = row.querySelector('[name="state_node_at"]')?.value;
const detail = row.querySelector('[name="state_node_detail"]')?.value?.trim();
const titleLabel = row.querySelector("[data-node-title-label]");
const detailLabel = row.querySelector("[data-node-detail-label]");
const timeLabel = row.querySelector("[data-node-time-label]");
if (titleLabel) titleLabel.textContent = title || "Untitled node";
if (detailLabel) detailLabel.textContent = detail || "No detail yet.";
if (timeLabel) timeLabel.textContent = formatNodeTime(at);
}
const rememberEndValue = () => {
cachedEndValue = endInput?.value || "";
};
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" : "";
}
}
const restoreEndValue = () => {
if (!endInput) {
return;
}
endInput.value = cachedEndValue;
};
function removeNodeRow(button) {
const row = findNodeRow(button);
if (row) row.remove();
updateNodeCount();
}
const ensureEndField = () => {
if (endField) {
restoreEndValue();
return;
}
const templateField = endFieldTemplate.content.firstElementChild;
if (!templateField) {
return;
}
startField.insertAdjacentElement("afterend", templateField.cloneNode(true));
refreshEndField();
restoreEndValue();
window.bindDatetimePickerTriggers?.(endField);
};
function addNodeRow() {
const tmpl = document.getElementById("node-template");
const clone = tmpl.content.cloneNode(true);
document.getElementById("nodes-list").appendChild(clone);
updateNodeCount();
}
const removeEndField = () => {
if (!endField) {
return;
}
rememberEndValue();
endField.remove();
refreshEndField();
};
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);
});
});
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 = () => {
if (!stateNodeEmpty) {
return;
}
stateNodeEmpty.hidden = stateNodeRows().length > 0;
};
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); });
const setStateNodePanelVisible = (visible) => {
if (!stateNodePanel) {
return;
}
stateNodePanel.hidden = !visible;
for (const field of stateNodePanel.querySelectorAll("input[name], textarea[name], select[name]")) {
field.disabled = !visible;
}
if (stateNodeAddButton) {
stateNodeAddButton.disabled = !visible;
}
for (const button of stateNodePanel.querySelectorAll("[data-state-node-remove]")) {
button.disabled = !visible;
}
};
const addStateNodeRow = () => {
if (!stateNodeList || !stateNodeRowTemplate) {
return;
}
const rowMarkup = stateNodeRowTemplate.innerHTML.replaceAll("__INDEX__", String(stateNodeIndex));
stateNodeIndex += 1;
stateNodeList.insertAdjacentHTML("beforeend", rowMarkup);
const row = stateNodeList.lastElementChild;
if (row) {
window.bindDatetimePickerTriggers?.(row);
row.querySelector('input[name="state_node_title"]')?.focus();
}
updateStateNodeEmpty();
};
stateNodeAddButton?.addEventListener("click", addStateNodeRow);
stateNodeList?.addEventListener("click", (event) => {
const removeButton = event.target.closest("[data-state-node-remove]");
if (!removeButton) {
return;
}
removeButton.closest("[data-state-node-row]")?.remove();
updateStateNodeEmpty();
});
const syncEntryKind = () => {
const isEvent = kindField.value === "event";
startLabel.textContent = isEvent ? "At" : "Start At";
if (isEvent) {
removeEndField();
setStateNodePanelVisible(false);
return;
}
ensureEndField();
setStateNodePanelVisible(true);
};
kindField.addEventListener("change", syncEntryKind);
updateStateNodeEmpty();
syncEntryKind();
})();
// ── 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>
{% endblock %}
{% include "_validation_dialog.html" %}
{% endblock %}