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
+107 -7
View File
@@ -1303,6 +1303,28 @@ def _append_docking_log(
note: str | None,
) -> None:
title = f"{child_name} docked to {parent_name}"
# 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",
)
)
else:
entry = AssetLogEntry(
entry_kind="state",
title=title,
@@ -1326,14 +1348,21 @@ 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", [])
],
+23
View File
@@ -12,6 +12,20 @@ function initSidebar() {
}
initSidebar();
// ─── Modal Helpers ───
function openModal(id) {
const el = document.getElementById(id);
if (el) el.classList.add('open');
}
function closeModal(id) {
const el = document.getElementById(id);
if (el) el.classList.remove('open');
}
document.addEventListener('keydown', function(e) {
if (e.key !== 'Escape') return;
document.querySelectorAll('.modal-bg.open').forEach(el => el.classList.remove('open'));
});
// ─── Global Time Control ───
function openTimeModal() {
const display = document.getElementById('sim-time-display');
@@ -42,6 +56,15 @@ async function shiftTime(days) {
async function resetTime() {
await setSimTimeAndReload('2060-03-12 09:00');
}
function toggleTheme() {
const root = document.documentElement;
const current = root.dataset.theme || 'dark';
const next = current === 'dark' ? 'light' : 'dark';
root.dataset.theme = next;
root.style.colorScheme = next;
try { localStorage.setItem('ksp-theme', next); } catch(e) {}
}
async function setSimTimeAndReload(timeStr) {
try {
await fetch('/api/v1/sim-time', {
+9
View File
@@ -79,6 +79,15 @@
<span class="ic"></span>燃料换算
</a>
<div class="nst" onclick="this.classList.toggle('on')">
<span class="ar"></span>控制台
</div>
<div class="niw">
<a class="ni su{% if active_page == 'docking_logs' %} act{% endif %}" href="{{ url_for('web.docking_log_list') }}">
<span class="ic"></span>对接日志管理
</a>
</div>
<a class="ni" href="{{ url_for('api.health') }}">
<span class="ic">📡</span>API 健康检查
</a>
+41
View File
@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block content %}
<div class="ph fi">
<div class="ey">Console</div>
<h1>对接日志管理</h1>
</div>
<table>
<thead><tr>
<th>对接时间</th><th>分离时间</th><th>母舰</th><th>子载具</th><th>备注</th><th>操作</th>
</tr></thead>
<tbody>
{% for e in events %}
<tr>
<td class="mt">{{ e.docked_at.strftime('%Y-%m-%d %H:%M') if e.docked_at else '-' }}</td>
<td class="mt">{{ e.undocked_at.strftime('%Y-%m-%d %H:%M') if e.undocked_at else '-' }}</td>
<td>
{% if e.parent_asset %}
<a href="{{ url_for('web.asset_detail', asset_id=e.parent_asset.id) }}" style="color:var(--accent)">{{ e.parent_asset.name }}</a>
{% else %}-{% endif %}
</td>
<td>
{% if e.child_asset %}
<a href="{{ url_for('web.asset_detail', asset_id=e.child_asset.id) }}" style="color:var(--accent)">{{ e.child_asset.name }}</a>
{% elif e.child_label %}{{ e.child_label }}
{% else %}-{% endif %}
</td>
<td class="mt" style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ e.dock_note or '-' }}</td>
<td>
<form method="post" action="{{ url_for('web.docking_log_delete', event_id=e.id) }}" style="display:inline" onsubmit="return confirm('确定删除这条对接记录?相关日志也会被清理。')">
<button class="danger-link" type="submit" style="font-size:.66rem;border:none;background:none;color:var(--red);cursor:pointer">Del</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="mt" style="margin-top:12px">{{ events|length }} records</p>
{% endblock %}