feat: docking log management, sim_time DB-only, modal helpers

- Add docking_log_list and docking_log_delete routes with dedicated page
- _append_docking_log: detect existing state interval before creating new entry
- _append_undocking_log: find entry by node title+at_time, accept docked_at param
- Fix _entry_state_label and _entry_location to return time-correct values
- Add api_set_sim_time POST endpoint; remove sim_time from all url_for() calls
- _resolve_simulation_time simplified to DB-only (no URL param)
- app.js: add openModal/closeModal/toggleTheme helpers
- base.html: add 对接日志管理 nav item, remove Today button

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 16:19:32 +08:00
co-authored by Claude Opus 4.7
parent dd5210c203
commit ac5141fd02
4 changed files with 196 additions and 23 deletions
+123 -23
View File
@@ -1303,37 +1303,66 @@ def _append_docking_log(
note: str | None,
) -> None:
title = f"{child_name} docked to {parent_name}"
entry = AssetLogEntry(
entry_kind="state",
title=title,
start_at=docked_at,
summary=note,
asset=asset,
)
entry.state_nodes.append(
AssetStateNode(
title=title,
detail=note,
at_time=docked_at,
target_location=target_location,
state_label="Docked",
# Check if an existing (non-dock) state interval covers the dock time
existing = AssetLogEntry.query.filter(
AssetLogEntry.asset_id == asset.id,
AssetLogEntry.entry_kind == "state",
AssetLogEntry.start_at <= docked_at,
).filter(
(AssetLogEntry.end_at.is_(None)) | (AssetLogEntry.end_at > docked_at)
).filter(
~AssetLogEntry.title.ilike("%docked to%")
).order_by(AssetLogEntry.start_at.asc()).first()
if existing is not None:
existing.state_nodes.append(
AssetStateNode(
title=title,
detail=note,
at_time=docked_at,
target_location=target_location,
state_label="Docked",
)
)
)
db.session.add(entry)
else:
entry = AssetLogEntry(
entry_kind="state",
title=title,
start_at=docked_at,
summary=note,
asset=asset,
)
entry.state_nodes.append(
AssetStateNode(
title=title,
detail=note,
at_time=docked_at,
target_location=target_location,
state_label="Docked",
)
)
db.session.add(entry)
def _append_undocking_log(
asset: Asset,
child_name: str,
parent_name: str,
docked_at: datetime,
undocked_at: datetime,
note: str | None,
) -> None:
"""Record an undocking event in the asset's log timeline."""
dock_title = f"{child_name} docked to {parent_name}"
entry = AssetLogEntry.query.filter_by(
asset_id=asset.id, entry_kind="state", title=dock_title
).order_by(AssetLogEntry.start_at.desc()).first()
# Find the entry that contains the dock node (may not be titled 'docked to')
entry = AssetLogEntry.query.filter(
AssetLogEntry.asset_id == asset.id,
AssetLogEntry.entry_kind == "state",
AssetLogEntry.state_nodes.any(
(AssetStateNode.title == dock_title) &
(AssetStateNode.at_time == docked_at)
)
).first()
if entry is not None:
entry.state_nodes.append(AssetStateNode(
@@ -4508,18 +4537,18 @@ def api_asset_undock(asset_id: object) -> object:
_remove_undocking_log(child_asset, child_name, parent_name)
docking_event.undocked_at = undock_time
docking_event.undock_note = note
_append_undocking_log(parent_asset, child_name, parent_name, undock_time, note)
_append_undocking_log(parent_asset, child_name, parent_name, docking_event.docked_at, undock_time, note)
if child_asset is not None:
_append_undocking_log(child_asset, child_name, parent_name, undock_time, note)
_append_undocking_log(child_asset, child_name, parent_name, docking_event.docked_at, undock_time, note)
else:
if docking_event.undocked_at is not None:
raise FormValidationError("Already undocked")
docking_event.undocked_at = undock_time
docking_event.undock_note = note
if parent_asset is not None:
_append_undocking_log(parent_asset, child_name, parent_name, undock_time, note)
_append_undocking_log(parent_asset, child_name, parent_name, docking_event.docked_at, undock_time, note)
if child_asset is not None:
_append_undocking_log(child_asset, child_name, parent_name, undock_time, note)
_append_undocking_log(child_asset, child_name, parent_name, docking_event.docked_at, undock_time, note)
db.session.commit()
except FormValidationError as exc:
@@ -4534,6 +4563,65 @@ def api_asset_undock(asset_id: object) -> object:
))
@web_bp.get("/docking-logs")
def docking_log_list() -> str:
simulation_time = _resolve_simulation_time()
simulation_time_input = _format_datetime_input(simulation_time)
events = (
db.session.execute(
select(DockingEvent)
.options(selectinload(DockingEvent.parent_asset), selectinload(DockingEvent.child_asset))
.order_by(DockingEvent.docked_at.desc())
).scalars().all()
)
return render_template(
"docking_logs.html",
title="对接日志管理",
active_page="docking_logs",
events=events,
simulation_time_display=_format_datetime_display(simulation_time),
simulation_time_input=simulation_time_input,
)
@web_bp.post("/docking-logs/<uuid:event_id>/delete")
def docking_log_delete(event_id: object) -> object:
event = db.session.get(DockingEvent, _parse_uuid_value(event_id))
if event is None:
flash("对接记录未找到。", "error")
return redirect(url_for("web.docking_log_list"))
parent = db.session.get(Asset, event.parent_asset_id) if event.parent_asset_id else None
child = db.session.get(Asset, event.child_asset_id) if event.child_asset_id else None
child_name = child.name if child else (event.child_label or "External vehicle")
parent_name = parent.name if parent else "Unknown"
# Remove docking log entries from both assets
dock_title = f"{child_name} docked to {parent_name}"
for asset in [parent, child]:
if asset is None:
continue
entry = AssetLogEntry.query.filter(
AssetLogEntry.asset_id == asset.id,
AssetLogEntry.entry_kind == "state",
AssetLogEntry.state_nodes.any(
(AssetStateNode.title == dock_title) &
(AssetStateNode.at_time == event.docked_at)
)
).first()
if entry:
nodes_to_remove = [n for n in entry.state_nodes if n.title == dock_title or 'undocked' in (n.title or '')]
for n in nodes_to_remove:
db.session.delete(n)
if not entry.state_nodes:
db.session.delete(entry)
db.session.delete(event)
db.session.commit()
flash("对接记录已删除。", "success")
return redirect(url_for("web.docking_log_list"))
@web_bp.post("/assets/<uuid:asset_id>/entries/<uuid:entry_id>/delete")
def asset_entry_delete(asset_id: object, entry_id: object) -> object:
asset = _load_asset_or_404(asset_id)
@@ -4724,6 +4812,7 @@ def mission_timeline_preview() -> str:
timeline_json_data = {
"range_start": _format_datetime_input(range_start),
"range_end": _format_datetime_input(range_end),
"bucket_origin": _format_datetime_input(_timeline_bucket_start(range_start, timeline_scale_mode)),
"scale": timeline_scale_mode,
"bucket_count": len(timeline_scale),
"rows": [
@@ -4742,6 +4831,7 @@ def mission_timeline_preview() -> str:
"start_position": float(s["start_position"]),
"body_end_position": float(s["body_end_position"]),
"display_end_position": float(s["display_end_position"]),
"track": int(s.get("track", 0)),
"start_at": str(s["start_at"]),
"end_at": str(s["end_at"]),
"body_color": str(s["body_color"]),
@@ -4758,6 +4848,16 @@ def mission_timeline_preview() -> str:
"at_iso": str(e.get("at_iso", "")),
"full_label": str(e.get("full_label", e["label"])),
"is_state_node": bool(e.get("is_state_node", False)),
"track": int(e.get("track", 0)),
"parent_entry_id": str(e.get("parent_entry_id", "")),
"parent_track": next(
(
int(segment.get("track", 0))
for segment in r.get("segments", [])
if segment.get("entry_id") == e.get("parent_entry_id")
),
None,
),
}
for e in r.get("events", [])
],