feat: improve mission asset operations
This commit is contained in:
+296
-60
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Vendored
+7
-7
@@ -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"],
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user