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:
+107
-7
@@ -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", [])
|
||||
],
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user