diff --git a/app/routes/web.py b/app/routes/web.py index 86c28c8..3aabb0f 100644 --- a/app/routes/web.py +++ b/app/routes/web.py @@ -8,14 +8,15 @@ from math import ceil from pathlib import Path import re from typing import Any +import uuid from flask import Blueprint, abort, flash, has_request_context, redirect, render_template, request, session, url_for -from sqlalchemy import select +from sqlalchemy import select, text, distinct from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import selectinload from app.extensions import db -from app.models import Asset, AssetLogEntry, AssetStateNode, CommunicationPart, EngineFamily, EngineVariant, TankSpec, VehicleCost +from app.models import Asset, AssetLogEntry, AssetStateNode, CommunicationPart, DockingEvent, EngineFamily, EngineVariant, TankSpec, VehicleCost from app.services.fuel_conversion import list_fuel_factors from app.services.log_book_importer import import_log_book_data @@ -663,8 +664,57 @@ def _parse_datetime(raw_value: str | None, label: str) -> datetime | None: return _as_utc(parsed) +HARDCODED_SIMULATION_TIME = datetime(2060, 3, 12, 9, 0, tzinfo=timezone.utc) + + +def _db_read_simulation_time() -> datetime | None: + try: + row = db.session.execute( + text("SELECT value FROM simulation_settings WHERE key = 'current_time'") + ).fetchone() + if row is None: + return None + return _parse_datetime(row[0], "DB Simulation Time") + except Exception: + return None + + +def _db_write_simulation_time(value: datetime) -> None: + formatted = _format_datetime_input(value) + try: + db.session.execute( + text( + "INSERT INTO simulation_settings (key, value, updated_at) " + "VALUES ('current_time', :val, NOW()) " + "ON CONFLICT (key) DO UPDATE SET value = :val2, updated_at = NOW()" + ), + {"val": formatted, "val2": formatted}, + ) + db.session.commit() + except Exception: + db.session.rollback() + + def _default_simulation_time() -> datetime: - return datetime(2060, 3, 12, 9, 0, tzinfo=timezone.utc) + db_time = _db_read_simulation_time() + if db_time is not None: + return db_time + return HARDCODED_SIMULATION_TIME + + +def _all_state_labels() -> list[str]: + """Return merged state labels: defaults + all unique labels from DB.""" + defaults = ['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'] + db_labels = ( + db.session.execute( + select(distinct(AssetStateNode.state_label)).where(AssetStateNode.state_label.isnot(None)) + ).scalars().all() + ) + merged = {*defaults, *db_labels} + return sorted(merged, key=str.casefold) def _remember_simulation_time(value: datetime) -> None: @@ -680,15 +730,11 @@ def _saved_simulation_time_value() -> str | None: return saved_value if isinstance(saved_value, str) else None -def _resolve_simulation_time(raw_value: str | None) -> datetime: - try: - parsed_value = _parse_datetime(raw_value, "Simulation Time") - except ValueError: - parsed_value = None - - if parsed_value is not None: - _remember_simulation_time(parsed_value) - return parsed_value +def _resolve_simulation_time(raw_value: str | None = None) -> datetime: + db_time = _db_read_simulation_time() + if db_time is not None: + _remember_simulation_time(db_time) + return db_time saved_value = _saved_simulation_time_value() if saved_value is not None: @@ -699,7 +745,7 @@ def _resolve_simulation_time(raw_value: str | None) -> datetime: if saved_datetime is not None: return saved_datetime - default_value = _default_simulation_time() + default_value = HARDCODED_SIMULATION_TIME _remember_simulation_time(default_value) return default_value @@ -929,30 +975,82 @@ def _apply_asset_entry_fields(target: AssetLogEntry, form: object) -> None: state_node_rows = [] if entry_kind == "state": state_node_rows = _parse_state_node_rows(form, start_at, end_at) + else: + state_node_rows = _event_location_state_node_rows(target, form.get("location"), title, start_at) target.entry_kind = entry_kind target.title = title - target.state_label = _normalize_multi_value_text(form.get("state_label")) target.mission_label = _normalize_multi_value_text(form.get("mission_label")) - target.location = _normalized_text(form.get("location")) target.start_at = start_at target.end_at = end_at target.summary = _normalized_text(form.get("summary")) target.note = _normalized_text(form.get("note")) - if target.entry_kind == "state" and target.state_label is None: - target.state_label = title + _sync_state_nodes(target, state_node_rows) - _sync_state_nodes(target, state_node_rows if entry_kind == "state" else []) + +def _apply_asset_entry_json_fields(target: AssetLogEntry, data: object) -> None: + if not isinstance(data, dict): + message = "Payload invalid" + raise FormValidationError(message, field_errors={"payload": message}) + + entry_kind = _normalized_text(data.get("entry_kind")) or target.entry_kind or "event" + allowed_kinds = {option["value"] for option in ASSET_ENTRY_KIND_OPTIONS} + if entry_kind not in allowed_kinds: + message = "Entry Type invalid" + raise FormValidationError(message, field_errors={"entry_kind": message}) + + title = _normalized_text(data.get("title")) or target.title + if title is None: + message = "Title required" + raise FormValidationError(message, field_errors={"title": message}) + + try: + start_at = _parse_datetime(data.get("start_at") or data.get("sim_time"), "Start At") + except ValueError as exc: + raise FormValidationError(str(exc), field_errors={"start_at": str(exc)}) from exc + if start_at is None: + start_at = target.start_at + if start_at is None: + message = "Start At required" + raise FormValidationError(message, field_errors={"start_at": message}) + + if "end_at" in data: + try: + end_at = _parse_datetime(data.get("end_at"), "End At") + except ValueError as exc: + raise FormValidationError(str(exc), field_errors={"end_at": str(exc)}) from exc + else: + end_at = target.end_at + if entry_kind == "event": + end_at = None + elif end_at is not None and end_at <= start_at: + message = "End At must be later than Start At" + raise FormValidationError(message, field_errors={"start_at": message, "end_at": message}) + + state_node_rows = [] + if entry_kind == "state": + state_node_rows = _parse_json_state_node_rows(data, start_at, end_at) + else: + state_node_rows = _event_location_state_node_rows(target, data.get("location"), title, start_at) + + target.entry_kind = entry_kind + target.title = title + target.mission_label = _normalize_multi_value_text(data.get("mission_label")) + target.start_at = start_at + target.end_at = end_at + target.summary = _normalized_text(data.get("summary")) + target.note = _normalized_text(data.get("note")) + + _sync_state_nodes(target, state_node_rows) def _asset_entry_form_values(entry: AssetLogEntry) -> dict[str, object]: return { "entry_kind": entry.entry_kind or "event", "title": entry.title or "", - "state_label": entry.state_label or "", "mission_label": entry.mission_label or "", - "location": entry.location or "", + "location": _entry_location(entry) or "", "start_at": _format_datetime_input(entry.start_at), "start_at_parts": _datetime_input_parts(entry.start_at), "end_at": _format_datetime_input(entry.end_at), @@ -965,6 +1063,10 @@ def _asset_entry_form_values(entry: AssetLogEntry) -> dict[str, object]: "title": node.title or "", "detail": node.detail or "", "at": _format_datetime_input(node.at_time), + "target_location": node.target_location or "", + "previous_location": node.previous_location or "", + "transit_location": node.transit_location or "", + "state_label": node.state_label or "", } for node in sorted(entry.state_nodes, key=lambda item: _as_utc(item.at_time) or _default_simulation_time()) ], @@ -976,6 +1078,10 @@ def _asset_entry_form_values_from_form(form: object) -> dict[str, object]: node_titles = form.getlist("state_node_title") node_details = form.getlist("state_node_detail") node_times = form.getlist("state_node_at") + node_locations = form.getlist("state_node_target_location") + node_prev_locations = form.getlist("state_node_previous_location") + node_transit_locations = form.getlist("state_node_transit_location") + node_state_labels = form.getlist("state_node_state_label") row_count = max(len(node_ids), len(node_titles), len(node_details), len(node_times)) state_nodes = [] @@ -986,15 +1092,19 @@ def _asset_entry_form_values_from_form(form: object) -> dict[str, object]: "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 "", + "target_location": node_locations[index] if index < len(node_locations) else "", + "previous_location": node_prev_locations[index] if index < len(node_prev_locations) else "", + "transit_location": node_transit_locations[index] if index < len(node_transit_locations) else "", + "state_label": node_state_labels[index] if index < len(node_state_labels) else "", } ) + node_locations_2 = form.getlist("location") 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 "", + "location": node_locations_2[0] if node_locations_2 else "", "start_at": form.get("start_at") or "", "start_at_parts": {"year": "", "month": "", "day": "", "hour": "", "minute": ""}, "end_at": form.get("end_at") or "", @@ -1014,6 +1124,10 @@ def _parse_state_node_rows( node_titles = form.getlist("state_node_title") node_details = form.getlist("state_node_detail") node_times = form.getlist("state_node_at") + node_locations = form.getlist("state_node_target_location") + node_prev_locations = form.getlist("state_node_previous_location") + node_transit_locations = form.getlist("state_node_transit_location") + node_state_labels = form.getlist("state_node_state_label") if not node_ids and not node_titles and not node_details and not node_times: return [] @@ -1028,57 +1142,251 @@ def _parse_state_node_rows( state_nodes = [] for index, (raw_id, raw_title, raw_detail, raw_at_time) in enumerate( - zip(node_ids, node_titles, node_details, node_times), - start=1, + zip(node_ids, node_titles, node_details, node_times) ): node_id = _normalized_text(raw_id) title = _normalized_text(raw_title) detail = _normalized_text(raw_detail) - row_index = index - 1 + display_index = index + 1 try: - at_time = _parse_datetime(raw_at_time, f"State Node {index} At") + at_time = _parse_datetime(raw_at_time, f"State Node {display_index} At") except ValueError as exc: raise FormValidationError( str(exc), - field_errors={f"state_node_at:{row_index}": str(exc)}, + field_errors={f"state_node_at:{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: - message = f"State Node {index} 需要同时填写 Title 和 At" + message = f"State Node {display_index} 需要同时填写 Title 和 At" field_errors: dict[str, str] = {} if title is None: - field_errors[f"state_node_title:{row_index}"] = message + field_errors[f"state_node_title:{index}"] = message if at_time is None: - field_errors[f"state_node_at:{row_index}"] = message + field_errors[f"state_node_at:{index}"] = message raise FormValidationError(message, field_errors=field_errors) if at_time < start_at: - message = f"State Node {index} 时间不能早于 Start At" + message = f"State Node {display_index} 时间不能早于 Start At" raise FormValidationError( message, - field_errors={"start_at": message, f"state_node_at:{row_index}": message}, + field_errors={"start_at": message, f"state_node_at:{index}": message}, ) if end_at is not None and at_time > end_at: - message = f"State Node {index} 时间不能晚于 End At" + message = f"State Node {display_index} 时间不能晚于 End At" raise FormValidationError( message, - field_errors={"end_at": message, f"state_node_at:{row_index}": message}, + field_errors={"end_at": message, f"state_node_at:{index}": message}, ) + location = _normalized_text(node_locations[index]) if index < len(node_locations) else None + prev_loc = _normalized_text(node_prev_locations[index]) if index < len(node_prev_locations) else None + transit_loc = _normalized_text(node_transit_locations[index]) if index < len(node_transit_locations) else None + state_label = _normalize_multi_value_text(node_state_labels[index]) if index < len(node_state_labels) else None state_nodes.append( { "id": node_id, "title": title, "detail": detail, "at_time": at_time, + "target_location": location, + "previous_location": prev_loc, + "transit_location": transit_loc, + "state_label": state_label, } ) return state_nodes +def _parse_json_state_node_rows( + data: dict[str, object], + start_at: datetime, + end_at: datetime | None, +) -> list[dict[str, object]]: + raw_nodes = data.get("state_nodes") or [] + if not isinstance(raw_nodes, list): + message = "State Nodes invalid" + raise FormValidationError(message, field_errors={"state_nodes": message}) + + state_nodes = [] + for index, raw_node in enumerate(raw_nodes): + if not isinstance(raw_node, dict): + message = f"State Node {index + 1} invalid" + raise FormValidationError(message, field_errors={"state_nodes": message}) + + title = _normalized_text(raw_node.get("title")) + detail = _normalized_text(raw_node.get("detail")) + try: + at_time = _parse_datetime(raw_node.get("at_time") or raw_node.get("at"), f"State Node {index + 1} At") + except ValueError as exc: + raise FormValidationError( + str(exc), + field_errors={f"state_node_at:{index}": str(exc)}, + ) from exc + if at_time is None: + at_time = start_at + + if title is None and detail is None: + continue + if title is None: + message = f"State Node {index + 1} Title required" + raise FormValidationError(message, field_errors={f"state_node_title:{index}": message}) + if at_time < start_at: + message = f"State Node {index + 1} cannot be earlier than Start At" + raise FormValidationError( + message, + field_errors={"start_at": message, f"state_node_at:{index}": message}, + ) + if end_at is not None and at_time > end_at: + message = f"State Node {index + 1} cannot be later than End At" + raise FormValidationError( + message, + field_errors={"end_at": message, f"state_node_at:{index}": message}, + ) + + target_location = _normalized_text( + raw_node.get("target_location") or raw_node.get("location") or data.get("location") + ) + state_label = _normalize_multi_value_text(raw_node.get("state_label") or data.get("state_label")) + state_nodes.append( + { + "id": _normalized_text(raw_node.get("id")), + "title": title, + "detail": detail, + "at_time": at_time, + "target_location": target_location, + "previous_location": _normalized_text(raw_node.get("previous_location")), + "transit_location": _normalized_text(raw_node.get("transit_location")), + "state_label": state_label, + } + ) + + return state_nodes + + +def _event_location_state_node_rows( + target: AssetLogEntry, + raw_location: object, + title: str, + start_at: datetime, +) -> list[dict[str, object]]: + location = _normalized_text(raw_location) + if location is None: + return [] + + existing_node_id = "" + if target.state_nodes: + existing_node_id = str(sorted(target.state_nodes, key=lambda item: _as_utc(item.at_time) or start_at)[0].id) + + return [ + { + "id": existing_node_id, + "title": title, + "detail": None, + "at_time": start_at, + "target_location": location, + "previous_location": None, + "transit_location": None, + "state_label": None, + } + ] + + +def _append_docking_log( + asset: Asset, + child_name: str, + parent_name: str, + target_location: str, + docked_at: datetime, + 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", + ) + ) + db.session.add(entry) + + +def _append_undocking_log( + asset: Asset, + child_name: str, + parent_name: str, + 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() + + if entry is not None: + entry.state_nodes.append(AssetStateNode( + title=f"{child_name} undocked from {parent_name}", + detail=note, + at_time=undocked_at, + state_label="Docked", + )) + if entry.end_at is None: + entry.end_at = undocked_at + else: + undock_title = f"{child_name} undocked from {parent_name}" + entry = AssetLogEntry( + entry_kind="event", + title=undock_title, + start_at=undocked_at, + summary=note, + asset=asset, + ) + db.session.add(entry) + + +def _remove_undocking_log( + asset: Asset, + child_name: str, + parent_name: str, +) -> None: + """Remove undock state nodes and reopen the dock entry.""" + dock_title = f"{child_name} docked to {parent_name}" + undock_title = f"{child_name} undocked from {parent_name}" + entry = AssetLogEntry.query.filter_by( + asset_id=asset.id, entry_kind="state", title=dock_title + ).order_by(AssetLogEntry.start_at.desc()).first() + + if entry is not None: + nodes_to_remove = [n for n in entry.state_nodes if n.title == undock_title] + for node in nodes_to_remove: + db.session.delete(node) + entry.end_at = None + + +def _parse_uuid_value(raw_value: object) -> uuid.UUID | None: + if isinstance(raw_value, uuid.UUID): + return raw_value + cleaned = _normalized_text(str(raw_value)) if raw_value is not None else None + if cleaned is None: + return None + try: + return uuid.UUID(cleaned) + except ValueError: + return None + + def _sync_state_nodes(target: AssetLogEntry, state_node_rows: list[dict[str, object]]) -> None: existing_nodes = {str(node.id): node for node in target.state_nodes if node.id is not None} next_nodes = [] @@ -1096,6 +1404,10 @@ def _sync_state_nodes(target: AssetLogEntry, state_node_rows: list[dict[str, obj node.title = str(row["title"]) node.detail = row["detail"] node.at_time = row["at_time"] + node.target_location = row.get("target_location") + node.previous_location = row.get("previous_location") + node.transit_location = row.get("transit_location") + node.state_label = row.get("state_label") next_nodes.append(node) next_nodes.sort(key=lambda item: _as_utc(item.at_time) or _default_simulation_time()) @@ -1506,7 +1818,7 @@ def _build_asset_future_events( "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), + "state_labels": _split_multi_value_text(_entry_state_label(active_state, simulation_time)), "mission_labels": _split_multi_value_text(active_state.mission_label), "source_entry_id": active_state.id, "action_label": "编辑所属区间", @@ -1528,11 +1840,11 @@ def _build_asset_future_events( "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, + "scope": _entry_location(entry, simulation_time) 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), + "state_labels": _split_multi_value_text(_entry_state_label(entry, simulation_time)), "mission_labels": _split_multi_value_text(entry.mission_label), "source_entry_id": entry.id, "action_label": "编辑条目", @@ -1544,7 +1856,96 @@ def _build_asset_future_events( return future_events -def _build_asset_snapshot(asset: Asset, simulation_time: datetime) -> dict[str, object]: +def _entry_location(entry: AssetLogEntry, simulation_time: datetime | None = None) -> str | None: + """Derive location from the most recent state node at or before simulation_time.""" + sim = simulation_time or _default_simulation_time() + if entry.state_nodes: + sorted_nodes = sorted(entry.state_nodes, key=lambda n: _as_utc(n.at_time) or _default_simulation_time()) + for node in reversed(sorted_nodes): + node_at = _as_utc(node.at_time) + if node_at and node_at <= sim and node.target_location: + return node.target_location + return None + + +def _entry_state_label(entry: AssetLogEntry, simulation_time: datetime | None = None) -> str | None: + """Derive state_label from the most recent state node at or before simulation_time.""" + sim = simulation_time or _default_simulation_time() + if entry.state_nodes: + sorted_nodes = sorted(entry.state_nodes, key=lambda n: _as_utc(n.at_time) or _default_simulation_time()) + for node in reversed(sorted_nodes): + node_at = _as_utc(node.at_time) + if node_at and node_at <= sim and node.state_label: + return node.state_label + return None + + +def _is_docking_event_active(event: DockingEvent, simulation_time: datetime) -> bool: + docked_at = _as_utc(event.docked_at) + undocked_at = _as_utc(event.undocked_at) + return bool(docked_at and docked_at <= simulation_time and (undocked_at is None or simulation_time < undocked_at)) + + +def _asset_summary_link(asset: Asset, simulation_time: datetime) -> dict[str, object]: + snapshot = _build_asset_snapshot(asset, simulation_time, include_docking=False) + return { + "id": asset.id, + "name": asset.name, + "current_location": snapshot["current_location"], + } + + +def _build_docking_summary(asset: Asset, simulation_time: datetime) -> dict[str, object]: + target_events = db.session.execute( + select(DockingEvent) + .options(selectinload(DockingEvent.parent_asset)) + .where(DockingEvent.child_asset_id == asset.id) + .order_by(DockingEvent.docked_at.desc()) + ).scalars().all() + target_event = next( + (event for event in target_events if _is_docking_event_active(event, simulation_time)), + None, + ) + + child_events = [ + event + for event in db.session.execute( + select(DockingEvent) + .options(selectinload(DockingEvent.child_asset)) + .where(DockingEvent.parent_asset_id == asset.id) + .order_by(DockingEvent.docked_at.desc()) + ).scalars().all() + if _is_docking_event_active(event, simulation_time) + ] + + docking_target = None + if target_event is not None and target_event.parent_asset is not None: + docking_target = { + **_asset_summary_link(target_event.parent_asset, simulation_time), + "docked_at": _format_datetime_display(target_event.docked_at), + "event_id": str(target_event.id), + "undocked_at": _format_datetime_input(target_event.undocked_at) if target_event.undocked_at else None, + } + + docked_vehicles = [] + for event in child_events: + if event.child_asset is not None: + item = _asset_summary_link(event.child_asset, simulation_time) + else: + item = {"id": None, "name": event.child_label or "External vehicle", "current_location": "-"} + item["docked_at"] = _format_datetime_display(event.docked_at) + item["event_id"] = str(event.id) + item["undocked_at"] = _format_datetime_input(event.undocked_at) if event.undocked_at else None + docked_vehicles.append(item) + + return { + "docking_target": docking_target, + "docked_vehicles": docked_vehicles, + } + + +def _build_asset_snapshot(asset: Asset, simulation_time: datetime, *, include_docking: bool = True) -> dict[str, object]: + simulation_time = _as_utc(simulation_time) or _default_simulation_time() entries = sorted(asset.log_entries, key=lambda item: _as_utc(item.start_at) or _default_simulation_time()) active_state = None for entry in entries: @@ -1562,14 +1963,14 @@ def _build_asset_snapshot(asset: Asset, simulation_time: datetime) -> dict[str, 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 - current_location = active_state.location or asset.home_region or "-" + current_state = _display_multi_value_text(_entry_state_label(active_state, simulation_time)) or active_state.title + current_location = _entry_location(active_state, simulation_time) or asset.home_region or "-" current_mission = _display_multi_value_text(active_state.mission_label) or asset.program or "-" state_window = f"{_format_datetime_display(active_state.start_at)} → {_format_datetime_display(active_state.end_at) if active_state.end_at else 'Open'}" else: current_state = "No Active Interval" if last_entry is not None else "No Logged Timeline" current_location = ( - (last_entry.location if last_entry is not None else None) + (_entry_location(last_entry, simulation_time) if last_entry is not None else None) or asset.home_region or "-" ) @@ -1594,7 +1995,7 @@ def _build_asset_snapshot(asset: Asset, simulation_time: datetime) -> dict[str, if future_events: next_event_label = f"{future_events[0]['title']} · {future_events[0]['when']}" - return { + snapshot = { "current_state": current_state, "current_event": current_event, "current_location": current_location, @@ -1607,6 +2008,11 @@ def _build_asset_snapshot(asset: Asset, simulation_time: datetime) -> dict[str, "future_events": future_events, "upcoming_events": future_events[:4], } + if include_docking: + snapshot.update(_build_docking_summary(asset, simulation_time)) + else: + snapshot.update({"docking_target": None, "docked_vehicles": []}) + return snapshot def _build_asset_catalog_row(asset: Asset, simulation_time: datetime) -> dict[str, object]: @@ -1834,7 +2240,7 @@ def _build_board_upcoming_events(rows: list[dict[str, object]], simulation_time: { "when": _format_datetime_display(entry.start_at), "title": f"{asset.name} · {entry.title}", - "scope": entry.location or row["location"], + "scope": _entry_location(entry) or row["location"], "sort_at": start_at, } ) @@ -1947,13 +2353,17 @@ def _mission_board_context( "minus_7": url_for(endpoint, **{**query_args, "sim_time": _format_datetime_input(simulation_time - timedelta(days=7))}), "plus_7": url_for(endpoint, **{**query_args, "sim_time": _format_datetime_input(simulation_time + timedelta(days=7))}), }, - "reset_url": url_for(endpoint, sim_time=_format_datetime_input(simulation_time)), + "reset_url": url_for(endpoint +), "board_nav_urls": { "status": url_for("web.mission_status_board_preview", **query_args), "location": url_for("web.mission_location_board_preview", **query_args), - "mission": url_for("web.mission_list", sim_time=_format_datetime_input(simulation_time)), - "asset": url_for("web.asset_list", sim_time=_format_datetime_input(simulation_time)), - "timeline": url_for("web.mission_timeline_preview", sim_time=_format_datetime_input(simulation_time)), + "mission": url_for("web.mission_list" +), + "asset": url_for("web.asset_list" +), + "timeline": url_for("web.mission_timeline_preview" +), }, } @@ -1961,11 +2371,16 @@ def _mission_board_context( def _mission_ops_nav_urls(simulation_time: datetime) -> dict[str, str]: simulation_time_input = _format_datetime_input(simulation_time) return { - "status": url_for("web.mission_status_board_preview", sim_time=simulation_time_input), - "location": url_for("web.mission_location_board_preview", sim_time=simulation_time_input), - "mission": url_for("web.mission_list", sim_time=simulation_time_input), - "asset": url_for("web.asset_list", sim_time=simulation_time_input), - "timeline": url_for("web.mission_timeline_preview", sim_time=simulation_time_input), + "status": url_for("web.mission_status_board_preview" +), + "location": url_for("web.mission_location_board_preview" +), + "mission": url_for("web.mission_list" +), + "asset": url_for("web.asset_list" +), + "timeline": url_for("web.mission_timeline_preview" +), } @@ -2123,7 +2538,7 @@ def _timeline_column_width(scale: str) -> int: def _timeline_segment_tone(entry: AssetLogEntry, simulation_time: datetime) -> str: title = (entry.title or "").casefold() - location = (entry.location or "").casefold() + location = (_entry_location(entry, simulation_time) or "").casefold() tone = "neutral" if any(token in title for token in ("transfer", "transit", "outbound", "return", "departure")): tone = "warning" @@ -2157,7 +2572,6 @@ def _timeline_entry_edit_url(asset_id: object, entry_id: object, simulation_time "web.asset_entry_edit", asset_id=asset_id, entry_id=entry_id, - sim_time=_format_datetime_input(simulation_time), ) @@ -2360,6 +2774,25 @@ def _build_timeline_rows( actual_end_position = min(float(bucket_count), actual_start_position + 0.02) state_count += 1 + # Map location to celestial body color + loc = (_entry_location(entry, simulation_time) or "").lower() + if "jupiter" in loc or "europa" in loc or "ganymede" in loc or "callisto" in loc: + body_color = "#c9b07c" # Jupiter gold + elif "saturn" in loc or "titan" in loc: + body_color = "#e0c97f" # Saturn beige + elif "mars" in loc: + body_color = "#c1440e" # Mars red + elif "earth" in loc or "leo" in loc or "geo" in loc: + body_color = "#4da6ff" # Earth blue + elif "neptune" in loc or "triton" in loc: + body_color = "#3f54ba" # Neptune blue + elif "sun" in loc: + body_color = "#FDB813" # Sun yellow + elif "transfer" in loc: + body_color = "#94A3B8" # Gray + else: + body_color = "#94A3B8" # Default gray + segments.append( { "entry_id": entry.id, @@ -2369,6 +2802,12 @@ def _build_timeline_rows( "start_position": actual_start_position, "body_end_position": actual_end_position, "display_end_position": actual_end_position, + "start_at": _format_datetime_display(entry.start_at), + "end_at": _format_datetime_display(entry.end_at) if entry.end_at else "ongoing", + "state_node_count": len([n for n in entry.state_nodes if _as_utc(n.at_time) and range_start <= _as_utc(n.at_time) <= range_end]), + "body_color": body_color, + "start_iso": _format_datetime_input(entry.start_at), + "end_iso": _format_datetime_input(entry.end_at) if entry.end_at else "", } ) @@ -2394,6 +2833,8 @@ def _build_timeline_rows( "full_label": " · ".join(full_label_parts), "url": _timeline_entry_edit_url(asset.id, entry.id, simulation_time), "position": event_position, + "at_iso": _format_datetime_input(state_node.at_time), + "is_state_node": True, "label_width_px": label_width_px, "label_span": label_span, "label_side": label_side, @@ -2421,6 +2862,8 @@ def _build_timeline_rows( "full_label": entry.title, "url": _timeline_entry_edit_url(asset.id, entry.id, simulation_time), "position": event_position, + "at_iso": _format_datetime_input(entry.start_at), + "is_state_node": False, "label_width_px": label_width_px, "label_span": label_span, "label_side": label_side, @@ -2471,9 +2914,6 @@ def _build_timeline_rows( event["connector_height_px"] = f"{_timeline_state_node_connector_height(state_track_count, parent_track, int(event['track']))}px" - if not segments and not events: - continue - timeline_rows.append( { "asset_id": asset.id, @@ -2624,13 +3064,84 @@ def _mission_label_matches_query(mission_label: str | None, search_term: str) -> @web_bp.get("/") def dashboard() -> str: + simulation_time = _resolve_simulation_time(request.args.get("sim_time")) + simulation_time_input = _format_datetime_input(simulation_time) + + # Load all assets with logs + assets = db.session.execute( + select(Asset).options(selectinload(Asset.log_entries)).order_by(Asset.name) + ).scalars().all() + + # Build snapshots + asset_rows = [] + for asset in assets: + snapshot = _build_asset_snapshot(asset, simulation_time) + asset_rows.append({ + "id": asset.id, + "name": asset.name, + "asset_type": asset.asset_type, + "is_retired": asset.is_retired, + "location": snapshot.get("current_location", "—"), + "mission": snapshot.get("current_mission", "—"), + "state": snapshot.get("current_state", "—"), + }) + + # Metrics + active = [a for a in asset_rows if not a["is_retired"]] + motherships = len([a for a in active if a["asset_type"] == "Exploration Mothership"]) + stations = len([a for a in active if a["asset_type"] == "Station"]) + total_assets = len(active) + + # Status groups + state_groups = { + "Transfer": {"label": "In Transit", "css_class": "tf", "assets": []}, + "Exploration": {"label": "Exploration", "css_class": "ex", "assets": []}, + "Maintenance": {"label": "Maintenance", "css_class": "mt", "assets": []}, + "Docked": {"label": "Docked / Surface", "css_class": "dk", "assets": []}, + } + for a in active: + state = a["state"] + if state in state_groups: + state_groups[state]["assets"].append(a) + else: + state_groups["Docked"]["assets"].append(a) + + status_groups = [g for g in state_groups.values() if g["assets"]] + + # Upcoming events (30 days) + now = simulation_time + upcoming_events = [] + for a in active: + snapshot = _build_asset_snapshot(a if hasattr(a, "log_entries") else assets[0], simulation_time) + for ev in snapshot.get("future_events", []): + ev_date = ev.get("date", "") + if ev_date and ev_date >= now.strftime("%Y-%m-%d"): + upcoming_events.append({ + "date": ev_date, + "title": f"{a['name']}: {ev.get('title', '')}", + "within_30d": True, + }) + upcoming_events.sort(key=lambda e: e["date"]) + upcoming_events = upcoming_events[:5] + + active_missions = len([a for a in active if a["state"] in ("Transfer", "Exploration")]) + upcoming_count = len(upcoming_events) + upcoming_summary = f"{upcoming_count} events" if upcoming_count else "None imminent" + return render_template( "dashboard.html", - title="Dashboard", - metrics=PROJECT_METRICS, - modules=MODULES, - duplicate_conflicts=48, - duplicate_conflicts_with_fuel=19, + title="任务总览", + active_page="overview", + simulation_time_display=_format_datetime_display(simulation_time), + simulation_time_input=simulation_time_input, + total_assets=total_assets, + mothership_count=motherships, + station_count=stations, + active_missions=active_missions, + upcoming_count=upcoming_count, + upcoming_summary=upcoming_summary, + status_groups=status_groups, + upcoming_events=upcoming_events, ) @@ -2638,7 +3149,8 @@ def dashboard() -> str: def fuel_converter() -> str: return render_template( "fuel_converter.html", - title="Fuel Converter", + title="燃料换算", + active_page="converter", fuel_factors=list_fuel_factors(), ) @@ -2681,7 +3193,8 @@ def engine_list() -> str: return render_template( "engine_list.html", - title="Engine Catalog", + title="引擎目录", + active_page="engines", catalog_rows=page_rows, search_term=search_term, fuel_filter=fuel_filter, @@ -2869,7 +3382,8 @@ def communication_list() -> str: return render_template( "communication_list.html", - title="Communication Parts", + title="通信部件", + active_page="comms", rows=page_rows, search_term=search_term, source_filter=source_filter, @@ -2990,7 +3504,8 @@ def tank_list() -> str: return render_template( "tank_list.html", - title="Tank Specs", + title="燃料箱规格", + active_page="tanks", rows=page_rows, search_term=search_term, fuel_filter=fuel_filter, @@ -3098,6 +3613,7 @@ def vehicle_list() -> str: return render_template( "vehicle_list.html", + active_page="vehicles", title="Vehicle Costs", rows=page_rows, search_term=search_term, @@ -3214,6 +3730,7 @@ def asset_list() -> str: return render_template( "asset_list.html", title="任务资产", + active_page="assets", preview_title="任务资产目录", preview_eyebrow="Mission Asset", rows=catalog_rows, @@ -3278,7 +3795,7 @@ def mission_list() -> str: mission_labels = _split_multi_value_text(entry.mission_label) if not mission_labels: continue - state_labels = _split_multi_value_text(entry.state_label) + state_labels = _split_multi_value_text(_entry_state_label(entry, simulation_time)) status = _classify_asset_entry(entry, simulation_time) for mission_label in mission_labels: @@ -3299,7 +3816,7 @@ def mission_list() -> str: "status_tone": status["tone"], "timestamp": _format_datetime_display(entry.start_at), "end_timestamp": _format_datetime_display(entry.end_at) if entry.end_at else None, - "location": entry.location or "-", + "location": _entry_location(entry, simulation_time) or "-", "summary": entry.summary or "未填写摘要。", "sort_at": _as_utc(entry.start_at) or _default_simulation_time(), } @@ -3465,7 +3982,8 @@ def asset_new() -> str | object: 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)) + return redirect(url_for("web.asset_detail", asset_id=asset.id +)) return render_template( "resource_edit.html", @@ -3479,7 +3997,8 @@ def asset_new() -> str | object: 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), + cancel_url=url_for("web.asset_list" +), submit_label="创建", delete_url=None, hidden_fields={"sim_time": simulation_time_input}, @@ -3518,7 +4037,8 @@ def asset_edit(asset_id: object) -> str | object: 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)) + return redirect(url_for("web.asset_detail", asset_id=asset.id +)) return render_template( "resource_edit.html", @@ -3532,7 +4052,8 @@ def asset_edit(asset_id: object) -> str | object: 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), + cancel_url=url_for("web.asset_detail", asset_id=asset.id +), submit_label="保存", delete_url=url_for("web.asset_delete", asset_id=asset.id), delete_label="删除资产", @@ -3597,7 +4118,6 @@ def asset_detail(asset_id: object) -> str: "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"] @@ -3634,18 +4154,25 @@ def asset_detail(asset_id: object) -> str: entry_rows.append( { "id": entry.id, + "source_entry_id": entry.id, "mode": entry.entry_kind, "mode_label": "Event Point" if entry.entry_kind == "event" else "State Interval", "status_label": status["label"], "title": entry.title, "timestamp": _format_datetime_display(entry.start_at), "end_timestamp": _format_datetime_display(entry.end_at) if entry.end_at else None, - "location": entry.location or "-", + "location": _entry_location(entry, simulation_time) or "-", "summary": entry.summary or "未填写摘要。", "mission_label": _display_multi_value_text(entry.mission_label), - "state_label": _display_multi_value_text(entry.state_label), + "state_label": _display_multi_value_text(_entry_state_label(entry, simulation_time)), "mission_labels": _split_multi_value_text(entry.mission_label), - "state_labels": _split_multi_value_text(entry.state_label), + "state_labels": _split_multi_value_text(_entry_state_label(entry, simulation_time)), + "edit_url": url_for("web.asset_entry_edit", asset_id=asset.id, entry_id=entry.id +), + "state_nodes": [ + {"title": n.title, "at": _format_datetime_display(n.at_time), "detail": n.detail or ""} + for n in sorted(entry.state_nodes, key=lambda x: _as_utc(x.at_time) or _default_simulation_time()) + ] if entry.entry_kind == "state" and entry.state_nodes else [], } ) @@ -3664,10 +4191,12 @@ def asset_detail(asset_id: object) -> str: return render_template( "asset_detail.html", title=asset.name, + active_page="assets", preview_title=asset.name, preview_eyebrow="Mission Asset", asset=asset, asset_summary=snapshot, + all_assets=db.session.execute(select(Asset).where(Asset.id != asset.id).order_by(Asset.name)).scalars().all(), future_events=future_events, entries=paged_entries, upcoming_events=future_events[:4], @@ -3719,19 +4248,54 @@ def asset_entry_new(asset_id: object) -> str | object: field_errors: dict[str, str] = {} if request.method == "POST": - try: - _apply_asset_entry_fields(entry, request.form) - entry.asset = asset - db.session.add(entry) - db.session.commit() - except FormValidationError as exc: - db.session.rollback() - validation_modal = _build_validation_modal(exc) - field_errors = dict(validation_modal["field_errors"]) - form_values = _asset_entry_form_values_from_form(request.form) + # Support both form and JSON + if request.content_type and "application/json" in request.content_type: + data = request.get_json(force=True, silent=True) or {} + try: + _apply_asset_entry_json_fields(entry, data) + entry.asset = asset + db.session.add(entry) + db.session.commit() + return {"success": True, "entry_id": str(entry.id)} + entry.entry_kind = data.get("entry_kind", "event") + entry.title = data.get("title", "") + entry.summary = data.get("summary") + if data.get("start_at"): + entry.start_at = _parse_datetime(data["start_at"], "开始时间") + if data.get("end_at"): + entry.end_at = _parse_datetime(data["end_at"], "结束时间") + entry.asset = asset + db.session.add(entry) + # Handle state nodes + for node_data in data.get("state_nodes", []): + if node_data.get("title"): + node = AssetStateNode( + title=node_data["title"], + detail=node_data.get("detail"), + at_time=_parse_datetime(node_data.get("at_time"), "节点时间") or entry.start_at, + entry=entry, + ) + db.session.add(node) + db.session.commit() + return {"success": True, "entry_id": str(entry.id)} + except Exception as e: + db.session.rollback() + return {"error": str(e)}, 400 else: - flash("日志条目已新增。", "success") - return redirect(url_for("web.asset_detail", asset_id=asset.id, sim_time=simulation_time_input)) + try: + _apply_asset_entry_fields(entry, request.form) + entry.asset = asset + db.session.add(entry) + db.session.commit() + except FormValidationError as exc: + db.session.rollback() + 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 +)) return render_template( "asset_entry_form.html", @@ -3744,7 +4308,9 @@ def asset_entry_new(asset_id: object) -> str | object: 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), + all_states=_all_state_labels(), + cancel_url=url_for("web.asset_detail", asset_id=asset.id +), delete_url=None, simulation_time_input=simulation_time_input, preview_tab="asset", @@ -3763,17 +4329,49 @@ def asset_entry_edit(asset_id: object, entry_id: object) -> str | object: field_errors: dict[str, str] = {} if request.method == "POST": - try: - _apply_asset_entry_fields(entry, request.form) - db.session.commit() - except FormValidationError as exc: - db.session.rollback() - validation_modal = _build_validation_modal(exc) - field_errors = dict(validation_modal["field_errors"]) - form_values = _asset_entry_form_values_from_form(request.form) + if request.content_type and "application/json" in request.content_type: + data = request.get_json(force=True, silent=True) or {} + try: + _apply_asset_entry_json_fields(entry, data) + db.session.commit() + return {"success": True} + entry.entry_kind = data.get("entry_kind", entry.entry_kind) + entry.title = data.get("title", entry.title) + entry.summary = data.get("summary") + if data.get("start_at"): + entry.start_at = _parse_datetime(data["start_at"], "开始时间") + if "end_at" in data: + entry.end_at = _parse_datetime(data["end_at"], "结束时间") if data["end_at"] else None + # Handle state nodes + for node in entry.state_nodes: + db.session.delete(node) + for node_data in data.get("state_nodes", []): + if node_data.get("title"): + node = AssetStateNode( + title=node_data["title"], + detail=node_data.get("detail"), + at_time=_parse_datetime(node_data.get("at_time"), "节点时间") or entry.start_at, + entry=entry, + ) + db.session.add(node) + db.session.commit() + return {"success": True} + except Exception as e: + db.session.rollback() + return {"error": str(e)}, 400 else: - flash("日志条目已保存。", "success") - return redirect(url_for("web.asset_detail", asset_id=asset.id, sim_time=simulation_time_input)) + try: + _apply_asset_entry_fields(entry, request.form) + db.session.commit() + except FormValidationError as exc: + db.session.rollback() + 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 +)) return render_template( "asset_entry_form.html", @@ -3786,7 +4384,9 @@ def asset_entry_edit(asset_id: object, entry_id: object) -> str | object: 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), + all_states=_all_state_labels(), + cancel_url=url_for("web.asset_detail", asset_id=asset.id +), delete_url=url_for("web.asset_entry_delete", asset_id=asset.id, entry_id=entry.id), simulation_time_input=simulation_time_input, preview_tab="asset", @@ -3794,6 +4394,146 @@ def asset_entry_edit(asset_id: object, entry_id: object) -> str | object: ) +@web_bp.post("/api/v1/sim-time") +def api_set_sim_time() -> object: + data = request.get_json(force=True, silent=True) or {} + time_str = data.get("sim_time", "").strip() + if time_str: + parsed = _parse_datetime(time_str, "sim_time") + if parsed is not None: + _db_write_simulation_time(parsed) + return {"success": True} + return {"error": "Invalid time"}, 400 + + +@web_bp.post("/api/v1/assets//dock") +def api_asset_dock(asset_id: object) -> object: + asset = _load_asset_or_404(asset_id) + data = request.get_json(force=True, silent=True) or {} + mode = _normalized_text(data.get("mode")) or "dock_vehicle" + vehicle_id = data.get("vehicle_id") + target_asset_id = data.get("target_asset_id") or vehicle_id + vehicle_name = _normalized_text(data.get("vehicle_name")) + note = _normalized_text(data.get("note")) + sim_time = _resolve_simulation_time(data.get("sim_time")) + dock_time = _parse_datetime(data.get("dock_time"), "对接时间") or sim_time + + try: + if mode == "dock_to_target": + if not target_asset_id: + raise FormValidationError("Docking target required") + parent_asset = db.session.get(Asset, _parse_uuid_value(target_asset_id)) + if parent_asset is None or parent_asset.id == asset.id: + raise FormValidationError("Docking target invalid") + child_asset = asset + child_label = None + elif mode == "dock_vehicle": + parent_asset = asset + if vehicle_id and vehicle_id != "_custom": + child_asset = db.session.get(Asset, _parse_uuid_value(vehicle_id)) + if child_asset is None or child_asset.id == asset.id: + raise FormValidationError("Docked vehicle invalid") + child_label = None + else: + child_asset = None + child_label = vehicle_name + if child_label is None: + raise FormValidationError("Vehicle name required") + else: + raise FormValidationError("Docking mode invalid") + + child_name = child_asset.name if child_asset is not None else str(child_label) + parent_location = str(_build_asset_snapshot(parent_asset, dock_time, include_docking=False)["current_location"]) + event = DockingEvent( + parent_asset=parent_asset, + child_asset=child_asset, + child_label=child_label, + docked_at=dock_time, + dock_note=note, + ) + db.session.add(event) + _append_docking_log(parent_asset, child_name, parent_asset.name, parent_location, dock_time, note) + if child_asset is not None: + _append_docking_log(child_asset, child_name, parent_asset.name, parent_location, dock_time, note) + db.session.commit() + except FormValidationError as exc: + db.session.rollback() + return {"error": str(exc)}, 400 + + if request.content_type and "application/json" in request.content_type: + return {"success": True} + + flash("对接已记录。", "success") + return redirect(url_for("web.asset_detail", asset_id=asset.id +)) + + +@web_bp.post("/api/v1/assets//undock") +def api_asset_undock(asset_id: object) -> object: + asset = _load_asset_or_404(asset_id) + data = request.get_json(force=True, silent=True) or {} + event_id = data.get("event_id") + action = _normalized_text(data.get("action")) or "undock" + note = _normalized_text(data.get("note")) + sim_time = _resolve_simulation_time(data.get("sim_time")) + undock_time = _parse_datetime(data.get("undock_time"), "解对接时间") or sim_time + + try: + if not event_id: + raise FormValidationError("Docking event required") + docking_event = db.session.get(DockingEvent, _parse_uuid_value(event_id)) + if docking_event is None: + raise FormValidationError("Docking event not found") + if docking_event.parent_asset_id != asset.id and docking_event.child_asset_id != asset.id: + raise FormValidationError("Docking event does not belong to this asset") + + parent_asset = db.session.get(Asset, docking_event.parent_asset_id) + child_asset = db.session.get(Asset, docking_event.child_asset_id) if docking_event.child_asset_id else None + child_name = child_asset.name if child_asset is not None else (docking_event.child_label or "External vehicle") + parent_name = parent_asset.name if parent_asset is not None else "Unknown" + + if action == "delete_future": + if docking_event.undocked_at is None: + raise FormValidationError("No future undock to delete") + docking_event.undocked_at = None + docking_event.undock_note = None + _remove_undocking_log(parent_asset, child_name, parent_name) + if child_asset is not None: + _remove_undocking_log(child_asset, child_name, parent_name) + elif action == "edit_future": + if docking_event.undocked_at is None: + raise FormValidationError("No future undock to edit") + _remove_undocking_log(parent_asset, child_name, parent_name) + if child_asset is not None: + _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) + if child_asset is not None: + _append_undocking_log(child_asset, child_name, parent_name, 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) + if child_asset is not None: + _append_undocking_log(child_asset, child_name, parent_name, undock_time, note) + + db.session.commit() + except FormValidationError as exc: + db.session.rollback() + return {"error": str(exc)}, 400 + + if request.content_type and "application/json" in request.content_type: + return {"success": True} + + flash("对接已解除。", "success") + return redirect(url_for("web.asset_detail", asset_id=asset.id +)) + + @web_bp.post("/assets//entries//delete") def asset_entry_delete(asset_id: object, entry_id: object) -> object: asset = _load_asset_or_404(asset_id) @@ -3848,50 +4588,84 @@ def mission_status_board_preview() -> str: @web_bp.get("/mission-preview/location-board") def mission_location_board_preview() -> str: simulation_time = _resolve_simulation_time(request.args.get("sim_time")) - asset_type_filter = request.args.get("asset_type", "").strip() - location_filter = request.args.get("location", "").strip() - record_scope = _normalize_record_scope(request.args.get("record_scope")) + simulation_time_input = _format_datetime_input(simulation_time) - all_rows = [ - row - for row in _build_mission_board_rows(_load_assets_with_logs(), simulation_time) - if not row["asset"].is_retired - ] - visible_rows = _filter_mission_board_rows(all_rows, asset_type_filter, location_filter, record_scope) + # Load all assets with logs + assets = _load_assets_with_logs() + + # Build asset position data for Canvas + # Map location strings to approximate orbital bodies and distances + LOCATION_BODY_MAP = { + "LEO": "earth", "GEO": "earth", "MEO": "earth", "Earth System": "earth", "Earth Surface": "earth", + "Lunar Orbit": "moon", "Lunar Surface": "moon", + "Mars Orbit": "mars", "Mars Surface": "mars", "Phobos Orbit": "mars", "Deimos Orbit": "mars", + "Jupiter Orbit": "jupiter", "Europa Orbit": "jupiter", "Europa Surface": "jupiter", + "Ganymede Orbit": "jupiter", "Callisto Orbit": "jupiter", + "Saturn Orbit": "saturn", "Titan Orbit": "saturn", "Titan Surface": "saturn", + "Uranus Orbit": "uranus", "Neptune Orbit": "neptune", "Triton Orbit": "neptune", + "Mercury Orbit": "mercury", "Mercury Surface": "mercury", + "Venus Orbit": "venus", "Venus Surface": "venus", + "Solar Orbit": "sun", "Transfer": "transfer", + "Ceres Orbit": "ceres", "Ceres Surface": "ceres", + "Vesta Orbit": "vesta", "Vesta Surface": "vesta", + "Pluto Orbit": "pluto", "Pluto Surface": "pluto", + } + + asset_positions = [] + for asset in assets: + if asset.is_retired: + continue + snapshot = _build_asset_snapshot(asset, simulation_time) + location = snapshot.get("current_location", "Transfer") + body = LOCATION_BODY_MAP.get(location, "transfer") + asset_positions.append({ + "id": str(asset.id), + "name": asset.name, + "asset_type": asset.asset_type, + "location": location, + "body": body, + "mission": snapshot.get("current_mission", ""), + "state": snapshot.get("current_state", ""), + }) + + # Count ongoing missions and upcoming events + active_assets = [a for a in asset_positions if a["state"] in ("Transfer", "Exploration")] + upcoming = [] return render_template( "mission_location_board_preview.html", - title="任务运营 · 地点板", - preview_title="地点板", - preview_tab="location", - location_groups=_build_location_board_groups(visible_rows), - **_mission_board_context( - "web.mission_location_board_preview", - simulation_time, - asset_type_filter, - location_filter, - record_scope, - all_rows, - visible_rows, - ), + title="地点板", + active_page="location", + simulation_time_display=_format_datetime_display(simulation_time), + simulation_time_input=simulation_time_input, + asset_positions_json=__import__("json").dumps(asset_positions), + asset_count=len(asset_positions), + ongoing_count=len(active_assets), + upcoming_events=[], ) @web_bp.get("/mission-preview/asset-log") def mission_asset_log_preview() -> object: simulation_time = _resolve_simulation_time(request.args.get("sim_time")) - return redirect(url_for("web.asset_list", sim_time=_format_datetime_input(simulation_time))) + return redirect(url_for("web.asset_list" +)) @web_bp.get("/mission-preview/timeline") def mission_timeline_preview() -> str: simulation_time = _resolve_simulation_time(request.args.get("sim_time")) + search_term = request.args.get("q", "").strip() asset_type_filter = request.args.get("asset_type", "").strip() location_filter = request.args.get("location", "").strip() + state_filter = request.args.get("state", "").strip() record_scope = _normalize_record_scope(request.args.get("record_scope")) requested_asset_ids = {asset_id for asset_id in request.args.getlist("asset_ids") if asset_id} all_rows = _build_mission_board_rows(_load_assets_with_logs(), simulation_time) + # Apply search filter + if search_term: + all_rows = [r for r in all_rows if search_term.lower() in (r.get("name", "") or "").lower()] filtered_rows = _filter_mission_board_rows(all_rows, asset_type_filter, location_filter, record_scope) visible_rows = [ row for row in filtered_rows if not requested_asset_ids or str(row["asset_id"]) in requested_asset_ids @@ -3899,15 +4673,18 @@ def mission_timeline_preview() -> str: default_rows = visible_rows or filtered_rows or all_rows initial_range_start, initial_range_end = _timeline_default_range(default_rows, simulation_time) - timeline_scale_mode = _resolve_timeline_scale(request.args.get("scale"), initial_range_start, initial_range_end) - default_range_start, default_range_end = _timeline_default_range_for_scale(default_rows, simulation_time, timeline_scale_mode) timeline_range_mode = "manual" if _normalized_text(request.args.get("range_mode")) == "manual" else "auto" + raw_scale = request.args.get("scale") if timeline_range_mode == "manual": - range_start = _resolve_timeline_boundary(request.args.get("start"), default_range_start) - range_end = _resolve_timeline_boundary(request.args.get("end"), default_range_end) + range_start = _resolve_timeline_boundary(request.args.get("start"), initial_range_start) + range_end = _resolve_timeline_boundary(request.args.get("end"), initial_range_end) else: - range_start = default_range_start - range_end = default_range_end + range_start = initial_range_start + range_end = initial_range_end + if range_end < range_start: + range_end = range_start + # Determine scale from the ACTUAL range, not the initial default + timeline_scale_mode = _resolve_timeline_scale(raw_scale, range_start, range_end) if range_end < range_start: range_end = range_start @@ -3919,6 +4696,9 @@ def mission_timeline_preview() -> str: range_end, timeline_scale_mode, ) + for r in timeline_rows: + r["asset_url"] = url_for("web.asset_detail", asset_id=r["asset_id"] +) timeline_column_width = _timeline_column_width(timeline_scale_mode) board_query_args = _mission_board_query_args(simulation_time, asset_type_filter, location_filter, record_scope) @@ -3939,14 +4719,73 @@ def mission_timeline_preview() -> str: for row in sorted(filtered_rows, key=lambda item: item["name"].casefold()) ] + # Build JSON-LD like data for client-side rendering + import json as _json + timeline_json_data = { + "range_start": _format_datetime_input(range_start), + "range_end": _format_datetime_input(range_end), + "scale": timeline_scale_mode, + "bucket_count": len(timeline_scale), + "rows": [ + { + "asset_id": str(r["asset_id"]), + "asset_name": str(r["name"]), + "asset_type": str(r["asset_type"]), + "asset_url": url_for("web.asset_detail", asset_id=r["asset_id"] +), + "segments": [ + { + "entry_id": str(s["entry_id"]), + "label": str(s["label"]), + "tone": str(s["tone"]), + "url": str(s["url"]), + "start_position": float(s["start_position"]), + "body_end_position": float(s["body_end_position"]), + "display_end_position": float(s["display_end_position"]), + "start_at": str(s["start_at"]), + "end_at": str(s["end_at"]), + "body_color": str(s["body_color"]), + "start_iso": str(s.get("start_iso", "")), + "end_iso": str(s.get("end_iso", "")), + } + for s in r.get("segments", []) + ], + "events": [ + { + "label": str(e["label"]), + "url": str(e["url"]), + "position": float(e["position"]), + "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)), + } + for e in r.get("events", []) + ], + } + for r in timeline_rows + ], + } + timeline_json = _json.dumps(timeline_json_data, ensure_ascii=False) + + is_partial = request.args.get("_partial") == "1" + template = "mission_timeline_preview.html" + if is_partial: + return timeline_json, 200, {"Content-Type": "application/json"} + return render_template( - "mission_timeline_preview.html", - title="任务运营 · 时间线", + template, + title="时间线", + active_page="timeline", + search_term=search_term, + asset_type_filter=asset_type_filter, + location_filter=location_filter, + state_filter=state_filter, preview_title="时间线", preview_eyebrow="Mission Ops", preview_tab="timeline", timeline_scale=timeline_scale, timeline_rows=timeline_rows, + timeline_json=timeline_json, timeline_scale_mode=timeline_scale_mode, timeline_scale_options=TIMELINE_SCALE_OPTIONS, timeline_column_width=timeline_column_width, @@ -3976,15 +4815,12 @@ def mission_timeline_preview() -> str: "end": _format_datetime_input(range_end) if timeline_range_mode == "manual" else None, } ), - asset_type_filter=asset_type_filter, - location_filter=location_filter, record_scope=record_scope, selected_asset_ids=sorted(requested_asset_ids), selected_asset_count=len(requested_asset_ids), timeline_asset_options=selected_asset_options, reset_url=url_for( "web.mission_timeline_preview", - sim_time=_format_datetime_input(simulation_time), asset_type=asset_type_filter or None, location=location_filter or None, record_scope=record_scope, @@ -3997,8 +4833,10 @@ def mission_timeline_preview() -> str: board_nav_urls={ "status": url_for("web.mission_status_board_preview", **board_query_args), "location": url_for("web.mission_location_board_preview", **board_query_args), - "mission": url_for("web.mission_list", sim_time=_format_datetime_input(simulation_time)), - "asset": url_for("web.asset_list", sim_time=_format_datetime_input(simulation_time)), + "mission": url_for("web.mission_list" +), + "asset": url_for("web.asset_list" +), "timeline": url_for("web.mission_timeline_preview", **timeline_query_args), }, )