feat: improve mission asset operations

This commit is contained in:
2026-05-28 11:32:11 +08:00
parent 1a8af63383
commit fee9b13ada
12 changed files with 562 additions and 134 deletions
+296 -60
View File
@@ -161,8 +161,8 @@ MISSION_STATUS_GROUPS = [
"accent": "neutral",
"items": [
{
"name": "XH-03 Zhuque",
"asset_type": "Shuttle",
"name": "XH-03 Changxi",
"asset_type": "Exploration Mothership",
"location": "Star Port",
"mission": "Refit for Jupiter relay deployment",
"state_window": "2060-01-08 to 2060-04-12",
@@ -198,8 +198,8 @@ MISSION_LOCATION_GROUPS = [
"next_event": "Crew Arrival · 2060-03-30",
},
{
"name": "XH-03 Zhuque",
"asset_type": "Shuttle",
"name": "XH-03 Changxi",
"asset_type": "Exploration Mothership",
"state": "At Port",
"mission": "Refit for Jupiter relay deployment",
"location": "Star Port",
@@ -762,12 +762,83 @@ def _parse_datetime_form_value(
return parsed_value
class FormValidationError(ValueError):
def __init__(
self,
message: str,
*,
field_errors: dict[str, str] | None = None,
messages: list[str] | None = None,
) -> None:
super().__init__(message)
self.field_errors = field_errors or {}
self.messages = messages or [message]
def _build_validation_modal(
error: Exception | str,
*,
title: str = "本次提交未保存",
field_errors: dict[str, str] | None = None,
) -> dict[str, object]:
if isinstance(error, FormValidationError):
merged_field_errors = {**error.field_errors, **(field_errors or {})}
messages = list(error.messages)
else:
merged_field_errors = field_errors or {}
messages = [str(error)]
for message in merged_field_errors.values():
if message not in messages:
messages.append(message)
return {
"title": title,
"messages": messages,
"field_errors": merged_field_errors,
}
def _collect_submitted_form_values(
form: object,
field_specs: list[dict[str, object]],
prefix: str = "",
) -> dict[str, object]:
values: dict[str, object] = {}
for field_spec in field_specs:
field_name = field_spec["name"]
full_name = f"{prefix}{field_name}"
field_kind = field_spec["kind"]
if field_kind == "checkbox":
values[field_name] = full_name in form
continue
if field_kind == "combo":
selected_value = form.get(full_name)
custom_value = form.get(f"{full_name}__custom")
if _normalized_text(custom_value) is not None:
values[field_name] = custom_value or ""
elif selected_value == CUSTOM_OPTION_VALUE:
values[field_name] = ""
else:
values[field_name] = selected_value or ""
continue
values[field_name] = form.get(full_name) or ""
return values
def _apply_fields(
target: object,
form: object,
field_specs: list[dict[str, object]],
prefix: str = "",
) -> None:
parsed_values: dict[str, object] = {}
for field_spec in field_specs:
field_name = field_spec["name"]
full_name = f"{prefix}{field_name}"
@@ -775,6 +846,7 @@ def _apply_fields(
label = field_spec["label"]
required = bool(field_spec.get("required"))
try:
if field_kind == "checkbox":
value = full_name in form
elif field_kind == "combo":
@@ -792,10 +864,16 @@ def _apply_fields(
value = _parse_integer(form.get(full_name), label)
else:
value = _normalized_text(form.get(full_name))
except ValueError as exc:
raise FormValidationError(str(exc), field_errors={field_name: str(exc)}) from exc
if required and value in (None, ""):
raise ValueError(f"{label} 不能为空")
message = f"{label} 不能为空"
raise FormValidationError(message, field_errors={field_name: message})
parsed_values[field_name] = value
for field_name, value in parsed_values.items():
setattr(target, field_name, value)
@@ -822,21 +900,31 @@ def _apply_asset_entry_fields(target: AssetLogEntry, form: object) -> None:
entry_kind = _normalized_text(form.get("entry_kind")) or "event"
allowed_kinds = {option["value"] for option in ASSET_ENTRY_KIND_OPTIONS}
if entry_kind not in allowed_kinds:
raise ValueError("Entry Type 无效")
message = "Entry Type 无效"
raise FormValidationError(message, field_errors={"entry_kind": message})
title = _normalized_text(form.get("title"))
if title is None:
raise ValueError("Title 不能为空")
message = "Title 不能为空"
raise FormValidationError(message, field_errors={"title": message})
try:
start_at = _parse_datetime_form_value(form, "start_at", "Start At", required=True)
except ValueError as exc:
raise FormValidationError(str(exc), field_errors={"start_at": str(exc)}) from exc
if start_at is None:
raise ValueError("Start At 不能为空")
message = "Start At 不能为空"
raise FormValidationError(message, field_errors={"start_at": message})
try:
end_at = _parse_datetime_form_value(form, "end_at", "End At")
except ValueError as exc:
raise FormValidationError(str(exc), field_errors={"end_at": str(exc)}) from exc
if entry_kind == "event":
end_at = None
elif end_at is not None and end_at <= start_at:
raise ValueError("End At 必须晚于 Start At")
message = "End At 必须晚于 Start At"
raise FormValidationError(message, field_errors={"start_at": message, "end_at": message})
state_node_rows = []
if entry_kind == "state":
@@ -883,6 +971,40 @@ def _asset_entry_form_values(entry: AssetLogEntry) -> dict[str, object]:
}
def _asset_entry_form_values_from_form(form: object) -> dict[str, object]:
node_ids = form.getlist("state_node_id")
node_titles = form.getlist("state_node_title")
node_details = form.getlist("state_node_detail")
node_times = form.getlist("state_node_at")
row_count = max(len(node_ids), len(node_titles), len(node_details), len(node_times))
state_nodes = []
for index in range(row_count):
state_nodes.append(
{
"id": node_ids[index] if index < len(node_ids) else "",
"title": node_titles[index] if index < len(node_titles) else "",
"detail": node_details[index] if index < len(node_details) else "",
"at": node_times[index] if index < len(node_times) else "",
}
)
return {
"entry_kind": form.get("entry_kind") or "event",
"title": form.get("title") or "",
"state_label": form.get("state_label") or "",
"mission_label": form.get("mission_label") or "",
"location": form.get("location") or "",
"start_at": form.get("start_at") or "",
"start_at_parts": {"year": "", "month": "", "day": "", "hour": "", "minute": ""},
"end_at": form.get("end_at") or "",
"end_at_parts": {"year": "", "month": "", "day": "", "hour": "", "minute": ""},
"summary": form.get("summary") or "",
"note": form.get("note") or "",
"state_nodes": state_nodes,
}
def _parse_state_node_rows(
form: object,
start_at: datetime,
@@ -901,7 +1023,8 @@ def _parse_state_node_rows(
or len(node_titles) != len(node_details)
or len(node_details) != len(node_times)
):
raise ValueError("State Node 数据不完整")
message = "State Node 数据不完整"
raise FormValidationError(message, field_errors={"state_nodes": message})
state_nodes = []
for index, (raw_id, raw_title, raw_detail, raw_at_time) in enumerate(
@@ -911,16 +1034,38 @@ def _parse_state_node_rows(
node_id = _normalized_text(raw_id)
title = _normalized_text(raw_title)
detail = _normalized_text(raw_detail)
row_index = index - 1
try:
at_time = _parse_datetime(raw_at_time, f"State Node {index} At")
except ValueError as exc:
raise FormValidationError(
str(exc),
field_errors={f"state_node_at:{row_index}": str(exc)},
) from exc
if title is None and detail is None and at_time is None:
continue
if title is None or at_time is None:
raise ValueError(f"State Node {index} 需要同时填写 Title 和 At")
message = f"State Node {index} 需要同时填写 Title 和 At"
field_errors: dict[str, str] = {}
if title is None:
field_errors[f"state_node_title:{row_index}"] = message
if at_time is None:
field_errors[f"state_node_at:{row_index}"] = message
raise FormValidationError(message, field_errors=field_errors)
if at_time < start_at:
raise ValueError(f"State Node {index} 时间不能早于 Start At")
message = f"State Node {index} 时间不能早于 Start At"
raise FormValidationError(
message,
field_errors={"start_at": message, f"state_node_at:{row_index}": message},
)
if end_at is not None and at_time > end_at:
raise ValueError(f"State Node {index} 时间不能晚于 End At")
message = f"State Node {index} 时间不能晚于 End At"
raise FormValidationError(
message,
field_errors={"end_at": message, f"state_node_at:{row_index}": message},
)
state_nodes.append(
{
@@ -943,7 +1088,8 @@ def _sync_state_nodes(target: AssetLogEntry, state_node_rows: list[dict[str, obj
if isinstance(raw_node_id, str) and raw_node_id:
node = existing_nodes.pop(raw_node_id, None)
if node is None:
raise ValueError("State Node 无效")
message = "State Node 无效"
raise FormValidationError(message, field_errors={"state_nodes": message})
else:
node = AssetStateNode()
@@ -952,6 +1098,7 @@ def _sync_state_nodes(target: AssetLogEntry, state_node_rows: list[dict[str, obj
node.at_time = row["at_time"]
next_nodes.append(node)
next_nodes.sort(key=lambda item: _as_utc(item.at_time) or _default_simulation_time())
target.state_nodes = next_nodes
@@ -1335,6 +1482,68 @@ def _entry_matches_date_range(
return start_at <= window_end and effective_end >= window_start
def _build_asset_future_events(
asset: Asset,
simulation_time: datetime,
active_state: AssetLogEntry | None,
) -> list[dict[str, object]]:
future_events: list[dict[str, object]] = []
if active_state is not None:
for state_node in sorted(
active_state.state_nodes,
key=lambda item: _as_utc(item.at_time) or simulation_time,
):
node_time = _as_utc(state_node.at_time)
if node_time is None or node_time <= simulation_time:
continue
future_events.append(
{
"sort_at": node_time,
"title": state_node.title,
"when": _format_datetime_display(state_node.at_time),
"scope": active_state.title or asset.name,
"summary": state_node.detail or "在所属 State Interval 中编辑该节点。",
"kind_label": "State Node",
"status_label": "Upcoming Node",
"state_labels": _split_multi_value_text(active_state.state_label),
"mission_labels": _split_multi_value_text(active_state.mission_label),
"source_entry_id": active_state.id,
"action_label": "编辑所属区间",
"edit_hint": "State Node 需要在所属 State Interval 中修改。",
}
)
for entry in sorted(
asset.log_entries,
key=lambda item: _as_utc(item.start_at) or _default_simulation_time(),
):
start_at = _as_utc(entry.start_at)
if start_at is None or start_at <= simulation_time:
continue
status = _classify_asset_entry(entry, simulation_time)
future_events.append(
{
"sort_at": start_at,
"title": entry.title,
"when": _format_datetime_display(entry.start_at),
"scope": entry.location or _display_multi_value_text(entry.mission_label) or asset.name,
"summary": entry.summary or "未填写摘要。",
"kind_label": "Event Point" if entry.entry_kind == "event" else "State Interval",
"status_label": status["label"],
"state_labels": _split_multi_value_text(entry.state_label),
"mission_labels": _split_multi_value_text(entry.mission_label),
"source_entry_id": entry.id,
"action_label": "编辑条目",
"edit_hint": None,
}
)
future_events.sort(key=lambda item: (item["sort_at"], str(item["title"])))
return future_events
def _build_asset_snapshot(asset: Asset, simulation_time: datetime) -> dict[str, object]:
entries = sorted(asset.log_entries, key=lambda item: _as_utc(item.start_at) or _default_simulation_time())
active_state = None
@@ -1350,14 +1559,7 @@ def _build_asset_snapshot(asset: Asset, simulation_time: datetime) -> dict[str,
last_entry = past_entries[-1] if past_entries else None
future_entries = [entry for entry in entries if (_as_utc(entry.start_at) or simulation_time) > simulation_time]
next_entry = future_entries[0] if future_entries else None
pending_state_nodes = [
node
for node in sorted(
active_state.state_nodes if active_state is not None else [],
key=lambda item: _as_utc(item.at_time) or simulation_time,
)
if (_as_utc(node.at_time) or simulation_time) > simulation_time
]
future_events = _build_asset_future_events(asset, simulation_time, active_state)
if active_state is not None:
current_state = _display_multi_value_text(active_state.state_label) or active_state.title
@@ -1389,27 +1591,8 @@ def _build_asset_snapshot(asset: Asset, simulation_time: datetime) -> dict[str,
current_event = active_state.title if active_state is not None else (last_entry.title if last_entry is not None else "No Logged Timeline")
next_event_label = "No future event recorded"
upcoming_events: list[dict[str, str]] = []
for state_node in pending_state_nodes:
upcoming_events.append(
{
"when": _format_datetime_display(state_node.at_time),
"title": state_node.title,
"scope": active_state.title if active_state is not None else asset.name,
}
)
for entry in future_entries:
upcoming_events.append(
{
"when": _format_datetime_display(entry.start_at),
"title": entry.title,
"scope": entry.location or _display_multi_value_text(entry.mission_label) or asset.name,
}
)
if upcoming_events:
next_event_label = f"{upcoming_events[0]['title']} · {upcoming_events[0]['when']}"
if future_events:
next_event_label = f"{future_events[0]['title']} · {future_events[0]['when']}"
return {
"current_state": current_state,
@@ -1421,7 +1604,8 @@ def _build_asset_snapshot(asset: Asset, simulation_time: datetime) -> dict[str,
"next_entry": next_entry,
"active_state": active_state,
"last_entry": last_entry,
"upcoming_events": upcoming_events[:4],
"future_events": future_events,
"upcoming_events": future_events[:4],
}
@@ -3257,18 +3441,28 @@ def asset_new() -> str | object:
form_options = _asset_form_options()
simulation_time = _resolve_simulation_time(request.form.get("sim_time") if request.method == "POST" else request.args.get("sim_time"))
simulation_time_input = _format_datetime_input(simulation_time)
values = _collect_form_values(asset, ASSET_FIELDS)
validation_modal: dict[str, object] | None = None
field_errors: dict[str, str] = {}
if request.method == "POST":
try:
_apply_fields(asset, request.form, ASSET_FIELDS)
db.session.add(asset)
db.session.commit()
except ValueError as exc:
except FormValidationError as exc:
db.session.rollback()
flash(str(exc), "error")
validation_modal = _build_validation_modal(exc)
field_errors = dict(validation_modal["field_errors"])
values = _collect_submitted_form_values(request.form, ASSET_FIELDS)
except IntegrityError:
db.session.rollback()
flash("创建失败:Asset 名称必须唯一。", "error")
validation_modal = _build_validation_modal(
"创建失败:Asset 名称必须唯一。",
field_errors={"name": "Asset 名称必须唯一。"},
)
field_errors = dict(validation_modal["field_errors"])
values = _collect_submitted_form_values(request.form, ASSET_FIELDS)
else:
flash("资产已创建。", "success")
return redirect(url_for("web.asset_detail", asset_id=asset.id, sim_time=simulation_time_input))
@@ -3280,9 +3474,11 @@ def asset_new() -> str | object:
heading="新增任务资产",
description=None,
fields=ASSET_FIELDS,
values=_collect_form_values(asset, ASSET_FIELDS),
values=values,
form_options=form_options,
custom_option_value=CUSTOM_OPTION_VALUE,
validation_modal=validation_modal,
field_errors=field_errors,
cancel_url=url_for("web.asset_list", sim_time=simulation_time_input),
submit_label="创建",
delete_url=None,
@@ -3299,17 +3495,27 @@ def asset_edit(asset_id: object) -> str | object:
form_options = _asset_form_options()
simulation_time = _resolve_simulation_time(request.form.get("sim_time") if request.method == "POST" else request.args.get("sim_time"))
simulation_time_input = _format_datetime_input(simulation_time)
values = _collect_form_values(asset, ASSET_FIELDS)
validation_modal: dict[str, object] | None = None
field_errors: dict[str, str] = {}
if request.method == "POST":
try:
_apply_fields(asset, request.form, ASSET_FIELDS)
db.session.commit()
except ValueError as exc:
except FormValidationError as exc:
db.session.rollback()
flash(str(exc), "error")
validation_modal = _build_validation_modal(exc)
field_errors = dict(validation_modal["field_errors"])
values = _collect_submitted_form_values(request.form, ASSET_FIELDS)
except IntegrityError:
db.session.rollback()
flash("保存失败:Asset 名称必须唯一。", "error")
validation_modal = _build_validation_modal(
"保存失败:Asset 名称必须唯一。",
field_errors={"name": "Asset 名称必须唯一。"},
)
field_errors = dict(validation_modal["field_errors"])
values = _collect_submitted_form_values(request.form, ASSET_FIELDS)
else:
flash("资产信息已保存。", "success")
return redirect(url_for("web.asset_detail", asset_id=asset.id, sim_time=simulation_time_input))
@@ -3321,9 +3527,11 @@ def asset_edit(asset_id: object) -> str | object:
heading=f"编辑 {asset.name}",
description=None,
fields=ASSET_FIELDS,
values=_collect_form_values(asset, ASSET_FIELDS),
values=values,
form_options=form_options,
custom_option_value=CUSTOM_OPTION_VALUE,
validation_modal=validation_modal,
field_errors=field_errors,
cancel_url=url_for("web.asset_detail", asset_id=asset.id, sim_time=simulation_time_input),
submit_label="保存",
delete_url=url_for("web.asset_delete", asset_id=asset.id),
@@ -3381,6 +3589,19 @@ def asset_detail(asset_id: object) -> str:
simulation_time = _resolve_simulation_time(request.args.get("sim_time"))
requested_page = request.args.get("page", default=1, type=int) or 1
snapshot = _build_asset_snapshot(asset, simulation_time)
simulation_time_input = _format_datetime_input(simulation_time)
future_events = [
{
**item,
"edit_url": url_for(
"web.asset_entry_edit",
asset_id=asset.id,
entry_id=item["source_entry_id"],
sim_time=simulation_time_input,
),
}
for item in snapshot["future_events"]
]
try:
entry_range_start = _parse_datetime(request.args.get("start"), "日志开始时间")
@@ -3447,8 +3668,9 @@ def asset_detail(asset_id: object) -> str:
preview_eyebrow="Mission Asset",
asset=asset,
asset_summary=snapshot,
future_events=future_events,
entries=paged_entries,
upcoming_events=snapshot["upcoming_events"],
upcoming_events=future_events[:4],
total_entry_count=len(asset.log_entries),
filtered_entry_count=len(entry_rows),
metrics=[
@@ -3468,7 +3690,7 @@ def asset_detail(asset_id: object) -> str:
preview_tab="asset",
board_nav_urls=_mission_ops_nav_urls(simulation_time),
simulation_time_display=_format_datetime_display(simulation_time),
simulation_time_input=_format_datetime_input(simulation_time),
simulation_time_input=simulation_time_input,
header_time_action=url_for("web.asset_detail", asset_id=asset.id),
header_time_field_id="asset-detail-header-sim-time",
header_time_submit_label="Apply Time",
@@ -3492,6 +3714,9 @@ def asset_entry_new(asset_id: object) -> str | object:
requested_kind = "event"
entry = AssetLogEntry(entry_kind=requested_kind)
form_values = _asset_entry_form_values(entry)
validation_modal: dict[str, object] | None = None
field_errors: dict[str, str] = {}
if request.method == "POST":
try:
@@ -3499,9 +3724,11 @@ def asset_entry_new(asset_id: object) -> str | object:
entry.asset = asset
db.session.add(entry)
db.session.commit()
except ValueError as exc:
except FormValidationError as exc:
db.session.rollback()
flash(str(exc), "error")
validation_modal = _build_validation_modal(exc)
field_errors = dict(validation_modal["field_errors"])
form_values = _asset_entry_form_values_from_form(request.form)
else:
flash("日志条目已新增。", "success")
return redirect(url_for("web.asset_detail", asset_id=asset.id, sim_time=simulation_time_input))
@@ -3513,8 +3740,10 @@ def asset_entry_new(asset_id: object) -> str | object:
heading=f"{asset.name} 新增日志条目",
description=None,
submit_label="创建条目",
form_values=_asset_entry_form_values(entry),
form_values=form_values,
kind_options=ASSET_ENTRY_KIND_OPTIONS,
validation_modal=validation_modal,
field_errors=field_errors,
cancel_url=url_for("web.asset_detail", asset_id=asset.id, sim_time=simulation_time_input),
delete_url=None,
simulation_time_input=simulation_time_input,
@@ -3529,14 +3758,19 @@ def asset_entry_edit(asset_id: object, entry_id: object) -> str | object:
entry = _load_asset_entry_or_404(asset.id, entry_id)
simulation_time = _resolve_simulation_time(request.form.get("sim_time") if request.method == "POST" else request.args.get("sim_time"))
simulation_time_input = _format_datetime_input(simulation_time)
form_values = _asset_entry_form_values(entry)
validation_modal: dict[str, object] | None = None
field_errors: dict[str, str] = {}
if request.method == "POST":
try:
_apply_asset_entry_fields(entry, request.form)
db.session.commit()
except ValueError as exc:
except FormValidationError as exc:
db.session.rollback()
flash(str(exc), "error")
validation_modal = _build_validation_modal(exc)
field_errors = dict(validation_modal["field_errors"])
form_values = _asset_entry_form_values_from_form(request.form)
else:
flash("日志条目已保存。", "success")
return redirect(url_for("web.asset_detail", asset_id=asset.id, sim_time=simulation_time_input))
@@ -3548,8 +3782,10 @@ def asset_entry_edit(asset_id: object, entry_id: object) -> str | object:
heading=f"编辑 {asset.name} 的日志条目",
description=None,
submit_label="保存条目",
form_values=_asset_entry_form_values(entry),
form_values=form_values,
kind_options=ASSET_ENTRY_KIND_OPTIONS,
validation_modal=validation_modal,
field_errors=field_errors,
cancel_url=url_for("web.asset_detail", asset_id=asset.id, sim_time=simulation_time_input),
delete_url=url_for("web.asset_entry_delete", asset_id=asset.id, entry_id=entry.id),
simulation_time_input=simulation_time_input,
+75
View File
@@ -281,6 +281,55 @@ html[data-theme="dark"] .theme-toggle-thumb {
border-color: var(--warning-line);
}
.form-inline-alert {
margin-bottom: 16px;
}
.field-error-shell {
position: relative;
}
.field-error-input,
.field-error-shell input:not([type="hidden"]),
.field-error-shell textarea,
.field-error-shell select {
border-color: var(--warning-line) !important;
box-shadow: 0 0 0 3px rgba(214, 138, 38, 0.12);
background: rgba(255, 248, 236, 0.92);
}
.validation-dialog {
width: min(560px, calc(100vw - 32px));
padding: 0;
border: 0;
background: transparent;
}
.validation-dialog::backdrop {
background: rgba(12, 20, 35, 0.38);
backdrop-filter: blur(6px);
}
.validation-dialog-card {
margin: 0;
padding: 20px 22px;
border-radius: 22px;
border: 1px solid var(--line);
background: color-mix(in srgb, var(--surface-strong) 92%, white 8%);
box-shadow: var(--shadow);
}
.validation-dialog-card h2 {
margin: 0;
}
.validation-dialog-list {
margin: 14px 0 0;
padding-left: 20px;
display: grid;
gap: 8px;
}
.hero-panel,
.panel,
.metric-card {
@@ -2082,6 +2131,12 @@ html[data-theme="dark"] a:focus-visible {
gap: 10px;
}
.mission-future-panel,
.mission-future-list {
display: grid;
gap: 12px;
}
.mission-upcoming-item {
padding: 12px 13px;
border-radius: 16px;
@@ -2089,6 +2144,21 @@ html[data-theme="dark"] a:focus-visible {
border: 1px solid var(--line);
}
.mission-upcoming-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.mission-future-action {
white-space: nowrap;
}
.mission-future-summary {
margin: 6px 0 0;
}
.mission-upcoming-date {
margin: 0;
color: var(--primary);
@@ -2360,6 +2430,11 @@ html[data-theme="dark"] a:focus-visible {
background: rgba(255, 255, 255, 0.82);
}
.state-node-row-error {
border-color: var(--warning-line);
box-shadow: 0 0 0 3px rgba(214, 138, 38, 0.12);
}
.state-node-row label,
.state-node-row .mission-datetime-control {
margin: 0;
+3 -3
View File
@@ -1,10 +1,10 @@
{% macro datetime_picker_field(label, name, value='', field_id=None, required=False, wrapper_class='', wrapper_id=None, label_id=None, button_label='选择') %}
{% macro datetime_picker_field(label, name, value='', field_id=None, required=False, wrapper_class='', wrapper_id=None, label_id=None, button_label='选择', error=False) %}
{% set display_value = value|replace('-', '/')|replace('T', ' ') if value else '' %}
{% set picker_id = (field_id or name) ~ '-picker' %}
<label class="mission-datetime-control {{ wrapper_class }}"{% if wrapper_id %} id="{{ wrapper_id }}"{% endif %}>
<label class="mission-datetime-control {{ wrapper_class }}{% if error %} field-error-shell{% endif %}"{% if wrapper_id %} id="{{ wrapper_id }}"{% endif %}{% if error %} data-field-error="true"{% endif %}>
<span class="field-label"{% if label_id %} id="{{ label_id }}"{% endif %}>{{ label }}</span>
<div class="mission-datetime-control-row" data-datetime-control>
<input class="mission-datetime-display-input" type="text" name="{{ name }}" value="{{ display_value }}" placeholder="YYYY/MM/DD HH:MM" autocomplete="off"{% if field_id %} id="{{ field_id }}"{% endif %}{% if required %} required{% endif %}>
<input class="mission-datetime-display-input{% if error %} field-error-input{% endif %}" type="text" name="{{ name }}" value="{{ display_value }}" placeholder="YYYY/MM/DD HH:MM" autocomplete="off"{% if field_id %} id="{{ field_id }}"{% endif %}{% if required %} required{% endif %}>
<input class="mission-datetime-picker-proxy" type="datetime-local" value="{{ value }}" id="{{ picker_id }}" tabindex="-1" aria-hidden="true">
<button class="secondary-button mission-datetime-picker-button" type="button" data-datetime-picker-target="{{ picker_id }}">{{ button_label }}</button>
</div>
+11 -1
View File
@@ -1,16 +1,26 @@
<aside class="panel mission-upcoming-panel">
<div class="panel-heading">
<p class="eyebrow">即将到来</p>
<h2>后续事件</h2>
<h2>未来事件</h2>
</div>
<div class="mission-upcoming-list">
{% if upcoming_events %}
{% for event in upcoming_events %}
<article class="mission-upcoming-item">
<div class="mission-upcoming-head">
<div>
<p class="mission-upcoming-date">{{ event.when }}</p>
<h3>{{ event.title }}</h3>
</div>
{% if event.edit_url %}
<a class="table-link mission-future-action" href="{{ event.edit_url }}">{{ event.action_label or '编辑条目' }}</a>
{% endif %}
</div>
<p class="small-muted">{{ event.scope }}</p>
{% if event.edit_hint %}
<p class="small-muted">{{ event.edit_hint }}</p>
{% endif %}
</article>
{% endfor %}
{% else %}
+50
View File
@@ -0,0 +1,50 @@
{% if validation_modal %}
<dialog class="validation-dialog" data-validation-dialog>
<form method="dialog" class="validation-dialog-card">
<p class="eyebrow">需要确认</p>
<h2>{{ validation_modal.title }}</h2>
<p class="small-muted">当前输入已保留。关闭这个窗口后,页面会高亮需要修改的位置。</p>
<ul class="validation-dialog-list">
{% for message in validation_modal.messages %}
<li>{{ message }}</li>
{% endfor %}
</ul>
<div class="button-row top-gap">
<button class="primary-button" type="submit">返回修改</button>
</div>
</form>
</dialog>
<script>
(() => {
const dialog = document.querySelector("[data-validation-dialog]");
if (!dialog) {
return;
}
const focusFirstInvalid = () => {
const control = document.querySelector(
"[data-field-error='true'] input:not([type='hidden']), [data-field-error='true'] select, [data-field-error='true'] textarea, [data-field-error='true'] button"
);
if (!control) {
return;
}
control.scrollIntoView?.({behavior: "smooth", block: "center"});
control.focus?.({preventScroll: true});
};
dialog.addEventListener("close", () => {
focusFirstInvalid();
});
if (typeof dialog.showModal === "function" && !dialog.open) {
dialog.showModal();
return;
}
dialog.setAttribute("open", "open");
})();
</script>
{% endif %}
+47 -1
View File
@@ -39,10 +39,56 @@
</div>
</section>
<section class="panel mission-future-panel">
<div class="mission-log-top">
<div class="panel-heading">
<p class="eyebrow">未来事件</p>
<h2>可编辑的未来记录</h2>
<p class="small-muted">这里汇总了未来的 Event Point、State Interval,以及当前区间里尚未发生的 State Node。</p>
</div>
<div class="mission-group-count">{{ future_events|length }} future items</div>
</div>
{% if future_events %}
<div class="mission-future-list">
{% for item in future_events %}
<article class="mission-upcoming-item mission-future-item">
<div class="mission-upcoming-head">
<div>
<p class="mission-upcoming-date">{{ item.when }}</p>
<h3>{{ item.title }}</h3>
<p class="small-muted">{{ item.kind_label }} · {{ item.status_label }}</p>
</div>
<a class="table-link mission-future-action" href="{{ item.edit_url }}">{{ item.action_label }}</a>
</div>
<p class="small-muted">{{ item.scope }}</p>
<p class="small-muted mission-future-summary">{{ item.summary }}</p>
{% if item.edit_hint %}
<p class="small-muted">{{ item.edit_hint }}</p>
{% endif %}
{% if item.state_labels or item.mission_labels %}
<div class="mission-asset-chip-row mission-entry-label-row">
{% for label in item.state_labels %}<span class="chip">{{ label }}</span>{% endfor %}
{% for label in item.mission_labels %}<span class="chip">{{ label }}</span>{% endfor %}
</div>
{% endif %}
</article>
{% endfor %}
</div>
{% 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>
<p class="eyebrow">全部日志</p>
<h2>资产日志</h2>
</div>
<div class="mission-group-count">{{ filtered_entry_count }} / {{ total_entry_count }} entries</div>
+33 -27
View File
@@ -3,19 +3,22 @@
{% block content %}
{% include "_mission_ops_tabs.html" %}
{% macro render_end_at_field(end_at_value) %}
{{ 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') }}
{% 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) %}
<div class="state-node-row" data-state-node-row>
{% 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>
<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 type="text" name="state_node_title" value="{{ node.title if node else '' }}" placeholder="State Node Title">
<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') }}
{{ 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>
@@ -43,69 +46,70 @@
<input type="text" value="{{ asset.name }}" disabled>
</label>
<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">
<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>
<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 type="text" name="location" value="{{ form_values.location }}">
<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">
<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 type="text" name="title" value="{{ form_values.title }}" required>
<input class="{% if field_error_map.get('title') %}field-error-input{% endif %}" type="text" name="title" value="{{ form_values.title }}" required>
</label>
<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 type="text" name="state_label" value="{{ form_values.state_label }}">
<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">
<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 type="text" name="mission_label" value="{{ form_values.mission_label }}">
<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') }}
{{ 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) }}
{{ render_end_at_field(form_values.end_at, field_error_map.get('end_at')) }}
{% endif %}
<div class="field-span-three state-node-panel" id="state-node-panel" {% if is_event_kind %}hidden{% 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) }}
{{ 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">
<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 name="summary" rows="4">{{ form_values.summary }}</textarea>
<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">
<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 name="note" rows="4">{{ form_values.note }}</textarea>
<textarea class="{% if field_error_map.get('note') %}field-error-input{% endif %}" name="note" rows="4">{{ form_values.note }}</textarea>
</label>
</div>
@@ -127,13 +131,15 @@
{% endif %}
<template id="entry-end-at-template">
{{ render_end_at_field(form_values.end_at) }}
{{ render_end_at_field(form_values.end_at, false) }}
</template>
<template id="state-node-row-template">
{{ render_state_node_row(none, '__INDEX__') }}
{{ render_state_node_row(none, '__INDEX__', {}) }}
</template>
{% include "_validation_dialog.html" %}
<script>
(() => {
const kindField = document.getElementById("entry-kind-select");
+12 -7
View File
@@ -11,6 +11,8 @@
</div>
</section>
{% set field_error_map = field_errors|default({}, true) %}
<form method="post" class="edit-layout">
{% set hidden_field_values = hidden_fields|default({}, true) %}
{% for name, value in hidden_field_values.items() %}
@@ -22,13 +24,14 @@
<div class="form-grid resource-form-grid">
{% for field in fields %}
{% set current_value = values.get(field.name) %}
{% set has_error = field_error_map.get(field.name) %}
{% if field.kind == 'textarea' %}
<label class="field-span-three">
<label class="field-span-three{% if has_error %} field-error-shell{% endif %}"{% if has_error %} data-field-error="true"{% endif %}>
<span class="field-label">{{ field.label }}</span>
<textarea name="{{ field.name }}" rows="4">{{ current_value if current_value is not none else '' }}</textarea>
<textarea class="{% if has_error %}field-error-input{% endif %}" name="{{ field.name }}" rows="4">{{ current_value if current_value is not none else '' }}</textarea>
</label>
{% elif field.kind == 'checkbox' %}
<label class="checkbox-row">
<label class="checkbox-row{% if has_error %} field-error-shell{% endif %}"{% if has_error %} data-field-error="true"{% endif %}>
<input type="checkbox" name="{{ field.name }}" {% if current_value %}checked{% endif %}>
<span>{{ field.label }}</span>
</label>
@@ -36,9 +39,9 @@
{% set normalized_value = current_value if current_value is not none else '' %}
{% set options = option_map.get(field.name, []) %}
{% set custom_value = '' if not normalized_value or normalized_value in options else normalized_value %}
<label>
<label class="{% if has_error %}field-error-shell{% endif %}"{% if has_error %} data-field-error="true"{% endif %}>
<span class="field-label">{{ field.label }}</span>
<select name="{{ field.name }}" {% if field.required and not custom_value %}required{% endif %}>
<select class="{% if has_error %}field-error-input{% endif %}" name="{{ field.name }}" {% if field.required and not custom_value %}required{% endif %}>
<option value="">请选择</option>
{% for option in options %}
<option value="{{ option }}" {% if option == normalized_value %}selected{% endif %}>{{ option }}</option>
@@ -46,7 +49,7 @@
<option value="{{ combo_custom_option_value }}" {% if custom_value %}selected{% endif %}>新增选项</option>
</select>
<input
class="combo-custom-input"
class="combo-custom-input{% if has_error %} field-error-input{% endif %}"
type="text"
name="{{ field.name }}__custom"
value="{{ custom_value }}"
@@ -54,9 +57,10 @@
>
</label>
{% else %}
<label>
<label class="{% if has_error %}field-error-shell{% endif %}"{% if has_error %} data-field-error="true"{% endif %}>
<span class="field-label">{{ field.label }}</span>
<input
class="{% if has_error %}field-error-input{% endif %}"
type="text"
name="{{ field.name }}"
value="{{ current_value if current_value is not none else '' }}"
@@ -86,4 +90,5 @@
</form>
</section>
{% endif %}
{% include "_validation_dialog.html" %}
{% endblock %}
+2 -2
View File
@@ -126,8 +126,8 @@
<article class="location-item">
<div class="card-top">
<div>
<h3>XH-03 Zhuque</h3>
<p class="asset-type">Shuttle</p>
<h3>XH-03 Changxi</h3>
<p class="asset-type">Exploration Mothership</p>
</div>
<span class="status-pill">At Port</span>
</div>
+7 -7
View File
@@ -9,7 +9,7 @@ const OVERVIEW_EVENTS = [
{ when: "2060-03-29 18:00", title: "XH-02 Taibai arrives at Mars", scope: "Mars System" },
{ when: "2060-03-30 08:00", title: "Expedition 16 crew arrives via Amalthea Crew Vehicle", scope: "Lingxiao Station" },
{ when: "2060-04-02 09:00", title: "Expedition 15 crew departs Lingxiao Station", scope: "Lingxiao Station" },
{ when: "2060-04-12 14:00", title: "XH-03 Zhuque rollout review", scope: "Star Port" },
{ when: "2060-04-12 14:00", title: "XH-03 Changxi rollout review", scope: "Star Port" },
].map((event) => ({
...event,
date: parseDateTime(event.when),
@@ -47,8 +47,8 @@ const OVERVIEW_ACTIVE_OPERATIONS = [
note: "Return trash loading remains before departure.",
},
{
asset: "XH-03 Zhuque",
assetType: "Shuttle",
asset: "XH-03 Changxi",
assetType: "Exploration Mothership",
location: "Star Port",
mission: "Jupiter relay refit",
start: "2060-01-08T09:00",
@@ -91,8 +91,8 @@ const OVERVIEW_ATTENTION_ITEMS = [
summary: "Review capture burn, cargo handoff staging, and surface drop order.",
},
{
title: "Approve XH-03 Zhuque rollout readiness package",
asset: "XH-03 Zhuque",
title: "Approve XH-03 Changxi rollout readiness package",
asset: "XH-03 Changxi",
priority: "medium",
due: "2060-04-12T14:00",
summary: "Complete avionics validation and relay payload handling plan.",
@@ -104,13 +104,13 @@ const TIMELINE_ASSET_CATALOG = [
{ id: "lingxiao", name: "Lingxiao Station", type: "Station", region: "Earth System", note: "Crew rotation hub" },
{ id: "campus-alpha", name: "Mars One Campus Alpha", type: "Surface Outpost", region: "Mars System", note: "Surface habitat expansion" },
{ id: "amalthea-4", name: "Amalthea Crew Vehicle 4", type: "Shuttle", region: "Earth System", note: "Crew rotation shuttle" },
{ id: "zhuque", name: "XH-03 Zhuque", type: "Shuttle", region: "Earth System", note: "Relay deployment refit" },
{ id: "changxi", name: "XH-03 Changxi", type: "Exploration Mothership", region: "Earth System", note: "Relay deployment refit" },
{ id: "tianhe-7", name: "Tianhe Cargo Pod 7", type: "Cargo Shuttle", region: "Earth System", note: "Station logistics shuttle" },
];
const TIMELINE_PRESETS = {
active: ["taibai", "lingxiao", "campus-alpha"],
earth: ["lingxiao", "amalthea-4", "zhuque", "tianhe-7"],
earth: ["lingxiao", "amalthea-4", "changxi", "tianhe-7"],
mars: ["taibai", "campus-alpha"],
crew: ["lingxiao", "amalthea-4", "taibai"],
};
+2 -2
View File
@@ -274,8 +274,8 @@
<article class="status-card soft">
<div class="card-top">
<div>
<h3>XH-03 Zhuque</h3>
<p class="asset-type">Shuttle</p>
<h3>XH-03 Changxi</h3>
<p class="asset-type">Exploration Mothership</p>
</div>
<span class="tone-pill active">Confirmed</span>
</div>
+3 -3
View File
@@ -249,10 +249,10 @@
</div>
</div>
<div class="timeline-row is-hidden" data-asset="zhuque">
<div class="timeline-row is-hidden" data-asset="changxi">
<article class="timeline-row-label">
<h3>XH-03 Zhuque</h3>
<p>Shuttle</p>
<h3>XH-03 Changxi</h3>
<p>Exploration Mothership</p>
</article>
<div class="timeline-lane">
<div class="timeline-segment hold" style="grid-column: 64 / span 22;">Port Refit</div>