From fee9b13ada23913851682f55bb2f711b327819a6 Mon Sep 17 00:00:00 2001 From: Armor00 <2654988228@qq.com> Date: Thu, 28 May 2026 11:32:11 +0800 Subject: [PATCH] feat: improve mission asset operations --- app/routes/web.py | 394 ++++++++++++++++---- app/static/styles.css | 75 ++++ app/templates/_datetime_picker_field.html | 6 +- app/templates/_mission_upcoming_events.html | 16 +- app/templates/_validation_dialog.html | 50 +++ app/templates/asset_detail.html | 48 ++- app/templates/asset_entry_form.html | 60 +-- app/templates/resource_edit.html | 19 +- prototypes/mission-ops/location-board.html | 4 +- prototypes/mission-ops/script.js | 14 +- prototypes/mission-ops/status-board.html | 4 +- prototypes/mission-ops/timeline-view.html | 6 +- 12 files changed, 562 insertions(+), 134 deletions(-) create mode 100644 app/templates/_validation_dialog.html diff --git a/app/routes/web.py b/app/routes/web.py index f9da422..86c28c8 100644 --- a/app/routes/web.py +++ b/app/routes/web.py @@ -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,27 +846,34 @@ def _apply_fields( label = field_spec["label"] required = bool(field_spec.get("required")) - if field_kind == "checkbox": - value = full_name in form - elif field_kind == "combo": - selected_value = _normalized_text(form.get(full_name)) - custom_value = _normalized_text(form.get(f"{full_name}__custom")) - if custom_value is not None: - value = custom_value - elif selected_value == CUSTOM_OPTION_VALUE: - value = None + try: + if field_kind == "checkbox": + value = full_name in form + elif field_kind == "combo": + selected_value = _normalized_text(form.get(full_name)) + custom_value = _normalized_text(form.get(f"{full_name}__custom")) + if custom_value is not None: + value = custom_value + elif selected_value == CUSTOM_OPTION_VALUE: + value = None + else: + value = selected_value + elif field_kind == "decimal": + value = _parse_decimal(form.get(full_name), label) + elif field_kind == "integer": + value = _parse_integer(form.get(full_name), label) else: - value = selected_value - elif field_kind == "decimal": - value = _parse_decimal(form.get(full_name), label) - elif field_kind == "integer": - value = _parse_integer(form.get(full_name), label) - else: - value = _normalized_text(form.get(full_name)) + 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}) - start_at = _parse_datetime_form_value(form, "start_at", "Start At", required=True) + 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}) - end_at = _parse_datetime_form_value(form, "end_at", "End At") + 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) - at_time = _parse_datetime(raw_at_time, f"State Node {index} At") + 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, diff --git a/app/static/styles.css b/app/static/styles.css index 3d4c065..100cbd8 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -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; diff --git a/app/templates/_datetime_picker_field.html b/app/templates/_datetime_picker_field.html index 0e03d15..7197f38 100644 --- a/app/templates/_datetime_picker_field.html +++ b/app/templates/_datetime_picker_field.html @@ -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' %} -