270 lines
13 KiB
HTML
270 lines
13 KiB
HTML
{% 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 '' }}">
|
|
|
|
<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>
|
|
|
|
{{ 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>
|
|
</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>
|
|
|
|
<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>
|
|
|
|
<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>
|
|
|
|
<div class="button-row top-gap">
|
|
<button class="primary-button" type="submit">{{ submit_label }}</button>
|
|
<a class="secondary-button" href="{{ cancel_url }}">返回</a>
|
|
</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>
|
|
{% endif %}
|
|
|
|
<template id="entry-end-at-template">
|
|
{{ render_end_at_field(form_values.end_at, false) }}
|
|
</template>
|
|
|
|
<template id="state-node-row-template">
|
|
{{ render_state_node_row(none, '__INDEX__', {}) }}
|
|
</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;
|
|
|
|
if (!kindField || !startLabel || !startField || !endFieldTemplate) {
|
|
return;
|
|
}
|
|
|
|
const refreshEndField = () => {
|
|
endField = document.getElementById("entry-end-at-field");
|
|
endInput = document.getElementById("entry-end-at-input");
|
|
};
|
|
|
|
const rememberEndValue = () => {
|
|
cachedEndValue = endInput?.value || "";
|
|
};
|
|
|
|
const restoreEndValue = () => {
|
|
if (!endInput) {
|
|
return;
|
|
}
|
|
endInput.value = cachedEndValue;
|
|
};
|
|
|
|
const ensureEndField = () => {
|
|
if (endField) {
|
|
restoreEndValue();
|
|
return;
|
|
}
|
|
const templateField = endFieldTemplate.content.firstElementChild;
|
|
if (!templateField) {
|
|
return;
|
|
}
|
|
startField.insertAdjacentElement("afterend", templateField.cloneNode(true));
|
|
refreshEndField();
|
|
restoreEndValue();
|
|
window.bindDatetimePickerTriggers?.(endField);
|
|
};
|
|
|
|
const removeEndField = () => {
|
|
if (!endField) {
|
|
return;
|
|
}
|
|
rememberEndValue();
|
|
endField.remove();
|
|
refreshEndField();
|
|
};
|
|
|
|
const stateNodeRows = () => Array.from(stateNodeList?.querySelectorAll("[data-state-node-row]") || []);
|
|
|
|
const updateStateNodeEmpty = () => {
|
|
if (!stateNodeEmpty) {
|
|
return;
|
|
}
|
|
stateNodeEmpty.hidden = stateNodeRows().length > 0;
|
|
};
|
|
|
|
const setStateNodePanelVisible = (visible) => {
|
|
if (!stateNodePanel) {
|
|
return;
|
|
}
|
|
stateNodePanel.hidden = !visible;
|
|
for (const field of stateNodePanel.querySelectorAll("input[name], textarea[name], select[name]")) {
|
|
field.disabled = !visible;
|
|
}
|
|
if (stateNodeAddButton) {
|
|
stateNodeAddButton.disabled = !visible;
|
|
}
|
|
for (const button of stateNodePanel.querySelectorAll("[data-state-node-remove]")) {
|
|
button.disabled = !visible;
|
|
}
|
|
};
|
|
|
|
const addStateNodeRow = () => {
|
|
if (!stateNodeList || !stateNodeRowTemplate) {
|
|
return;
|
|
}
|
|
const rowMarkup = stateNodeRowTemplate.innerHTML.replaceAll("__INDEX__", String(stateNodeIndex));
|
|
stateNodeIndex += 1;
|
|
stateNodeList.insertAdjacentHTML("beforeend", rowMarkup);
|
|
const row = stateNodeList.lastElementChild;
|
|
if (row) {
|
|
window.bindDatetimePickerTriggers?.(row);
|
|
row.querySelector('input[name="state_node_title"]')?.focus();
|
|
}
|
|
updateStateNodeEmpty();
|
|
};
|
|
|
|
stateNodeAddButton?.addEventListener("click", addStateNodeRow);
|
|
stateNodeList?.addEventListener("click", (event) => {
|
|
const removeButton = event.target.closest("[data-state-node-remove]");
|
|
if (!removeButton) {
|
|
return;
|
|
}
|
|
removeButton.closest("[data-state-node-row]")?.remove();
|
|
updateStateNodeEmpty();
|
|
});
|
|
|
|
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();
|
|
})();
|
|
</script>
|
|
{% endblock %} |