diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cc17c13 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.venv +__pycache__ +.idea +.env +instance diff --git a/.env.example b/.env.example index e69de29..beb2092 100644 --- a/.env.example +++ b/.env.example @@ -0,0 +1,17 @@ +# .env.example - 供 LLM 或示例使用的安全示例值(无敏感信息) +# LLM 可以直接参考下面的键和值格式来推断或生成连接字符串。 + +PGHOST=127.0.0.1 +PGPORT=5432 +PGUSER=example_user +PGPASSWORD=example_password +PGDATABASE=example_db + +# 额外示例变量: +APP_ENV=development +APP_PORT=8000 +APP_SECRET_KEY=change-me +WORKBOOK_PATH=KSP Engine Tweak Chart.xlsx +SQLALCHEMY_ECHO=false + +# 说明:此文件为示例,不包含真实凭据。将真实凭据写入本地的 .env 文件并确保其被 .gitignore 忽略。 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7359121 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.venv/ +__pycache__/ +*.py[cod] +*.sqlite3 +instance/ +.env +.idea/ +.pytest_cache/ +.mypy_cache/ diff --git a/.idea/misc.xml b/.idea/misc.xml index 932a1c3..c3df08d 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,7 @@ + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..179cdcd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "main:app"] diff --git a/KSP Engine Tweak Chart.xlsx b/KSP Engine Tweak Chart.xlsx new file mode 100644 index 0000000..340f607 Binary files /dev/null and b/KSP Engine Tweak Chart.xlsx differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..cfd26e1 --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +# KSP Data Hangar + +这是当前项目的第一版可运行骨架,目标是把 Excel 中的 KSP 数据逐步整理为可导入、可管理、可检索的 Flask 应用。 + +## 当前已落地 + +- Flask 应用骨架,包含网页路由和 JSON API +- SQLAlchemy 数据模型,覆盖 Engine、Communication、Tank、Vehicle Cost +- Engine 列表页、详情页、编辑页 +- Fuel Chart 的前端工具页和 API 接口 +- Excel 巡检脚本,用来核对工作簿结构和候选唯一键冲突 +- Excel 导入脚本与导入服务 +- Dockerfile 与 docker-compose 基线 + +## 关键建模结论 + +- 不能把 Engine + Config Name 当成数据库硬唯一键 +- 当前源数据里有 48 组 Engine + Config Name 冲突 +- 即使补上 Fuel Type,仍然还有 19 组冲突 +- 因此数据库主键采用 UUID,原始自然键只保留做展示和导入告警 + +## 本地运行 + +1. 复制 .env.example 到 .env,并填写真实 PG 连接信息 +2. 安装依赖 + +```bash +pip install -r requirements.txt +``` + +3. 启动开发服务器 + +```bash +python main.py +``` + +4. 打开以下地址 + +- http://127.0.0.1:8000/ +- http://127.0.0.1:8000/engines +- http://127.0.0.1:8000/fuel-converter +- http://127.0.0.1:8000/api/v1/health + +## 初始化数据库 + +```bash +python -m flask --app main:app db init +python -m flask --app main:app db migrate -m "initial schema" +python -m flask --app main:app db upgrade +``` + +如果仓库里已经有 migrations 目录,只需要执行最后一条 upgrade。 + +## 巡检工作簿 + +```bash +python scripts/inspect_workbook.py +``` + +这个脚本会输出: + +- 工作表列表与行数 +- Engine Database 表头 +- Work Env 枚举值 +- 自然键冲突统计 +- 字段稳定性分析 + +## 导入工作簿 + +```bash +python scripts/import_workbook.py +``` + +如果需要覆盖数据库中已有的导入数据: + +```bash +python scripts/import_workbook.py --replace +``` + +导入脚本会处理: + +- Engine Database +- Communication +- Tank Chart +- KSP Vehicle Cost + +## Docker 运行 + +```bash +docker compose up --build +``` + +应用默认监听容器内 8000 端口,宿主机端口取决于 APP_PORT。 + +## 关于 .env + +- 根目录已经补上 .gitignore,避免后续再次提交 .env +- 如果 .env 已经在 Git 索引里,.gitignore 不会自动取消跟踪,需要你本地手动把它移出索引后再提交 + +## 下一步建议 + +1. 把 Communication、Tank、Vehicle Cost 的页面也补成 CRUD +2. 为导入脚本增加增量更新和冲突报告导出 +3. 增加筛选、分页和排序 +4. 补上数据库迁移和导入的自动化测试 diff --git a/Timeline.xlsx b/Timeline.xlsx new file mode 100644 index 0000000..57b0197 Binary files /dev/null and b/Timeline.xlsx differ diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..3172968 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP + +from flask import Flask + +from app.config import Config +from app.extensions import db, migrate +from app import models +from app.routes.api import api_bp +from app.routes.web import web_bp + + +def _format_decimal(value: object, digits: int = 4) -> str: + if value is None: + return "-" + + try: + decimal_value = Decimal(str(value)) + except (InvalidOperation, ValueError, TypeError): + return str(value) + + quantized = decimal_value.quantize(Decimal(f"1.{'0' * digits}"), rounding=ROUND_HALF_UP) + normalized = format(quantized, "f").rstrip("0").rstrip(".") + return normalized or "0" + + +def create_app(config_object: type[Config] = Config) -> Flask: + app = Flask(__name__, instance_relative_config=True) + app.config.from_object(config_object) + + db.init_app(app) + migrate.init_app(app, db) + + app.register_blueprint(web_bp) + app.register_blueprint(api_bp) + app.jinja_env.filters["fmt_decimal"] = _format_decimal + + return app diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..d6c98aa --- /dev/null +++ b/app/config.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import os +from pathlib import Path +from urllib.parse import quote_plus + +from dotenv import load_dotenv + + +BASE_DIR = Path(__file__).resolve().parent.parent +DEFAULT_SQLITE_PATH = BASE_DIR / "instance" / "ksp.sqlite3" + +load_dotenv(BASE_DIR / ".env") + + +def _build_database_uri() -> str: + host = os.getenv("PGHOST") + port = os.getenv("PGPORT") + user = os.getenv("PGUSER") + password = os.getenv("PGPASSWORD") + database = os.getenv("PGDATABASE") + + if all([host, port, user, password, database]): + return ( + "postgresql+psycopg://" + f"{quote_plus(user)}:{quote_plus(password)}@{host}:{port}/{quote_plus(database)}" + ) + + DEFAULT_SQLITE_PATH.parent.mkdir(parents=True, exist_ok=True) + return f"sqlite:///{DEFAULT_SQLITE_PATH.as_posix()}" + + +class Config: + APP_ENV = os.getenv("APP_ENV", "development") + APP_PORT = int(os.getenv("APP_PORT", "8000")) + APP_SECRET_KEY = os.getenv("APP_SECRET_KEY", "development-only-secret-key") + PROJECT_TITLE = os.getenv("PROJECT_TITLE", "KSP Data Hangar") + WORKBOOK_PATH = os.getenv("WORKBOOK_PATH", "KSP Engine Tweak Chart.xlsx") + SQLALCHEMY_DATABASE_URI = _build_database_uri() + SQLALCHEMY_TRACK_MODIFICATIONS = False + SQLALCHEMY_ECHO = os.getenv("SQLALCHEMY_ECHO", "false").lower() == "true" + SECRET_KEY = APP_SECRET_KEY diff --git a/app/extensions.py b/app/extensions.py new file mode 100644 index 0000000..314cc74 --- /dev/null +++ b/app/extensions.py @@ -0,0 +1,6 @@ +from flask_migrate import Migrate +from flask_sqlalchemy import SQLAlchemy + + +db = SQLAlchemy() +migrate = Migrate() diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..69a8961 --- /dev/null +++ b/app/models.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Numeric, String, Text, Uuid +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.extensions import db + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False + ) + + +class EngineFamily(TimestampMixin, db.Model): + __tablename__ = "engine_families" + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + engine_name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + part_name: Mapped[str | None] = mapped_column(String(255)) + cycle: Mapped[str | None] = mapped_column(String(120)) + size_m: Mapped[float | None] = mapped_column(Numeric(8, 3)) + entry_cost: Mapped[int | None] = mapped_column(Integer) + + variants: Mapped[list["EngineVariant"]] = relationship( + back_populates="family", + cascade="all, delete-orphan", + lazy="selectin", + order_by="EngineVariant.source_sheet_row", + ) + + +class EngineVariant(TimestampMixin, db.Model): + __tablename__ = "engine_variants" + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + family_id: Mapped[uuid.UUID] = mapped_column( + Uuid, ForeignKey("engine_families.id", ondelete="CASCADE"), nullable=False, index=True + ) + fuel_type: Mapped[str] = mapped_column(String(120), nullable=False) + work_env: Mapped[str | None] = mapped_column(String(32)) + sl_isp: Mapped[float | None] = mapped_column(Numeric(10, 3)) + vac_isp: Mapped[float | None] = mapped_column(Numeric(10, 3)) + min_thrust_kn: Mapped[float | None] = mapped_column(Numeric(12, 3)) + max_thrust_kn: Mapped[float | None] = mapped_column(Numeric(12, 3)) + mass_t: Mapped[float | None] = mapped_column(Numeric(10, 4)) + twr: Mapped[float | None] = mapped_column(Numeric(12, 4)) + throttle_ratio: Mapped[float | None] = mapped_column(Numeric(8, 4)) + tvc_deg: Mapped[float | None] = mapped_column(Numeric(8, 3)) + price_kd: Mapped[int | None] = mapped_column(Integer) + ignitions: Mapped[int | None] = mapped_column(Integer) + has_unlimited_ignitions: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + mod_source: Mapped[str | None] = mapped_column(String(120)) + engine_note: Mapped[str | None] = mapped_column(Text) + config_name: Mapped[str | None] = mapped_column(String(255), index=True) + tech_required: Mapped[str | None] = mapped_column(String(120)) + config_note: Mapped[str | None] = mapped_column(Text) + source_sheet_row: Mapped[int | None] = mapped_column(Integer) + + family: Mapped[EngineFamily] = relationship(back_populates="variants") + + +class CommunicationPart(TimestampMixin, db.Model): + __tablename__ = "communication_parts" + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + part_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + display_name: Mapped[str | None] = mapped_column(String(255)) + mass_t: Mapped[float | None] = mapped_column(Numeric(10, 4)) + is_active: Mapped[bool | None] = mapped_column(Boolean) + is_deployable: Mapped[bool | None] = mapped_column(Boolean) + antenna_type: Mapped[str | None] = mapped_column(String(120)) + deployed_diameter_m: Mapped[float | None] = mapped_column(Numeric(10, 3)) + range_raw: Mapped[float | None] = mapped_column(Numeric(24, 3)) + range_km: Mapped[float | None] = mapped_column(Numeric(24, 3)) + range_au: Mapped[float | None] = mapped_column(Numeric(18, 6)) + range_light_year: Mapped[float | None] = mapped_column(Numeric(18, 6)) + angle_deg: Mapped[float | None] = mapped_column(Numeric(10, 3)) + speed: Mapped[float | None] = mapped_column(Numeric(10, 3)) + idle_power_watt: Mapped[float | None] = mapped_column(Numeric(12, 4)) + idle_power_text: Mapped[str | None] = mapped_column(String(64)) + transmitting_power_watt: Mapped[float | None] = mapped_column(Numeric(12, 4)) + transmitting_power_text: Mapped[str | None] = mapped_column(String(64)) + source: Mapped[str | None] = mapped_column(String(120)) + entry_cost: Mapped[int | None] = mapped_column(Integer) + cost: Mapped[int | None] = mapped_column(Integer) + description: Mapped[str | None] = mapped_column(Text) + rescale_factor: Mapped[float | None] = mapped_column(Numeric(8, 3)) + tweakscale: Mapped[str | None] = mapped_column(String(64)) + is_feeder: Mapped[bool | None] = mapped_column(Boolean) + tech_required: Mapped[str | None] = mapped_column(String(120)) + note: Mapped[str | None] = mapped_column(Text) + + +class TankSpec(TimestampMixin, db.Model): + __tablename__ = "tank_specs" + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + tank_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + fuel_type: Mapped[str | None] = mapped_column(String(120)) + dry_mass_t: Mapped[float | None] = mapped_column(Numeric(10, 4)) + fuel_mass_t: Mapped[float | None] = mapped_column(Numeric(10, 4)) + wet_mass_t: Mapped[float | None] = mapped_column(Numeric(10, 4)) + tank_volume_l: Mapped[float | None] = mapped_column(Numeric(12, 3)) + mass_ratio: Mapped[float | None] = mapped_column(Numeric(12, 4)) + kiloliters_per_ton: Mapped[float | None] = mapped_column(Numeric(12, 4)) + vehicle_name: Mapped[str | None] = mapped_column(String(120)) + source: Mapped[str | None] = mapped_column(String(120)) + note: Mapped[str | None] = mapped_column(Text) + + +class VehicleCost(TimestampMixin, db.Model): + __tablename__ = "vehicle_costs" + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + vehicle_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + launch_price: Mapped[int | None] = mapped_column(Integer) + source: Mapped[str | None] = mapped_column(String(120)) + + +class Asset(TimestampMixin, db.Model): + __tablename__ = "assets" + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True) + asset_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True) + is_retired: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + program: Mapped[str | None] = mapped_column(String(255)) + home_region: Mapped[str | None] = mapped_column(String(120)) + note: Mapped[str | None] = mapped_column(Text) + + log_entries: Mapped[list["AssetLogEntry"]] = relationship( + back_populates="asset", + cascade="all, delete-orphan", + lazy="selectin", + order_by="AssetLogEntry.start_at", + ) + + +class AssetLogEntry(TimestampMixin, db.Model): + __tablename__ = "asset_log_entries" + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + asset_id: Mapped[uuid.UUID] = mapped_column( + Uuid, ForeignKey("assets.id", ondelete="CASCADE"), nullable=False, index=True + ) + entry_kind: Mapped[str] = mapped_column(String(16), nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + state_label: Mapped[str | None] = mapped_column(String(120)) + mission_label: Mapped[str | None] = mapped_column(String(255)) + location: Mapped[str | None] = mapped_column(String(255)) + start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + end_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + summary: Mapped[str | None] = mapped_column(Text) + note: Mapped[str | None] = mapped_column(Text) + + asset: Mapped[Asset] = relationship(back_populates="log_entries") + state_nodes: Mapped[list["AssetStateNode"]] = relationship( + back_populates="entry", + cascade="all, delete-orphan", + lazy="selectin", + order_by="AssetStateNode.at_time", + ) + + +class AssetStateNode(TimestampMixin, db.Model): + __tablename__ = "asset_state_nodes" + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + entry_id: Mapped[uuid.UUID] = mapped_column( + Uuid, ForeignKey("asset_log_entries.id", ondelete="CASCADE"), nullable=False, index=True + ) + title: Mapped[str] = mapped_column(String(255), nullable=False) + detail: Mapped[str | None] = mapped_column(Text) + at_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + + entry: Mapped[AssetLogEntry] = relationship(back_populates="state_nodes") diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..dec3d8e --- /dev/null +++ b/app/routes/__init__.py @@ -0,0 +1 @@ +"""Route blueprints for the KSP application.""" diff --git a/app/routes/api.py b/app/routes/api.py new file mode 100644 index 0000000..d949117 --- /dev/null +++ b/app/routes/api.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from flask import Blueprint, jsonify, request + +from app.services.fuel_conversion import convert_value, list_fuel_factors + + +api_bp = Blueprint("api", __name__, url_prefix="/api/v1") + + +@api_bp.get("/health") +def health() -> tuple[str, int] | tuple[dict[str, str], int] | object: + return jsonify({"status": "ok", "service": "ksp-data-hangar"}) + + +@api_bp.get("/fuel-converter/fuels") +def fuel_factors() -> object: + return jsonify({"fuels": list_fuel_factors()}) + + +@api_bp.route("/fuel-converter/convert", methods=["GET", "POST"]) +def convert_fuel() -> object: + payload = request.get_json(silent=True) or request.args + mode = payload.get("mode", "") + value = payload.get("value") + + try: + result = convert_value(mode=mode, raw_value=value) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + + return jsonify(result) diff --git a/app/routes/web.py b/app/routes/web.py new file mode 100644 index 0000000..f9da422 --- /dev/null +++ b/app/routes/web.py @@ -0,0 +1,3768 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from decimal import Decimal, InvalidOperation +from difflib import SequenceMatcher +from math import ceil +from pathlib import Path +import re +from typing import Any + +from flask import Blueprint, abort, flash, has_request_context, redirect, render_template, request, session, url_for +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import selectinload + +from app.extensions import db +from app.models import Asset, AssetLogEntry, AssetStateNode, CommunicationPart, EngineFamily, EngineVariant, TankSpec, VehicleCost +from app.services.fuel_conversion import list_fuel_factors +from app.services.log_book_importer import import_log_book_data + + +web_bp = Blueprint("web", __name__) + +DEFAULT_PAGE_SIZE = 30 +CUSTOM_OPTION_VALUE = "__custom__" +LOG_BOOK_PATH = Path(__file__).resolve().parents[2] / "log_book.xlsx" +SIMULATION_TIME_SESSION_KEY = "simulation_time" + +ENGINE_SORT_OPTIONS = [ + {"value": "engine_name_asc", "label": "名称 A-Z"}, + {"value": "engine_name_desc", "label": "名称 Z-A"}, + {"value": "mass_t_asc", "label": "重量从低到高"}, + {"value": "mass_t_desc", "label": "重量从高到低"}, + {"value": "vac_thrust_asc", "label": "真空推力从低到高"}, + {"value": "vac_thrust_desc", "label": "真空推力从高到低"}, + {"value": "sl_thrust_asc", "label": "海平面推力从低到高"}, + {"value": "sl_thrust_desc", "label": "海平面推力从高到低"}, + {"value": "vac_isp_asc", "label": "真空比冲从低到高"}, + {"value": "vac_isp_desc", "label": "真空比冲从高到低"}, + {"value": "sl_isp_asc", "label": "海平面比冲从低到高"}, + {"value": "sl_isp_desc", "label": "海平面比冲从高到低"}, +] + +ENGINE_SORT_COLUMNS = [ + {"key": "engine_name", "label": "Engine"}, + {"key": "mass_t", "label": "Mass"}, + {"key": "sl_thrust", "label": "SL Thrust"}, + {"key": "vac_thrust", "label": "Vac Thrust"}, + {"key": "sl_isp", "label": "SL ISP"}, + {"key": "vac_isp", "label": "Vac ISP"}, +] + +COMM_SORT_OPTIONS = [ + {"value": "part_name", "label": "名称 A-Z"}, + {"value": "range_desc", "label": "通信距离从高到低"}, + {"value": "mass_desc", "label": "质量从高到低"}, +] + +TANK_SORT_OPTIONS = [ + {"value": "tank_name", "label": "名称 A-Z"}, + {"value": "volume_desc", "label": "容积从高到低"}, + {"value": "ratio_desc", "label": "质量比从高到低"}, +] + +VEHICLE_SORT_OPTIONS = [ + {"value": "vehicle_name", "label": "名称 A-Z"}, + {"value": "launch_price_desc", "label": "发射成本从高到低"}, + {"value": "launch_price_asc", "label": "发射成本从低到高"}, +] + +ASSET_SORT_OPTIONS = [ + {"value": "name_asc", "label": "名称 A-Z"}, + {"value": "asset_type_asc", "label": "类型 A-Z"}, + {"value": "updated_desc", "label": "最近更新优先"}, +] + +MISSION_PREVIEW_TIME = "2060-03-12 09:00" + +MISSION_PREVIEW_METRICS = [ + {"label": "Tracked Assets", "value": "14"}, + {"label": "Active Missions", "value": "6"}, + {"label": "Upcoming Events (30d)", "value": "9"}, +] + +MISSION_PREVIEW_FILTERS = { + "simulation_time": MISSION_PREVIEW_TIME, + "asset_type": "All Asset Types", + "location": "Earth System", + "record_scope": "Confirmed + Planned", +} + +MISSION_BOARD_RECORD_SCOPE_OPTIONS = [ + {"value": "all", "label": "全部资产"}, + {"value": "active", "label": "仅活动中"}, + {"value": "active_planned", "label": "活动中 + 计划中"}, + {"value": "recorded", "label": "仅已记录"}, +] + +TIMELINE_SCALE_OPTIONS = [ + {"value": "day", "label": "按日"}, + {"value": "month", "label": "按月"}, + {"value": "year", "label": "按年"}, +] + +MISSION_STATUS_GROUPS = [ + { + "title": "In Transit", + "accent": "warning", + "items": [ + { + "name": "XH-02 Taibai", + "asset_type": "Exploration Mothership", + "location": "Earth to Mars Transfer", + "mission": "Support Mars One Campus", + "state_window": "2060-02-26 to 2060-03-29", + "next_event": "Mars Arrival · 2060-03-29", + "summary": "31-day transfer, planned delta-v margin 105 km/s.", + "record_status": "Confirmed", + }, + { + "name": "Amalthea Crew Vehicle 4", + "asset_type": "Shuttle", + "location": "LKO to Lingxiao Station", + "mission": "Expedition 16 Crew Rotation", + "state_window": "2060-03-28 to 2060-03-30", + "next_event": "Docking Window · 2060-03-30", + "summary": "10 crew manifest uploaded, rendezvous burn pending.", + "record_status": "Planned", + }, + ], + }, + { + "title": "Docked / On Station", + "accent": "primary", + "items": [ + { + "name": "Lingxiao Station", + "asset_type": "Station", + "location": "Low Kerbin Orbit", + "mission": "Continuous habitation and logistics hub", + "state_window": "2059-07-23 to present", + "next_event": "Expedition 16 Crew Arrival · 2060-03-30", + "summary": "Expedition 15 is onboard, crew handover prep in progress.", + "record_status": "Active", + }, + { + "name": "Tianhe Cargo Pod 7", + "asset_type": "Cargo Shuttle", + "location": "Lingxiao Forward Berth", + "mission": "Dry cargo replenishment", + "state_window": "2060-03-01 to 2060-03-18", + "next_event": "Undock · 2060-03-18", + "summary": "Food, station spares, and EVA consumables unloaded.", + "record_status": "Confirmed", + }, + ], + }, + { + "title": "At Port / Surface Ops", + "accent": "neutral", + "items": [ + { + "name": "XH-03 Zhuque", + "asset_type": "Shuttle", + "location": "Star Port", + "mission": "Refit for Jupiter relay deployment", + "state_window": "2060-01-08 to 2060-04-12", + "next_event": "Rollout Review · 2060-04-12", + "summary": "Main engine refurbishment at bay 4, payload integration queued.", + "record_status": "Confirmed", + }, + { + "name": "Mars One Campus Alpha", + "asset_type": "Surface Outpost", + "location": "Mars Surface", + "mission": "Habitat expansion and ISRU staging", + "state_window": "2059-10-20 to present", + "next_event": "Taibai Supply Window · 2060-03-29", + "summary": "Surface crew awaiting structural truss shipment and greenhouse kit.", + "record_status": "Active", + }, + ], + }, +] + +MISSION_LOCATION_GROUPS = [ + { + "title": "Earth System", + "subtitle": "Ports, orbital stations, and nearby transfers", + "items": [ + { + "name": "Lingxiao Station", + "asset_type": "Station", + "state": "Docked / On Station", + "mission": "Crew rotation and logistics hub", + "location": "Low Kerbin Orbit", + "next_event": "Crew Arrival · 2060-03-30", + }, + { + "name": "XH-03 Zhuque", + "asset_type": "Shuttle", + "state": "At Port", + "mission": "Refit for Jupiter relay deployment", + "location": "Star Port", + "next_event": "Rollout Review · 2060-04-12", + }, + { + "name": "Amalthea Crew Vehicle 4", + "asset_type": "Shuttle", + "state": "In Transit", + "mission": "Expedition 16 Crew Rotation", + "location": "LKO Transfer Corridor", + "next_event": "Docking Window · 2060-03-30", + }, + ], + }, + { + "title": "Mars System", + "subtitle": "Orbital assets and surface support network", + "items": [ + { + "name": "Mars One Campus Alpha", + "asset_type": "Surface Outpost", + "state": "Surface Ops", + "mission": "Habitat expansion and ISRU staging", + "location": "Mars Surface", + "next_event": "Taibai Supply Window · 2060-03-29", + }, + { + "name": "XH-02 Taibai", + "asset_type": "Exploration Mothership", + "state": "In Transit", + "mission": "Support Mars One Campus", + "location": "Mars Transfer Lane", + "next_event": "Mars Arrival · 2060-03-29", + }, + ], + }, +] + +MISSION_UPCOMING_EVENTS = [ + {"when": "2060-03-18", "title": "Tianhe Cargo Pod 7 undock from Lingxiao Station", "scope": "Lingxiao Station"}, + {"when": "2060-03-29", "title": "XH-02 Taibai arrives at Mars", "scope": "Mars System"}, + {"when": "2060-03-30", "title": "Expedition 16 crew arrives via Amalthea Crew Vehicle", "scope": "Lingxiao Station"}, + {"when": "2060-04-02", "title": "Expedition 15 crew departs Lingxiao Station", "scope": "Lingxiao Station"}, +] + +MISSION_LOG_HEADER = { + "name": "XH-02 Taibai", + "asset_type": "Exploration Mothership", + "program": "Mars Expedition Program", + "current_state": "In Transit", + "current_location": "Earth to Mars Transfer", + "current_mission": "Support Mars One Campus", + "state_window": "2060-02-26 06:00 to 2060-03-29 18:00", + "next_event": "Mars Arrival · 2060-03-29 18:00", +} + +MISSION_LOG_ENTRIES = [ + { + "mode": "event", + "timestamp": "2058-03-15 10:20", + "title": "First module delivered to Star Port", + "location": "Star Port", + "record_status": "Completed", + "summary": "Core transfer spine and habitat ring unloaded for final assembly.", + }, + { + "mode": "event", + "timestamp": "2059-08-06 09:00", + "title": "Commissioned", + "location": "Star Port", + "record_status": "Completed", + "summary": "Flight certification signed for Mars transport duties.", + }, + { + "mode": "state", + "timestamp": "2059-08-08 04:10", + "end_timestamp": "2059-10-17 13:40", + "title": "Outbound transfer carrying Mars Expedition 3", + "location": "Earth to Mars Transfer", + "record_status": "Completed", + "summary": "Departure from Earth with full crew and descent stack attached.", + }, + { + "mode": "event", + "timestamp": "2059-10-17 13:40", + "title": "Mars Arrival", + "location": "Mars Orbit", + "record_status": "Completed", + "summary": "Insertion burn nominal. Crew descent staging began next day.", + }, + { + "mode": "state", + "timestamp": "2059-10-18 08:00", + "end_timestamp": "2059-10-19 06:10", + "title": "Mars orbit standby and crew pickup window", + "location": "Mars Orbit", + "record_status": "Completed", + "summary": "Received Mars Expedition 1 return crew after descent vehicle separation.", + }, + { + "mode": "state", + "timestamp": "2059-10-19 06:10", + "end_timestamp": "2059-12-25 22:30", + "title": "Return transfer from Mars to Earth", + "location": "Mars to Earth Transfer", + "record_status": "Completed", + "summary": "Return trajectory with Expedition 1 crew aboard.", + }, + { + "mode": "state", + "timestamp": "2060-02-26 06:00", + "end_timestamp": "2060-03-29 18:00", + "title": "Support mission transfer to Mars One Campus", + "location": "Earth to Mars Transfer", + "record_status": "Active", + "summary": "Carrying structural truss and surface greenhouse kit; 105 km/s, 31-day transition.", + }, + { + "mode": "event", + "timestamp": "2060-03-29 18:00", + "title": "Mars Arrival", + "location": "Mars Orbit", + "record_status": "Planned", + "summary": "Planned capture burn and cargo handoff to surface fleet.", + }, +] + +MISSION_TIMELINE_ROWS = [ + { + "name": "XH-02 Taibai", + "asset_type": "Exploration Mothership", + "segments": [ + {"label": "Transit to Mars", "start": 6, "span": 22, "tone": "warning"}, + {"label": "Mars Orbit", "start": 29, "span": 6, "tone": "primary"}, + {"label": "Return Transit", "start": 38, "span": 21, "tone": "neutral"}, + {"label": "Port Stay", "start": 61, "span": 8, "tone": "soft"}, + {"label": "Support Transit", "start": 73, "span": 22, "tone": "warning current"}, + ], + "events": [ + {"label": "Commissioned", "offset": 1}, + {"label": "Mars Arrival", "offset": 28}, + {"label": "Earth Arrival", "offset": 59}, + {"label": "Next Mars Arrival", "offset": 95}, + ], + }, + { + "name": "Lingxiao Station", + "asset_type": "Station", + "segments": [ + {"label": "Continuous Ops", "start": 0, "span": 96, "tone": "primary current"}, + ], + "events": [ + {"label": "Crew 9 Arrival", "offset": 10}, + {"label": "Expedition 15 Arrival", "offset": 62}, + {"label": "Expedition 16 Arrival", "offset": 88}, + ], + }, + { + "name": "Mars One Campus Alpha", + "asset_type": "Surface Outpost", + "segments": [ + {"label": "Surface Expansion", "start": 34, "span": 62, "tone": "neutral current"}, + ], + "events": [ + {"label": "First Crew Descent", "offset": 37}, + {"label": "Taibai Supply Window", "offset": 95}, + ], + }, +] + +MISSION_TIMELINE_SCALE = [ + "2059-08", + "2059-09", + "2059-10", + "2059-11", + "2059-12", + "2060-01", + "2060-02", + "2060-03", +] + + +def _mission_preview_context() -> dict[str, object]: + return { + "simulation_time": MISSION_PREVIEW_TIME, + "metrics": MISSION_PREVIEW_METRICS, + "filters": MISSION_PREVIEW_FILTERS, + "upcoming_events": MISSION_UPCOMING_EVENTS, + } + +FAMILY_FORM_FIELDS = [ + {"name": "engine_name", "label": "Engine", "kind": "text", "required": True}, + {"name": "part_name", "label": "Part Name", "kind": "text", "required": False}, + {"name": "cycle", "label": "Cycle", "kind": "combo", "required": False}, + {"name": "size_m", "label": "Size (m)", "kind": "decimal", "required": False}, + {"name": "entry_cost", "label": "Entry Cost", "kind": "integer", "required": False}, +] + +VARIANT_FORM_FIELDS = [ + {"name": "fuel_type", "label": "Fuel Type", "kind": "combo", "required": True}, + {"name": "config_name", "label": "Config Name", "kind": "text", "required": False}, + {"name": "work_env", "label": "Work Env", "kind": "text", "required": False}, + {"name": "sl_isp", "label": "SL ISP", "kind": "decimal", "required": False}, + {"name": "vac_isp", "label": "Vac ISP", "kind": "decimal", "required": False}, + {"name": "min_thrust_kn", "label": "Min Thrust (kN)", "kind": "decimal", "required": False}, + {"name": "max_thrust_kn", "label": "Max Thrust (kN)", "kind": "decimal", "required": False}, + {"name": "mass_t", "label": "Mass (t)", "kind": "decimal", "required": False}, + {"name": "twr", "label": "TWR", "kind": "decimal", "required": False}, + {"name": "throttle_ratio", "label": "Throttle Ratio", "kind": "decimal", "required": False}, + {"name": "tvc_deg", "label": "TVC (deg)", "kind": "decimal", "required": False}, + {"name": "price_kd", "label": "Price (KD)", "kind": "integer", "required": False}, + {"name": "ignitions", "label": "Ignitions", "kind": "integer", "required": False}, + { + "name": "has_unlimited_ignitions", + "label": "Unlimited Ignitions", + "kind": "checkbox", + "required": False, + }, + {"name": "mod_source", "label": "Mod Source", "kind": "combo", "required": False}, + {"name": "tech_required", "label": "Tech Required", "kind": "text", "required": False}, + {"name": "engine_note", "label": "Engine Note", "kind": "textarea", "required": False}, + {"name": "config_note", "label": "Config Note", "kind": "textarea", "required": False}, +] + +VARIANT_CREATE_FIELDS = [ + {"name": "fuel_type", "label": "Fuel Type", "kind": "combo", "required": True}, + {"name": "config_name", "label": "Config Name", "kind": "text", "required": False}, + {"name": "work_env", "label": "Work Env", "kind": "text", "required": False}, + {"name": "sl_isp", "label": "SL ISP", "kind": "decimal", "required": False}, + {"name": "vac_isp", "label": "Vac ISP", "kind": "decimal", "required": False}, + {"name": "max_thrust_kn", "label": "Max Thrust (kN)", "kind": "decimal", "required": False}, + {"name": "mod_source", "label": "Mod Source", "kind": "combo", "required": False}, +] + +COMMUNICATION_FIELDS = [ + {"name": "part_name", "label": "Part", "kind": "text", "required": True}, + {"name": "display_name", "label": "Part Showname", "kind": "text", "required": False}, + {"name": "antenna_type", "label": "Type", "kind": "text", "required": False}, + {"name": "source", "label": "Source", "kind": "text", "required": False}, + {"name": "mass_t", "label": "Mass (t)", "kind": "decimal", "required": False}, + {"name": "range_km", "label": "Range km", "kind": "decimal", "required": False}, + {"name": "angle_deg", "label": "Angle", "kind": "decimal", "required": False}, + {"name": "idle_power_watt", "label": "Idle Power (W)", "kind": "decimal", "required": False}, + { + "name": "transmitting_power_watt", + "label": "Transmitting Power (W)", + "kind": "decimal", + "required": False, + }, + {"name": "entry_cost", "label": "Entry Cost", "kind": "integer", "required": False}, + {"name": "cost", "label": "Cost", "kind": "integer", "required": False}, + {"name": "is_active", "label": "Is Active", "kind": "checkbox", "required": False}, + { + "name": "is_deployable", + "label": "Is Deployable", + "kind": "checkbox", + "required": False, + }, + {"name": "is_feeder", "label": "Is Feeder", "kind": "checkbox", "required": False}, + {"name": "description", "label": "Description", "kind": "textarea", "required": False}, + {"name": "note", "label": "Note", "kind": "textarea", "required": False}, +] + +TANK_FIELDS = [ + {"name": "tank_name", "label": "Tank", "kind": "text", "required": True}, + {"name": "fuel_type", "label": "Fuel Type", "kind": "text", "required": False}, + {"name": "vehicle_name", "label": "Vehicle", "kind": "text", "required": False}, + {"name": "source", "label": "Source", "kind": "text", "required": False}, + {"name": "dry_mass_t", "label": "Dry Mass", "kind": "decimal", "required": False}, + {"name": "fuel_mass_t", "label": "Fuel Mass", "kind": "decimal", "required": False}, + {"name": "wet_mass_t", "label": "Wet Mass", "kind": "decimal", "required": False}, + {"name": "tank_volume_l", "label": "Tank Volume (L)", "kind": "decimal", "required": False}, + {"name": "mass_ratio", "label": "Mass Ratio", "kind": "decimal", "required": False}, + { + "name": "kiloliters_per_ton", + "label": "KL per ton", + "kind": "decimal", + "required": False, + }, + {"name": "note", "label": "Note", "kind": "textarea", "required": False}, +] + +VEHICLE_FIELDS = [ + {"name": "vehicle_name", "label": "Vehicle", "kind": "text", "required": True}, + {"name": "launch_price", "label": "Launch Price", "kind": "integer", "required": False}, + {"name": "source", "label": "Source", "kind": "text", "required": False}, +] + +ASSET_FIELDS = [ + {"name": "name", "label": "Asset", "kind": "text", "required": True}, + {"name": "asset_type", "label": "Asset Type", "kind": "combo", "required": True}, + {"name": "is_retired", "label": "Retired", "kind": "checkbox", "required": False}, + {"name": "program", "label": "Program", "kind": "text", "required": False}, + {"name": "home_region", "label": "Home Region", "kind": "text", "required": False}, + {"name": "note", "label": "Note", "kind": "textarea", "required": False}, +] + +ASSET_TYPE_BASE_OPTIONS = ( + "Shuttle", + "Station", + "Surface Outpost", + "Exploration Mothership", +) + +ASSET_ENTRY_KIND_OPTIONS = [ + {"value": "event", "label": "Event Point"}, + {"value": "state", "label": "State Interval"}, +] + +PROJECT_METRICS = [ + {"label": "Engine rows", "value": "478"}, + {"label": "Communication rows", "value": "50"}, + {"label": "Tank rows", "value": "27"}, + {"label": "Vehicle cost rows", "value": "10"}, +] + +MODULES = [ + { + "title": "Engine catalog", + "status": "live", + "description": "Core engine families and variants with filters, pagination, create/delete, and editable variants.", + "endpoint": "web.engine_list", + }, + { + "title": "Communication parts", + "status": "live", + "description": "Paginated communication part list with source/type filters and edit forms.", + "endpoint": "web.communication_list", + }, + { + "title": "Tank specs", + "status": "live", + "description": "Paginated tank list with fuel/source filters and edit forms.", + "endpoint": "web.tank_list", + }, + { + "title": "Vehicle costs", + "status": "live", + "description": "Paginated vehicle cost list with sorting and edit forms.", + "endpoint": "web.vehicle_list", + }, + { + "title": "Mission assets", + "status": "beta", + "description": "Asset CRUD with real asset log entries and simulation-time-derived current state.", + "endpoint": "web.asset_list", + }, + { + "title": "Fuel converter", + "status": "live", + "description": "A working liters-to-tons and tons-to-liters converter based on the workbook coefficients.", + "endpoint": "web.fuel_converter", + }, +] + + +@dataclass(slots=True) +class EngineCatalogRow: + family: EngineFamily + config_count: int + fuel_types: tuple[str, ...] + mod_sources: tuple[str, ...] + mass_t: Decimal | None + twr: Decimal | None + vac_thrust_kn: Decimal | None + sl_thrust_kn: Decimal | None + vac_isp: Decimal | None + sl_isp: Decimal | None + + +def _engine_family_statement() -> object: + return select(EngineFamily).options(selectinload(EngineFamily.variants)) + + +def _load_model_or_404(model: type[Any], object_id: object) -> Any: + instance = db.session.get(model, object_id) + if instance is None: + abort(404) + return instance + + +def _load_engine_family_or_404(family_id: object) -> EngineFamily: + statement = _engine_family_statement().where(EngineFamily.id == family_id) + family = db.session.execute(statement).scalar_one_or_none() + if family is None: + abort(404) + return family + + +def _normalized_text(raw_value: str | None) -> str | None: + if raw_value is None: + return None + cleaned = raw_value.strip() + return cleaned or None + + +def _parse_decimal(raw_value: str | None, label: str) -> Decimal | None: + cleaned = _normalized_text(raw_value) + if cleaned is None: + return None + + try: + return Decimal(cleaned) + except InvalidOperation as exc: + raise ValueError(f"{label} 必须是数值") from exc + + +def _parse_integer(raw_value: str | None, label: str) -> int | None: + cleaned = _normalized_text(raw_value) + if cleaned is None: + return None + + try: + decimal_value = Decimal(cleaned) + except InvalidOperation as exc: + raise ValueError(f"{label} 必须是整数") from exc + + if decimal_value != decimal_value.to_integral_value(): + raise ValueError(f"{label} 必须是整数") + + return int(decimal_value) + + +def _as_utc(value: datetime | None) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def _parse_datetime(raw_value: str | None, label: str) -> datetime | None: + cleaned = _normalized_text(raw_value) + if cleaned is None: + return None + + try: + parsed = datetime.fromisoformat(cleaned.replace(" ", "T")) + except ValueError as exc: + normalized = ( + cleaned.replace("年", "/") + .replace("月", "/") + .replace("日", " ") + .replace("T", " ") + ) + match = re.fullmatch(r"(\d{4})[/-](\d{1,2})[/-](\d{1,2})\s+(\d{1,2}):(\d{1,2})", " ".join(normalized.split())) + if match is None: + raise ValueError(f"{label} 必须是合法时间") from exc + try: + parsed = datetime( + int(match.group(1)), + int(match.group(2)), + int(match.group(3)), + int(match.group(4)), + int(match.group(5)), + ) + except ValueError as inner_exc: + raise ValueError(f"{label} 必须是合法时间") from inner_exc + + return _as_utc(parsed) + + +def _default_simulation_time() -> datetime: + return datetime(2060, 3, 12, 9, 0, tzinfo=timezone.utc) + + +def _remember_simulation_time(value: datetime) -> None: + if not has_request_context(): + return + session[SIMULATION_TIME_SESSION_KEY] = _format_datetime_input(value) + + +def _saved_simulation_time_value() -> str | None: + if not has_request_context(): + return None + saved_value = session.get(SIMULATION_TIME_SESSION_KEY) + return saved_value if isinstance(saved_value, str) else None + + +def _resolve_simulation_time(raw_value: str | None) -> datetime: + try: + parsed_value = _parse_datetime(raw_value, "Simulation Time") + except ValueError: + parsed_value = None + + if parsed_value is not None: + _remember_simulation_time(parsed_value) + return parsed_value + + saved_value = _saved_simulation_time_value() + if saved_value is not None: + try: + saved_datetime = _parse_datetime(saved_value, "Simulation Time") + except ValueError: + saved_datetime = None + if saved_datetime is not None: + return saved_datetime + + default_value = _default_simulation_time() + _remember_simulation_time(default_value) + return default_value + + +def _format_datetime_display(value: datetime | None) -> str: + normalized = _as_utc(value) + if normalized is None: + return "-" + return normalized.strftime("%Y-%m-%d %H:%M") + + +def _format_datetime_input(value: datetime | None) -> str: + normalized = _as_utc(value) + if normalized is None: + return "" + return normalized.strftime("%Y-%m-%dT%H:%M") + + +def _datetime_input_parts(value: datetime | None) -> dict[str, str]: + normalized = _as_utc(value) + if normalized is None: + return {"year": "", "month": "", "day": "", "hour": "", "minute": ""} + return { + "year": normalized.strftime("%Y"), + "month": normalized.strftime("%m"), + "day": normalized.strftime("%d"), + "hour": normalized.strftime("%H"), + "minute": normalized.strftime("%M"), + } + + +def _parse_datetime_form_value( + form: object, + field_name: str, + label: str, + *, + required: bool = False, +) -> datetime | None: + part_names = ("year", "month", "day", "hour", "minute") + part_values = {part: _normalized_text(form.get(f"{field_name}_{part}")) for part in part_names} + + if any(value is not None for value in part_values.values()): + if any(value is None for value in part_values.values()): + raise ValueError(f"{label} 需要完整填写年月日时分") + try: + return datetime( + int(part_values["year"]), + int(part_values["month"]), + int(part_values["day"]), + int(part_values["hour"]), + int(part_values["minute"]), + tzinfo=timezone.utc, + ) + except ValueError as exc: + raise ValueError(f"{label} 必须是合法时间") from exc + + parsed_value = _parse_datetime(form.get(field_name), label) + if required and parsed_value is None: + raise ValueError(f"{label} 不能为空") + return parsed_value + + +def _apply_fields( + target: object, + form: object, + field_specs: list[dict[str, object]], + prefix: str = "", +) -> None: + for field_spec in field_specs: + field_name = field_spec["name"] + full_name = f"{prefix}{field_name}" + field_kind = field_spec["kind"] + 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 + 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)) + + if required and value in (None, ""): + raise ValueError(f"{label} 不能为空") + + setattr(target, field_name, value) + + +def _collect_form_values(entity: object, field_specs: list[dict[str, object]]) -> dict[str, object]: + return {field["name"]: getattr(entity, field["name"], None) for field in field_specs} + + +def _load_asset_or_404(asset_id: object) -> Asset: + statement = select(Asset).options(selectinload(Asset.log_entries).selectinload(AssetLogEntry.state_nodes)).where(Asset.id == asset_id) + asset = db.session.execute(statement).scalar_one_or_none() + if asset is None: + abort(404) + return asset + + +def _load_asset_entry_or_404(asset_id: object, entry_id: object) -> AssetLogEntry: + entry = db.session.get(AssetLogEntry, entry_id) + if entry is None or entry.asset_id != asset_id: + abort(404) + return entry + + +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 无效") + + title = _normalized_text(form.get("title")) + if title is None: + raise ValueError("Title 不能为空") + + start_at = _parse_datetime_form_value(form, "start_at", "Start At", required=True) + if start_at is None: + raise ValueError("Start At 不能为空") + + end_at = _parse_datetime_form_value(form, "end_at", "End At") + if entry_kind == "event": + end_at = None + elif end_at is not None and end_at <= start_at: + raise ValueError("End At 必须晚于 Start At") + + state_node_rows = [] + if entry_kind == "state": + state_node_rows = _parse_state_node_rows(form, start_at, end_at) + + target.entry_kind = entry_kind + target.title = title + target.state_label = _normalize_multi_value_text(form.get("state_label")) + target.mission_label = _normalize_multi_value_text(form.get("mission_label")) + target.location = _normalized_text(form.get("location")) + target.start_at = start_at + target.end_at = end_at + target.summary = _normalized_text(form.get("summary")) + target.note = _normalized_text(form.get("note")) + + if target.entry_kind == "state" and target.state_label is None: + target.state_label = title + + _sync_state_nodes(target, state_node_rows if entry_kind == "state" else []) + + +def _asset_entry_form_values(entry: AssetLogEntry) -> dict[str, object]: + return { + "entry_kind": entry.entry_kind or "event", + "title": entry.title or "", + "state_label": entry.state_label or "", + "mission_label": entry.mission_label or "", + "location": entry.location or "", + "start_at": _format_datetime_input(entry.start_at), + "start_at_parts": _datetime_input_parts(entry.start_at), + "end_at": _format_datetime_input(entry.end_at), + "end_at_parts": _datetime_input_parts(entry.end_at), + "summary": entry.summary or "", + "note": entry.note or "", + "state_nodes": [ + { + "id": str(node.id) if node.id is not None else "", + "title": node.title or "", + "detail": node.detail or "", + "at": _format_datetime_input(node.at_time), + } + for node in sorted(entry.state_nodes, key=lambda item: _as_utc(item.at_time) or _default_simulation_time()) + ], + } + + +def _parse_state_node_rows( + form: object, + start_at: datetime, + end_at: datetime | None, +) -> list[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") + + if not node_ids and not node_titles and not node_details and not node_times: + return [] + + if ( + len(node_ids) != len(node_titles) + or len(node_titles) != len(node_details) + or len(node_details) != len(node_times) + ): + raise ValueError("State Node 数据不完整") + + state_nodes = [] + for index, (raw_id, raw_title, raw_detail, raw_at_time) in enumerate( + zip(node_ids, node_titles, node_details, node_times), + start=1, + ): + 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") + + 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") + if at_time < start_at: + raise ValueError(f"State Node {index} 时间不能早于 Start At") + if end_at is not None and at_time > end_at: + raise ValueError(f"State Node {index} 时间不能晚于 End At") + + state_nodes.append( + { + "id": node_id, + "title": title, + "detail": detail, + "at_time": at_time, + } + ) + + return state_nodes + + +def _sync_state_nodes(target: AssetLogEntry, state_node_rows: list[dict[str, object]]) -> None: + existing_nodes = {str(node.id): node for node in target.state_nodes if node.id is not None} + next_nodes = [] + + for row in state_node_rows: + raw_node_id = row["id"] + 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 无效") + else: + node = AssetStateNode() + + node.title = str(row["title"]) + node.detail = row["detail"] + node.at_time = row["at_time"] + next_nodes.append(node) + + target.state_nodes = next_nodes + + +def _normalize_variant_ignitions(variant: EngineVariant) -> None: + if variant.has_unlimited_ignitions: + variant.ignitions = None + return + + if variant.ignitions is None: + variant.has_unlimited_ignitions = True + + +def _calculate_variant_twr(max_thrust: Decimal | None, mass: Decimal | None) -> Decimal | None: + if max_thrust is None or mass in (None, Decimal("0")): + return None + return max_thrust / Decimal("9.80665") / mass + + +def _calculate_variant_throttle_ratio( + min_thrust: Decimal | None, max_thrust: Decimal | None +) -> Decimal | None: + if min_thrust is None or max_thrust in (None, Decimal("0")): + return None + return min_thrust / max_thrust + + +def _refresh_variant_derived_fields(variant: EngineVariant) -> None: + variant.twr = _calculate_variant_twr(variant.max_thrust_kn, variant.mass_t) + variant.throttle_ratio = _calculate_variant_throttle_ratio( + variant.min_thrust_kn, + variant.max_thrust_kn, + ) + + +def _distinct_variant_values(family: EngineFamily, attribute_name: str) -> tuple[str, ...]: + values = { + getattr(variant, attribute_name) + for variant in family.variants + if getattr(variant, attribute_name) not in (None, "") + } + return tuple(sorted(values, key=str.casefold)) + + +def _distinct_text_values(values: list[str | None]) -> tuple[str, ...]: + cleaned_values = {value.strip() for value in values if value and value.strip()} + return tuple(sorted(cleaned_values, key=str.casefold)) + + +def _engine_form_options() -> dict[str, tuple[str, ...]]: + cycle_values = db.session.execute(select(EngineFamily.cycle)).scalars().all() + fuel_values = db.session.execute(select(EngineVariant.fuel_type)).scalars().all() + mod_values = db.session.execute(select(EngineVariant.mod_source)).scalars().all() + return { + "cycle": _distinct_text_values(cycle_values), + "fuel_type": _distinct_text_values(fuel_values), + "mod_source": _distinct_text_values(mod_values), + } + + +def _asset_form_options() -> dict[str, tuple[str, ...]]: + asset_type_values = db.session.execute(select(Asset.asset_type)).scalars().all() + return { + "asset_type": _distinct_text_values([*ASSET_TYPE_BASE_OPTIONS, *asset_type_values]), + } + + +def _max_variant_decimal(family: EngineFamily, attribute_name: str) -> Decimal | None: + values = [ + getattr(variant, attribute_name) + for variant in family.variants + if getattr(variant, attribute_name) is not None + ] + return max(values) if values else None + + +def _estimate_sl_thrust_kn(variant: EngineVariant) -> Decimal | None: + if variant.max_thrust_kn is None: + return None + if variant.sl_isp is None or variant.vac_isp in (None, Decimal("0")): + return None + return variant.max_thrust_kn * variant.sl_isp / variant.vac_isp + + +def _build_catalog_row(family: EngineFamily) -> EngineCatalogRow: + sl_thrust_values = [ + value for value in (_estimate_sl_thrust_kn(variant) for variant in family.variants) if value is not None + ] + return EngineCatalogRow( + family=family, + config_count=len(family.variants), + fuel_types=_distinct_variant_values(family, "fuel_type"), + mod_sources=_distinct_variant_values(family, "mod_source"), + mass_t=_max_variant_decimal(family, "mass_t"), + twr=_max_variant_decimal(family, "twr"), + vac_thrust_kn=_max_variant_decimal(family, "max_thrust_kn"), + sl_thrust_kn=max(sl_thrust_values) if sl_thrust_values else None, + vac_isp=_max_variant_decimal(family, "vac_isp"), + sl_isp=_max_variant_decimal(family, "sl_isp"), + ) + + +def _build_catalog_rows() -> list[EngineCatalogRow]: + families = db.session.execute( + _engine_family_statement().order_by(EngineFamily.engine_name.asc()) + ).scalars().unique().all() + return [_build_catalog_row(family) for family in families] + + +def _matches_catalog_search(row: EngineCatalogRow, search_term: str) -> bool: + normalized = search_term.casefold() + search_targets = [ + row.family.engine_name, + row.family.part_name or "", + row.family.cycle or "", + *row.fuel_types, + *row.mod_sources, + *(variant.config_name or "" for variant in row.family.variants), + ] + return any(normalized in target.casefold() for target in search_targets if target) + + +def _decimal_sort_key(value: Decimal | None, descending: bool = False) -> tuple[int, Decimal]: + if value is None: + return (1, Decimal("0")) + return (0, -value if descending else value) + + +def _sort_catalog_rows(rows: list[EngineCatalogRow], sort_option: str) -> str: + allowed = {option["value"] for option in ENGINE_SORT_OPTIONS} + normalized = sort_option if sort_option in allowed else "engine_name_asc" + + if normalized == "engine_name_desc": + rows.sort(key=lambda row: row.family.engine_name.casefold(), reverse=True) + return normalized + + if normalized == "vac_thrust_asc": + rows.sort( + key=lambda row: ( + _decimal_sort_key(row.vac_thrust_kn), + row.family.engine_name.casefold(), + ) + ) + return normalized + + if normalized == "vac_thrust_desc": + rows.sort( + key=lambda row: ( + _decimal_sort_key(row.vac_thrust_kn, descending=True), + row.family.engine_name.casefold(), + ) + ) + return normalized + + if normalized == "sl_thrust_asc": + rows.sort( + key=lambda row: ( + _decimal_sort_key(row.sl_thrust_kn), + row.family.engine_name.casefold(), + ) + ) + return normalized + + if normalized == "sl_thrust_desc": + rows.sort( + key=lambda row: ( + _decimal_sort_key(row.sl_thrust_kn, descending=True), + row.family.engine_name.casefold(), + ) + ) + return normalized + + if normalized == "mass_t_asc": + rows.sort( + key=lambda row: ( + _decimal_sort_key(row.mass_t), + row.family.engine_name.casefold(), + ) + ) + return normalized + + if normalized == "mass_t_desc": + rows.sort( + key=lambda row: ( + _decimal_sort_key(row.mass_t, descending=True), + row.family.engine_name.casefold(), + ) + ) + return normalized + + if normalized == "vac_isp_asc": + rows.sort( + key=lambda row: ( + _decimal_sort_key(row.vac_isp), + row.family.engine_name.casefold(), + ) + ) + return normalized + + if normalized == "vac_isp_desc": + rows.sort( + key=lambda row: ( + _decimal_sort_key(row.vac_isp, descending=True), + row.family.engine_name.casefold(), + ) + ) + return normalized + + if normalized == "sl_isp_asc": + rows.sort( + key=lambda row: ( + _decimal_sort_key(row.sl_isp), + row.family.engine_name.casefold(), + ) + ) + return normalized + + if normalized == "sl_isp_desc": + rows.sort( + key=lambda row: ( + _decimal_sort_key(row.sl_isp, descending=True), + row.family.engine_name.casefold(), + ) + ) + return normalized + + rows.sort(key=lambda row: row.family.engine_name.casefold()) + return normalized + + +def _build_engine_sort_links(query_args: dict[str, object], current_sort: str) -> list[dict[str, object]]: + columns = [] + for column in ENGINE_SORT_COLUMNS: + asc_value = f"{column['key']}_asc" + desc_value = f"{column['key']}_desc" + columns.append( + { + "key": column["key"], + "label": column["label"], + "asc_url": _page_url("web.engine_list", {**query_args, "sort": asc_value}, 1), + "desc_url": _page_url("web.engine_list", {**query_args, "sort": desc_value}, 1), + "asc_active": current_sort == asc_value, + "desc_active": current_sort == desc_value, + } + ) + return columns + + +def _sort_communications(rows: list[CommunicationPart], sort_option: str) -> str: + allowed = {option["value"] for option in COMM_SORT_OPTIONS} + normalized = sort_option if sort_option in allowed else "part_name" + + if normalized == "range_desc": + rows.sort( + key=lambda item: ( + _decimal_sort_key(item.range_km, descending=True), + item.part_name.casefold(), + ) + ) + return normalized + + if normalized == "mass_desc": + rows.sort( + key=lambda item: ( + _decimal_sort_key(item.mass_t, descending=True), + item.part_name.casefold(), + ) + ) + return normalized + + rows.sort(key=lambda item: item.part_name.casefold()) + return normalized + + +def _sort_tanks(rows: list[TankSpec], sort_option: str) -> str: + allowed = {option["value"] for option in TANK_SORT_OPTIONS} + normalized = sort_option if sort_option in allowed else "tank_name" + + if normalized == "volume_desc": + rows.sort( + key=lambda item: ( + _decimal_sort_key(item.tank_volume_l, descending=True), + item.tank_name.casefold(), + ) + ) + return normalized + + if normalized == "ratio_desc": + rows.sort( + key=lambda item: ( + _decimal_sort_key(item.mass_ratio, descending=True), + item.tank_name.casefold(), + ) + ) + return normalized + + rows.sort(key=lambda item: item.tank_name.casefold()) + return normalized + + +def _sort_vehicles(rows: list[VehicleCost], sort_option: str) -> str: + allowed = {option["value"] for option in VEHICLE_SORT_OPTIONS} + normalized = sort_option if sort_option in allowed else "vehicle_name" + + if normalized == "launch_price_desc": + rows.sort( + key=lambda item: ( + item.launch_price is None, + -(item.launch_price or 0), + item.vehicle_name.casefold(), + ) + ) + return normalized + + if normalized == "launch_price_asc": + rows.sort( + key=lambda item: ( + item.launch_price is None, + item.launch_price or 0, + item.vehicle_name.casefold(), + ) + ) + return normalized + + rows.sort(key=lambda item: item.vehicle_name.casefold()) + return normalized + + +def _sort_assets(rows: list[Asset], sort_option: str) -> str: + allowed = {option["value"] for option in ASSET_SORT_OPTIONS} + normalized = sort_option if sort_option in allowed else "name_asc" + + if normalized == "updated_desc": + rows.sort( + key=lambda item: ( + _as_utc(item.updated_at) or datetime.min.replace(tzinfo=timezone.utc), + item.name.casefold(), + ), + reverse=True, + ) + elif normalized == "asset_type_asc": + rows.sort(key=lambda item: ((item.asset_type or "").casefold(), item.name.casefold())) + else: + rows.sort(key=lambda item: item.name.casefold()) + + rows.sort(key=lambda item: bool(item.is_retired)) + return normalized + + +def _classify_asset_entry(entry: AssetLogEntry, simulation_time: datetime) -> dict[str, str]: + start_at = _as_utc(entry.start_at) + end_at = _as_utc(entry.end_at) + + if entry.entry_kind == "state": + if start_at and start_at <= simulation_time and (end_at is None or simulation_time < end_at): + return {"label": "Current Interval", "tone": "active"} + if start_at and simulation_time < start_at: + return {"label": "Upcoming Interval", "tone": "planned"} + return {"label": "Past Interval", "tone": "completed"} + + if start_at and simulation_time < start_at: + return {"label": "Upcoming Event", "tone": "planned"} + return {"label": "Past Event", "tone": "completed"} + + +def _entry_matches_date_range( + entry: AssetLogEntry, + range_start: datetime | None, + range_end: datetime | None, +) -> bool: + start_at = _as_utc(entry.start_at) + if start_at is None: + return False + + window_start = range_start or datetime.min.replace(tzinfo=timezone.utc) + window_end = range_end or datetime.max.replace(tzinfo=timezone.utc) + + if entry.entry_kind == "event": + return window_start <= start_at <= window_end + + effective_end = _as_utc(entry.end_at) or datetime.max.replace(tzinfo=timezone.utc) + return start_at <= window_end and effective_end >= window_start + + +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 + for entry in entries: + if entry.entry_kind != "state": + continue + start_at = _as_utc(entry.start_at) + end_at = _as_utc(entry.end_at) + if start_at and start_at <= simulation_time and (end_at is None or simulation_time < end_at): + active_state = entry + + past_entries = [entry for entry in entries if (_as_utc(entry.start_at) or simulation_time) <= simulation_time] + 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 + ] + + if active_state is not None: + current_state = _display_multi_value_text(active_state.state_label) or active_state.title + current_location = active_state.location or asset.home_region or "-" + current_mission = _display_multi_value_text(active_state.mission_label) or asset.program or "-" + state_window = f"{_format_datetime_display(active_state.start_at)} → {_format_datetime_display(active_state.end_at) if active_state.end_at else 'Open'}" + else: + current_state = "No Active Interval" if last_entry is not None else "No Logged Timeline" + current_location = ( + (last_entry.location if last_entry is not None else None) + or asset.home_region + or "-" + ) + current_mission = ( + (_display_multi_value_text(last_entry.mission_label) if last_entry is not None else None) + or asset.program + or "-" + ) + if last_entry is None: + state_window = "No entry covers selected Simulation Time" + elif last_entry.entry_kind == "state": + state_window = ( + f"Last interval · {_format_datetime_display(last_entry.start_at)}" + f" → {_format_datetime_display(last_entry.end_at) if last_entry.end_at else 'Open'}" + ) + else: + state_window = f"Last event · {_format_datetime_display(last_entry.start_at)}" + + 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']}" + + return { + "current_state": current_state, + "current_event": current_event, + "current_location": current_location, + "current_mission": current_mission, + "state_window": state_window, + "next_event": next_event_label, + "next_entry": next_entry, + "active_state": active_state, + "last_entry": last_entry, + "upcoming_events": upcoming_events[:4], + } + + +def _build_asset_catalog_row(asset: Asset, simulation_time: datetime) -> dict[str, object]: + snapshot = _build_asset_snapshot(asset, simulation_time) + return { + "asset": asset, + "is_retired": asset.is_retired, + "current_event": snapshot["current_event"], + "current_location": snapshot["current_location"], + "next_event": snapshot["next_event"], + "entry_count": len(asset.log_entries), + } + + +def _load_assets_with_logs() -> list[Asset]: + return db.session.execute( + select(Asset).options(selectinload(Asset.log_entries).selectinload(AssetLogEntry.state_nodes)).order_by(Asset.name.asc()) + ).scalars().all() + + +def _classify_asset_board_record(snapshot: dict[str, object]) -> dict[str, object]: + if snapshot["active_state"] is not None: + return {"label": "Active", "rank": 0} + if snapshot["next_entry"] is not None: + return {"label": "Planned", "rank": 1} + if snapshot["last_entry"] is not None: + return {"label": "Recorded", "rank": 2} + return {"label": "Unlogged", "rank": 3} + + +def _derive_status_group_title(snapshot: dict[str, object], record_status: dict[str, object]) -> str: + if snapshot["active_state"] is not None: + return str(snapshot["current_state"]) + if record_status["label"] == "Planned": + return "Upcoming / Not Yet Active" + if record_status["label"] == "Recorded": + return "No Active Interval" + return "No Logged Timeline" + + +def _status_group_accent(group_title: str, record_label: str) -> str: + normalized = group_title.casefold() + if record_label == "Planned": + return "warning" + if "transit" in normalized or "transfer" in normalized or "cruise" in normalized: + return "warning" + if "station" in normalized or "dock" in normalized or "orbit" in normalized: + return "primary" + if "surface" in normalized or "port" in normalized or "outpost" in normalized or "base" in normalized: + return "neutral" + if "no active" in normalized or "no logged" in normalized: + return "soft" + if record_label == "Active": + return "primary" + if record_label == "Recorded": + return "soft" + return "neutral" + + +def _asset_board_summary(asset: Asset, snapshot: dict[str, object]) -> str: + summary_source = snapshot["active_state"] or snapshot["last_entry"] or snapshot["next_entry"] + if summary_source is not None and summary_source.summary: + return summary_source.summary + if asset.note: + return asset.note + if snapshot["last_entry"] is not None: + return f"Last logged entry: {snapshot['last_entry'].title}." + if snapshot["next_entry"] is not None: + return f"Next recorded entry: {snapshot['next_entry'].title}." + return "No summary recorded." + + +def _normalize_location_group(location: str) -> str: + if not location or location == "-": + return "Unknown Location" + return location + + +def _build_mission_board_rows(assets: list[Asset], simulation_time: datetime) -> list[dict[str, object]]: + rows = [] + for asset in assets: + snapshot = _build_asset_snapshot(asset, simulation_time) + record_status = _classify_asset_board_record(snapshot) + state_group = _derive_status_group_title(snapshot, record_status) + current_location = _normalize_location_group(str(snapshot["current_location"])) + state_display = state_group if snapshot["active_state"] is None else str(snapshot["current_state"]) + rows.append( + { + "asset": asset, + "snapshot": snapshot, + "record_status": record_status, + "name": asset.name, + "asset_id": asset.id, + "asset_type": asset.asset_type, + "location": current_location, + "mission": snapshot["current_mission"], + "state_window": snapshot["state_window"], + "next_event": snapshot["next_event"], + "summary": _asset_board_summary(asset, snapshot), + "status_group": state_group, + "status_accent": _status_group_accent(state_group, str(record_status["label"])), + "state_display": state_display, + } + ) + return rows + + +def _normalize_record_scope(raw_value: str | None) -> str: + allowed = {option["value"] for option in MISSION_BOARD_RECORD_SCOPE_OPTIONS} + normalized = _normalized_text(raw_value) or "all" + return normalized if normalized in allowed else "all" + + +def _matches_record_scope(row: dict[str, object], record_scope: str) -> bool: + record_label = row["record_status"]["label"] + if record_scope == "active": + return record_label == "Active" + if record_scope == "active_planned": + return record_label in {"Active", "Planned"} + if record_scope == "recorded": + return record_label == "Recorded" + return True + + +def _filter_mission_board_rows( + rows: list[dict[str, object]], + asset_type_filter: str, + location_filter: str, + record_scope: str, +) -> list[dict[str, object]]: + filtered_rows = [] + for row in rows: + if asset_type_filter and asset_type_filter != (row["asset_type"] or ""): + continue + if location_filter and location_filter != row["location"]: + continue + if not _matches_record_scope(row, record_scope): + continue + filtered_rows.append(row) + return filtered_rows + + +def _build_status_board_groups(rows: list[dict[str, object]]) -> list[dict[str, object]]: + groups_by_title: dict[str, dict[str, object]] = {} + for row in rows: + group = groups_by_title.setdefault( + row["status_group"], + { + "title": row["status_group"], + "accent": row["status_accent"], + "rank": row["record_status"]["rank"], + "items": [], + }, + ) + group["rank"] = min(group["rank"], row["record_status"]["rank"]) + group["items"].append( + { + "asset_id": row["asset_id"], + "name": row["name"], + "asset_type": row["asset_type"], + "location": row["location"], + "mission": row["mission"], + "state_window": row["state_window"], + "next_event": row["next_event"], + "summary": row["summary"], + "record_status": row["record_status"]["label"], + "record_sort_rank": row["record_status"]["rank"], + } + ) + + groups = list(groups_by_title.values()) + for group in groups: + group["items"].sort(key=lambda item: (item["record_sort_rank"], item["name"].casefold())) + groups.sort(key=lambda item: (item["rank"], item["title"].casefold())) + return groups + + +def _build_location_board_groups(rows: list[dict[str, object]]) -> list[dict[str, object]]: + groups_by_location: dict[str, dict[str, object]] = {} + for row in rows: + group = groups_by_location.setdefault( + row["location"], + { + "title": row["location"], + "subtitle": "Derived from active interval or latest logged event.", + "items": [], + }, + ) + group["items"].append( + { + "asset_id": row["asset_id"], + "name": row["name"], + "asset_type": row["asset_type"], + "state": row["state_display"], + "mission": row["mission"], + "location": row["location"], + "next_event": row["next_event"], + "record_sort_rank": row["record_status"]["rank"], + } + ) + + groups = list(groups_by_location.values()) + for group in groups: + item_count = len(group["items"]) + if group["title"] == "Unknown Location": + group["subtitle"] = "No active interval or logged location is available yet." + elif item_count == 1: + group["subtitle"] = "1 asset currently attributed to this location." + else: + group["subtitle"] = f"{item_count} assets currently attributed to this location." + group["items"].sort(key=lambda item: (item["record_sort_rank"], item["name"].casefold())) + groups.sort(key=lambda item: (item["title"] == "Unknown Location", item["title"].casefold())) + return groups + + +def _build_board_upcoming_events(rows: list[dict[str, object]], simulation_time: datetime) -> list[dict[str, str]]: + future_entries = [] + for row in rows: + asset = row["asset"] + for entry in asset.log_entries: + start_at = _as_utc(entry.start_at) + if start_at is None or start_at <= simulation_time: + continue + future_entries.append( + { + "when": _format_datetime_display(entry.start_at), + "title": f"{asset.name} · {entry.title}", + "scope": entry.location or row["location"], + "sort_at": start_at, + } + ) + + future_entries.sort(key=lambda item: item["sort_at"]) + return [ + {"when": item["when"], "title": item["title"], "scope": item["scope"]} + for item in future_entries[:6] + ] + + +def _build_board_metrics(all_rows: list[dict[str, object]], visible_rows: list[dict[str, object]], simulation_time: datetime) -> list[dict[str, str]]: + upcoming_window_limit = simulation_time + timedelta(days=30) + upcoming_30d_count = 0 + for row in visible_rows: + for entry in row["asset"].log_entries: + start_at = _as_utc(entry.start_at) + if start_at is not None and simulation_time < start_at <= upcoming_window_limit: + upcoming_30d_count += 1 + + return [ + {"label": "Total Assets", "value": str(len(all_rows))}, + {"label": "Visible Cards", "value": str(len(visible_rows))}, + {"label": "30-Day Events", "value": str(upcoming_30d_count)}, + ] + + +def _build_asset_catalog_metrics(all_assets: list[Asset], visible_assets: list[Asset], simulation_time: datetime) -> list[dict[str, str]]: + upcoming_window_limit = simulation_time + timedelta(days=30) + upcoming_30d_count = 0 + for asset in visible_assets: + for entry in asset.log_entries: + start_at = _as_utc(entry.start_at) + if start_at is not None and simulation_time < start_at <= upcoming_window_limit: + upcoming_30d_count += 1 + + return [ + {"label": "Total Assets", "value": str(len(all_assets))}, + {"label": "Current Results", "value": str(len(visible_assets))}, + {"label": "30-Day Events", "value": str(upcoming_30d_count)}, + ] + + +def _build_header_hidden_fields(values: dict[str, object]) -> list[dict[str, str]]: + hidden_fields: list[dict[str, str]] = [] + for name, value in values.items(): + if value in (None, "", [], (), set()): + continue + if isinstance(value, (list, tuple, set)): + for item in value: + if item in (None, ""): + continue + hidden_fields.append({"name": name, "value": str(item)}) + continue + hidden_fields.append({"name": name, "value": str(value)}) + return hidden_fields + + +def _mission_board_query_args( + simulation_time: datetime, + asset_type_filter: str, + location_filter: str, + record_scope: str, +) -> dict[str, str]: + params = { + "sim_time": _format_datetime_input(simulation_time), + "record_scope": record_scope, + } + if asset_type_filter: + params["asset_type"] = asset_type_filter + if location_filter: + params["location"] = location_filter + return params + + +def _mission_board_context( + endpoint: str, + simulation_time: datetime, + asset_type_filter: str, + location_filter: str, + record_scope: str, + all_rows: list[dict[str, object]], + visible_rows: list[dict[str, object]], +) -> dict[str, object]: + query_args = _mission_board_query_args(simulation_time, asset_type_filter, location_filter, record_scope) + return { + "simulation_time": _format_datetime_display(simulation_time), + "simulation_time_display": _format_datetime_display(simulation_time), + "simulation_time_input": _format_datetime_input(simulation_time), + "metrics": _build_board_metrics(all_rows, visible_rows, simulation_time), + "preview_eyebrow": "Mission Ops", + "header_time_action": url_for(endpoint), + "header_time_field_id": f"{endpoint.rsplit('.', 1)[-1]}-header-sim-time", + "header_time_submit_label": "Apply Time", + "header_hidden_fields": _build_header_hidden_fields( + { + "asset_type": asset_type_filter, + "location": location_filter, + "record_scope": record_scope, + } + ), + "upcoming_events": _build_board_upcoming_events(visible_rows, simulation_time), + "asset_type_filter": asset_type_filter, + "location_filter": location_filter, + "record_scope": record_scope, + "asset_type_options": sorted({row["asset_type"] for row in all_rows if row["asset_type"]}, key=str.casefold), + "location_options": sorted({row["location"] for row in all_rows if row["location"]}, key=str.casefold), + "record_scope_options": MISSION_BOARD_RECORD_SCOPE_OPTIONS, + "time_shift_urls": { + "minus_7": url_for(endpoint, **{**query_args, "sim_time": _format_datetime_input(simulation_time - timedelta(days=7))}), + "plus_7": url_for(endpoint, **{**query_args, "sim_time": _format_datetime_input(simulation_time + timedelta(days=7))}), + }, + "reset_url": url_for(endpoint, sim_time=_format_datetime_input(simulation_time)), + "board_nav_urls": { + "status": url_for("web.mission_status_board_preview", **query_args), + "location": url_for("web.mission_location_board_preview", **query_args), + "mission": url_for("web.mission_list", sim_time=_format_datetime_input(simulation_time)), + "asset": url_for("web.asset_list", sim_time=_format_datetime_input(simulation_time)), + "timeline": url_for("web.mission_timeline_preview", sim_time=_format_datetime_input(simulation_time)), + }, + } + + +def _mission_ops_nav_urls(simulation_time: datetime) -> dict[str, str]: + simulation_time_input = _format_datetime_input(simulation_time) + return { + "status": url_for("web.mission_status_board_preview", sim_time=simulation_time_input), + "location": url_for("web.mission_location_board_preview", sim_time=simulation_time_input), + "mission": url_for("web.mission_list", sim_time=simulation_time_input), + "asset": url_for("web.asset_list", sim_time=simulation_time_input), + "timeline": url_for("web.mission_timeline_preview", sim_time=simulation_time_input), + } + + +def _month_start(value: datetime) -> datetime: + normalized = _as_utc(value) or _default_simulation_time() + return normalized.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + +def _month_end(value: datetime) -> datetime: + start = _month_start(value) + if start.month == 12: + return start.replace(year=start.year + 1, month=1) + return start.replace(month=start.month + 1) + + +def _month_delta(start: datetime, end: datetime) -> int: + return (end.year - start.year) * 12 + (end.month - start.month) + + +def _day_start(value: datetime) -> datetime: + normalized = _as_utc(value) or _default_simulation_time() + return normalized.replace(hour=0, minute=0, second=0, microsecond=0) + + +def _days_in_month(year: int, month: int) -> int: + if month == 12: + next_month = datetime(year + 1, 1, 1) + else: + next_month = datetime(year, month + 1, 1) + return (next_month - datetime(year, month, 1)).days + + +def _timeline_add_units(value: datetime, scale: str, count: int = 1) -> datetime: + normalized = _as_utc(value) or _default_simulation_time() + if scale == "day": + return normalized + timedelta(days=count) + if scale == "year": + target_year = normalized.year + count + target_day = min(normalized.day, _days_in_month(target_year, normalized.month)) + return normalized.replace(year=target_year, day=target_day) + + total_month = (normalized.year * 12 + (normalized.month - 1)) + count + target_year = total_month // 12 + target_month = (total_month % 12) + 1 + target_day = min(normalized.day, _days_in_month(target_year, target_month)) + return normalized.replace(year=target_year, month=target_month, day=target_day) + + +def _timeline_bucket_start(value: datetime, scale: str) -> datetime: + normalized = _as_utc(value) or _default_simulation_time() + if scale == "year": + return normalized.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0) + if scale == "day": + return _day_start(normalized) + return _month_start(normalized) + + +def _timeline_next_bucket(value: datetime, scale: str) -> datetime: + bucket_start = _timeline_bucket_start(value, scale) + if scale == "year": + return bucket_start.replace(year=bucket_start.year + 1) + if scale == "day": + return bucket_start + timedelta(days=1) + return _month_end(bucket_start) + + +def _timeline_bucket_delta(start: datetime, end: datetime, scale: str) -> int: + start_bucket = _timeline_bucket_start(start, scale) + end_bucket = _timeline_bucket_start(end, scale) + if scale == "year": + return end_bucket.year - start_bucket.year + if scale == "day": + return (end_bucket - start_bucket).days + return _month_delta(start_bucket, end_bucket) + + +def _resolve_timeline_scale(raw_value: str | None, range_start: datetime, range_end: datetime) -> str: + allowed = {option["value"] for option in TIMELINE_SCALE_OPTIONS} + normalized = _normalized_text(raw_value) + if normalized in allowed: + return str(normalized) + + total_days = max(1, int((_timeline_bucket_start(range_end, "day") - _timeline_bucket_start(range_start, "day")).days) + 1) + if total_days <= 120: + return "day" + if total_days <= 365 * 4: + return "month" + return "year" + + +def _resolve_timeline_boundary(raw_value: str | None, fallback: datetime) -> datetime: + try: + return _parse_datetime(raw_value, "Timeline Boundary") or fallback + except ValueError: + return fallback + + +def _timeline_default_range(rows: list[dict[str, object]], simulation_time: datetime) -> tuple[datetime, datetime]: + return _timeline_default_range_for_scale(rows, simulation_time, "month") + + +def _timeline_default_range_for_scale(rows: list[dict[str, object]], simulation_time: datetime, scale: str) -> tuple[datetime, datetime]: + start_timestamps = [] + effective_end_timestamps = [] + for row in rows: + for entry in row["asset"].log_entries: + start_at = _as_utc(entry.start_at) + end_at = _as_utc(entry.end_at) + if start_at is not None: + start_timestamps.append(start_at) + effective_end_timestamps.append(end_at or start_at) + + all_timestamps = [*start_timestamps, *effective_end_timestamps] + if not all_timestamps: + fallback_start = _day_start(simulation_time) + return fallback_start, _timeline_add_units(fallback_start, scale) + + earliest_start = min(start_timestamps or all_timestamps) + latest_finish = max(effective_end_timestamps or all_timestamps) + return earliest_start, _timeline_add_units(latest_finish, scale) + + +def _timeline_scale_labels(start_at: datetime, end_at: datetime, scale: str) -> list[str]: + labels = [] + cursor = _timeline_bucket_start(start_at, scale) + boundary = _timeline_bucket_start(end_at, scale) + while cursor <= boundary: + if scale == "year": + labels.append(cursor.strftime("%Y")) + elif scale == "day": + labels.append(cursor.strftime("%Y-%m-%d")) + else: + labels.append(cursor.strftime("%Y-%m")) + cursor = _timeline_next_bucket(cursor, scale) + return labels + + +def _timeline_range_display(start_at: datetime, end_at: datetime, scale: str) -> str: + start_bucket = _timeline_bucket_start(start_at, scale) + end_bucket = _timeline_bucket_start(end_at, scale) + if scale == "year": + return f"{start_bucket.strftime('%Y')} to {end_bucket.strftime('%Y')}" + if scale == "day": + return f"{start_bucket.strftime('%Y-%m-%d')} to {end_bucket.strftime('%Y-%m-%d')}" + return f"{start_bucket.strftime('%Y-%m')} to {end_bucket.strftime('%Y-%m')}" + + +def _timeline_column_width(scale: str) -> int: + if scale == "day": + return 48 + if scale == "year": + return 84 + return 72 + + +def _timeline_segment_tone(entry: AssetLogEntry, simulation_time: datetime) -> str: + title = (entry.title or "").casefold() + location = (entry.location or "").casefold() + tone = "neutral" + if any(token in title for token in ("transfer", "transit", "outbound", "return", "departure")): + tone = "warning" + elif any(token in title for token in ("operation", "ops", "orbit", "station", "trial", "expedition")): + tone = "primary" + elif any(token in title for token in ("maintenance", "upgrade", "construction", "fitting", "reconfig")): + tone = "soft" + elif any(token in location for token in ("mars", "europa", "jupiter", "saturn", "neptune", "surface", "orbit")): + tone = "primary" + + start_at = _as_utc(entry.start_at) + end_at = _as_utc(entry.end_at) + if start_at is not None and start_at <= simulation_time and (end_at is None or simulation_time < end_at): + return f"{tone} current" + return tone + + +def _entry_overlaps_timeline(entry: AssetLogEntry, range_start: datetime, range_end: datetime) -> bool: + start_at = _as_utc(entry.start_at) + end_at = _as_utc(entry.end_at) + if start_at is None: + return False + if entry.entry_kind == "event": + return range_start <= start_at <= range_end + effective_end = end_at or range_end + return start_at <= range_end and effective_end > range_start + + +def _timeline_entry_edit_url(asset_id: object, entry_id: object, simulation_time: datetime) -> str: + return url_for( + "web.asset_entry_edit", + asset_id=asset_id, + entry_id=entry_id, + sim_time=_format_datetime_input(simulation_time), + ) + + +def _timeline_state_windows(asset: Asset) -> dict[object, tuple[datetime, datetime | None]]: + state_entries = [ + entry + for entry in sorted(asset.log_entries, key=lambda item: _as_utc(item.start_at) or _default_simulation_time()) + if entry.entry_kind == "state" and _as_utc(entry.start_at) is not None + ] + + windows: dict[object, tuple[datetime, datetime | None]] = {} + for entry in state_entries: + start_at = _as_utc(entry.start_at) + if start_at is None: + continue + + windows[entry.id] = (start_at, _as_utc(entry.end_at)) + + return windows + + +def _timeline_state_is_active( + entry: AssetLogEntry, + at_time: datetime, + state_windows: dict[object, tuple[datetime, datetime | None]], +) -> bool: + start_at, end_at = state_windows.get(entry.id, (_as_utc(entry.start_at) or at_time, _as_utc(entry.end_at))) + return start_at <= at_time and (end_at is None or at_time < end_at) + + +def _timeline_state_overlaps( + entry: AssetLogEntry, + range_start: datetime, + range_end: datetime, + state_windows: dict[object, tuple[datetime, datetime | None]], +) -> bool: + start_at, end_at = state_windows.get(entry.id, (_as_utc(entry.start_at) or range_start, _as_utc(entry.end_at))) + effective_end = end_at or range_end + return start_at <= range_end and effective_end > range_start + + +def _timeline_position(value: datetime, bucket_origin: datetime, scale: str, bucket_count: int) -> float: + normalized = _as_utc(value) or bucket_origin + if normalized <= bucket_origin: + return 0.0 + + bucket_start = _timeline_bucket_start(normalized, scale) + bucket_end = _timeline_next_bucket(bucket_start, scale) + bucket_index = _timeline_bucket_delta(bucket_origin, bucket_start, scale) + bucket_duration = max((bucket_end - bucket_start).total_seconds(), 1.0) + fraction = max(0.0, min(1.0, (normalized - bucket_start).total_seconds() / bucket_duration)) + return max(0.0, min(float(bucket_count), bucket_index + fraction)) + + +def _timeline_percent(position: float, bucket_count: int) -> str: + if bucket_count <= 0: + return "0%" + return f"{(position / bucket_count) * 100:.6f}%" + + +def _assign_timeline_tracks( + items: list[dict[str, object]], + *, + start_key: str, + end_key: str, + minimum_span: float, + gap: float = 0.0, + span_key: str | None = None, +) -> int: + if not items: + return 1 + + track_ends: list[float] = [] + for item in items: + start_position = float(item[start_key]) + item_span = minimum_span + if span_key is not None: + span_value = item.get(span_key) + if span_value is not None: + item_span = max(item_span, float(span_value)) + + end_position = max(float(item[end_key]), start_position + item_span) + track_index = 0 + while track_index < len(track_ends) and start_position < track_ends[track_index] + gap: + track_index += 1 + + if track_index == len(track_ends): + track_ends.append(end_position) + else: + track_ends[track_index] = end_position + + item["track"] = track_index + + return max(1, len(track_ends)) + + +def _timeline_state_track_settings(scale: str) -> tuple[float, float]: + if scale == "year": + return 0.9, 0.04 + if scale == "day": + return 0.18, 0.02 + return 0.24, 0.03 + + +def _timeline_event_track_settings(scale: str) -> tuple[float, float]: + label_span = 110 / max(_timeline_column_width(scale), 1) + if scale == "year": + return max(0.95, label_span), 0.08 + if scale == "day": + return max(1.75, label_span), 0.04 + return max(1.35, label_span), 0.06 + + +def _timeline_event_label_width_px(label: str, scale: str) -> int: + weighted_length = 0.0 + for character in label.strip(): + if character.isspace(): + weighted_length += 0.3 + elif ord(character) < 128: + weighted_length += 0.85 + else: + weighted_length += 1.55 + + if scale == "year": + minimum_width, maximum_width = 110, 190 + elif scale == "day": + minimum_width, maximum_width = 150, 260 + else: + minimum_width, maximum_width = 132, 220 + + estimated_width = int(28 + weighted_length * 7) + return max(minimum_width, min(maximum_width, estimated_width)) + + +def _timeline_event_label_span(width_px: int, scale: str) -> float: + return width_px / max(_timeline_column_width(scale), 1) + + +def _timeline_state_lane_height(track_count: int) -> int: + return max(1, track_count) * 38 + 22 + + +def _timeline_state_segment_center(track_index: int) -> int: + return 25 + max(track_index, 0) * 38 + + +def _timeline_event_node_anchor(track_index: int) -> int: + return 23 + max(track_index, 0) * 54 + + +def _timeline_state_node_connector_height( + state_track_count: int, + state_track_index: int, + event_track_index: int, +) -> int: + return ( + _timeline_state_lane_height(state_track_count) + + 8 + + _timeline_event_node_anchor(event_track_index) + - _timeline_state_segment_center(state_track_index) + ) + + +def _build_timeline_rows( + rows: list[dict[str, object]], + simulation_time: datetime, + range_start: datetime, + range_end: datetime, + scale: str, +) -> tuple[list[dict[str, object]], dict[str, int]]: + bucket_origin = _timeline_bucket_start(range_start, scale) + bucket_count = len(_timeline_scale_labels(range_start, range_end, scale)) + timeline_rows = [] + state_count = 0 + event_count = 0 + + for row in rows: + asset = row["asset"] + state_windows = _timeline_state_windows(asset) + segments = [] + events = [] + + for entry in sorted(asset.log_entries, key=lambda item: _as_utc(item.start_at) or range_start): + if entry.entry_kind == "state": + if not _timeline_state_overlaps(entry, range_start, range_end, state_windows): + continue + + state_start_at, state_end_at = state_windows.get(entry.id, (_as_utc(entry.start_at) or range_start, _as_utc(entry.end_at))) + start_at = max(state_start_at, range_start) + exclusive_end = state_end_at + if exclusive_end is not None: + exclusive_end = exclusive_end - timedelta(microseconds=1) + end_at = min(exclusive_end or range_end, range_end) + if end_at < start_at: + end_at = start_at + + actual_start_position = _timeline_position(start_at, bucket_origin, scale, bucket_count) + actual_end_position = _timeline_position(end_at, bucket_origin, scale, bucket_count) + if actual_end_position <= actual_start_position: + actual_end_position = min(float(bucket_count), actual_start_position + 0.02) + + state_count += 1 + segments.append( + { + "entry_id": entry.id, + "label": entry.title, + "tone": _timeline_segment_tone(entry, simulation_time), + "url": _timeline_entry_edit_url(entry.asset_id, entry.id, simulation_time), + "start_position": actual_start_position, + "body_end_position": actual_end_position, + "display_end_position": actual_end_position, + } + ) + + for state_node in sorted(entry.state_nodes, key=lambda item: _as_utc(item.at_time) or range_start): + node_at = _as_utc(state_node.at_time) + if node_at is None or node_at < range_start or node_at > range_end: + continue + + full_label_parts = [state_node.title, entry.title] + if state_node.detail: + full_label_parts.append(state_node.detail) + + event_position = _timeline_position(node_at, bucket_origin, scale, bucket_count) + label_width_px = _timeline_event_label_width_px(state_node.title, scale) + label_span = _timeline_event_label_span(label_width_px, scale) + label_side = "left" if event_position >= bucket_count * 0.6 else "right" + track_start_position = max(0.0, event_position - label_span) if label_side == "left" else event_position + track_end_position = event_position if label_side == "left" else min(float(bucket_count), event_position + label_span) + + events.append( + { + "label": state_node.title, + "full_label": " · ".join(full_label_parts), + "url": _timeline_entry_edit_url(asset.id, entry.id, simulation_time), + "position": event_position, + "label_width_px": label_width_px, + "label_span": label_span, + "label_side": label_side, + "track_start_position": track_start_position, + "track_end_position": track_end_position, + "state_node": True, + "parent_entry_id": entry.id, + } + ) + event_count += 1 + continue + + if not _entry_overlaps_timeline(entry, range_start, range_end): + continue + + event_position = _timeline_position(_as_utc(entry.start_at) or range_start, bucket_origin, scale, bucket_count) + label_width_px = _timeline_event_label_width_px(entry.title, scale) + label_span = _timeline_event_label_span(label_width_px, scale) + label_side = "left" if event_position >= bucket_count * 0.6 else "right" + track_start_position = max(0.0, event_position - label_span) if label_side == "left" else event_position + track_end_position = event_position if label_side == "left" else min(float(bucket_count), event_position + label_span) + events.append( + { + "label": entry.title, + "full_label": entry.title, + "url": _timeline_entry_edit_url(asset.id, entry.id, simulation_time), + "position": event_position, + "label_width_px": label_width_px, + "label_span": label_span, + "label_side": label_side, + "track_start_position": track_start_position, + "track_end_position": track_end_position, + "state_node": False, + } + ) + event_count += 1 + + segments.sort(key=lambda item: float(item["start_position"])) + events.sort(key=lambda item: float(item["position"])) + + state_minimum_span, state_gap = _timeline_state_track_settings(scale) + event_minimum_span, event_gap = _timeline_event_track_settings(scale) + state_track_count = _assign_timeline_tracks( + segments, + start_key="start_position", + end_key="display_end_position", + minimum_span=state_minimum_span, + gap=state_gap, + ) + segment_tracks = {segment["entry_id"]: int(segment["track"]) for segment in segments} + event_track_count = _assign_timeline_tracks( + events, + start_key="track_start_position", + end_key="track_end_position", + minimum_span=event_minimum_span, + gap=event_gap, + ) + + for segment in segments: + display_span = max(float(segment["display_end_position"]) - float(segment["start_position"]), 0.02) + body_span = max(float(segment["body_end_position"]) - float(segment["start_position"]), 0.02) + segment["left_pct"] = _timeline_percent(float(segment["start_position"]), bucket_count) + segment["track_width_pct"] = _timeline_percent(display_span, bucket_count) + segment["body_width_pct"] = f"{(body_span / display_span) * 100:.6f}%" + segment["compact"] = (scale == "year" and body_span < 0.45) or (state_track_count > 1 and body_span < 0.75) + + for event in events: + event["left_pct"] = _timeline_percent(float(event["position"]), bucket_count) + if not event.get("state_node"): + continue + + parent_track = segment_tracks.get(event["parent_entry_id"]) + if parent_track is None: + continue + + event["connector_height_px"] = f"{_timeline_state_node_connector_height(state_track_count, parent_track, int(event['track']))}px" + + if not segments and not events: + continue + + timeline_rows.append( + { + "asset_id": asset.id, + "name": asset.name, + "asset_type": asset.asset_type, + "current_state": row["state_display"], + "segments": segments, + "events": events, + "state_track_count": state_track_count, + "event_track_count": event_track_count, + } + ) + + timeline_rows.sort(key=lambda item: item["name"].casefold()) + return timeline_rows, {"states": state_count, "events": event_count} + + +def _page_url(endpoint: str, query_args: dict[str, object], page_number: int) -> str: + params = {key: value for key, value in query_args.items() if value not in (None, "")} + params["page"] = page_number + return url_for(endpoint, **params) + + +def _page_markers(total_pages: int, current_page: int) -> list[int | None]: + if total_pages <= 9: + return list(range(1, total_pages + 1)) + + markers: list[int | None] = [1] + start = max(2, current_page - 2) + end = min(total_pages - 1, current_page + 2) + + if start > 2: + markers.append(None) + markers.extend(range(start, end + 1)) + if end < total_pages - 1: + markers.append(None) + + markers.append(total_pages) + return markers + + +def _paginate( + items: list[Any], + endpoint: str, + query_args: dict[str, object], + requested_page: int, +) -> tuple[list[Any], dict[str, Any]]: + total_count = len(items) + total_pages = max(1, ceil(total_count / DEFAULT_PAGE_SIZE)) + page = min(max(requested_page, 1), total_pages) + start_index = (page - 1) * DEFAULT_PAGE_SIZE + page_items = items[start_index : start_index + DEFAULT_PAGE_SIZE] + + markers = _page_markers(total_pages, page) + page_links = [] + for marker in markers: + if marker is None: + page_links.append({"ellipsis": True}) + continue + page_links.append( + { + "ellipsis": False, + "number": marker, + "url": _page_url(endpoint, query_args, marker), + "current": marker == page, + } + ) + + context = { + "page": page, + "total_pages": total_pages, + "total_count": total_count, + "has_prev": page > 1, + "has_next": page < total_pages, + "prev_url": _page_url(endpoint, query_args, page - 1) if page > 1 else None, + "next_url": _page_url(endpoint, query_args, page + 1) if page < total_pages else None, + "page_links": page_links, + } + return page_items, context + + +def _entity_matches_query(values: list[str | None], search_term: str) -> bool: + normalized = search_term.casefold() + return any(normalized in (value or "").casefold() for value in values) + + +def _split_multi_value_text(value: str | None) -> list[str]: + if value is None: + return [] + + labels: list[str] = [] + for part in str(value).split(";"): + cleaned = _normalized_text(part) + if cleaned is None or cleaned in labels: + continue + labels.append(cleaned) + return labels + + +def _normalize_multi_value_text(value: str | None) -> str | None: + labels = _split_multi_value_text(value) + if not labels: + return None + return "; ".join(labels) + + +def _display_multi_value_text(value: str | None, separator: str = " · ") -> str | None: + labels = _split_multi_value_text(value) + if not labels: + return None + return separator.join(labels) + + +def _mission_label_search_key(value: str | None) -> str: + return re.sub(r"[\s\-_]+", "", (value or "").casefold()) + + +def _mission_label_matches_query(mission_label: str | None, search_term: str) -> bool: + if mission_label is None: + return False + + raw_query = search_term.casefold().strip() + raw_label = mission_label.casefold().strip() + if not raw_query or not raw_label: + return False + + normalized_query = _mission_label_search_key(search_term) + normalized_label = _mission_label_search_key(mission_label) + if normalized_query and normalized_query in normalized_label: + return True + if raw_query in raw_label: + return True + + query_tokens = [token for token in re.split(r"[\s\-_]+", raw_query) if token] + label_tokens = [token for token in re.split(r"[\s\-_]+", raw_label) if token] + if query_tokens and all( + any( + token in label_token + or label_token in token + or SequenceMatcher(None, token, label_token).ratio() >= 0.86 + for label_token in label_tokens + ) + for token in query_tokens + ): + return True + return False + + +@web_bp.get("/") +def dashboard() -> str: + return render_template( + "dashboard.html", + title="Dashboard", + metrics=PROJECT_METRICS, + modules=MODULES, + duplicate_conflicts=48, + duplicate_conflicts_with_fuel=19, + ) + + +@web_bp.get("/fuel-converter") +def fuel_converter() -> str: + return render_template( + "fuel_converter.html", + title="Fuel Converter", + fuel_factors=list_fuel_factors(), + ) + + +@web_bp.get("/engines") +def engine_list() -> str: + search_term = request.args.get("q", "").strip() + fuel_filter = request.args.get("fuel_type", "").strip() + cycle_filter = request.args.get("cycle", "").strip() + mod_source_filter = request.args.get("mod_source", "").strip() + sort_option = request.args.get("sort", "engine_name_asc").strip() + requested_page = request.args.get("page", default=1, type=int) or 1 + + catalog_rows = _build_catalog_rows() + fuel_options = sorted({fuel for row in catalog_rows for fuel in row.fuel_types}, key=str.casefold) + cycle_options = sorted({row.family.cycle for row in catalog_rows if row.family.cycle}, key=str.casefold) + mod_source_options = sorted({source for row in catalog_rows for source in row.mod_sources}, key=str.casefold) + + filtered_rows = [] + for row in catalog_rows: + if search_term and not _matches_catalog_search(row, search_term): + continue + if fuel_filter and fuel_filter not in row.fuel_types: + continue + if cycle_filter and cycle_filter != (row.family.cycle or ""): + continue + if mod_source_filter and mod_source_filter not in row.mod_sources: + continue + filtered_rows.append(row) + + normalized_sort = _sort_catalog_rows(filtered_rows, sort_option) + query_args = { + "q": search_term, + "fuel_type": fuel_filter, + "cycle": cycle_filter, + "mod_source": mod_source_filter, + } + page_rows, paging = _paginate(filtered_rows, "web.engine_list", query_args, requested_page) + sort_columns = _build_engine_sort_links(query_args, normalized_sort) + + return render_template( + "engine_list.html", + title="Engine Catalog", + catalog_rows=page_rows, + search_term=search_term, + fuel_filter=fuel_filter, + cycle_filter=cycle_filter, + mod_source_filter=mod_source_filter, + sort_option=normalized_sort, + sort_columns=sort_columns, + fuel_options=fuel_options, + cycle_options=cycle_options, + mod_source_options=mod_source_options, + total_catalog_count=len(catalog_rows), + **paging, + ) + + +@web_bp.route("/engines/new", methods=["GET", "POST"]) +def engine_new() -> str | object: + family = EngineFamily() + variant = EngineVariant() + form_options = _engine_form_options() + + if request.method == "POST": + try: + _apply_fields(family, request.form, FAMILY_FORM_FIELDS) + _apply_fields(variant, request.form, VARIANT_CREATE_FIELDS, prefix="initial-") + _normalize_variant_ignitions(variant) + variant.family = family + db.session.add(family) + db.session.add(variant) + db.session.commit() + except ValueError as exc: + db.session.rollback() + flash(str(exc), "error") + except IntegrityError: + db.session.rollback() + flash("创建失败:Engine 名称可能已存在。", "error") + else: + flash("引擎已创建。", "success") + return redirect(url_for("web.engine_detail", family_id=family.id)) + + return render_template( + "engine_create.html", + title="Create Engine", + family_fields=FAMILY_FORM_FIELDS, + variant_fields=VARIANT_CREATE_FIELDS, + form_options=form_options, + custom_option_value=CUSTOM_OPTION_VALUE, + ) + + +@web_bp.get("/engines/") +def engine_detail(family_id: object) -> str: + family = _load_engine_family_or_404(family_id) + variant_rows = [ + { + "variant": variant, + "sl_thrust_kn": _estimate_sl_thrust_kn(variant), + "vac_thrust_kn": variant.max_thrust_kn, + } + for variant in family.variants + ] + return render_template( + "engine_detail.html", + title=family.engine_name, + family=family, + variant_rows=variant_rows, + ) + + +@web_bp.route("/engines//edit", methods=["GET", "POST"]) +def engine_edit(family_id: object) -> str: + family = _load_engine_family_or_404(family_id) + form_options = _engine_form_options() + + if request.method == "POST": + try: + _apply_fields(family, request.form, FAMILY_FORM_FIELDS) + for variant in family.variants: + _apply_fields( + variant, + request.form, + VARIANT_FORM_FIELDS, + prefix=f"variant-{variant.id}-", + ) + _normalize_variant_ignitions(variant) + _refresh_variant_derived_fields(variant) + db.session.commit() + except ValueError as exc: + db.session.rollback() + flash(str(exc), "error") + except IntegrityError: + db.session.rollback() + flash("保存失败:Engine 名称必须唯一。", "error") + else: + flash("引擎数据已保存。", "success") + return redirect(url_for("web.engine_detail", family_id=family.id)) + + return render_template( + "engine_edit.html", + title=f"Edit · {family.engine_name}", + family=family, + family_fields=FAMILY_FORM_FIELDS, + variant_fields=VARIANT_FORM_FIELDS, + variant_create_fields=VARIANT_CREATE_FIELDS, + form_options=form_options, + custom_option_value=CUSTOM_OPTION_VALUE, + ) + + +@web_bp.post("/engines//variants/add") +def engine_variant_add(family_id: object) -> object: + family = _load_engine_family_or_404(family_id) + variant = EngineVariant(family=family) + + try: + _apply_fields(variant, request.form, VARIANT_CREATE_FIELDS, prefix="add-") + _normalize_variant_ignitions(variant) + db.session.add(variant) + db.session.commit() + except ValueError as exc: + db.session.rollback() + flash(str(exc), "error") + else: + flash("已新增配置项。", "success") + + return redirect(url_for("web.engine_edit", family_id=family.id)) + + +@web_bp.post("/engines//variants//delete") +def engine_variant_delete(family_id: object, variant_id: object) -> object: + family = _load_engine_family_or_404(family_id) + variant = db.session.get(EngineVariant, variant_id) + if variant is None or variant.family_id != family.id: + abort(404) + + db.session.delete(variant) + db.session.commit() + flash("配置项已删除。", "success") + return redirect(url_for("web.engine_edit", family_id=family.id)) + + +@web_bp.post("/engines//delete") +def engine_delete(family_id: object) -> object: + family = _load_engine_family_or_404(family_id) + db.session.delete(family) + db.session.commit() + flash(f"已删除引擎:{family.engine_name}", "success") + return redirect(url_for("web.engine_list")) + + +@web_bp.get("/communications") +def communication_list() -> str: + search_term = request.args.get("q", "").strip() + source_filter = request.args.get("source", "").strip() + type_filter = request.args.get("antenna_type", "").strip() + sort_option = request.args.get("sort", "part_name").strip() + requested_page = request.args.get("page", default=1, type=int) or 1 + + rows = db.session.execute(select(CommunicationPart).order_by(CommunicationPart.part_name.asc())).scalars().all() + + source_options = sorted({row.source for row in rows if row.source}, key=str.casefold) + type_options = sorted({row.antenna_type for row in rows if row.antenna_type}, key=str.casefold) + + filtered_rows = [] + for row in rows: + if search_term and not _entity_matches_query( + [row.part_name, row.display_name, row.source, row.antenna_type], + search_term, + ): + continue + if source_filter and source_filter != (row.source or ""): + continue + if type_filter and type_filter != (row.antenna_type or ""): + continue + filtered_rows.append(row) + + normalized_sort = _sort_communications(filtered_rows, sort_option) + query_args = { + "q": search_term, + "source": source_filter, + "antenna_type": type_filter, + "sort": normalized_sort, + } + page_rows, paging = _paginate(filtered_rows, "web.communication_list", query_args, requested_page) + + return render_template( + "communication_list.html", + title="Communication Parts", + rows=page_rows, + search_term=search_term, + source_filter=source_filter, + antenna_type_filter=type_filter, + sort_option=normalized_sort, + sort_options=COMM_SORT_OPTIONS, + source_options=source_options, + type_options=type_options, + total_catalog_count=len(rows), + **paging, + ) + + +@web_bp.route("/communications/new", methods=["GET", "POST"]) +def communication_new() -> str | object: + part = CommunicationPart() + + if request.method == "POST": + try: + _apply_fields(part, request.form, COMMUNICATION_FIELDS) + db.session.add(part) + db.session.commit() + except ValueError as exc: + db.session.rollback() + flash(str(exc), "error") + else: + flash("已新增通信部件。", "success") + return redirect(url_for("web.communication_edit", part_id=part.id)) + + return render_template( + "resource_edit.html", + title="Create Communication", + eyebrow="Communication", + heading="新增通信部件", + description="新建一条通信部件记录。", + fields=COMMUNICATION_FIELDS, + values=_collect_form_values(part, COMMUNICATION_FIELDS), + cancel_url=url_for("web.communication_list"), + submit_label="创建", + delete_url=None, + ) + + +@web_bp.route("/communications//edit", methods=["GET", "POST"]) +def communication_edit(part_id: object) -> str | object: + part = _load_model_or_404(CommunicationPart, part_id) + + if request.method == "POST": + try: + _apply_fields(part, request.form, COMMUNICATION_FIELDS) + db.session.commit() + except ValueError as exc: + db.session.rollback() + flash(str(exc), "error") + else: + flash("通信部件已保存。", "success") + return redirect(url_for("web.communication_list")) + + return render_template( + "resource_edit.html", + title=f"Edit · {part.part_name}", + eyebrow="Communication", + heading=f"编辑 {part.part_name}", + description="修改通信部件字段。", + fields=COMMUNICATION_FIELDS, + values=_collect_form_values(part, COMMUNICATION_FIELDS), + cancel_url=url_for("web.communication_list"), + submit_label="保存", + delete_url=url_for("web.communication_delete", part_id=part.id), + delete_label="删除通信部件", + delete_confirm="确认删除这个通信部件?", + ) + + +@web_bp.post("/communications//delete") +def communication_delete(part_id: object) -> object: + part = _load_model_or_404(CommunicationPart, part_id) + db.session.delete(part) + db.session.commit() + flash("通信部件已删除。", "success") + return redirect(url_for("web.communication_list")) + + +@web_bp.get("/tanks") +def tank_list() -> str: + search_term = request.args.get("q", "").strip() + fuel_filter = request.args.get("fuel_type", "").strip() + source_filter = request.args.get("source", "").strip() + sort_option = request.args.get("sort", "tank_name").strip() + requested_page = request.args.get("page", default=1, type=int) or 1 + + rows = db.session.execute(select(TankSpec).order_by(TankSpec.tank_name.asc())).scalars().all() + + fuel_options = sorted({row.fuel_type for row in rows if row.fuel_type}, key=str.casefold) + source_options = sorted({row.source for row in rows if row.source}, key=str.casefold) + + filtered_rows = [] + for row in rows: + if search_term and not _entity_matches_query( + [row.tank_name, row.vehicle_name, row.source, row.fuel_type], + search_term, + ): + continue + if fuel_filter and fuel_filter != (row.fuel_type or ""): + continue + if source_filter and source_filter != (row.source or ""): + continue + filtered_rows.append(row) + + normalized_sort = _sort_tanks(filtered_rows, sort_option) + query_args = { + "q": search_term, + "fuel_type": fuel_filter, + "source": source_filter, + "sort": normalized_sort, + } + page_rows, paging = _paginate(filtered_rows, "web.tank_list", query_args, requested_page) + + return render_template( + "tank_list.html", + title="Tank Specs", + rows=page_rows, + search_term=search_term, + fuel_filter=fuel_filter, + source_filter=source_filter, + sort_option=normalized_sort, + sort_options=TANK_SORT_OPTIONS, + fuel_options=fuel_options, + source_options=source_options, + total_catalog_count=len(rows), + **paging, + ) + + +@web_bp.route("/tanks/new", methods=["GET", "POST"]) +def tank_new() -> str | object: + tank = TankSpec() + + if request.method == "POST": + try: + _apply_fields(tank, request.form, TANK_FIELDS) + db.session.add(tank) + db.session.commit() + except ValueError as exc: + db.session.rollback() + flash(str(exc), "error") + else: + flash("已新增燃料箱记录。", "success") + return redirect(url_for("web.tank_edit", tank_id=tank.id)) + + return render_template( + "resource_edit.html", + title="Create Tank", + eyebrow="Tank", + heading="新增燃料箱记录", + description="录入一条 Tank Chart 数据。", + fields=TANK_FIELDS, + values=_collect_form_values(tank, TANK_FIELDS), + cancel_url=url_for("web.tank_list"), + submit_label="创建", + delete_url=None, + ) + + +@web_bp.route("/tanks//edit", methods=["GET", "POST"]) +def tank_edit(tank_id: object) -> str | object: + tank = _load_model_or_404(TankSpec, tank_id) + + if request.method == "POST": + try: + _apply_fields(tank, request.form, TANK_FIELDS) + db.session.commit() + except ValueError as exc: + db.session.rollback() + flash(str(exc), "error") + else: + flash("燃料箱记录已保存。", "success") + return redirect(url_for("web.tank_list")) + + return render_template( + "resource_edit.html", + title=f"Edit · {tank.tank_name}", + eyebrow="Tank", + heading=f"编辑 {tank.tank_name}", + description="修改燃料箱字段。", + fields=TANK_FIELDS, + values=_collect_form_values(tank, TANK_FIELDS), + cancel_url=url_for("web.tank_list"), + submit_label="保存", + delete_url=url_for("web.tank_delete", tank_id=tank.id), + delete_label="删除燃料箱记录", + delete_confirm="确认删除这个燃料箱记录?", + ) + + +@web_bp.post("/tanks//delete") +def tank_delete(tank_id: object) -> object: + tank = _load_model_or_404(TankSpec, tank_id) + db.session.delete(tank) + db.session.commit() + flash("燃料箱记录已删除。", "success") + return redirect(url_for("web.tank_list")) + + +@web_bp.get("/vehicles") +def vehicle_list() -> str: + search_term = request.args.get("q", "").strip() + source_filter = request.args.get("source", "").strip() + sort_option = request.args.get("sort", "vehicle_name").strip() + requested_page = request.args.get("page", default=1, type=int) or 1 + + rows = db.session.execute(select(VehicleCost).order_by(VehicleCost.vehicle_name.asc())).scalars().all() + source_options = sorted({row.source for row in rows if row.source}, key=str.casefold) + + filtered_rows = [] + for row in rows: + if search_term and not _entity_matches_query([row.vehicle_name, row.source], search_term): + continue + if source_filter and source_filter != (row.source or ""): + continue + filtered_rows.append(row) + + normalized_sort = _sort_vehicles(filtered_rows, sort_option) + query_args = {"q": search_term, "source": source_filter, "sort": normalized_sort} + page_rows, paging = _paginate(filtered_rows, "web.vehicle_list", query_args, requested_page) + + return render_template( + "vehicle_list.html", + title="Vehicle Costs", + rows=page_rows, + search_term=search_term, + source_filter=source_filter, + sort_option=normalized_sort, + sort_options=VEHICLE_SORT_OPTIONS, + source_options=source_options, + total_catalog_count=len(rows), + **paging, + ) + + +@web_bp.route("/vehicles/new", methods=["GET", "POST"]) +def vehicle_new() -> str | object: + vehicle = VehicleCost() + + if request.method == "POST": + try: + _apply_fields(vehicle, request.form, VEHICLE_FIELDS) + db.session.add(vehicle) + db.session.commit() + except ValueError as exc: + db.session.rollback() + flash(str(exc), "error") + else: + flash("已新增载具成本记录。", "success") + return redirect(url_for("web.vehicle_edit", vehicle_id=vehicle.id)) + + return render_template( + "resource_edit.html", + title="Create Vehicle", + eyebrow="Vehicle", + heading="新增载具成本", + description="录入一条 Vehicle Cost 数据。", + fields=VEHICLE_FIELDS, + values=_collect_form_values(vehicle, VEHICLE_FIELDS), + cancel_url=url_for("web.vehicle_list"), + submit_label="创建", + delete_url=None, + ) + + +@web_bp.route("/vehicles//edit", methods=["GET", "POST"]) +def vehicle_edit(vehicle_id: object) -> str | object: + vehicle = _load_model_or_404(VehicleCost, vehicle_id) + + if request.method == "POST": + try: + _apply_fields(vehicle, request.form, VEHICLE_FIELDS) + db.session.commit() + except ValueError as exc: + db.session.rollback() + flash(str(exc), "error") + else: + flash("载具成本记录已保存。", "success") + return redirect(url_for("web.vehicle_list")) + + return render_template( + "resource_edit.html", + title=f"Edit · {vehicle.vehicle_name}", + eyebrow="Vehicle", + heading=f"编辑 {vehicle.vehicle_name}", + description="修改发射成本字段。", + fields=VEHICLE_FIELDS, + values=_collect_form_values(vehicle, VEHICLE_FIELDS), + cancel_url=url_for("web.vehicle_list"), + submit_label="保存", + delete_url=url_for("web.vehicle_delete", vehicle_id=vehicle.id), + delete_label="删除载具成本记录", + delete_confirm="确认删除这个载具成本记录?", + ) + + +@web_bp.post("/vehicles//delete") +def vehicle_delete(vehicle_id: object) -> object: + vehicle = _load_model_or_404(VehicleCost, vehicle_id) + db.session.delete(vehicle) + db.session.commit() + flash("载具成本记录已删除。", "success") + return redirect(url_for("web.vehicle_list")) + + +@web_bp.get("/assets") +def asset_list() -> str: + search_term = request.args.get("q", "").strip() + asset_type_filter = request.args.get("asset_type", "").strip() + sort_option = request.args.get("sort", "name_asc").strip() + requested_page = request.args.get("page", default=1, type=int) or 1 + simulation_time = _resolve_simulation_time(request.args.get("sim_time")) + + rows = db.session.execute( + select(Asset).options(selectinload(Asset.log_entries)).order_by(Asset.name.asc()) + ).scalars().all() + asset_type_options = sorted({row.asset_type for row in rows if row.asset_type}, key=str.casefold) + + filtered_rows = [] + for row in rows: + if search_term and not _entity_matches_query([row.name, row.asset_type, row.program, row.home_region], search_term): + continue + if asset_type_filter and asset_type_filter != (row.asset_type or ""): + continue + filtered_rows.append(row) + + normalized_sort = _sort_assets(filtered_rows, sort_option) + query_args = { + "q": search_term, + "asset_type": asset_type_filter, + "sort": normalized_sort, + "sim_time": _format_datetime_input(simulation_time), + } + page_rows, paging = _paginate(filtered_rows, "web.asset_list", query_args, requested_page) + catalog_rows = [_build_asset_catalog_row(asset, simulation_time) for asset in page_rows] + + return render_template( + "asset_list.html", + title="任务资产", + preview_title="任务资产目录", + preview_eyebrow="Mission Asset", + rows=catalog_rows, + search_term=search_term, + asset_type_filter=asset_type_filter, + sort_option=normalized_sort, + sort_options=ASSET_SORT_OPTIONS, + asset_type_options=asset_type_options, + total_catalog_count=len(rows), + metrics=_build_asset_catalog_metrics(rows, filtered_rows, simulation_time), + 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), + header_time_action=url_for("web.asset_list"), + header_time_field_id="asset-list-header-sim-time", + header_time_submit_label="Apply Time", + header_hidden_fields=_build_header_hidden_fields( + { + "q": search_term, + "asset_type": asset_type_filter, + "sort": normalized_sort, + } + ), + **paging, + ) + + +@web_bp.get("/missions") +def mission_list() -> str: + simulation_time = _resolve_simulation_time(request.args.get("sim_time")) + search_term = request.args.get("q", "").strip() + asset_id_filter = request.args.get("asset_id", "").strip() + mission_label_filter = request.args.get("mission_label", "").strip() + location_filter = request.args.get("location", "").strip() + + try: + range_start = _parse_datetime(request.args.get("start"), "任务开始时间") + except ValueError as exc: + range_start = None + flash(str(exc), "error") + + try: + range_end = _parse_datetime(request.args.get("end"), "任务结束时间") + except ValueError as exc: + range_end = None + flash(str(exc), "error") + + if range_start is not None and range_end is not None and range_end < range_start: + flash("任务结束时间不能早于开始时间。", "error") + range_end = range_start + + all_rows = [] + for asset in _load_assets_with_logs(): + if asset.is_retired: + continue + for entry in sorted( + asset.log_entries, + key=lambda item: _as_utc(item.start_at) or _default_simulation_time(), + reverse=True, + ): + mission_labels = _split_multi_value_text(entry.mission_label) + if not mission_labels: + continue + state_labels = _split_multi_value_text(entry.state_label) + + status = _classify_asset_entry(entry, simulation_time) + for mission_label in mission_labels: + all_rows.append( + { + "asset": asset, + "asset_id": str(asset.id), + "asset_name": asset.name, + "asset_type": asset.asset_type or "-", + "entry": entry, + "entry_id": entry.id, + "mission_label": mission_label, + "mission_labels": mission_labels, + "state_labels": state_labels, + "title": entry.title, + "mode_label": "Event Point" if entry.entry_kind == "event" else "State Interval", + "status_label": status["label"], + "status_tone": status["tone"], + "timestamp": _format_datetime_display(entry.start_at), + "end_timestamp": _format_datetime_display(entry.end_at) if entry.end_at else None, + "location": entry.location or "-", + "summary": entry.summary or "未填写摘要。", + "sort_at": _as_utc(entry.start_at) or _default_simulation_time(), + } + ) + + asset_options = [ + {"value": asset_id, "label": asset_name} + for asset_id, asset_name in sorted( + {item["asset_id"]: item["asset_name"] for item in all_rows}.items(), + key=lambda item: item[1].casefold(), + ) + ] + mission_label_options = sorted({row["mission_label"] for row in all_rows}, key=str.casefold) + location_options = sorted( + {row["location"] for row in all_rows if row["location"] and row["location"] != "-"}, + key=str.casefold, + ) + + filtered_rows = [] + for row in all_rows: + if asset_id_filter and asset_id_filter != row["asset_id"]: + continue + if mission_label_filter and mission_label_filter != row["mission_label"]: + continue + if location_filter and location_filter != row["location"]: + continue + if search_term and not _mission_label_matches_query(row["mission_label"], search_term): + continue + if not _entry_matches_date_range(row["entry"], range_start, range_end): + continue + filtered_rows.append(row) + + grouped_rows: dict[str, dict[str, object]] = {} + for row in filtered_rows: + mission_label = str(row["mission_label"]) + group = grouped_rows.setdefault( + mission_label, + { + "mission_label": mission_label, + "entries": [], + "asset_names": set(), + "locations": set(), + "active_count": 0, + "planned_count": 0, + "latest_at": row["sort_at"], + }, + ) + group["entries"].append(row) + group["asset_names"].add(str(row["asset_name"])) + if row["location"] != "-": + group["locations"].add(str(row["location"])) + if row["status_tone"] == "active": + group["active_count"] += 1 + elif row["status_tone"] == "planned": + group["planned_count"] += 1 + if row["sort_at"] > group["latest_at"]: + group["latest_at"] = row["sort_at"] + + mission_groups = [] + for group in grouped_rows.values(): + entries = sorted(group["entries"], key=lambda item: item["sort_at"], reverse=True) + asset_names = sorted(group["asset_names"], key=str.casefold) + locations = sorted(group["locations"], key=str.casefold) + if group["active_count"]: + group_status = "Active" + group_rank = 0 + elif group["planned_count"]: + group_status = "Upcoming" + group_rank = 1 + else: + group_status = "Recorded" + group_rank = 2 + mission_groups.append( + { + "mission_label": group["mission_label"], + "entries": entries, + "entry_count": len(entries), + "asset_names": asset_names, + "asset_count": len(asset_names), + "locations": locations or ["-"], + "status_label": group_status, + "group_rank": group_rank, + "latest_timestamp": _format_datetime_display(group["latest_at"]), + } + ) + + mission_groups.sort(key=lambda item: (item["group_rank"], item["mission_label"].casefold())) + selected_asset_name = next( + (option["label"] for option in asset_options if option["value"] == asset_id_filter), + "全部资产", + ) + + return render_template( + "mission_list.html", + title="任务运营 · 任务", + preview_title="任务", + preview_eyebrow="Mission Label", + preview_tab="mission", + mission_groups=mission_groups, + search_term=search_term, + asset_id_filter=asset_id_filter, + mission_label_filter=mission_label_filter, + location_filter=location_filter, + asset_options=asset_options, + mission_label_options=mission_label_options, + location_options=location_options, + selected_asset_name=selected_asset_name, + total_entry_count=len(all_rows), + filtered_entry_count=len(filtered_rows), + metrics=[ + {"label": "Visible Missions", "value": str(len(mission_groups))}, + {"label": "Visible Entries", "value": str(len(filtered_rows))}, + {"label": "Active Missions", "value": str(sum(1 for group in mission_groups if group["status_label"] == "Active"))}, + ], + simulation_time_display=_format_datetime_display(simulation_time), + simulation_time_input=_format_datetime_input(simulation_time), + range_start_input=_format_datetime_input(range_start), + range_end_input=_format_datetime_input(range_end), + header_time_action=url_for("web.mission_list"), + header_time_field_id="mission-list-header-sim-time", + header_time_submit_label="Apply Time", + header_hidden_fields=_build_header_hidden_fields( + { + "q": search_term, + "asset_id": asset_id_filter, + "mission_label": mission_label_filter, + "location": location_filter, + "start": _format_datetime_input(range_start), + "end": _format_datetime_input(range_end), + } + ), + board_nav_urls=_mission_ops_nav_urls(simulation_time), + ) + + +@web_bp.route("/assets/new", methods=["GET", "POST"]) +def asset_new() -> str | object: + asset = Asset() + 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) + + if request.method == "POST": + try: + _apply_fields(asset, request.form, ASSET_FIELDS) + db.session.add(asset) + db.session.commit() + except ValueError as exc: + db.session.rollback() + flash(str(exc), "error") + except IntegrityError: + db.session.rollback() + flash("创建失败:Asset 名称必须唯一。", "error") + else: + flash("资产已创建。", "success") + return redirect(url_for("web.asset_detail", asset_id=asset.id, sim_time=simulation_time_input)) + + return render_template( + "resource_edit.html", + title="新增任务资产", + eyebrow="Mission Asset", + heading="新增任务资产", + description=None, + fields=ASSET_FIELDS, + values=_collect_form_values(asset, ASSET_FIELDS), + form_options=form_options, + custom_option_value=CUSTOM_OPTION_VALUE, + cancel_url=url_for("web.asset_list", sim_time=simulation_time_input), + submit_label="创建", + delete_url=None, + hidden_fields={"sim_time": simulation_time_input}, + mission_ops_tabs=True, + preview_tab="asset", + board_nav_urls=_mission_ops_nav_urls(simulation_time), + ) + + +@web_bp.route("/assets//edit", methods=["GET", "POST"]) +def asset_edit(asset_id: object) -> str | object: + asset = _load_asset_or_404(asset_id) + 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) + + if request.method == "POST": + try: + _apply_fields(asset, request.form, ASSET_FIELDS) + db.session.commit() + except ValueError as exc: + db.session.rollback() + flash(str(exc), "error") + except IntegrityError: + db.session.rollback() + flash("保存失败:Asset 名称必须唯一。", "error") + else: + flash("资产信息已保存。", "success") + return redirect(url_for("web.asset_detail", asset_id=asset.id, sim_time=simulation_time_input)) + + return render_template( + "resource_edit.html", + title=f"编辑任务资产 · {asset.name}", + eyebrow="Mission Asset", + heading=f"编辑 {asset.name}", + description=None, + fields=ASSET_FIELDS, + values=_collect_form_values(asset, ASSET_FIELDS), + form_options=form_options, + custom_option_value=CUSTOM_OPTION_VALUE, + 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), + delete_label="删除资产", + delete_confirm="确认删除这个资产及其日志?", + hidden_fields={"sim_time": simulation_time_input}, + delete_hidden_fields={"sim_time": simulation_time_input}, + mission_ops_tabs=True, + preview_tab="asset", + board_nav_urls=_mission_ops_nav_urls(simulation_time), + ) + + +@web_bp.post("/assets//delete") +def asset_delete(asset_id: object) -> object: + asset = _load_asset_or_404(asset_id) + simulation_time_input = _normalized_text(request.form.get("sim_time")) + db.session.delete(asset) + db.session.commit() + flash("资产已删除。", "success") + redirect_kwargs: dict[str, str] = {} + if simulation_time_input is not None: + redirect_kwargs["sim_time"] = simulation_time_input + return redirect(url_for("web.asset_list", **redirect_kwargs)) + + +@web_bp.post("/assets/import-log-book") +def asset_import_log_book() -> object: + redirect_kwargs: dict[str, str] = {} + simulation_time_input = _normalized_text(request.form.get("sim_time")) + if simulation_time_input is not None: + redirect_kwargs["sim_time"] = simulation_time_input + + try: + summary = import_log_book_data(LOG_BOOK_PATH) + except FileNotFoundError as exc: + flash(str(exc), "error") + except Exception as exc: + flash(f"导入 log_book 失败:{exc}", "error") + else: + imported_sheet_names = ", ".join(item.sheet_name for item in summary.imported_assets) or "无" + total_states = sum(item.state_entries for item in summary.imported_assets) + total_events = sum(item.event_entries for item in summary.imported_assets) + flash( + f"已从 {summary.workbook} 导入 {len(summary.imported_assets)} 个载具 sheet({imported_sheet_names}),共 {total_states} 条 state 和 {total_events} 条 event。", + "success", + ) + + return redirect(url_for("web.asset_list", **redirect_kwargs)) + + +@web_bp.get("/assets/") +def asset_detail(asset_id: object) -> str: + asset = _load_asset_or_404(asset_id) + 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) + + try: + entry_range_start = _parse_datetime(request.args.get("start"), "日志开始时间") + except ValueError as exc: + entry_range_start = None + flash(str(exc), "error") + + try: + entry_range_end = _parse_datetime(request.args.get("end"), "日志结束时间") + except ValueError as exc: + entry_range_end = None + flash(str(exc), "error") + + if entry_range_start is not None and entry_range_end is not None and entry_range_end < entry_range_start: + flash("日志结束时间不能早于开始时间。", "error") + entry_range_end = entry_range_start + + entry_rows = [] + filtered_entries = [ + entry + for entry in sorted( + asset.log_entries, + key=lambda item: _as_utc(item.start_at) or _default_simulation_time(), + reverse=True, + ) + if _entry_matches_date_range(entry, entry_range_start, entry_range_end) + ] + for entry in filtered_entries: + status = _classify_asset_entry(entry, simulation_time) + entry_rows.append( + { + "id": entry.id, + "mode": entry.entry_kind, + "mode_label": "Event Point" if entry.entry_kind == "event" else "State Interval", + "status_label": status["label"], + "title": entry.title, + "timestamp": _format_datetime_display(entry.start_at), + "end_timestamp": _format_datetime_display(entry.end_at) if entry.end_at else None, + "location": entry.location or "-", + "summary": entry.summary or "未填写摘要。", + "mission_label": _display_multi_value_text(entry.mission_label), + "state_label": _display_multi_value_text(entry.state_label), + "mission_labels": _split_multi_value_text(entry.mission_label), + "state_labels": _split_multi_value_text(entry.state_label), + } + ) + + paged_entries, pagination_context = _paginate( + entry_rows, + "web.asset_detail", + { + "asset_id": asset.id, + "sim_time": _format_datetime_input(simulation_time), + "start": _format_datetime_input(entry_range_start), + "end": _format_datetime_input(entry_range_end), + }, + requested_page, + ) + + return render_template( + "asset_detail.html", + title=asset.name, + preview_title=asset.name, + preview_eyebrow="Mission Asset", + asset=asset, + asset_summary=snapshot, + entries=paged_entries, + upcoming_events=snapshot["upcoming_events"], + total_entry_count=len(asset.log_entries), + filtered_entry_count=len(entry_rows), + metrics=[ + {"label": "Asset Type", "value": asset.asset_type}, + {"label": "Entries", "value": str(len(asset.log_entries))}, + {"label": "Current State", "value": str(snapshot["current_state"])}, + ], + entry_filter_start_input=_format_datetime_input(entry_range_start), + entry_filter_end_input=_format_datetime_input(entry_range_end), + entry_page=pagination_context["page"], + entry_total_pages=pagination_context["total_pages"], + entry_has_prev=pagination_context["has_prev"], + entry_has_next=pagination_context["has_next"], + entry_prev_url=pagination_context["prev_url"], + entry_next_url=pagination_context["next_url"], + entry_page_links=pagination_context["page_links"], + 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), + 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", + header_hidden_fields=_build_header_hidden_fields( + { + "start": _format_datetime_input(entry_range_start), + "end": _format_datetime_input(entry_range_end), + "page": requested_page if requested_page > 1 else None, + } + ), + ) + + +@web_bp.route("/assets//entries/new", methods=["GET", "POST"]) +def asset_entry_new(asset_id: object) -> str | object: + asset = _load_asset_or_404(asset_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) + requested_kind = _normalized_text(request.args.get("kind")) or "event" + if requested_kind not in {option["value"] for option in ASSET_ENTRY_KIND_OPTIONS}: + requested_kind = "event" + + entry = AssetLogEntry(entry_kind=requested_kind) + + if request.method == "POST": + try: + _apply_asset_entry_fields(entry, request.form) + entry.asset = asset + db.session.add(entry) + db.session.commit() + except ValueError as exc: + db.session.rollback() + flash(str(exc), "error") + else: + flash("日志条目已新增。", "success") + return redirect(url_for("web.asset_detail", asset_id=asset.id, sim_time=simulation_time_input)) + + return render_template( + "asset_entry_form.html", + title=f"新增日志 · {asset.name}", + asset=asset, + heading=f"为 {asset.name} 新增日志条目", + description=None, + submit_label="创建条目", + form_values=_asset_entry_form_values(entry), + kind_options=ASSET_ENTRY_KIND_OPTIONS, + 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, + preview_tab="asset", + board_nav_urls=_mission_ops_nav_urls(simulation_time), + ) + + +@web_bp.route("/assets//entries//edit", methods=["GET", "POST"]) +def asset_entry_edit(asset_id: object, entry_id: object) -> str | object: + asset = _load_asset_or_404(asset_id) + 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) + + if request.method == "POST": + try: + _apply_asset_entry_fields(entry, request.form) + db.session.commit() + except ValueError as exc: + db.session.rollback() + flash(str(exc), "error") + else: + flash("日志条目已保存。", "success") + return redirect(url_for("web.asset_detail", asset_id=asset.id, sim_time=simulation_time_input)) + + return render_template( + "asset_entry_form.html", + title=f"编辑日志 · {asset.name}", + asset=asset, + heading=f"编辑 {asset.name} 的日志条目", + description=None, + submit_label="保存条目", + form_values=_asset_entry_form_values(entry), + kind_options=ASSET_ENTRY_KIND_OPTIONS, + 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, + preview_tab="asset", + board_nav_urls=_mission_ops_nav_urls(simulation_time), + ) + + +@web_bp.post("/assets//entries//delete") +def asset_entry_delete(asset_id: object, entry_id: object) -> object: + asset = _load_asset_or_404(asset_id) + entry = _load_asset_entry_or_404(asset.id, entry_id) + simulation_time_input = _normalized_text(request.form.get("sim_time")) + db.session.delete(entry) + db.session.commit() + flash("日志条目已删除。", "success") + redirect_kwargs: dict[str, str | object] = {"asset_id": asset.id} + if simulation_time_input is not None: + redirect_kwargs["sim_time"] = simulation_time_input + return redirect(url_for("web.asset_detail", **redirect_kwargs)) + + +@web_bp.get("/mission-preview") +def mission_preview_home() -> object: + return redirect(url_for("web.mission_status_board_preview")) + + +@web_bp.get("/mission-preview/status-board") +def mission_status_board_preview() -> str: + simulation_time = _resolve_simulation_time(request.args.get("sim_time")) + asset_type_filter = request.args.get("asset_type", "").strip() + location_filter = request.args.get("location", "").strip() + record_scope = _normalize_record_scope(request.args.get("record_scope")) + + all_rows = [ + row + for row in _build_mission_board_rows(_load_assets_with_logs(), simulation_time) + if not row["asset"].is_retired + ] + visible_rows = _filter_mission_board_rows(all_rows, asset_type_filter, location_filter, record_scope) + + return render_template( + "mission_status_board_preview.html", + title="任务运营 · 状态板", + preview_title="状态板", + preview_tab="status", + status_groups=_build_status_board_groups(visible_rows), + **_mission_board_context( + "web.mission_status_board_preview", + simulation_time, + asset_type_filter, + location_filter, + record_scope, + all_rows, + visible_rows, + ), + ) + + +@web_bp.get("/mission-preview/location-board") +def mission_location_board_preview() -> str: + simulation_time = _resolve_simulation_time(request.args.get("sim_time")) + asset_type_filter = request.args.get("asset_type", "").strip() + location_filter = request.args.get("location", "").strip() + record_scope = _normalize_record_scope(request.args.get("record_scope")) + + all_rows = [ + row + for row in _build_mission_board_rows(_load_assets_with_logs(), simulation_time) + if not row["asset"].is_retired + ] + visible_rows = _filter_mission_board_rows(all_rows, asset_type_filter, location_filter, record_scope) + + return render_template( + "mission_location_board_preview.html", + title="任务运营 · 地点板", + preview_title="地点板", + preview_tab="location", + location_groups=_build_location_board_groups(visible_rows), + **_mission_board_context( + "web.mission_location_board_preview", + simulation_time, + asset_type_filter, + location_filter, + record_scope, + all_rows, + visible_rows, + ), + ) + + +@web_bp.get("/mission-preview/asset-log") +def mission_asset_log_preview() -> object: + simulation_time = _resolve_simulation_time(request.args.get("sim_time")) + return redirect(url_for("web.asset_list", sim_time=_format_datetime_input(simulation_time))) + + +@web_bp.get("/mission-preview/timeline") +def mission_timeline_preview() -> str: + simulation_time = _resolve_simulation_time(request.args.get("sim_time")) + asset_type_filter = request.args.get("asset_type", "").strip() + location_filter = request.args.get("location", "").strip() + record_scope = _normalize_record_scope(request.args.get("record_scope")) + requested_asset_ids = {asset_id for asset_id in request.args.getlist("asset_ids") if asset_id} + + all_rows = _build_mission_board_rows(_load_assets_with_logs(), simulation_time) + filtered_rows = _filter_mission_board_rows(all_rows, asset_type_filter, location_filter, record_scope) + visible_rows = [ + row for row in filtered_rows if not requested_asset_ids or str(row["asset_id"]) in requested_asset_ids + ] + + default_rows = visible_rows or filtered_rows or all_rows + initial_range_start, initial_range_end = _timeline_default_range(default_rows, simulation_time) + timeline_scale_mode = _resolve_timeline_scale(request.args.get("scale"), initial_range_start, initial_range_end) + default_range_start, default_range_end = _timeline_default_range_for_scale(default_rows, simulation_time, timeline_scale_mode) + timeline_range_mode = "manual" if _normalized_text(request.args.get("range_mode")) == "manual" else "auto" + if timeline_range_mode == "manual": + range_start = _resolve_timeline_boundary(request.args.get("start"), default_range_start) + range_end = _resolve_timeline_boundary(request.args.get("end"), default_range_end) + else: + range_start = default_range_start + range_end = default_range_end + if range_end < range_start: + range_end = range_start + + timeline_scale = _timeline_scale_labels(range_start, range_end, timeline_scale_mode) + timeline_rows, timeline_counts = _build_timeline_rows( + visible_rows, + simulation_time, + range_start, + range_end, + timeline_scale_mode, + ) + timeline_column_width = _timeline_column_width(timeline_scale_mode) + + board_query_args = _mission_board_query_args(simulation_time, asset_type_filter, location_filter, record_scope) + timeline_query_args = {**board_query_args, "scale": timeline_scale_mode} + if requested_asset_ids: + timeline_query_args["asset_ids"] = sorted(requested_asset_ids) + if timeline_range_mode == "manual": + timeline_query_args["start"] = _format_datetime_input(range_start) + timeline_query_args["end"] = _format_datetime_input(range_end) + timeline_query_args["range_mode"] = timeline_range_mode + selected_asset_options = [ + { + "value": str(row["asset_id"]), + "label": row["name"], + "asset_type": row["asset_type"], + "selected": str(row["asset_id"]) in requested_asset_ids, + } + for row in sorted(filtered_rows, key=lambda item: item["name"].casefold()) + ] + + return render_template( + "mission_timeline_preview.html", + title="任务运营 · 时间线", + preview_title="时间线", + preview_eyebrow="Mission Ops", + preview_tab="timeline", + timeline_scale=timeline_scale, + timeline_rows=timeline_rows, + timeline_scale_mode=timeline_scale_mode, + timeline_scale_options=TIMELINE_SCALE_OPTIONS, + timeline_column_width=timeline_column_width, + timeline_range_start=_format_datetime_input(range_start), + timeline_range_end=_format_datetime_input(range_end), + timeline_range_mode=timeline_range_mode, + timeline_range_display=_timeline_range_display(range_start, range_end, timeline_scale_mode), + simulation_time=_format_datetime_display(simulation_time), + simulation_time_input=_format_datetime_input(simulation_time), + metrics=[ + {"label": "Visible Assets", "value": str(len(timeline_rows))}, + {"label": "State Segments", "value": str(timeline_counts["states"] )}, + {"label": "Event Nodes", "value": str(timeline_counts["events"] )}, + ], + header_time_action=url_for("web.mission_timeline_preview"), + header_time_field_id="timeline-header-sim-time", + header_time_submit_label="Apply Time", + header_hidden_fields=_build_header_hidden_fields( + { + "asset_type": asset_type_filter, + "location": location_filter, + "record_scope": record_scope, + "scale": timeline_scale_mode, + "asset_ids": sorted(requested_asset_ids), + "range_mode": timeline_range_mode if timeline_range_mode == "manual" else None, + "start": _format_datetime_input(range_start) if timeline_range_mode == "manual" else None, + "end": _format_datetime_input(range_end) if timeline_range_mode == "manual" else None, + } + ), + asset_type_filter=asset_type_filter, + location_filter=location_filter, + record_scope=record_scope, + selected_asset_ids=sorted(requested_asset_ids), + selected_asset_count=len(requested_asset_ids), + timeline_asset_options=selected_asset_options, + reset_url=url_for( + "web.mission_timeline_preview", + sim_time=_format_datetime_input(simulation_time), + asset_type=asset_type_filter or None, + location=location_filter or None, + record_scope=record_scope, + scale=timeline_scale_mode, + asset_ids=sorted(requested_asset_ids), + ), + asset_type_options=sorted({row["asset_type"] for row in all_rows if row["asset_type"]}, key=str.casefold), + location_options=sorted({row["location"] for row in all_rows if row["location"]}, key=str.casefold), + record_scope_options=MISSION_BOARD_RECORD_SCOPE_OPTIONS, + board_nav_urls={ + "status": url_for("web.mission_status_board_preview", **board_query_args), + "location": url_for("web.mission_location_board_preview", **board_query_args), + "mission": url_for("web.mission_list", sim_time=_format_datetime_input(simulation_time)), + "asset": url_for("web.asset_list", sim_time=_format_datetime_input(simulation_time)), + "timeline": url_for("web.mission_timeline_preview", **timeline_query_args), + }, + ) diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..c5ae291 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +"""Application service helpers.""" diff --git a/app/services/fuel_conversion.py b/app/services/fuel_conversion.py new file mode 100644 index 0000000..64a5018 --- /dev/null +++ b/app/services/fuel_conversion.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP + + +FUEL_FACTORS: list[tuple[str, Decimal]] = [ + ("HTPB", Decimal("0.00177")), + ("Solid", Decimal("0.00178")), + ("PBAN", Decimal("0.001772")), + ("Kerolox", Decimal("0.0010290673")), + ("Hydrolox", Decimal("0.00036235886")), + ("Methalox", Decimal("0.000845043157")), + ("Hydrazine", Decimal("0.001004")), + ("MMH/NTO", Decimal("0.00116557")), +] + +FUEL_DENSITY_T_PER_LITER = dict(FUEL_FACTORS) +VALUE_STEP = Decimal("0.000001") + + +def list_fuel_factors() -> list[dict[str, float | str]]: + return [ + {"fuel": fuel, "tonnes_per_liter": _to_float(factor)} + for fuel, factor in FUEL_FACTORS + ] + + +def convert_value(mode: str, raw_value: object) -> dict[str, object]: + value = _parse_positive_decimal(raw_value) + normalized_mode = mode.strip().lower() + + if normalized_mode == "volume": + results = [ + { + "fuel": fuel, + "value": _to_float(value * factor), + "unit": "t", + } + for fuel, factor in FUEL_FACTORS + ] + return { + "mode": normalized_mode, + "input_value": _to_float(value), + "input_unit": "L", + "results": results, + } + + if normalized_mode == "mass": + results = [ + { + "fuel": fuel, + "value": _to_float(value / factor), + "unit": "L", + } + for fuel, factor in FUEL_FACTORS + ] + return { + "mode": normalized_mode, + "input_value": _to_float(value), + "input_unit": "t", + "results": results, + } + + raise ValueError("mode must be either 'volume' or 'mass'") + + +def _parse_positive_decimal(raw_value: object) -> Decimal: + try: + value = Decimal(str(raw_value)) + except (InvalidOperation, ValueError, TypeError) as exc: + raise ValueError("value must be numeric") from exc + + if value <= 0: + raise ValueError("value must be greater than zero") + + return value + + +def _to_float(value: Decimal) -> float: + return float(value.quantize(VALUE_STEP, rounding=ROUND_HALF_UP)) diff --git a/app/services/log_book_importer.py b/app/services/log_book_importer.py new file mode 100644 index 0000000..68fcb00 --- /dev/null +++ b/app/services/log_book_importer.py @@ -0,0 +1,278 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path + +from openpyxl import load_workbook +from sqlalchemy import delete, select + +from app.extensions import db +from app.models import Asset, AssetLogEntry + + +EVENT_LINE_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2})\s+(.+)$") +LOG_BOOK_IGNORED_SHEETS = {"Overview", "Model"} + +BODY_LOCATION_KEYWORDS = [ + ("earth-sun l2", "Earth-Sun L2"), + ("earth-sun lagrange point 2", "Earth-Sun L2"), + ("star port", "Star Port"), + ("iapetus", "Iapetus"), + ("callisto", "Callisto"), + ("ganymede", "Ganymede"), + ("europa", "Europa"), + ("jupiter", "Jupiter"), + ("saturn", "Saturn"), + ("neptune", "Neptune"), + ("mars", "Mars"), + ("venus", "Venus"), + ("mercury", "Mercury"), + ("lunar orbit", "Lunar Orbit"), + ("leo", "LEO"), + ("earth orbit", "Earth"), + ("earth", "Earth"), + ("sun", "Sun"), +] + +LOG_BOOK_ASSET_SPECS = { + "ST-01": { + "name": "万星源号 ST-01", + "asset_type": "Exploration Mothership", + "program": "Stellaria", + "home_region": "LEO", + "note": "First Class of Stellaria", + }, + "XH-01": { + "name": "羲和号 XH-01", + "asset_type": "Exploration Mothership", + "program": "羲和计划", + "home_region": "LEO", + "note": "羲和计划首舰", + }, +} + + +@dataclass +class LogBookSheetSummary: + sheet_name: str + asset_name: str + state_entries: int = 0 + event_entries: int = 0 + + def to_dict(self) -> dict[str, object]: + return { + "sheet_name": self.sheet_name, + "asset_name": self.asset_name, + "state_entries": self.state_entries, + "event_entries": self.event_entries, + } + + +@dataclass +class LogBookImportSummary: + workbook: str + imported_assets: list[LogBookSheetSummary] = field(default_factory=list) + + def to_dict(self) -> dict[str, object]: + return { + "workbook": self.workbook, + "imported_assets": [item.to_dict() for item in self.imported_assets], + } + + +def import_log_book_data(workbook_path: str | Path) -> LogBookImportSummary: + path = Path(workbook_path) + if not path.exists(): + raise FileNotFoundError(f"Workbook not found: {path}") + + workbook = load_workbook(path, data_only=True, read_only=True) + summary = LogBookImportSummary(workbook=path.name) + + try: + for sheet_name in _iter_importable_sheet_names(workbook.sheetnames): + asset_spec = _resolve_asset_spec(sheet_name) + sheet_summary = _import_sheet(workbook[sheet_name], sheet_name, asset_spec) + summary.imported_assets.append(sheet_summary) + db.session.commit() + except Exception: + db.session.rollback() + raise + + return summary + + +def _iter_importable_sheet_names(sheet_names: list[str]) -> list[str]: + return [sheet_name for sheet_name in sheet_names if sheet_name not in LOG_BOOK_IGNORED_SHEETS] + + +def _resolve_asset_spec(sheet_name: str) -> dict[str, str | None]: + asset_spec: dict[str, str | None] = { + "name": sheet_name, + "asset_type": "Vehicle", + "program": None, + "home_region": None, + "note": f"Imported from log_book.xlsx sheet {sheet_name}.", + } + asset_spec.update(LOG_BOOK_ASSET_SPECS.get(sheet_name, {})) + return asset_spec + + +def _import_sheet(worksheet: object, sheet_name: str, asset_spec: dict[str, str]) -> LogBookSheetSummary: + asset = db.session.execute(select(Asset).where(Asset.name == asset_spec["name"])).scalar_one_or_none() + if asset is None: + asset = Asset( + name=asset_spec["name"], + asset_type=asset_spec["asset_type"], + program=asset_spec["program"], + home_region=asset_spec["home_region"], + note=asset_spec["note"], + ) + db.session.add(asset) + db.session.flush() + + asset.asset_type = asset_spec["asset_type"] + asset.program = asset_spec["program"] + asset.home_region = asset_spec["home_region"] + asset.note = asset_spec["note"] + + db.session.execute(delete(AssetLogEntry).where(AssetLogEntry.asset_id == asset.id)) + db.session.flush() + + summary = LogBookSheetSummary(sheet_name=sheet_name, asset_name=asset.name) + for row in worksheet.iter_rows(min_row=2, values_only=True): + if not any(value not in (None, "") for value in row): + continue + + log_name = _clean_text(row[0]) + start_at = _coerce_datetime(row[1]) + end_at = _coerce_datetime(row[2]) + mission_detail = _clean_text(row[3]) + sub_log = _clean_text(row[4]) + explicit_location = _clean_text(row[7]) + inferred_location = _infer_location(log_name, mission_detail, sub_log, explicit_location) + + if log_name is None or start_at is None: + continue + + db.session.add( + AssetLogEntry( + asset_id=asset.id, + entry_kind="state", + title=log_name, + state_label=log_name, + mission_label=log_name, + location=inferred_location, + start_at=start_at, + end_at=end_at, + summary=mission_detail, + note=sub_log, + ) + ) + summary.state_entries += 1 + + return summary + + +def _clean_text(value: object) -> str | None: + if value in (None, ""): + return None + cleaned = str(value).strip() + return cleaned or None + + +def _coerce_datetime(value: object) -> datetime | None: + if value in (None, ""): + return None + if isinstance(value, datetime): + normalized = value + else: + normalized = datetime.fromisoformat(str(value)) + if normalized.tzinfo is None: + return normalized.replace(tzinfo=timezone.utc) + return normalized.astimezone(timezone.utc) + + +def _infer_location( + log_name: str | None, + mission_detail: str | None, + sub_log: str | None, + explicit_location: str | None, +) -> str | None: + if explicit_location: + return explicit_location + + haystacks = [ + (log_name or "").casefold(), + (mission_detail or "").casefold(), + (sub_log or "").casefold(), + ] + for keyword, location in BODY_LOCATION_KEYWORDS: + if any(keyword in haystack for haystack in haystacks): + return location + + if mission_detail and "transfer" in mission_detail.casefold(): + return "Sun" + if log_name and "maintenance" in log_name.casefold(): + return "LEO" + if log_name and "construction" in log_name.casefold(): + return "LEO" + return None + + +def _infer_event_location(description: str, default_location: str | None) -> str | None: + lowered = description.casefold() + for keyword, location in BODY_LOCATION_KEYWORDS: + if keyword in lowered: + return location + return default_location + + +def _parse_sub_log_events( + asset_id: object, + parent_title: str, + parent_start_at: datetime, + parent_end_at: datetime | None, + default_location: str | None, + sub_log: str | None, +) -> list[AssetLogEntry]: + if sub_log is None: + return [] + + events = [] + for raw_line in sub_log.splitlines(): + line = raw_line.strip() + if not line: + continue + match = EVENT_LINE_PATTERN.match(line) + if match is None: + continue + + event_at = _coerce_datetime(match.group(1)) + if event_at is None: + continue + + # Skip obvious workbook typos that fall far outside the parent interval. + if event_at < parent_start_at.replace(year=parent_start_at.year - 1): + continue + if parent_end_at is not None and event_at > parent_end_at.replace(year=parent_end_at.year + 1): + continue + + description = match.group(2).strip() + events.append( + AssetLogEntry( + asset_id=asset_id, + entry_kind="event", + title=description, + state_label=None, + mission_label=parent_title, + location=_infer_event_location(description, default_location), + start_at=event_at, + end_at=None, + summary=parent_title, + note=line, + ) + ) + + return events \ No newline at end of file diff --git a/app/services/workbook_importer.py b/app/services/workbook_importer.py new file mode 100644 index 0000000..e8f4d9c --- /dev/null +++ b/app/services/workbook_importer.py @@ -0,0 +1,316 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal, InvalidOperation +from pathlib import Path + +from openpyxl import load_workbook +from sqlalchemy import delete, inspect + +from app.extensions import db +from app.models import CommunicationPart, EngineFamily, EngineVariant, TankSpec, VehicleCost + + +@dataclass +class WorkbookImportSummary: + workbook: str + replaced_existing: bool + imported_rows: dict[str, int] = field(default_factory=dict) + warnings: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, object]: + return { + "workbook": self.workbook, + "replaced_existing": self.replaced_existing, + "imported_rows": self.imported_rows, + "warnings": self.warnings, + } + + +def import_workbook_data( + workbook_path: str | Path, replace_existing: bool = False +) -> WorkbookImportSummary: + path = Path(workbook_path) + if not path.exists(): + raise FileNotFoundError(f"Workbook not found: {path}") + + _assert_schema_ready() + + summary = WorkbookImportSummary(workbook=path.name, replaced_existing=replace_existing) + workbook = load_workbook(path, data_only=True, read_only=True) + + try: + if replace_existing: + _clear_import_tables() + elif _database_has_data(): + raise ValueError("数据库已有数据。若要重导,请使用 --replace。") + + family_count, variant_count = _import_engines(workbook["Engine Database"], summary) + communication_count = _import_communication_parts(workbook["Communication"]) + tank_count = _import_tank_specs(workbook["Tank Chart"]) + vehicle_cost_count = _import_vehicle_costs(workbook["KSP Vehicle Cost"]) + + db.session.commit() + except Exception: + db.session.rollback() + raise + + summary.imported_rows = { + "engine_families": family_count, + "engine_variants": variant_count, + "communication_parts": communication_count, + "tank_specs": tank_count, + "vehicle_costs": vehicle_cost_count, + } + return summary + + +def _assert_schema_ready() -> None: + existing_tables = set(inspect(db.engine).get_table_names()) + required_tables = { + "engine_families", + "engine_variants", + "communication_parts", + "tank_specs", + "vehicle_costs", + } + missing_tables = sorted(required_tables - existing_tables) + if missing_tables: + missing = ", ".join(missing_tables) + raise ValueError(f"数据库缺少表:{missing}。请先执行数据库迁移。") + + +def _database_has_data() -> bool: + for model in (EngineFamily, EngineVariant, CommunicationPart, TankSpec, VehicleCost): + if db.session.query(model).first() is not None: + return True + return False + + +def _clear_import_tables() -> None: + for model in (EngineVariant, EngineFamily, CommunicationPart, TankSpec, VehicleCost): + db.session.execute(delete(model)) + db.session.flush() + + +def _import_engines(worksheet: object, summary: WorkbookImportSummary) -> tuple[int, int]: + families_by_name: dict[str, EngineFamily] = {} + variant_count = 0 + + for row_number, row in enumerate(worksheet.iter_rows(min_row=2, values_only=True), start=2): + if not _row_has_values(row): + continue + + engine_name = _text(row[0]) + if engine_name is None: + summary.warnings.append(f"Engine Database 第 {row_number} 行缺少 Engine,已跳过。") + continue + + family = families_by_name.get(engine_name) + if family is None: + family = EngineFamily( + engine_name=engine_name, + part_name=_text(row[18]), + cycle=_text(row[2]), + size_m=_decimal(row[12]), + entry_cost=_integer(row[14]), + ) + families_by_name[engine_name] = family + db.session.add(family) + else: + family.part_name = family.part_name or _text(row[18]) + family.cycle = family.cycle or _text(row[2]) + family.size_m = family.size_m or _decimal(row[12]) + family.entry_cost = family.entry_cost or _integer(row[14]) + + fuel_type = _text(row[1]) + if fuel_type is None: + summary.warnings.append(f"Engine Database 第 {row_number} 行缺少 Fuel Type,已跳过。") + continue + + min_thrust = _decimal(row[6]) + max_thrust = _decimal(row[7]) + mass = _decimal(row[8]) + + variant = EngineVariant( + family=family, + fuel_type=fuel_type, + work_env=_text(row[3]), + sl_isp=_decimal(row[4]), + vac_isp=_decimal(row[5]), + min_thrust_kn=min_thrust, + max_thrust_kn=max_thrust, + mass_t=mass, + twr=_decimal(row[9]) or _calculate_twr(max_thrust, mass), + throttle_ratio=_decimal(row[10]) or _calculate_throttle_ratio(min_thrust, max_thrust), + tvc_deg=_decimal(row[11]), + price_kd=_integer(row[13]), + ignitions=_integer(row[15]), + has_unlimited_ignitions=_integer(row[15]) is None, + mod_source=_text(row[16]), + engine_note=_text(row[17]), + config_name=_text(row[19]), + tech_required=_text(row[20]), + config_note=_text(row[21]), + source_sheet_row=row_number, + ) + db.session.add(variant) + variant_count += 1 + + return len(families_by_name), variant_count + + +def _import_communication_parts(worksheet: object) -> int: + count = 0 + for row in worksheet.iter_rows(min_row=2, values_only=True): + if not _row_has_values(row): + continue + + part_name = _text(row[0]) + if part_name is None: + continue + + db.session.add( + CommunicationPart( + part_name=part_name, + display_name=_text(row[1]), + mass_t=_decimal(row[2]), + is_active=_boolean(row[3]), + is_deployable=_boolean(row[4]), + antenna_type=_text(row[5]), + deployed_diameter_m=_decimal(row[6]), + range_raw=_decimal(row[7]), + range_km=_decimal(row[8]), + range_au=_decimal(row[9]), + range_light_year=_decimal(row[10]), + angle_deg=_decimal(row[11]), + speed=_decimal(row[12]), + idle_power_watt=_decimal(row[13]), + idle_power_text=_text(row[14]), + transmitting_power_watt=_decimal(row[15]), + transmitting_power_text=_text(row[16]), + source=_text(row[17]), + entry_cost=_integer(row[18]), + cost=_integer(row[19]), + description=_text(row[20]), + rescale_factor=_decimal(row[21]), + tweakscale=_text(row[22]), + is_feeder=_boolean(row[23]), + tech_required=_text(row[24]), + note=_text(row[25]), + ) + ) + count += 1 + + return count + + +def _import_tank_specs(worksheet: object) -> int: + count = 0 + for row in worksheet.iter_rows(min_row=2, values_only=True): + if not _row_has_values(row): + continue + + tank_name = _text(row[0]) + if tank_name is None: + continue + + db.session.add( + TankSpec( + tank_name=tank_name, + fuel_type=_text(row[1]), + dry_mass_t=_decimal(row[2]), + fuel_mass_t=_decimal(row[3]), + wet_mass_t=_decimal(row[4]), + tank_volume_l=_decimal(row[5]), + mass_ratio=_decimal(row[6]), + kiloliters_per_ton=_decimal(row[7]), + vehicle_name=_text(row[8]), + source=_text(row[9]), + note=_text(row[10]), + ) + ) + count += 1 + + return count + + +def _import_vehicle_costs(worksheet: object) -> int: + count = 0 + for row in worksheet.iter_rows(min_row=2, values_only=True): + if not _row_has_values(row): + continue + + vehicle_name = _text(row[0]) + if vehicle_name is None: + continue + + db.session.add( + VehicleCost( + vehicle_name=vehicle_name, + launch_price=_integer(row[1]), + source=_text(row[2]), + ) + ) + count += 1 + + return count + + +def _row_has_values(row: tuple[object, ...]) -> bool: + return any(value not in (None, "") for value in row) + + +def _text(value: object) -> str | None: + if value in (None, ""): + return None + cleaned = str(value).strip() + return cleaned or None + + +def _decimal(value: object) -> Decimal | None: + if value in (None, ""): + return None + if isinstance(value, Decimal): + return value + try: + return Decimal(str(value)) + except (InvalidOperation, ValueError, TypeError): + return None + + +def _integer(value: object) -> int | None: + decimal_value = _decimal(value) + if decimal_value is None: + return None + return int(decimal_value.to_integral_value()) + + +def _boolean(value: object) -> bool | None: + if value in (None, ""): + return None + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + + text = str(value).strip().lower() + if text in {"true", "1", "yes", "y"}: + return True + if text in {"false", "0", "no", "n"}: + return False + return None + + +def _calculate_twr(max_thrust: Decimal | None, mass: Decimal | None) -> Decimal | None: + if max_thrust is None or mass in (None, Decimal("0")): + return None + return max_thrust / Decimal("9.80665") / mass + + +def _calculate_throttle_ratio( + min_thrust: Decimal | None, max_thrust: Decimal | None +) -> Decimal | None: + if min_thrust is None or max_thrust in (None, Decimal("0")): + return None + return min_thrust / max_thrust diff --git a/app/services/workbook_inspector.py b/app/services/workbook_inspector.py new file mode 100644 index 0000000..3325d72 --- /dev/null +++ b/app/services/workbook_inspector.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from collections import Counter, defaultdict +from pathlib import Path + +from openpyxl import load_workbook + + +ENGINE_FIELD_INDICES = { + "fuel_type": 1, + "cycle": 2, + "work_env": 3, + "tvc": 11, + "size": 12, + "price_kd": 13, + "entry_cost": 14, + "classification": 16, + "note": 17, + "part_name": 18, + "config_name": 19, +} + + +def summarize_workbook(workbook_path: str | Path) -> dict[str, object]: + path = Path(workbook_path) + workbook = load_workbook(path, data_only=False) + + sheet_row_counts = { + sheet_name: len(_non_empty_rows(workbook[sheet_name])) + for sheet_name in workbook.sheetnames + } + + engine_sheet = workbook["Engine Database"] + engine_rows = _non_empty_rows(engine_sheet) + engine_headers = [ + cell.value + for cell in next(engine_sheet.iter_rows(min_row=1, max_row=1)) + if cell.value is not None + ] + + duplicate_engine_config = _sample_duplicates( + Counter((row[0], row[19]) for row in engine_rows if _present(row[0])) + ) + duplicate_engine_config_with_fuel = _sample_duplicates( + Counter((row[0], row[19], row[1]) for row in engine_rows if _present(row[0])) + ) + + work_env_values = sorted( + {row[3] for row in engine_rows if _present(row[3])}, key=str + ) + + return { + "workbook": path.name, + "sheet_names": workbook.sheetnames, + "sheet_row_counts": sheet_row_counts, + "engine_headers": engine_headers, + "engine_work_env_values": work_env_values, + "candidate_key_conflicts": { + "engine_plus_config_name": { + "count": len(duplicate_engine_config), + "samples": duplicate_engine_config[:12], + }, + "engine_plus_config_name_plus_fuel_type": { + "count": len(duplicate_engine_config_with_fuel), + "samples": duplicate_engine_config_with_fuel[:12], + }, + }, + "engine_field_variance": _field_variance(engine_rows), + "recommended_family_fields": [ + "engine_name", + "part_name", + "cycle", + "size_m", + "entry_cost", + ], + "recommended_variant_fields": [ + "fuel_type", + "work_env", + "sl_isp", + "vac_isp", + "min_thrust_kn", + "max_thrust_kn", + "mass_t", + "twr", + "throttle_ratio", + "tvc_deg", + "price_kd", + "ignitions", + "has_unlimited_ignitions", + "mod_source", + "engine_note", + "config_name", + "tech_required", + "config_note", + ], + } + + +def _field_variance(engine_rows: list[tuple[object, ...]]) -> dict[str, int]: + grouped_rows: dict[object, list[tuple[object, ...]]] = defaultdict(list) + for row in engine_rows: + grouped_rows[row[0]].append(row) + + variance: dict[str, int] = {} + for field_name, index in ENGINE_FIELD_INDICES.items(): + changing = 0 + for rows in grouped_rows.values(): + values = {row[index] for row in rows if _present(row[index])} + if len(values) > 1: + changing += 1 + variance[field_name] = changing + return variance + + +def _sample_duplicates(counter: Counter[tuple[object, ...]]) -> list[dict[str, object]]: + samples: list[dict[str, object]] = [] + for key, count in counter.items(): + if count <= 1: + continue + + record = {"count": count} + for index, value in enumerate(key): + record[f"key_{index + 1}"] = value + samples.append(record) + + return samples + + +def _non_empty_rows(worksheet: object) -> list[tuple[object, ...]]: + return [ + row + for row in worksheet.iter_rows(min_row=2, values_only=True) + if any(_present(value) for value in row) + ] + + +def _present(value: object) -> bool: + return value not in (None, "") diff --git a/app/static/styles.css b/app/static/styles.css new file mode 100644 index 0000000..3d4c065 --- /dev/null +++ b/app/static/styles.css @@ -0,0 +1,2815 @@ +:root { + color-scheme: light; + --bg: #e7f0fb; + --bg-accent: #bfd8ff; + --bg-top: #f3f8ff; + --bg-bottom: #e4eefb; + --bg-radial-1: rgba(23, 103, 213, 0.18); + --bg-radial-2: rgba(73, 156, 255, 0.16); + --surface: rgba(245, 250, 255, 0.84); + --surface-strong: rgba(255, 255, 255, 0.9); + --surface-soft: rgba(255, 255, 255, 0.72); + --surface-soft-strong: rgba(255, 255, 255, 0.78); + --surface-highlight: rgba(248, 251, 255, 0.96); + --surface-raised: rgba(255, 255, 255, 0.82); + --surface-hero-start: rgba(250, 253, 255, 0.95); + --surface-hero-end: rgba(224, 237, 255, 0.84); + --surface-panel-start: rgba(248, 251, 255, 0.88); + --surface-panel-end: rgba(241, 247, 255, 0.88); + --text: #12233e; + --muted: #5b6f8f; + --line: rgba(18, 35, 62, 0.12); + --primary: #1767d5; + --primary-strong: #0d4fa8; + --danger: #b63439; + --warning: #1b7cf2; + --shadow: 0 18px 48px rgba(18, 48, 96, 0.09); + --primary-soft: rgba(23, 103, 213, 0.08); + --primary-soft-strong: rgba(23, 103, 213, 0.14); + --primary-line: rgba(23, 103, 213, 0.16); + --primary-line-strong: rgba(23, 103, 213, 0.28); + --neutral-soft: rgba(91, 111, 143, 0.08); + --neutral-soft-strong: rgba(91, 111, 143, 0.16); + --danger-soft: rgba(182, 52, 57, 0.08); + --danger-line: rgba(182, 52, 57, 0.25); + --success-line: rgba(15, 107, 91, 0.28); + --warning-line: rgba(201, 105, 39, 0.28); + --timeline-grid: rgba(18, 35, 62, 0.08); + --timeline-grid-soft: rgba(18, 35, 62, 0.06); + --timeline-label-shadow: 14px 0 18px -18px rgba(18, 35, 62, 0.28); + --timeline-label-shadow-active: 14px 0 20px -18px rgba(18, 35, 62, 0.32), 0 0 0 3px rgba(23, 103, 213, 0.08); + --theme-toggle-bg: rgba(255, 255, 255, 0.78); + --theme-toggle-border: rgba(18, 35, 62, 0.12); + --theme-toggle-track: rgba(23, 103, 213, 0.16); + --theme-toggle-thumb: linear-gradient(135deg, #1767d5, #0d4fa8); + --tooltip-bg: rgba(18, 35, 62, 0.96); + --tooltip-text: #fff; + --radius-lg: 28px; + --radius-md: 18px; + --font-main: "Noto Sans SC", "Microsoft YaHei UI", "Segoe UI Variable", sans-serif; +} + +html[data-theme="dark"] { + color-scheme: dark; + --bg: #0d1522; + --bg-accent: #1d3153; + --bg-top: #111b2b; + --bg-bottom: #09111d; + --bg-radial-1: rgba(91, 149, 255, 0.2); + --bg-radial-2: rgba(49, 99, 186, 0.24); + --surface: rgba(17, 25, 39, 0.84); + --surface-strong: rgba(18, 28, 44, 0.92); + --surface-soft: rgba(18, 28, 44, 0.76); + --surface-soft-strong: rgba(21, 32, 49, 0.82); + --surface-highlight: rgba(24, 36, 54, 0.96); + --surface-raised: rgba(23, 34, 51, 0.88); + --surface-hero-start: rgba(22, 34, 52, 0.94); + --surface-hero-end: rgba(12, 19, 31, 0.92); + --surface-panel-start: rgba(24, 36, 54, 0.9); + --surface-panel-end: rgba(16, 25, 39, 0.92); + --text: #e8effc; + --muted: #9eafcb; + --line: rgba(160, 183, 219, 0.18); + --primary: #7eb5ff; + --primary-strong: #b2d0ff; + --danger: #ff9a96; + --warning: #8ac2ff; + --shadow: 0 22px 52px rgba(0, 0, 0, 0.34); + --primary-soft: rgba(96, 149, 255, 0.14); + --primary-soft-strong: rgba(96, 149, 255, 0.24); + --primary-line: rgba(132, 176, 255, 0.24); + --primary-line-strong: rgba(132, 176, 255, 0.34); + --neutral-soft: rgba(121, 140, 173, 0.14); + --neutral-soft-strong: rgba(121, 140, 173, 0.22); + --danger-soft: rgba(255, 124, 124, 0.1); + --danger-line: rgba(255, 154, 150, 0.24); + --success-line: rgba(71, 198, 176, 0.28); + --warning-line: rgba(255, 179, 102, 0.24); + --timeline-grid: rgba(165, 184, 214, 0.1); + --timeline-grid-soft: rgba(165, 184, 214, 0.08); + --timeline-label-shadow: 16px 0 22px -20px rgba(0, 0, 0, 0.5); + --timeline-label-shadow-active: 16px 0 22px -20px rgba(0, 0, 0, 0.54), 0 0 0 3px rgba(96, 149, 255, 0.14); + --theme-toggle-bg: rgba(18, 28, 44, 0.84); + --theme-toggle-border: rgba(160, 183, 219, 0.2); + --theme-toggle-track: rgba(96, 149, 255, 0.24); + --theme-toggle-thumb: linear-gradient(135deg, #9bc5ff, #6b9bff); + --tooltip-bg: rgba(7, 12, 21, 0.98); + --tooltip-text: #eef4ff; +} + +* { + box-sizing: border-box; +} + +html { + background: var(--bg-bottom); +} + +body { + margin: 0; + min-height: 100vh; + color: var(--text); + font-family: var(--font-main); + background: + radial-gradient(circle at top left, var(--bg-radial-1), transparent 24%), + radial-gradient(circle at top right, var(--bg-radial-2), transparent 26%), + linear-gradient(180deg, var(--bg-top) 0%, var(--bg-bottom) 100%); + transition: background 180ms ease, color 180ms ease; +} + +a, +button, +input, +select, +textarea { + transition: + background-color 180ms ease, + border-color 180ms ease, + color 180ms ease, + box-shadow 180ms ease, + transform 180ms ease; +} + +.page-shell { + width: calc(100vw - 16px); + max-width: none; + margin: 0 auto; + padding: 14px 0 36px; +} + +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-bottom: 28px; +} + +.site-header-tools { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 14px; + flex-wrap: wrap; +} + +.brand { + display: inline-flex; + flex-direction: column; + text-decoration: none; + color: var(--text); +} + +.brand-kicker { + font-size: 12px; + letter-spacing: 0.32em; + text-transform: uppercase; + color: var(--muted); +} + +.brand-name { + font-size: 28px; + font-weight: 700; +} + +.site-nav { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + +.site-nav a { + color: var(--text); + text-decoration: none; + padding: 9px 12px; + border-radius: 999px; + background: color-mix(in srgb, var(--surface-strong) 72%, transparent); + border: 1px solid var(--line); +} + +.theme-toggle { + display: inline-flex; + align-items: center; + gap: 10px; + min-height: 44px; + padding: 7px 12px; + border-radius: 999px; + border: 1px solid var(--theme-toggle-border); + background: var(--theme-toggle-bg); + color: var(--text); + box-shadow: var(--shadow); + cursor: pointer; +} + +.theme-toggle:hover, +.theme-toggle:focus-visible, +.site-nav a:hover, +.site-nav a:focus-visible { + border-color: var(--primary-line-strong); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 12%, transparent); +} + +.theme-toggle:focus-visible, +.site-nav a:focus-visible { + outline: none; +} + +.theme-toggle-track { + position: relative; + width: 42px; + height: 24px; + flex: 0 0 42px; + border-radius: 999px; + background: var(--theme-toggle-track); +} + +.theme-toggle-thumb { + position: absolute; + top: 3px; + left: 3px; + width: 18px; + height: 18px; + border-radius: 999px; + background: var(--theme-toggle-thumb); + box-shadow: 0 6px 14px rgba(18, 35, 62, 0.24); + transition: transform 180ms ease, background 180ms ease, box-shadow 180ms ease; +} + +html[data-theme="dark"] .theme-toggle-thumb { + transform: translateX(18px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.36); +} + +.theme-toggle-copy { + display: grid; + gap: 1px; + text-align: left; +} + +.theme-toggle-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--muted); +} + +.theme-toggle-value { + font-size: 13px; + font-weight: 700; +} + +.flash-stack { + display: grid; + gap: 12px; + margin-bottom: 20px; +} + +.flash-message { + padding: 14px 16px; + border-radius: 16px; + border: 1px solid var(--line); + background: color-mix(in srgb, var(--surface-strong) 78%, transparent); +} + +.flash-message.success { + border-color: var(--success-line); +} + +.flash-message.error { + border-color: var(--warning-line); +} + +.hero-panel, +.panel, +.metric-card { + border: 1px solid var(--line); + box-shadow: var(--shadow); + backdrop-filter: blur(12px); +} + +.hero-panel { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 24px; + padding: 28px; + border-radius: var(--radius-lg); + background: linear-gradient(135deg, var(--surface-hero-start), var(--surface-hero-end)); +} + +.hero-panel.compact { + grid-template-columns: 1fr; +} + +.page-header-compact { + width: min(980px, 100%); + padding: 12px 14px; + gap: 10px; +} + +.page-header-compact .eyebrow { + margin-bottom: 4px; + font-size: 10px; +} + +.page-header-compact .hero-copy h1 { + font-size: clamp(22px, 2vw, 30px); + max-width: none; + line-height: 1; +} + +.page-header-compact .hero-text { + margin: 4px 0 0; + font-size: 13px; + line-height: 1.35; + max-width: 72ch; +} + +.engine-detail-hero { + width: min(720px, 100%); + padding: 8px 12px; + gap: 8px; +} + +.engine-detail-hero .hero-copy h1 { + font-size: clamp(20px, 1.9vw, 28px); + max-width: none; + line-height: 1; +} + +.engine-detail-hero .eyebrow { + margin-bottom: 3px; + font-size: 10px; +} + +.engine-detail-hero .hero-text { + margin: 4px 0 0; + font-size: 11px; + line-height: 1.2; + max-width: 58ch; +} + +.eyebrow { + margin: 0 0 10px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--primary); +} + +.hero-copy h1, +.panel-heading h2 { + margin: 0; + line-height: 1.1; +} + +.hero-copy h1 { + font-size: clamp(34px, 4vw, 56px); + max-width: 12ch; +} + +.hero-text, +.panel p, +.module-card p { + color: var(--muted); + line-height: 1.7; +} + +.hero-callout { + padding: 22px; + border-radius: var(--radius-md); + background: var(--primary-soft); + border: 1px solid var(--primary-line); +} + +.callout-label, +.callout-subtext { + margin: 0; + color: var(--muted); +} + +.callout-number { + margin: 12px 0 10px; + font-size: 72px; + font-weight: 700; + line-height: 1; + color: var(--warning); +} + +.callout-text { + margin: 0 0 10px; + font-weight: 600; +} + +.metric-grid, +.content-grid, +.decision-grid, +.factor-grid, +.engine-grid, +.detail-grid, +.summary-grid { + display: grid; + gap: 20px; +} + +.metric-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin: 22px 0; +} + +.metric-card { + display: flex; + flex-direction: column; + gap: 8px; + padding: 20px; + border-radius: var(--radius-md); + background: var(--surface); +} + +.metric-value { + font-size: 34px; + font-weight: 700; +} + +.metric-label { + color: var(--muted); +} + +.content-grid { + grid-template-columns: 1.4fr 1fr; + margin-bottom: 22px; +} + +.fuel-layout { + align-items: start; +} + +.panel { + padding: 18px; + border-radius: var(--radius-lg); + background: var(--surface); +} + +.catalog-header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 20px; + align-items: start; +} + +.catalog-header-tight { + padding: 10px 14px; + gap: 12px; +} + +.catalog-page-header { + grid-template-columns: minmax(0, 1.15fr) minmax(320px, 0.95fr); + justify-content: stretch; + align-items: stretch; +} + +.catalog-header-copy { + min-width: 0; + display: grid; + align-content: center; +} + +.catalog-header-copy .eyebrow { + margin-bottom: 4px; +} + +.catalog-header-copy h1 { + margin: 0; + font-size: clamp(24px, 2.2vw, 32px); + line-height: 1; +} + +.catalog-header-copy .hero-text { + margin: 6px 0 0; + font-size: 14px; + line-height: 1.35; + max-width: 52ch; +} + +.engine-catalog-hero { + align-items: stretch; +} + +.catalog-header-side { + display: grid; + gap: 10px; + align-content: start; + justify-self: stretch; +} + +.catalog-metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + align-self: start; + justify-self: stretch; + min-width: 0; + width: 100%; +} + +.catalog-metrics-duo { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.catalog-header-time-form { + display: grid; + grid-template-columns: minmax(240px, 1fr) auto; + gap: 10px; + align-items: end; +} + +.catalog-header-time-form .mission-datetime-control { + margin: 0; +} + +.catalog-header-time-form .secondary-button { + white-space: nowrap; +} + +.catalog-header-tight .summary-card { + padding: 10px 12px; + border-radius: 14px; + min-height: 74px; + display: grid; + align-content: space-between; +} + +.catalog-header-tight .summary-label { + margin-bottom: 4px; + font-size: 10px; +} + +.catalog-header-tight .summary-card.compact strong { + font-size: clamp(18px, 1.6vw, 28px); + line-height: 1.05; +} + +.catalog-sidebar { + display: grid; + gap: 12px; + align-content: start; +} + +.catalog-actions-panel { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; +} + +.catalog-actions-panel .eyebrow { + margin-bottom: 2px; + font-size: 10px; +} + +.catalog-actions-panel h2 { + margin: 0; + font-size: 17px; + line-height: 1.1; +} + +.catalog-actions-panel .small-muted { + margin: 2px 0 0; +} + +.summary-card.compact strong { + font-size: 24px; +} + +.panel-wide { + min-width: 0; +} + +.top-gap { + margin-top: 18px; +} + +.panel-heading { + margin-bottom: 18px; +} + +.decision-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.decision-grid h3, +.module-card h3, +.factor-card h3 { + margin: 0 0 8px; +} + +.module-list { + display: grid; + gap: 14px; +} + +.module-card { + padding: 16px; + border-radius: 16px; + background: var(--surface-strong); + border: 1px solid var(--line); +} + +.module-card.live { + border-color: rgba(23, 103, 213, 0.28); +} + +.module-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.module-status { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--primary); +} + +.module-link { + display: inline-flex; + margin-top: 12px; + color: var(--primary-strong); + font-weight: 700; + text-decoration: none; +} + +.module-link.muted { + color: var(--muted); +} + +.secondary-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 9px 12px; + border-radius: 999px; + border: 1px solid var(--line); + background: var(--surface-soft); + color: var(--text); + text-decoration: none; + font-weight: 600; +} + +.filter-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.catalog-layout { + display: grid; + grid-template-columns: clamp(220px, 15vw, 248px) minmax(0, 1fr); + gap: 14px; + margin-top: 14px; + align-items: start; +} + +.filter-panel { + position: sticky; + top: 8px; +} + +.filter-panel-tight { + padding: 14px; +} + +.filter-form { + display: grid; + gap: 10px; +} + +.filter-form input, +.filter-form select { + width: 100%; + padding: 9px 10px; + border-radius: 10px; + border: 1px solid var(--line); + background: var(--surface-soft-strong); + color: var(--text); + font: inherit; +} + +.catalog-panel { + padding: 12px 14px; +} + +.results-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; +} + +.button-row { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.compact-row { + gap: 8px; +} + +.search-form { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 10px; + align-items: end; +} + +.filter-row { + grid-template-columns: minmax(260px, 2fr) repeat(3, minmax(140px, 1fr)) auto auto; +} + +.search-form input, +.search-form select, +.form-grid input, +.form-grid textarea, +.form-grid select { + width: 100%; + padding: 10px 11px; + border-radius: 10px; + border: 1px solid var(--line); + background: var(--surface-soft); + color: var(--text); + font: inherit; +} + +.form-grid textarea { + resize: vertical; +} + +.mission-datetime-field, +.mission-datetime-control { + display: grid; + gap: 6px; +} + +.mission-datetime-inputs, +.mission-datetime-control-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.mission-datetime-control-row { + align-items: stretch; + position: relative; +} + +.mission-datetime-control-input, +.mission-datetime-display-input { + flex: 1 1 220px; + min-width: 0; +} + +.mission-datetime-display-input { + width: 100%; +} + +.mission-datetime-picker-proxy { + position: absolute; + right: 0; + bottom: 0; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + border: 0 !important; + opacity: 0; + pointer-events: none; +} + +.mission-datetime-picker-button { + padding-inline: 12px; +} + +.mission-datetime-part { + flex: 0 0 62px; + text-align: center; +} + +.mission-datetime-year { + flex-basis: 84px; +} + +.mission-datetime-separator { + color: var(--muted); + font-size: 13px; + font-weight: 700; + line-height: 1; +} + +.combo-custom-input { + margin-top: 8px; +} + +.engine-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: 22px; +} + +.engine-card h2 { + margin: 0; + font-size: 24px; +} + +.chip-row { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin: 12px 0; +} + +.chip { + display: inline-flex; + align-items: center; + padding: 6px 10px; + border-radius: 999px; + background: var(--primary-soft); + color: var(--primary-strong); + font-size: 13px; + font-weight: 600; +} + +.chip.strong { + background: var(--primary-soft-strong); + color: var(--primary-strong); +} + +.small-muted { + color: var(--muted); +} + +.preview-list { + display: grid; + gap: 10px; + margin: 16px 0 4px; +} + +.preview-row { + display: grid; + grid-template-columns: 1.5fr 1fr 1fr; + gap: 10px; + padding: 12px 14px; + border-radius: 14px; + background: var(--surface-soft); + border: 1px solid var(--line); +} + +.muted-row { + color: var(--muted); +} + +.empty-state { + text-align: center; +} + +.detail-grid { + grid-template-columns: 0.9fr 1.4fr; + margin-top: 22px; +} + +.summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.summary-card { + padding: 16px; + border-radius: 16px; + background: var(--surface-strong); + border: 1px solid var(--line); +} + +.summary-label { + display: block; + margin-bottom: 6px; + color: var(--muted); + font-size: 13px; +} + +.table-shell { + overflow-x: auto; +} + +.data-table { + width: 100%; + border-collapse: collapse; +} + +.compact-table { + table-layout: fixed; +} + +.compact-table td, +.compact-table th { + padding: 7px 8px; +} + +.compact-table td { + font-size: 12.5px; + line-height: 1.3; +} + +.compact-table th { + font-size: 13px; +} + +.table-subline { + margin-top: 1px; + color: var(--muted); + font-size: 12px; +} + +.table-title { + display: block; + font-size: 13px; + line-height: 1.18; +} + +.two-line-cell { + min-width: 190px; + max-width: 240px; +} + +.single-line-cell { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.clamp-line { + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + word-break: break-word; +} + +.column-sort-head { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 6px; + min-width: 0; + width: 100%; +} + +.numeric-head { + display: grid; + justify-items: end; + align-content: center; + gap: 2px; +} + +.sort-chip-group { + display: inline-flex; + gap: 4px; +} + +.sort-chip { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 17px; + padding: 1px 4px; + border-radius: 999px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.75); + color: var(--muted); + text-decoration: none; + font-size: 9px; + font-weight: 700; + line-height: 1.1; +} + +.sort-chip.active { + background: rgba(23, 103, 213, 0.14); + color: var(--primary-strong); + border-color: rgba(23, 103, 213, 0.28); +} + +.table-actions { + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.inline-form { + display: inline-flex; + margin: 0; +} + +.table-link { + color: var(--primary-strong); + font-weight: 700; + text-decoration: none; +} + +.danger-link { + border: 1px solid rgba(182, 52, 57, 0.25); + background: rgba(182, 52, 57, 0.08); + color: var(--danger); + border-radius: 999px; + padding: 6px 10px; + font: inherit; + font-weight: 700; + cursor: pointer; + text-decoration: none; +} + +.pagination-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin-top: 18px; + flex-wrap: wrap; +} + +.pagination-pages { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.pagination-link { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 42px; + padding: 10px 14px; + border-radius: 999px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.72); + color: var(--text); + text-decoration: none; + font-weight: 600; +} + +.pagination-link.current { + background: linear-gradient(135deg, var(--primary), var(--primary-strong)); + color: white; + border-color: transparent; +} + +.pagination-link.disabled { + color: var(--muted); + opacity: 0.6; +} + +.pagination-ellipsis { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 32px; + color: var(--muted); +} + +.compact-empty { + padding: 26px 8px 8px; +} + +.data-table th, +.data-table td { + padding: 7px 8px; + text-align: left; + border-bottom: 1px solid var(--line); + vertical-align: middle; +} + +.data-table th { + color: var(--muted); + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.engine-name-col { + width: 16%; +} + +.cycle-col { + width: 7%; +} + +.fuel-col { + width: 12%; +} + +.mod-col { + width: 9%; +} + +.count-col { + width: 5%; + text-align: center; +} + +.numeric-col { + width: 6%; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.action-col { + width: 15%; + text-align: right; +} + +.action-col .table-actions { + justify-content: flex-end; +} + +.action-col .table-actions, +.sort-chip-group { + white-space: nowrap; +} + +.variant-note { + margin-top: 6px; + color: var(--muted); + font-size: 13px; + line-height: 1.5; +} + +.edit-layout { + display: grid; + gap: 14px; + margin-top: 14px; +} + +.form-grid { + display: grid; + gap: 14px; +} + +.family-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.variant-editor-stack { + display: grid; + gap: 16px; +} + +.variant-editor { + padding: 18px; + border-radius: 20px; + background: rgba(255, 255, 255, 0.62); + border: 1px solid var(--line); +} + +.variant-editor-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: 14px; +} + +.variant-add-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.resource-form-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.field-span-three { + grid-column: span 3; +} + +.field-span-two { + grid-column: span 2; +} + +.checkbox-row { + display: flex; + align-items: center; + gap: 10px; +} + +.checkbox-row input { + width: auto; +} + +.converter-form { + display: grid; + gap: 12px; +} + +.field-label { + font-weight: 600; +} + +.field-hint { + display: block; + margin-top: 6px; + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.converter-form input, +.converter-form select { + width: 100%; + padding: 14px 16px; + border-radius: 14px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.72); + color: var(--text); + font: inherit; +} + +.primary-button { + padding: 9px 13px; + border: 0; + border-radius: 999px; + background: linear-gradient(135deg, var(--primary), var(--primary-strong)); + color: white; + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.primary-button, +.secondary-button, +.danger-link { + white-space: nowrap; +} + +.danger-panel { + border-color: rgba(182, 52, 57, 0.28); + background: rgba(255, 244, 245, 0.9); +} + +.form-error { + margin: 0; + color: var(--warning); + font-weight: 600; +} + +.result-summary { + margin-bottom: 14px; + color: var(--muted); +} + +.result-table { + display: grid; + gap: 10px; +} + +.result-row { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 12px; + align-items: center; + padding: 14px 16px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.72); + border: 1px solid var(--line); +} + +.factor-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.factor-card { + padding: 16px; + border-radius: 16px; + background: var(--surface-strong); + border: 1px solid var(--line); +} + +.mission-hero-side { + display: grid; + gap: 10px; +} + +.mission-time-callout { + padding: 18px; +} + +.mission-time-value { + margin: 8px 0 0; + font-size: 30px; + font-weight: 700; + line-height: 1; + color: var(--primary-strong); +} + +.mission-preview-nav { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 16px; +} + +.mission-preview-nav-top { + margin-top: 0; +} + +.mission-page-header { + display: grid; + grid-template-columns: minmax(220px, 0.78fr) minmax(0, 1.45fr) auto; + gap: 12px; + align-items: center; + padding: 12px 16px; +} + +.mission-page-header-copy { + min-width: 0; +} + +.mission-page-header-copy h1 { + margin: 0; + font-size: clamp(26px, 2.3vw, 36px); + line-height: 1.05; +} + +.mission-page-header-metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + min-width: 0; +} + +.mission-page-header-controls { + min-width: 0; + justify-self: end; + width: min(100%, 380px); +} + +.mission-page-time-form { + display: flex; + gap: 8px; + align-items: center; + justify-content: flex-end; + width: 100%; +} + +.mission-page-inline-datetime { + display: grid; + grid-template-columns: 64px minmax(160px, 1fr); + gap: 8px; + align-items: center; + margin: 0; + flex: 1 1 auto; +} + +.mission-page-inline-datetime .field-label { + margin: 0; + white-space: nowrap; + text-align: right; +} + +.mission-page-inline-datetime .mission-datetime-control-row { + min-width: 0; +} + +.mission-page-inline-datetime .mission-datetime-display-input { + flex: 1 1 160px; +} + +.mission-summary-card { + padding: 10px 12px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.76); + min-height: 62px; + display: grid; + align-content: space-between; +} + +.mission-summary-card strong { + font-size: clamp(18px, 1.45vw, 24px); + line-height: 1.05; +} + +.mission-summary-card-time strong { + font-size: clamp(15px, 0.95vw, 18px); + color: var(--primary-strong); + white-space: nowrap; +} + +.mission-ops-tabs-panel { + padding: 10px 12px; +} + +.preview-tab { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 12px; + border-radius: 999px; + border: 1px solid var(--line); + background: var(--surface-soft); + color: var(--text); + text-decoration: none; + font-weight: 600; + transition: background-color 180ms ease, border-color 180ms ease, box-shadow 180ms ease, color 180ms ease; +} + +.preview-tab:hover, +.preview-tab:focus-visible { + border-color: var(--primary-line); + background: color-mix(in srgb, var(--surface-soft) 84%, var(--primary-soft)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 10%, transparent); +} + +.preview-tab:focus-visible { + outline: none; +} + +.preview-tab.active { + background: linear-gradient(135deg, var(--primary), var(--primary-strong)); + color: white; + border-color: transparent; +} + +.preview-tab.active:hover, +.preview-tab.active:focus-visible { + border-color: transparent; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); +} + +.mission-metric-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.mission-page-time-form .secondary-button { + white-space: nowrap; +} + +.mission-control-panel { + padding: 12px 14px; +} + +.mission-toolbar-grid { + display: grid; + grid-template-columns: minmax(280px, 0.9fr) minmax(0, 1.25fr); + gap: 16px; + align-items: end; +} + +.mission-toolbar-block { + display: grid; + gap: 8px; +} + +.mission-toolbar-span-two { + grid-column: 1 / -1; +} + +.mission-toolbar-inline { + display: flex; + gap: 10px; + flex-wrap: wrap; + align-items: center; +} + +.mission-control-panel input, +.mission-control-panel select { + width: 100%; + padding: 10px 11px; + border-radius: 10px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.72); + color: var(--text); + font: inherit; +} + +.mission-toolbar-inline input { + flex: 1 1 220px; +} + +.mission-toolbar-inline .mission-datetime-control { + min-width: min(280px, 100%); +} + +.mission-filter-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)) auto auto; + gap: 10px; + align-items: end; +} + +.mission-filter-pill-row { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 12px; +} + +.mission-filter-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 999px; + background: rgba(23, 103, 213, 0.08); + border: 1px solid rgba(23, 103, 213, 0.14); + color: var(--primary-strong); + font-size: 12px; + font-weight: 600; +} + +.mission-asset-picker-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.mission-asset-picker { + display: grid; + gap: 10px; +} + +.mission-asset-picker-toolbar { + display: grid; + grid-template-columns: minmax(220px, 1fr) auto; + gap: 10px; + align-items: end; +} + +.mission-asset-picker-search { + display: grid; + gap: 6px; +} + +.mission-asset-picker-search input { + width: 100%; + padding: 10px 11px; + border-radius: 10px; + border: 1px solid var(--line); + background: var(--surface-soft); + color: var(--text); + font: inherit; +} + +.mission-asset-picker-search input::placeholder { + color: color-mix(in srgb, var(--muted) 72%, transparent); +} + +.mission-asset-picker-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; +} + +.mission-asset-picker-item { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface-soft); + color: var(--text); + cursor: pointer; + transition: background-color 180ms ease, border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease; +} + +.mission-asset-picker-item:hover { + border-color: var(--primary-line); + background: color-mix(in srgb, var(--surface-soft) 84%, var(--primary-soft)); +} + +.mission-asset-picker-item:has(input:checked) { + border-color: var(--primary-line-strong); + background: color-mix(in srgb, var(--surface-soft) 74%, var(--primary-soft-strong)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 12%, transparent); +} + +.mission-asset-picker-item input { + width: auto; + margin: 2px 0 0; + accent-color: var(--primary); +} + +.mission-asset-picker-item strong, +.mission-asset-picker-item small { + display: block; +} + +.mission-asset-picker-item strong { + color: var(--text); +} + +.mission-asset-picker-item small { + margin-top: 2px; + color: var(--muted); + font-size: 11px; +} + +.mission-board-shell, +.mission-detail-layout { + display: grid; + grid-template-columns: minmax(0, 1.45fr) 340px; + gap: 16px; + align-items: start; +} + +.mission-board-main, +.mission-board-side, +.mission-detail-main, +.mission-detail-side { + display: grid; + gap: 14px; +} + +.mission-group-panel, +.mission-log-panel, +.mission-asset-overview, +.mission-timeline-panel { + display: grid; + gap: 14px; +} + +.mission-log-filter-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)) auto auto; + gap: 10px; + align-items: end; +} + +.mission-log-filter-grid input { + width: 100%; + padding: 10px 11px; + border-radius: 10px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.72); + color: var(--text); + font: inherit; +} + +.mission-log-table td { + vertical-align: top; +} + +.mission-log-table .chip { + font-size: 11px; + padding: 5px 8px; +} + +.mission-entry-label-row { + gap: 6px; +} + +.mission-group-header, +.mission-card-top, +.mission-log-top, +.mission-asset-head, +.mission-timeline-toolbar { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + flex-wrap: wrap; +} + +.mission-group-header h2, +.mission-card-top h3, +.mission-asset-head h2, +.mission-upcoming-item h3, +.mission-log-body h3, +.mission-timeline-label h3 { + margin: 0; +} + +.mission-group-count { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 7px 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.82); + border: 1px solid var(--line); + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.mission-group-subtitle, +.mission-type { + margin: 4px 0 0; +} + +.mission-card-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +html[data-theme="dark"] .page-shell { + background-image: + linear-gradient(to bottom, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0)); +} + +html[data-theme="dark"] .site-nav a, +html[data-theme="dark"] .secondary-button, +html[data-theme="dark"] .mission-datetime-picker-button, +html[data-theme="dark"] .mission-asset-picker-search input, +html[data-theme="dark"] .mission-asset-picker-item, +html[data-theme="dark"] .flash-message, +html[data-theme="dark"] .preview-row, +html[data-theme="dark"] .mission-asset-card, +html[data-theme="dark"] .mission-upcoming-item, +html[data-theme="dark"] .mission-log-body, +html[data-theme="dark"] .legend-item, +html[data-theme="dark"] .state-node-row, +html[data-theme="dark"] .mission-timeline-label, +html[data-theme="dark"] .catalog-sidebar, +html[data-theme="dark"] .catalog-panel, +html[data-theme="dark"] .filter-panel, +html[data-theme="dark"] .panel, +html[data-theme="dark"] .metric-card, +html[data-theme="dark"] .summary-card, +html[data-theme="dark"] .module-card { + background: var(--surface-soft); +} + +html[data-theme="dark"] .hero-panel { + background: linear-gradient(135deg, var(--surface-hero-start), var(--surface-hero-end)); +} + +html[data-theme="dark"] .filter-form input, +html[data-theme="dark"] .filter-form select, +html[data-theme="dark"] .search-form input, +html[data-theme="dark"] .search-form select, +html[data-theme="dark"] input[type="search"], +html[data-theme="dark"] .form-grid input, +html[data-theme="dark"] .form-grid textarea, +html[data-theme="dark"] .form-grid select, +html[data-theme="dark"] input[type="datetime-local"], +html[data-theme="dark"] input[type="text"], +html[data-theme="dark"] input[type="number"], +html[data-theme="dark"] input[type="date"], +html[data-theme="dark"] input[type="time"], +html[data-theme="dark"] select, +html[data-theme="dark"] textarea { + background: var(--surface-soft-strong); + color: var(--text); + border-color: var(--line); +} + +html[data-theme="dark"] input::placeholder, +html[data-theme="dark"] textarea::placeholder { + color: color-mix(in srgb, var(--muted) 82%, transparent); +} + +html[data-theme="dark"] .data-table th, +html[data-theme="dark"] .data-table td, +html[data-theme="dark"] .catalog-table th, +html[data-theme="dark"] .catalog-table td { + border-color: var(--line); +} + +html[data-theme="dark"] .data-table thead th, +html[data-theme="dark"] .catalog-table thead th { + background: color-mix(in srgb, var(--surface-highlight) 80%, transparent); +} + +html[data-theme="dark"] .mission-tone-primary { + background: linear-gradient(180deg, rgba(52, 96, 171, 0.38), rgba(19, 31, 50, 0.9)); +} + +html[data-theme="dark"] .mission-tone-warning { + background: linear-gradient(180deg, rgba(39, 76, 140, 0.4), rgba(19, 31, 50, 0.9)); +} + +html[data-theme="dark"] .mission-tone-neutral { + background: linear-gradient(180deg, rgba(55, 66, 84, 0.38), rgba(19, 31, 50, 0.9)); +} + +html[data-theme="dark"] .mission-tone-soft { + background: rgba(20, 30, 46, 0.82); +} + +html[data-theme="dark"] .mission-status-pill, +html[data-theme="dark"] .chip, +html[data-theme="dark"] .mission-segment, +html[data-theme="dark"] .hero-callout { + background: var(--primary-soft); + border-color: var(--primary-line); +} + +html[data-theme="dark"] .chip.strong, +html[data-theme="dark"] .mission-tone-warning .mission-segment-body, +html[data-theme="dark"] .mission-tone-primary .mission-segment-body, +html[data-theme="dark"] .mission-tone-current.mission-segment { + background: var(--primary-soft-strong); +} + +html[data-theme="dark"] .mission-tone-primary.mission-segment, +html[data-theme="dark"] .mission-status-pill, +html[data-theme="dark"] .chip, +html[data-theme="dark"] .chip.strong, +html[data-theme="dark"] .mission-segment, +html[data-theme="dark"] .module-status, +html[data-theme="dark"] .mission-upcoming-date, +html[data-theme="dark"] .mission-log-kicker, +html[data-theme="dark"] .eyebrow { + color: var(--primary-strong); +} + +html[data-theme="dark"] .mission-log-rail::before { + background: color-mix(in srgb, var(--muted) 36%, transparent); +} + +html[data-theme="dark"] .mission-asset-picker-item:hover { + border-color: var(--primary-line-strong); + background: color-mix(in srgb, var(--surface-soft-strong) 78%, var(--primary-soft)); +} + +html[data-theme="dark"] .mission-asset-picker-item:has(input:checked) { + background: color-mix(in srgb, var(--surface-soft-strong) 70%, var(--primary-soft-strong)); + box-shadow: 0 0 0 3px rgba(96, 149, 255, 0.14); +} + +html[data-theme="dark"] .mission-log-dot { + border-color: rgba(12, 19, 31, 0.94); + box-shadow: 0 0 0 2px rgba(96, 149, 255, 0.14); +} + +html[data-theme="dark"] .mission-log-entry.state .mission-log-dot, +html[data-theme="dark"] .mission-event-node-state::before { + background: #8ea8d9; +} + +html[data-theme="dark"] .mission-timeline-corner, +html[data-theme="dark"] .state-node-panel { + background: linear-gradient(180deg, var(--surface-panel-start), var(--surface-panel-end)); +} + +html[data-theme="dark"] .mission-timeline-row { + border-bottom-color: color-mix(in srgb, var(--line) 70%, transparent); +} + +html[data-theme="dark"] .mission-timeline-label { + background: var(--surface-highlight); + box-shadow: var(--timeline-label-shadow); +} + +html[data-theme="dark"] .mission-timeline-label-link:hover, +html[data-theme="dark"] .mission-timeline-label-link:focus-visible { + border-color: var(--primary-line-strong); + box-shadow: var(--timeline-label-shadow-active); +} + +html[data-theme="dark"] .mission-timeline-lane { + background: + repeating-linear-gradient( + to right, + var(--timeline-grid) 0, + var(--timeline-grid) 1px, + transparent 1px, + transparent calc(100% / var(--timeline-columns, 1)) + ), + linear-gradient(180deg, rgba(20, 30, 47, 0.88), rgba(13, 21, 34, 0.92)); +} + +html[data-theme="dark"] .mission-timeline-state-lane { + background: + repeating-linear-gradient( + to right, + var(--timeline-grid) 0, + var(--timeline-grid) 1px, + transparent 1px, + transparent calc(100% / var(--timeline-columns, 1)) + ), + linear-gradient(180deg, rgba(21, 33, 51, 0.88), rgba(14, 22, 35, 0.92)); +} + +html[data-theme="dark"] .mission-timeline-event-lane { + background: + repeating-linear-gradient( + to right, + var(--timeline-grid-soft) 0, + var(--timeline-grid-soft) 1px, + transparent 1px, + transparent calc(100% / var(--timeline-columns, 1)) + ), + linear-gradient(180deg, rgba(25, 37, 57, 0.92), rgba(16, 24, 38, 0.94)); +} + +html[data-theme="dark"] .mission-segment-body { + background: var(--primary-soft-strong); + border-right-color: color-mix(in srgb, var(--primary) 36%, transparent); +} + +html[data-theme="dark"] .mission-tone-neutral.mission-segment, +html[data-theme="dark"] .mission-tone-soft.mission-segment { + background: var(--neutral-soft); + border-color: color-mix(in srgb, var(--muted) 30%, transparent); + color: #dbe6fa; +} + +html[data-theme="dark"] .mission-tone-neutral .mission-segment-body, +html[data-theme="dark"] .mission-tone-soft .mission-segment-body { + background: var(--neutral-soft-strong); +} + +html[data-theme="dark"] .mission-event-node::before { + box-shadow: 0 0 0 2px rgba(7, 12, 21, 0.98), 0 0 0 3px rgba(96, 149, 255, 0.1); +} + +html[data-theme="dark"] .mission-event-node-label, +html[data-theme="dark"] .legend-item, +html[data-theme="dark"] .table-subline, +html[data-theme="dark"] .mission-summary-text, +html[data-theme="dark"] .mission-card-foot, +html[data-theme="dark"] .hero-text, +html[data-theme="dark"] .panel p, +html[data-theme="dark"] .module-card p { + color: var(--muted); +} + +html[data-theme="dark"] .mission-state-node-connector { + border-left-color: color-mix(in srgb, var(--muted) 42%, transparent); +} + +html[data-theme="dark"] .mission-segment:hover::after, +html[data-theme="dark"] .mission-segment:focus-visible::after, +html[data-theme="dark"] .mission-event-node:hover::after, +html[data-theme="dark"] .mission-event-node:focus-visible::after { + background: var(--tooltip-bg); + color: var(--tooltip-text); + border: 1px solid color-mix(in srgb, var(--line) 90%, transparent); +} + +html[data-theme="dark"] .flash-message.error, +html[data-theme="dark"] .warning-box, +html[data-theme="dark"] .field-error { + background: var(--danger-soft); + border-color: var(--danger-line); +} + +html[data-theme="dark"] input:focus-visible, +html[data-theme="dark"] select:focus-visible, +html[data-theme="dark"] textarea:focus-visible, +html[data-theme="dark"] button:focus-visible, +html[data-theme="dark"] a:focus-visible { + outline: none; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); +} + +@media (max-width: 1120px) { + .site-header { + flex-direction: column; + align-items: flex-start; + } + + .site-header-tools { + width: 100%; + justify-content: flex-start; + } +} + +@media (max-width: 760px) { + .site-header-tools { + gap: 10px; + } + + .theme-toggle { + width: 100%; + justify-content: center; + } +} + +.mission-asset-card { + display: grid; + gap: 12px; + padding: 16px; + border-radius: 20px; + background: rgba(255, 255, 255, 0.72); + border: 1px solid var(--line); +} + +.mission-tone-primary { + background: linear-gradient(180deg, rgba(219, 235, 255, 0.86), rgba(255, 255, 255, 0.82)); +} + +.mission-tone-warning { + background: linear-gradient(180deg, rgba(231, 242, 255, 0.96), rgba(255, 255, 255, 0.82)); +} + +.mission-tone-neutral { + background: linear-gradient(180deg, rgba(245, 248, 252, 0.96), rgba(255, 255, 255, 0.84)); +} + +.mission-tone-soft { + background: rgba(255, 255, 255, 0.76); +} + +.mission-status-pill { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px 10px; + border-radius: 999px; + background: rgba(23, 103, 213, 0.1); + border: 1px solid rgba(23, 103, 213, 0.15); + color: var(--primary-strong); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.mission-meta-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 12px; +} + +.mission-meta-item { + margin: 0; +} + +.mission-meta-item dt { + margin: 0; + color: var(--muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.mission-meta-item dd { + margin: 5px 0 0; + font-weight: 600; + line-height: 1.45; +} + +.mission-summary-text { + margin: 0; + color: var(--muted); + line-height: 1.55; +} + +.mission-card-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + padding-top: 10px; + border-top: 1px dashed var(--line); + color: var(--muted); + font-size: 12px; +} + +.mission-upcoming-list, +.mission-action-stack { + display: grid; + gap: 10px; +} + +.mission-upcoming-item { + padding: 12px 13px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.74); + border: 1px solid var(--line); +} + +.mission-upcoming-date { + margin: 0; + color: var(--primary); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.06em; +} + +.mission-prototype-list { + display: grid; + gap: 10px; + margin: 0; + padding-left: 18px; + color: var(--muted); + line-height: 1.55; +} + +.mission-asset-chip-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.mission-log-list { + display: grid; + gap: 12px; +} + +.mission-log-entry { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + gap: 12px; +} + +.mission-log-rail { + position: relative; + display: flex; + justify-content: center; +} + +.mission-log-rail::before { + content: ""; + position: absolute; + top: 14px; + bottom: -14px; + width: 2px; + background: rgba(18, 35, 62, 0.1); +} + +.mission-log-dot { + position: relative; + z-index: 1; + width: 14px; + height: 14px; + margin-top: 6px; + border-radius: 999px; + background: var(--primary-strong); + border: 3px solid rgba(255, 255, 255, 0.92); + box-shadow: 0 0 0 2px rgba(23, 103, 213, 0.16); +} + +.mission-log-entry.state .mission-log-dot { + background: #3e6fb9; +} + +.mission-log-body { + display: grid; + gap: 8px; + padding: 14px 16px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.74); + border: 1px solid var(--line); +} + +.mission-log-kicker { + color: var(--primary); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.mission-log-time { + margin: 0; + color: var(--muted); + font-size: 13px; +} + +.mission-action-stack .primary-button, +.mission-action-stack .secondary-button { + width: 100%; +} + +.mission-legend { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.legend-item { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.8); + border: 1px solid var(--line); + color: var(--muted); + font-size: 12px; + font-weight: 600; +} + +.legend-swatch { + width: 12px; + height: 12px; + border-radius: 999px; + display: inline-block; +} + +.legend-swatch.warning { + background: rgba(23, 103, 213, 0.36); +} + +.legend-swatch.primary { + background: rgba(13, 79, 168, 0.42); +} + +.legend-swatch.neutral { + background: rgba(91, 111, 143, 0.36); +} + +.legend-swatch.current { + background: rgba(23, 103, 213, 0.78); + box-shadow: 0 0 0 2px rgba(23, 103, 213, 0.16); +} + +.mission-timeline-scroll { + overflow-x: auto; +} + +.mission-timeline-frame { + min-width: 1120px; +} + +.mission-timeline-scale { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: 12px; + margin-bottom: 6px; +} + +.mission-timeline-corner { + position: sticky; + left: 0; + z-index: 5; + background: linear-gradient(180deg, rgba(245, 249, 255, 0.98), rgba(240, 246, 255, 0.98)); + border-radius: 16px; +} + +.mission-scale-labels { + display: grid; + grid-template-columns: repeat(var(--timeline-columns, 8), minmax(var(--timeline-column-width, 72px), 1fr)); + gap: 8px; +} + +.mission-scale-labels span { + padding: 0 0 6px; + color: var(--muted); + font-size: 12px; + font-weight: 700; + text-align: center; +} + +.mission-timeline-rows { + display: grid; + gap: 10px; +} + +.mission-timeline-row { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: 12px; + align-items: start; + padding-bottom: 10px; + border-bottom: 1px solid rgba(18, 35, 62, 0.08); +} + +.mission-timeline-row:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.mission-timeline-label { + position: sticky; + left: 0; + z-index: 4; + display: block; + padding: 12px 14px; + border-radius: 18px; + background: rgba(248, 251, 255, 0.96); + border: 1px solid var(--line); + box-shadow: 14px 0 18px -18px rgba(18, 35, 62, 0.28); +} + +.mission-timeline-label-link { + color: inherit; + text-decoration: none; + transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease; +} + +.mission-timeline-label-link:hover, +.mission-timeline-label-link:focus-visible { + border-color: rgba(23, 103, 213, 0.24); + box-shadow: 14px 0 20px -18px rgba(18, 35, 62, 0.32), 0 0 0 3px rgba(23, 103, 213, 0.08); +} + +.mission-timeline-label-link:focus-visible { + outline: none; +} + +.mission-timeline-label p { + margin: 4px 0 0; +} + +.mission-timeline-meta, +.mission-timeline-status { + font-size: 11px; + line-height: 1.35; +} + +.mission-timeline-status { + margin-top: 2px; +} + +.state-node-panel { + display: grid; + gap: 14px; + padding: 16px 18px; + border-radius: 18px; + border: 1px solid var(--line); + background: linear-gradient(180deg, rgba(248, 251, 255, 0.88), rgba(241, 247, 255, 0.88)); +} + +.state-node-panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.state-node-panel-header .field-hint, +.state-node-empty { + margin: 6px 0 0; +} + +.state-node-list { + display: grid; + gap: 12px; +} + +.state-node-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(260px, 0.95fr) minmax(0, 1.2fr) auto; + gap: 12px; + align-items: end; + padding: 12px 14px; + border-radius: 14px; + border: 1px solid rgba(18, 35, 62, 0.08); + background: rgba(255, 255, 255, 0.82); +} + +.state-node-row label, +.state-node-row .mission-datetime-control { + margin: 0; +} + +.state-node-detail-field textarea { + min-height: 72px; + resize: vertical; +} + +.state-node-datetime { + min-width: 0; +} + +.state-node-remove-button { + align-self: end; +} + +.mission-timeline-track-stack { + display: grid; + gap: 8px; +} + +.mission-timeline-lane { + position: relative; + min-height: calc(var(--timeline-track-count, 1) * var(--timeline-track-step, 38px) + var(--timeline-track-base, 24px)); + border-radius: 18px; + background: + repeating-linear-gradient( + to right, + rgba(18, 35, 62, 0.08) 0, + rgba(18, 35, 62, 0.08) 1px, + transparent 1px, + transparent calc(100% / var(--timeline-columns, 1)) + ), + linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(238, 245, 255, 0.72)); + border: 1px solid var(--line); + overflow: visible; +} + +.mission-timeline-state-lane { + --timeline-track-step: 38px; + --timeline-track-base: 22px; + background: + repeating-linear-gradient( + to right, + rgba(18, 35, 62, 0.08) 0, + rgba(18, 35, 62, 0.08) 1px, + transparent 1px, + transparent calc(100% / var(--timeline-columns, 1)) + ), + linear-gradient(180deg, rgba(255, 255, 255, 0.74), rgba(237, 244, 255, 0.8)); +} + +.mission-timeline-event-lane { + --timeline-track-step: 54px; + --timeline-track-base: 18px; + background: + repeating-linear-gradient( + to right, + rgba(18, 35, 62, 0.06) 0, + rgba(18, 35, 62, 0.06) 1px, + transparent 1px, + transparent calc(100% / var(--timeline-columns, 1)) + ), + linear-gradient(180deg, rgba(251, 253, 255, 0.92), rgba(244, 248, 255, 0.88)); +} + +.mission-timeline-lane-year { + border-radius: 20px; +} + +.mission-segment { + position: absolute; + top: calc(10px + var(--timeline-track-index, 0) * 38px); + z-index: 1; + min-height: 30px; + padding: 0 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + color: var(--primary-strong); + background: rgba(23, 103, 213, 0.08); + border: 1px solid rgba(23, 103, 213, 0.16); + cursor: pointer; + text-decoration: none; + overflow: hidden; +} + +.mission-timeline-state-lane .mission-segment { + top: calc(10px + var(--timeline-track-index, 0) * var(--timeline-track-step, 38px)); +} + +.mission-segment-compact { + padding: 0 7px; +} + +.mission-segment-compact .mission-segment-text { + font-size: 10px; +} + +.mission-segment-body { + position: absolute; + inset: 0 auto 0 0; + min-width: 8px; + border-radius: 999px; + background: rgba(23, 103, 213, 0.16); + border-right: 1px solid rgba(23, 103, 213, 0.12); +} + +.mission-segment-text { + position: relative; + z-index: 1; + display: block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mission-tone-warning.mission-segment { + background: rgba(23, 103, 213, 0.09); +} + +.mission-tone-warning .mission-segment-body { + background: rgba(23, 103, 213, 0.16); +} + +.mission-tone-primary.mission-segment { + background: rgba(13, 79, 168, 0.1); + color: #0a3b7d; +} + +.mission-tone-primary .mission-segment-body { + background: rgba(13, 79, 168, 0.2); +} + +.mission-tone-neutral.mission-segment, +.mission-tone-soft.mission-segment { + background: rgba(91, 111, 143, 0.08); + border-color: rgba(91, 111, 143, 0.18); + color: #42546d; +} + +.mission-tone-neutral .mission-segment-body, +.mission-tone-soft .mission-segment-body { + background: rgba(91, 111, 143, 0.16); +} + +.mission-tone-current.mission-segment { + box-shadow: 0 0 0 2px rgba(23, 103, 213, 0.18); +} + +.mission-event-node { + display: flex; + align-items: flex-end; + justify-content: flex-start; + width: 0; + min-width: 0; + min-height: 48px; + cursor: pointer; + text-decoration: none; + position: absolute; + top: calc(6px + var(--timeline-track-index, 0) * var(--timeline-track-step, 54px)); + z-index: 2; + white-space: nowrap; + overflow: visible; +} + +.mission-event-node::before { + content: ""; + position: absolute; + left: -5px; + top: 12px; + width: 10px; + height: 10px; + border-radius: 999px; + background: var(--primary-strong); + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.96), 0 0 0 4px rgba(23, 103, 213, 0.14); + z-index: 1; +} + +.mission-event-node-label { + position: absolute; + left: 10px; + bottom: 0; + display: block; + width: var(--event-label-width, 110px); + color: var(--muted); + font-size: 11px; + line-height: 1.35; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + z-index: 1; +} + +.mission-event-node-label-left .mission-event-node-label { + left: auto; + right: 10px; + text-align: right; +} + +.mission-event-node-state::before { + background: #5b6f8f; + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.96), 0 0 0 4px rgba(91, 111, 143, 0.16); +} + +.mission-state-node-connector { + position: absolute; + left: 0; + top: calc(17px - var(--state-node-connector-height, 0px)); + height: var(--state-node-connector-height, 0px); + border-left: 2px dashed rgba(91, 111, 143, 0.6); + pointer-events: none; + z-index: 0; +} + +.mission-segment:hover, +.mission-event-node:hover { + z-index: 3; +} + +.mission-segment:hover::after, +.mission-segment:focus-visible::after, +.mission-event-node:hover::after, +.mission-event-node:focus-visible::after { + content: attr(data-full-label); + position: absolute; + top: calc(100% + 8px); + left: 0; + max-width: min(320px, 42vw); + padding: 8px 10px; + border-radius: 10px; + background: rgba(18, 35, 62, 0.96); + color: #fff; + font-size: 12px; + font-weight: 600; + line-height: 1.45; + white-space: normal; + box-shadow: var(--shadow); + pointer-events: none; + z-index: 5; +} + +.mission-event-node:hover::after, +.mission-event-node:focus-visible::after { + top: auto; + bottom: calc(100% + 10px); + left: 50%; + transform: translateX(-50%); +} + +@media (max-width: 1180px) { + .mission-hero, + .mission-toolbar-grid, + .mission-board-shell, + .mission-detail-layout, + .mission-card-grid { + grid-template-columns: 1fr; + } + + .mission-filter-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .mission-asset-picker-toolbar { + grid-template-columns: 1fr; + } + + .mission-asset-picker-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .mission-log-filter-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .state-node-row { + grid-template-columns: 1fr; + } + + .mission-hero-side, + .mission-board-side, + .mission-detail-side { + position: static; + } +} + +@media (max-width: 820px) { + .mission-page-header { + grid-template-columns: minmax(220px, 0.75fr) minmax(0, 1fr); + } + + .state-node-panel-header { + flex-direction: column; + } + + .state-node-remove-button { + width: 100%; + } + + .mission-page-header-copy { + grid-column: 1; + grid-row: 1 / span 2; + } + + .mission-page-header-metrics, + .mission-page-header-controls { + grid-column: 2; + } + + .mission-page-header-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 1120px) { + .mission-metric-grid, + .mission-meta-grid, + .mission-filter-grid, + .mission-asset-picker-grid, + .mission-card-grid, + .mission-timeline-row, + .mission-timeline-scale { + grid-template-columns: 1fr; + } + + .mission-toolbar-inline, + .mission-card-foot { + flex-direction: column; + align-items: flex-start; + } + + .mission-page-header { + grid-template-columns: 1fr; + } + + .mission-page-header-copy, + .mission-page-header-metrics, + .mission-page-header-controls { + grid-column: auto; + grid-row: auto; + } + + .mission-page-time-form { + flex-wrap: wrap; + justify-content: flex-start; + } + + .mission-page-inline-datetime { + grid-template-columns: 1fr; + } + + .mission-event-node span { + position: static; + transform: none; + width: auto; + margin-top: 24px; + } + + .mission-timeline-label, + .mission-timeline-corner { + position: static; + } +} + +@media (max-width: 960px) { + .hero-panel, + .catalog-layout, + .content-grid, + .metric-grid, + .decision-grid, + .factor-grid, + .engine-grid, + .detail-grid, + .summary-grid, + .family-grid, + .variant-editor-grid, + .variant-add-grid, + .resource-form-grid { + grid-template-columns: 1fr; + } + + .search-form, + .filter-row { + grid-template-columns: 1fr; + } + + .filter-panel { + position: static; + } + + .results-header, + .pagination-bar { + flex-direction: column; + align-items: flex-start; + } + + .catalog-header, + .catalog-page-header { + grid-template-columns: 1fr; + } + + .catalog-metrics { + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + width: 100%; + } + + .catalog-header-time-form { + grid-template-columns: minmax(0, 1fr) auto; + } + + .catalog-actions-panel { + flex-direction: column; + align-items: flex-start; + } + + .preview-row { + grid-template-columns: 1fr; + } + + .field-span-two { + grid-column: span 1; + } +} + +@media (max-width: 720px) { + .catalog-header { + grid-template-columns: 1fr; + } + + .catalog-metrics, + .catalog-metrics-duo, + .catalog-header-time-form { + grid-template-columns: 1fr; + width: 100%; + } + + .field-span-three { + grid-column: span 1; + } + + .site-header { + flex-direction: column; + align-items: flex-start; + } + + .hero-copy h1 { + max-width: none; + } +} diff --git a/app/templates/_datetime_picker_field.html b/app/templates/_datetime_picker_field.html new file mode 100644 index 0000000..0e03d15 --- /dev/null +++ b/app/templates/_datetime_picker_field.html @@ -0,0 +1,12 @@ +{% macro datetime_picker_field(label, name, value='', field_id=None, required=False, wrapper_class='', wrapper_id=None, label_id=None, button_label='选择') %} +{% set display_value = value|replace('-', '/')|replace('T', ' ') if value else '' %} +{% set picker_id = (field_id or name) ~ '-picker' %} + +{% endmacro %} \ No newline at end of file diff --git a/app/templates/_mission_ops_tabs.html b/app/templates/_mission_ops_tabs.html new file mode 100644 index 0000000..af5c609 --- /dev/null +++ b/app/templates/_mission_ops_tabs.html @@ -0,0 +1,9 @@ +
+ +
\ No newline at end of file diff --git a/app/templates/_mission_preview_controls.html b/app/templates/_mission_preview_controls.html new file mode 100644 index 0000000..7590cf9 --- /dev/null +++ b/app/templates/_mission_preview_controls.html @@ -0,0 +1,40 @@ +
+
+
+ Time Controls +
+ + + + +
+
+ +
+ Board Filters +
+ + + + + +
+
+
+ +
\ No newline at end of file diff --git a/app/templates/_mission_preview_header.html b/app/templates/_mission_preview_header.html new file mode 100644 index 0000000..048e4cb --- /dev/null +++ b/app/templates/_mission_preview_header.html @@ -0,0 +1,40 @@ +{% from "_datetime_picker_field.html" import datetime_picker_field %} +{% set header_metrics = metrics if metrics is defined else [] %} +{% set header_eyebrow = preview_eyebrow if preview_eyebrow is defined else 'Mission Ops' %} +{% set time_action = header_time_action if header_time_action is defined else None %} +{% set time_submit_label = header_time_submit_label if header_time_submit_label is defined else 'Apply Time' %} +{% set time_field_id = header_time_field_id if header_time_field_id is defined else 'page-header-sim-time' %} +{% set hidden_fields = header_hidden_fields if header_hidden_fields is defined else [] %} + +
+
+

{{ header_eyebrow }}

+

{{ preview_title }}

+
+ +
+ {% for metric in header_metrics %} +
+ {{ metric.label }} + {{ metric.value }} +
+ {% endfor %} + +
+ Current Simulation Time + {{ simulation_time_display or simulation_time }} +
+
+ +
+ {% if time_action and simulation_time_input %} +
+ {% for field in hidden_fields %} + + {% endfor %} + {{ datetime_picker_field('Edit Time', 'sim_time', simulation_time_input, field_id=time_field_id, wrapper_class='mission-page-inline-datetime') }} + +
+ {% endif %} +
+
\ No newline at end of file diff --git a/app/templates/_mission_upcoming_events.html b/app/templates/_mission_upcoming_events.html new file mode 100644 index 0000000..632c872 --- /dev/null +++ b/app/templates/_mission_upcoming_events.html @@ -0,0 +1,23 @@ + \ No newline at end of file diff --git a/app/templates/asset_detail.html b/app/templates/asset_detail.html new file mode 100644 index 0000000..b23e1d0 --- /dev/null +++ b/app/templates/asset_detail.html @@ -0,0 +1,185 @@ +{% extends "base.html" %} +{% from "_datetime_picker_field.html" import datetime_picker_field %} + +{% block content %} +{% include "_mission_ops_tabs.html" %} +{% include "_mission_preview_header.html" %} + +
+
+
+
+
+

Asset Summary

+

{{ asset.name }}

+

{{ asset.asset_type }} · {{ asset.program or 'No program assigned' }}

+
+
+ {{ asset_summary.current_state }} + {{ asset_summary.current_location }} + {% if asset.home_region %} + {{ asset.home_region }} + {% endif %} +
+
+ +
+
+
Current Mission
+
{{ asset_summary.current_mission }}
+
+
+
Active Window
+
{{ asset_summary.state_window }}
+
+
+
Next Planned Event
+
{{ asset_summary.next_event }}
+
+
+
+ +
+
+
+

历史记录

+

资产日志

+
+
{{ filtered_entry_count }} / {{ total_entry_count }} entries
+
+ +
+ + {{ datetime_picker_field('Start', 'start', entry_filter_start_input, field_id='asset-log-start') }} + {{ datetime_picker_field('End', 'end', entry_filter_end_input, field_id='asset-log-end') }} + + 重置 +
+ + {% if entries %} +
+ + + + + + + + + + + {% for entry in entries %} + + + + + + + {% endfor %} + +
TimeEntryLabelsAction
+ {{ entry.timestamp }} + {% if entry.end_timestamp %} +
→ {{ entry.end_timestamp }}
+ {% endif %} +
{{ entry.location }}
+
+ {{ entry.title }} +
{{ entry.mode_label }} · {{ entry.status_label }}
+
{{ entry.summary }}
+
+ {% if entry.state_labels or entry.mission_labels %} + + {% else %} + - + {% endif %} + + +
+
+ + {% if entry_total_pages > 1 %} + + {% endif %} + {% else %} +
+ {% if total_entry_count %} +

没有命中筛选条件的日志条目

+

调整起止时间,或者重置筛选后重试。

+ {% else %} +

还没有日志条目

+

先为这个资产新增一个 Event Point 或 State Interval。

+ {% endif %} +
+ {% endif %} +
+
+ + +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/asset_entry_form.html b/app/templates/asset_entry_form.html new file mode 100644 index 0000000..64aadac --- /dev/null +++ b/app/templates/asset_entry_form.html @@ -0,0 +1,264 @@ +{% extends "base.html" %} +{% from "_datetime_picker_field.html" import datetime_picker_field %} + +{% 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') }} +{% endmacro %} +{% macro render_state_node_row(node, row_index) %} +
+ + + + + {{ 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') }} + + + + +
+{% endmacro %} + +
+
+

资产日志

+

{{ heading }}

+
+
+ +
+ +
+ {% set is_event_kind = form_values.entry_kind == 'event' %} +
+ + + + + + + + + + + + + {{ 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') }} + + {% if not is_event_kind %} + {{ render_end_at_field(form_values.end_at) }} + {% endif %} + +
+
+
+ State Nodes +

每个 State Node 包含时间、标题和 Node Detail,并会在 Timeline 的 Event Point 泳道中显示。

+
+ +
+ +
+ {% for node in form_values.state_nodes %} + {{ render_state_node_row(node, loop.index0) }} + {% endfor %} +
+ +

当前没有 State Node,可按需添加。

+
+ + + + +
+ +
+ + 返回 +
+
+
+ +{% if delete_url %} +
+

删除后不可恢复,请谨慎操作。

+
+ + +
+
+{% endif %} + + + + + + +{% endblock %} \ No newline at end of file diff --git a/app/templates/asset_list.html b/app/templates/asset_list.html new file mode 100644 index 0000000..6d9a268 --- /dev/null +++ b/app/templates/asset_list.html @@ -0,0 +1,114 @@ +{% extends "base.html" %} +{% from "_datetime_picker_field.html" import datetime_picker_field %} + +{% block content %} +{% include "_mission_ops_tabs.html" %} +{% include "_mission_preview_header.html" %} + +
+
+

Actions

+

新增内容

+
+
+ 新增任务资产 +
+ + +
+
+
+ +
+
+ + + + + + + + 重置 +
+ + {% if rows %} +
+ + + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + + {% endfor %} + +
AssetTypeProgramCurrent EventNext EventEntriesAction
+ {{ row.asset.name }} + {{ row.asset.asset_type }}{{ row.asset.program or '-' }}{{ row.current_event }}{{ row.next_event }}{{ row.entry_count }} + +
+
+ + {% if total_pages > 1 %} + + {% endif %} + {% else %} +
+

没有命中结果

+

调整筛选条件或重置后重试。

+
+ {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..af645e9 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,284 @@ + + + + + + + {{ title }} · {{ config["PROJECT_TITLE"] }} + + + + +
+ + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+
+ + + + diff --git a/app/templates/communication_list.html b/app/templates/communication_list.html new file mode 100644 index 0000000..8e59932 --- /dev/null +++ b/app/templates/communication_list.html @@ -0,0 +1,148 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Communication

+

通信部件目录

+

支持分页、筛选、排序和快速编辑。

+
+
+
+
+ 总记录 + {{ total_catalog_count }} +
+
+ 当前结果 + {{ total_count }} +
+
+ 当前页 + {{ page }} / {{ total_pages }} +
+
+
+
+ +
+
+

Actions

+

新增内容

+
+ 新增通信部件 +
+ +
+
+ + + + + + + + + + 重置 +
+ + {% if rows %} +
+ + + + + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + + + + {% endfor %} + +
PartDisplayTypeRange (km)Mass (t)Idle (W)SourceFlagsAction
{{ row.part_name }}{{ row.display_name or '-' }}{{ row.antenna_type or '-' }}{{ row.range_km if row.range_km is not none else '-' }}{{ row.mass_t if row.mass_t is not none else '-' }}{{ row.idle_power_watt if row.idle_power_watt is not none else '-' }}{{ row.source or '-' }} + {% if row.is_active %}Active{% else %}-{% endif %} + {% if row.is_deployable %} / Deployable{% endif %} + {% if row.is_feeder %} / Feeder{% endif %} + +
+ 编辑 +
+ +
+
+
+
+ + {% if total_pages > 1 %} + + {% endif %} + {% else %} +
+

没有命中结果

+

调整筛选条件或重置后重试。

+
+ {% endif %} +
+{% endblock %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..8e7641a --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,80 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

项目起步版本

+

先把数据模型和导入边界定死,再做 CRUD。

+

+ 这个骨架现在已经提供 Flask 应用、规范化数据库模型、Docker 运行基线、Excel 巡检脚本, + 以及第一块可直接使用的 Fuel Chart 页面工具。 +

+
+
+

关键风险

+

{{ duplicate_conflicts }}

+

组 Engine + Config Name 在原始工作簿中存在重复,不能作为数据库硬唯一键。

+

即便补上 Fuel Type,仍然还有 {{ duplicate_conflicts_with_fuel }} 组冲突。

+
+
+ +
+ {% for metric in metrics %} +
+ {{ metric.value }} + {{ metric.label }} +
+ {% endfor %} +
+ +
+
+
+

建模决策

+

当前推荐的数据库拆分

+
+
+
+

engine_families

+

只放经 Excel 校验后稳定的字段:Engine、Name、Cycle、Size、entry Cost。

+
+
+

engine_variants

+

放所有配置级字段:Fuel Type、Work Env、ISP、推力、TVC、价格、点火次数、Tech、备注。

+
+
+

代理主键

+

所有核心表使用 UUID 主键,导入时保留原始自然键,并单独输出重复告警。

+
+
+

Fuel Chart

+

不先入库,先作为常量配置和 API 小工具,后面再看是否要抽成字典表。

+
+
+
+ +
+
+

模块状态

+

第一批交付

+
+
+ {% for module in modules %} +
+
+ {{ module.status }} +

{{ module.title }}

+
+

{{ module.description }}

+ {% if module.endpoint %} + 立即打开 + {% else %} + 下一阶段实现 + {% endif %} +
+ {% endfor %} +
+
+
+{% endblock %} + diff --git a/app/templates/engine_create.html b/app/templates/engine_create.html new file mode 100644 index 0000000..1b060cb --- /dev/null +++ b/app/templates/engine_create.html @@ -0,0 +1,97 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Engine Create

+

新增引擎

+

创建一个新的引擎家族,并同时录入第一条配置项。

+
+
+ +
+
+
+

Family

+

基础字段

+
+
+ {% for field in family_fields %} + {% set field_value = request.form.get(field.name, '') %} + + {% endfor %} +
+
+ +
+
+

Initial Variant

+

首个配置项

+
+
+ {% for field in variant_fields %} + {% set input_name = 'initial-' ~ field.name %} + {% set field_value = request.form.get(input_name, '') %} + + {% endfor %} +
+ +
+ + 取消 +
+
+
+{% endblock %} diff --git a/app/templates/engine_detail.html b/app/templates/engine_detail.html new file mode 100644 index 0000000..7d554d9 --- /dev/null +++ b/app/templates/engine_detail.html @@ -0,0 +1,109 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Engine Detail

+

{{ family.engine_name }}

+

查看家族级字段,以及该引擎下全部配置项的关键性能参数。

+
+
+ +
+
+
+

Family

+

基础信息

+
+
+
+ Part Name + {{ family.part_name or "-" }} +
+
+ Cycle + {{ family.cycle or "-" }} +
+
+ Size + {{ family.size_m|fmt_decimal }} +
+
+ Entry Cost + {{ family.entry_cost if family.entry_cost is not none else "-" }} +
+
+
+ 编辑这个引擎 + 返回列表 +
+ +
+
+
+ +
+
+

Variants

+

配置列表

+
+
+ + + + + + + + + + + + + + + + + + + + {% for row in variant_rows %} + {% set variant = row.variant %} + + + + + + + + + + + + + + + + {% endfor %} + +
ConfigFuelEnvSL ThrustVac ThrustSL ISPVac ISPMassTWRPriceIgnitionsModTech
+ {{ variant.config_name or "Default" }} + {% if variant.config_note %} +
{{ variant.config_note }}
+ {% endif %} +
{{ variant.fuel_type }}{{ variant.work_env or "-" }}{{ row.sl_thrust_kn|fmt_decimal }}{{ row.vac_thrust_kn|fmt_decimal }}{{ variant.sl_isp|fmt_decimal }}{{ variant.vac_isp|fmt_decimal }}{{ variant.mass_t|fmt_decimal }}{{ variant.twr|fmt_decimal }}{{ variant.price_kd if variant.price_kd is not none else "-" }} + {% if variant.has_unlimited_ignitions %} + Unlimited + {% else %} + {{ variant.ignitions if variant.ignitions is not none else "-" }} + {% endif %} + {{ variant.mod_source or "-" }}{{ variant.tech_required or "-" }}
+
+
+
+{% endblock %} diff --git a/app/templates/engine_edit.html b/app/templates/engine_edit.html new file mode 100644 index 0000000..e8db97e --- /dev/null +++ b/app/templates/engine_edit.html @@ -0,0 +1,247 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Engine Edit

+

编辑 {{ family.engine_name }}

+

支持家族字段编辑、配置项新增、配置项删除,以及整机删除。

+
+
+ +
+
+

Add Variant

+

新增配置项

+
+
+ {% for field in variant_create_fields %} + {% set input_name = 'add-' ~ field.name %} + + {% endfor %} +
+ +
+
+
+ +
+
+
+

Family

+

基础字段

+
+
+ {% for field in family_fields %} + {% set current_value = family|attr(field.name) if family|attr(field.name) is not none else '' %} + + {% endfor %} +
+
+ +
+
+

Variants

+

配置字段

+
+ +
+ {% for variant in family.variants %} +
+
+

{{ variant.config_name or "Default" }}

+
+ Row {{ variant.source_sheet_row or "-" }} + +
+
+ +
+ {% for field in variant_fields %} + {% set current_value = variant|attr(field.name) %} + {% set input_name = 'variant-' ~ variant.id ~ '-' ~ field.name %} + {% if field.kind == 'textarea' %} + + {% elif field.kind == 'checkbox' %} + + {% elif field.kind == 'combo' %} + + {% else %} + + {% endif %} + {% endfor %} +
+
+ {% endfor %} +
+ +
+ + 取消 + +
+
+
+ + +{% endblock %} diff --git a/app/templates/engine_list.html b/app/templates/engine_list.html new file mode 100644 index 0000000..f39d2e9 --- /dev/null +++ b/app/templates/engine_list.html @@ -0,0 +1,262 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Engine Catalog

+

引擎目录

+

宽屏高密度视图,支持分页、燃料/循环/Mod 筛选,并按推力或比冲排序。

+
+
+
+
+ 总引擎家族 + {{ total_catalog_count }} +
+
+ 当前结果 + {{ total_count }} +
+
+ 当前页 + {{ page }} / {{ total_pages }} +
+
+
+
+ +
+
+ + +
+
+

Actions

+

新增内容

+

创建新的引擎家族和初始配置。

+
+ 新增引擎 +
+
+ +
+
+
+

Results

+

结果列表

+

每页 30 条,单行压缩并补充海平面/真空核心参数。

+
+
+ + {% if catalog_rows %} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% for row in catalog_rows %} + + + + + + + + + + + + + + + {% endfor %} + +
+
+ Engine + {% for column in sort_columns if column.key == 'engine_name' %} + + + + + {% endfor %} +
+
CycleFuelModConfigs +
+ Mass + {% for column in sort_columns if column.key == 'mass_t' %} + + + + + {% endfor %} +
+
+
+ SL Thrust + {% for column in sort_columns if column.key == 'sl_thrust' %} + + + + + {% endfor %} +
+
+
+ Vac Thrust + {% for column in sort_columns if column.key == 'vac_thrust' %} + + + + + {% endfor %} +
+
+
+ SL ISP + {% for column in sort_columns if column.key == 'sl_isp' %} + + + + + {% endfor %} +
+
+
+ Vac ISP + {% for column in sort_columns if column.key == 'vac_isp' %} + + + + + {% endfor %} +
+
TWRAction
+ {{ row.family.engine_name }} +
{{ row.family.part_name or "No part name" }}
+
{{ row.family.cycle or "-" }}{{ row.fuel_types|join(', ') if row.fuel_types else "-" }}{{ row.mod_sources|join(', ') if row.mod_sources else "-" }}{{ row.config_count }}{{ row.mass_t|fmt_decimal }}{{ row.sl_thrust_kn|fmt_decimal }}{{ row.vac_thrust_kn|fmt_decimal }}{{ row.sl_isp|fmt_decimal }}{{ row.vac_isp|fmt_decimal }}{{ row.twr|fmt_decimal }} +
+ 详情 + 编辑 +
+ +
+
+
+
+ + {% if total_pages > 1 %} + + {% endif %} + {% else %} +
+

没有命中结果

+

调整筛选条件,或者重置后重新查看全部引擎。

+
+ {% endif %} +
+
+{% endblock %} diff --git a/app/templates/fuel_converter.html b/app/templates/fuel_converter.html new file mode 100644 index 0000000..0224970 --- /dev/null +++ b/app/templates/fuel_converter.html @@ -0,0 +1,109 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Fuel Chart 工具化

+

燃料体积 / 质量快速换算

+

+ 系数直接来自工作簿第二行。输入升数可换算吨位,输入吨位可反推不同燃料对应的体积。 +

+
+
+ +
+
+
+

换算输入

+

输入一个值

+
+ +
+ + + + + + + + +
+
+ +
+
+

换算结果

+

输出列表

+
+ +
输入 75 t 或 75 L 后,这里会显示结果。
+
+
+
+ +
+
+

当前系数

+

每升对应吨位

+
+
+ {% for factor in fuel_factors %} +
+

{{ factor.fuel }}

+

{{ factor.tonnes_per_liter }} t / L

+
+ {% endfor %} +
+
+ + +{% endblock %} diff --git a/app/templates/mission_asset_log_preview.html b/app/templates/mission_asset_log_preview.html new file mode 100644 index 0000000..f8af814 --- /dev/null +++ b/app/templates/mission_asset_log_preview.html @@ -0,0 +1,113 @@ +{% extends "base.html" %} + +{% block content %} +{% include "_mission_preview_header.html" %} +{% include "_mission_preview_controls.html" %} + +
+
+
+
+
+

Asset Summary

+

{{ asset.name }}

+

{{ asset.asset_type }} · {{ asset.program }}

+
+
+ {{ asset.current_state }} + {{ asset.current_location }} +
+
+ +
+
+
Current Mission
+
{{ asset.current_mission }}
+
+
+
Active Window
+
{{ asset.state_window }}
+
+
+
Next Planned Event
+
{{ asset.next_event }}
+
+
+
+ +
+
+

History

+

Asset Timeline Entries

+
+ +
+ {% for entry in entries %} +
+
+ +
+ +
+
+ {% if entry.mode == 'event' %}Event Point{% else %}State Interval{% endif %} + {{ entry.record_status }} +
+ +

{{ entry.title }}

+ +

+ {{ entry.timestamp }} + {% if entry.end_timestamp %} + → {{ entry.end_timestamp }} + {% endif %} + · {{ entry.location }} +

+ +

{{ entry.summary }}

+
+
+ {% endfor %} +
+
+
+ + +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/mission_list.html b/app/templates/mission_list.html new file mode 100644 index 0000000..87c6991 --- /dev/null +++ b/app/templates/mission_list.html @@ -0,0 +1,137 @@ +{% extends "base.html" %} +{% from "_datetime_picker_field.html" import datetime_picker_field %} + +{% block content %} +{% include "_mission_ops_tabs.html" %} +{% include "_mission_preview_header.html" %} + +
+
+ + +
+ Mission Label 检索与筛选 +
+ + + + + {{ datetime_picker_field('开始', 'start', range_start_input, field_id='mission-list-start') }} + {{ datetime_picker_field('结束', 'end', range_end_input, field_id='mission-list-end') }} + + 重置 +
+
+
+
+ +
+
+ Asset: {{ selected_asset_name }} + Mission: {{ mission_label_filter or (search_term or '全部') }} + 地点: {{ location_filter or '全部地点' }} + 日志: {{ filtered_entry_count }} / {{ total_entry_count }} +
+
+ +{% if mission_groups %} + {% for group in mission_groups %} +
+
+
+

Mission Label

+

{{ group.mission_label }}

+

{{ group.status_label }} · {{ group.asset_count }} 个 Asset · {{ group.entry_count }} 条日志

+
+ 最近条目 {{ group.latest_timestamp }} +
+ +
+ Assets: {{ group.asset_names|join(' · ') }} + 地点: {{ group.locations|join(' · ') }} +
+ +
+ + + + + + + + + + + + {% for entry in group.entries %} + + + + + + + + {% endfor %} + +
TimeAssetEntryLocationAction
+ {{ entry.timestamp }} + {% if entry.end_timestamp %} +
→ {{ entry.end_timestamp }}
+ {% endif %} +
{{ entry.status_label }}
+
+ {{ entry.asset_name }} +
{{ entry.asset_type }}
+
+ {{ entry.title }} +
{{ entry.mode_label }}
+
{{ entry.summary }}
+ {% if entry.state_labels or entry.mission_labels %} + + {% endif %} +
{{ entry.location }} + +
+
+
+ {% endfor %} +{% else %} +
+

当前筛选条件下没有 Mission 日志

+

调整 Asset、时间、Mission Label、地点筛选,或者清空 Mission Label 搜索后重试。

+
+{% endif %} +{% endblock %} \ No newline at end of file diff --git a/app/templates/mission_location_board_preview.html b/app/templates/mission_location_board_preview.html new file mode 100644 index 0000000..f3d869b --- /dev/null +++ b/app/templates/mission_location_board_preview.html @@ -0,0 +1,106 @@ +{% extends "base.html" %} +{% from "_datetime_picker_field.html" import datetime_picker_field %} + +{% block content %} +{% include "_mission_ops_tabs.html" %} +{% include "_mission_preview_header.html" %} + +
+
+
+ 面板筛选 +
+ + + + + 重置 +
+
+
+ +
+ +
+
+ {% if location_groups %} + {% for group in location_groups %} +
+
+
+

地点分组

+

{{ group.title }}

+

{{ group.subtitle }}

+
+ {{ group['items']|length }} 项资产 +
+ +
+ {% for item in group['items'] %} +
+
+
+

{{ item.name }}

+

{{ item.asset_type }}

+
+ {{ item.state }} +
+ +
+
+
Location
+
{{ item.location }}
+
+
+
Mission
+
{{ item.mission }}
+
+
+
Next Event
+
{{ item.next_event }}
+
+
+ + +
+ {% endfor %} +
+
+ {% endfor %} + {% else %} +
+

当前筛选条件下没有资产

+
+ {% endif %} +
+ +
+ {% include "_mission_upcoming_events.html" %} +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/mission_status_board_preview.html b/app/templates/mission_status_board_preview.html new file mode 100644 index 0000000..8cdde2f --- /dev/null +++ b/app/templates/mission_status_board_preview.html @@ -0,0 +1,111 @@ +{% extends "base.html" %} +{% from "_datetime_picker_field.html" import datetime_picker_field %} + +{% block content %} +{% include "_mission_ops_tabs.html" %} +{% include "_mission_preview_header.html" %} + +
+
+
+ 面板筛选 +
+ + + + + 重置 +
+
+
+ +
+ +
+
+ {% if status_groups %} + {% for group in status_groups %} +
+
+
+

状态分组

+

{{ group.title }}

+
+ {{ group['items']|length }} 项资产 +
+ +
+ {% for item in group['items'] %} +
+
+
+

{{ item.name }}

+

{{ item.asset_type }}

+
+ {{ item.record_status }} +
+ +
+
+
Location
+
{{ item.location }}
+
+
+
Mission
+
{{ item.mission }}
+
+
+
State Window
+
{{ item.state_window }}
+
+
+
Next Event
+
{{ item.next_event }}
+
+
+ +

{{ item.summary }}

+ + +
+ {% endfor %} +
+
+ {% endfor %} + {% else %} +
+

当前筛选条件下没有资产

+
+ {% endif %} +
+ +
+ {% include "_mission_upcoming_events.html" %} +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/mission_timeline_preview.html b/app/templates/mission_timeline_preview.html new file mode 100644 index 0000000..ad5a4c7 --- /dev/null +++ b/app/templates/mission_timeline_preview.html @@ -0,0 +1,240 @@ +{% extends "base.html" %} +{% from "_datetime_picker_field.html" import datetime_picker_field %} + +{% block content %} +{% include "_mission_ops_tabs.html" %} +{% include "_mission_preview_header.html" %} + +
+
+ +
+ 时间范围 +
+ {{ datetime_picker_field('开始', 'start', timeline_range_start, field_id='timeline-start') }} + {{ datetime_picker_field('结束', 'end', timeline_range_end, field_id='timeline-end') }} + +
+
+ +
+ 时间线筛选 +
+ + + + + 重置 +
+
+ +
+ 显示资产 + {% if timeline_asset_options %} +
+
+ +
+ + + +
+
+ +
+ {% for option in timeline_asset_options %} + + {% endfor %} +
+
+

未勾选任何 Asset 时,默认显示当前筛选命中的全部资产。

+ {% else %} +

当前筛选条件下没有可选资产。

+ {% endif %} +
+
+
+ +
+
+
+

Timeline

+

多资产时间线

+
+
+ 转移段 + 持续运行 + 停泊 / 地表 / 待命 + 当前活动状态 +
+
+ +
+ 范围: {{ timeline_range_display }} + 尺度: {{ timeline_scale_mode|title }} + 资产: {% if selected_asset_count %}已选 {{ selected_asset_count }} 个{% else %}显示 {{ timeline_rows|length }} 条{% endif %} + 模式: {{ record_scope_options|selectattr('value', 'equalto', record_scope)|map(attribute='label')|first }} +
+ + {% if timeline_rows %} +
+
+
+
+
+ {% for label in timeline_scale %} + {{ label }} + {% endfor %} +
+
+ +
+ {% for row in timeline_rows %} +
+ +

{{ row.name }}

+

{{ row.asset_type }}

+

Current Status: {{ row.current_state }}

+
+ +
+
+ {% for segment in row.segments %} + + + {{ segment.label }} + + {% endfor %} +
+ +
+ {% for event in row.events %} + + {% if event.state_node %} + + {% endif %} + {{ event.label }} + + {% endfor %} +
+
+
+ {% endfor %} +
+
+
+ {% else %} +
+

当前时间范围内没有时间线条目

+
+ {% endif %} +
+ + +{% endblock %} \ No newline at end of file diff --git a/app/templates/resource_edit.html b/app/templates/resource_edit.html new file mode 100644 index 0000000..9646e4d --- /dev/null +++ b/app/templates/resource_edit.html @@ -0,0 +1,89 @@ +{% extends "base.html" %} + +{% block content %} +{% if mission_ops_tabs %} +{% include "_mission_ops_tabs.html" %} +{% endif %} +
+
+

{{ eyebrow }}

+

{{ heading }}

+
+
+ +
+ {% set hidden_field_values = hidden_fields|default({}, true) %} + {% for name, value in hidden_field_values.items() %} + + {% endfor %} +
+ {% set option_map = form_options|default({}) %} + {% set combo_custom_option_value = custom_option_value|default('__custom__') %} +
+ {% for field in fields %} + {% set current_value = values.get(field.name) %} + {% if field.kind == 'textarea' %} + + {% elif field.kind == 'checkbox' %} + + {% elif field.kind == 'combo' %} + {% 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 %} + + {% else %} + + {% endif %} + {% endfor %} +
+ +
+ + 返回 +
+
+
+ +{% if delete_url %} +
+

删除后不可恢复,请谨慎操作。

+
+ {% set delete_hidden_field_values = delete_hidden_fields|default(hidden_field_values, true) %} + {% for name, value in delete_hidden_field_values.items() %} + + {% endfor %} + +
+
+{% endif %} +{% endblock %} diff --git a/app/templates/tank_list.html b/app/templates/tank_list.html new file mode 100644 index 0000000..f594cff --- /dev/null +++ b/app/templates/tank_list.html @@ -0,0 +1,146 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Tank

+

燃料箱目录

+

支持分页、筛选、排序和快速编辑。

+
+
+
+
+ 总记录 + {{ total_catalog_count }} +
+
+ 当前结果 + {{ total_count }} +
+
+ 当前页 + {{ page }} / {{ total_pages }} +
+
+
+
+ +
+
+

Actions

+

新增内容

+
+ 新增燃料箱 +
+ +
+
+ + + + + + + + + + 重置 +
+ + {% if rows %} +
+ + + + + + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + + + + + {% endfor %} + +
TankFuelVehicleVolume (L)Dry (t)Fuel (t)Wet (t)RatioSourceAction
{{ row.tank_name }}{{ row.fuel_type or '-' }}{{ row.vehicle_name or '-' }}{{ row.tank_volume_l if row.tank_volume_l is not none else '-' }}{{ row.dry_mass_t if row.dry_mass_t is not none else '-' }}{{ row.fuel_mass_t if row.fuel_mass_t is not none else '-' }}{{ row.wet_mass_t if row.wet_mass_t is not none else '-' }}{{ row.mass_ratio if row.mass_ratio is not none else '-' }}{{ row.source or '-' }} +
+ 编辑 +
+ +
+
+
+
+ + {% if total_pages > 1 %} + + {% endif %} + {% else %} +
+

没有命中结果

+

调整筛选条件或重置后重试。

+
+ {% endif %} +
+{% endblock %} diff --git a/app/templates/vehicle_list.html b/app/templates/vehicle_list.html new file mode 100644 index 0000000..57a8d77 --- /dev/null +++ b/app/templates/vehicle_list.html @@ -0,0 +1,127 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Vehicle

+

载具成本目录

+

支持分页、来源筛选、成本排序与编辑。

+
+
+
+
+ 总记录 + {{ total_catalog_count }} +
+
+ 当前结果 + {{ total_count }} +
+
+ 当前页 + {{ page }} / {{ total_pages }} +
+
+
+
+ +
+
+

Actions

+

新增内容

+
+ 新增载具成本 +
+ +
+
+ + + + + + + + 重置 +
+ + {% if rows %} +
+ + + + + + + + + + + {% for row in rows %} + + + + + + + {% endfor %} + +
VehicleLaunch PriceSourceAction
{{ row.vehicle_name }}{{ row.launch_price if row.launch_price is not none else '-' }}{{ row.source or '-' }} +
+ 编辑 +
+ +
+
+
+
+ + {% if total_pages > 1 %} + + {% endif %} + {% else %} +
+

没有命中结果

+

调整筛选条件或重置后重试。

+
+ {% endif %} +
+{% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..faf6580 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + web: + build: + context: . + env_file: + - .env + ports: + - "${APP_PORT:-8000}:8000" + restart: unless-stopped + command: gunicorn --bind 0.0.0.0:8000 main:app diff --git a/job.md b/job.md index e69de29..24e850c 100644 --- a/job.md +++ b/job.md @@ -0,0 +1,82 @@ +我们需要搭建一个基于python+flask架构的后端服务器+一个前端界面,来实现一些数据的管理工作。 +我会给你一些信息,然后我们开始讨论这个项目要落地需要怎么做。你把关键信息整理下来,如果有问题可以和我沟通。 +沟通到你认为没有问题了,我们就开始动手搭建这个项目。 + +后端服务器连接的是pg库,连接信息已经保存在.env文件里面了。你不可以查看.env文件,因为里面有敏感信息。但是有一个.env.example文件,你可以查看这个文件,来了解一下连接信息的格式。 + + +这是一个个人项目,前后端都应当部署在我的NAS上,以docker的形式运行。 +你可以先查看KSP Engine Tweak Chart.xlsx,这个里面记载了我需要管理的数据 +我先给你解释一下里面的表结构: + +- Engine Database表:这个表记录了所有的火箭发动机的数据。某种程度上来说算是这个项目的核心表。 +- Communication表:记录了所有的天线数据 +- Work Area表:记录了一些WIP的引擎信息,你可以忽略 +- Fuel Chart: 记录了不同燃料的一个快速换算信息。你可以查看一下这个表,尤其是里面公式的部分,这个可以作为一个快速换算工具来实现(后续不依赖这个表)。这部分你可以先看,肯定会有一些问题,我们讨论实现。 +- Tank Chart:记录了一些燃料箱的数据 +- KSP Vehicle Cost:记录了一些发射载具的成本数据 +- Tube、TQ series:予以忽略 + +后续我们会根据这个表来设计数据库的表结构,来实现数据的管理功能。 +引擎表是核心,你需要搞清楚里面每个字段的含义,来设计数据库表结构和前端界面。你看一下这个表,有什么不理解的地方可以问我。 +你先把表都弄清楚,然后我们再讨论一下这个项目的功能需求,来设计数据库表结构和前端界面,接下来把表上传到数据库,随后我们就可以开始实现数据的管理功能了。 + + +第一轮问题: +## 6. 第一轮需要你确认的问题 + +下面这些问题会直接影响数据库设计和前端表单设计: + +### 关于 `Engine Database` + +1. `Work Env` 的枚举含义是什么? + - 目前看到像 `Lower`、`Upper` 等,是否表示适用飞行环境/任务场景? + - Answer:是的,这是一个引擎的分类表示,表示这些引擎是上面级引擎还是起飞级引擎 + +2. `Classification` 是什么语义? + - 是模组来源(如 `Squad`、`NFS`、`Ven`、`Real/...`)还是引擎分类标签? + - Answer:是Mod来源 + +3. `Name` 是不是游戏/模组里的**内部 part name**? + - Answer:是的。为空的原因是有一些我没有记录上去。这些可以先置空,后续我慢慢补 + +4. `Config Name` 是不是某个引擎的配置名/variant 名? + - 如果是,它显然不是全局唯一键;后续唯一标识你倾向于用什么? + - `Engine + Fuel Type + Config Name`?还是另有内部 ID? + - Answer:应当使用Engine + Config Name作为唯一ID。如果你发现有重复的,反馈给我,我来确认是什么情况 + +5. `Price KD` 是不是 Kerbal Dollars 的部件价格? + - Answer:是的 +6. `entry Cost` 是不是科技树解锁成本 / 研发成本? + - Answer:是研发成本 +7. `TVC` 的单位是否是“度”(gimbal range)? + Answer:是的 +8. `Size` 的单位是否是米(或零件直径规格)? + Answer:是米 +9. `Ignitions` 为空时表示: + - Answer:无限次点火 +10. `Note` 与 `Config Note` 的区别是什么? + - Answer:一个是引擎总体备注,一个是某个配置备注 + +### 关于 `Fuel Chart` + +11. 这个换算功能你希望最终怎么实现? + - 单独页面小工具 + +12. 第二行这些值,本质上是不是“燃料密度 / 升到吨的换算系数”? + - 是的 + +### 关于 `KSP Vehicle Cost` + +13. `Launch Price` 为空的行,是不是表示“暂无数据,允许为空”? + - 是的 + +## 7. 下一步建议 + +等你回答上面的问题后,我建议立即进入下面阶段: + +1. 先把 `Engine Database` 字段定义彻底确认 +2. 画出数据库草图(PostgreSQL) +3. 确定 Flask 后端模块划分 +4. 确定前端页面:列表 / 搜索 / 详情 / 编辑 / 导入 +5. 再开始初始化项目结构、Docker、数据库迁移、Excel 导入脚本 \ No newline at end of file diff --git a/log_book.xlsx b/log_book.xlsx new file mode 100644 index 0000000..663bc21 Binary files /dev/null and b/log_book.xlsx differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..e463ecb --- /dev/null +++ b/main.py @@ -0,0 +1,12 @@ +from app import create_app + + +app = create_app() + + +if __name__ == "__main__": + app.run( + host="0.0.0.0", + port=app.config["APP_PORT"], + debug=app.config["APP_ENV"] == "development", + ) diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,113 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/0f1b9bb2156c_add_detail_to_asset_state_nodes.py b/migrations/versions/0f1b9bb2156c_add_detail_to_asset_state_nodes.py new file mode 100644 index 0000000..d746496 --- /dev/null +++ b/migrations/versions/0f1b9bb2156c_add_detail_to_asset_state_nodes.py @@ -0,0 +1,26 @@ +"""add detail to asset state nodes + +Revision ID: 0f1b9bb2156c +Revises: f4f0c9c9426e +Create Date: 2026-04-25 23:15:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0f1b9bb2156c' +down_revision = 'f4f0c9c9426e' +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table('asset_state_nodes', schema=None) as batch_op: + batch_op.add_column(sa.Column('detail', sa.Text(), nullable=True)) + + +def downgrade(): + with op.batch_alter_table('asset_state_nodes', schema=None) as batch_op: + batch_op.drop_column('detail') \ No newline at end of file diff --git a/migrations/versions/6c2a57f2b1e9_add_asset_log_tables.py b/migrations/versions/6c2a57f2b1e9_add_asset_log_tables.py new file mode 100644 index 0000000..6bba7cf --- /dev/null +++ b/migrations/versions/6c2a57f2b1e9_add_asset_log_tables.py @@ -0,0 +1,71 @@ +"""add asset log tables + +Revision ID: 6c2a57f2b1e9 +Revises: 83be4d38636d +Create Date: 2026-04-24 09:40:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6c2a57f2b1e9' +down_revision = '83be4d38636d' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'assets', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('asset_type', sa.String(length=120), nullable=False), + sa.Column('program', sa.String(length=255), nullable=True), + sa.Column('home_region', sa.String(length=120), nullable=True), + sa.Column('note', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name'), + ) + with op.batch_alter_table('assets', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_assets_asset_type'), ['asset_type'], unique=False) + batch_op.create_index(batch_op.f('ix_assets_name'), ['name'], unique=False) + + op.create_table( + 'asset_log_entries', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('asset_id', sa.Uuid(), nullable=False), + sa.Column('entry_kind', sa.String(length=16), nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('state_label', sa.String(length=120), nullable=True), + sa.Column('mission_label', sa.String(length=255), nullable=True), + sa.Column('location', sa.String(length=255), nullable=True), + sa.Column('start_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('end_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('summary', sa.Text(), nullable=True), + sa.Column('note', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['asset_id'], ['assets.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + with op.batch_alter_table('asset_log_entries', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_asset_log_entries_asset_id'), ['asset_id'], unique=False) + batch_op.create_index(batch_op.f('ix_asset_log_entries_start_at'), ['start_at'], unique=False) + + +def downgrade(): + with op.batch_alter_table('asset_log_entries', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_asset_log_entries_start_at')) + batch_op.drop_index(batch_op.f('ix_asset_log_entries_asset_id')) + + op.drop_table('asset_log_entries') + + with op.batch_alter_table('assets', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_assets_name')) + batch_op.drop_index(batch_op.f('ix_assets_asset_type')) + + op.drop_table('assets') \ No newline at end of file diff --git a/migrations/versions/83be4d38636d_expand_communication_range_precision.py b/migrations/versions/83be4d38636d_expand_communication_range_precision.py new file mode 100644 index 0000000..b581498 --- /dev/null +++ b/migrations/versions/83be4d38636d_expand_communication_range_precision.py @@ -0,0 +1,46 @@ +"""expand communication range precision + +Revision ID: 83be4d38636d +Revises: f1fa035d3afe +Create Date: 2026-04-23 14:39:48.339810 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '83be4d38636d' +down_revision = 'f1fa035d3afe' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('communication_parts', schema=None) as batch_op: + batch_op.alter_column('range_raw', + existing_type=sa.NUMERIC(precision=18, scale=3), + type_=sa.Numeric(precision=24, scale=3), + existing_nullable=True) + batch_op.alter_column('range_km', + existing_type=sa.NUMERIC(precision=18, scale=3), + type_=sa.Numeric(precision=24, scale=3), + existing_nullable=True) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('communication_parts', schema=None) as batch_op: + batch_op.alter_column('range_km', + existing_type=sa.Numeric(precision=24, scale=3), + type_=sa.NUMERIC(precision=18, scale=3), + existing_nullable=True) + batch_op.alter_column('range_raw', + existing_type=sa.Numeric(precision=24, scale=3), + type_=sa.NUMERIC(precision=18, scale=3), + existing_nullable=True) + + # ### end Alembic commands ### diff --git a/migrations/versions/8bbf69ea2013_add_retired_flag_to_assets.py b/migrations/versions/8bbf69ea2013_add_retired_flag_to_assets.py new file mode 100644 index 0000000..6ea105e --- /dev/null +++ b/migrations/versions/8bbf69ea2013_add_retired_flag_to_assets.py @@ -0,0 +1,29 @@ +"""add retired flag to assets + +Revision ID: 8bbf69ea2013 +Revises: 0f1b9bb2156c +Create Date: 2026-04-26 01:25:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8bbf69ea2013' +down_revision = '0f1b9bb2156c' +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table('assets', schema=None) as batch_op: + batch_op.add_column(sa.Column('is_retired', sa.Boolean(), nullable=False, server_default=sa.false())) + + with op.batch_alter_table('assets', schema=None) as batch_op: + batch_op.alter_column('is_retired', server_default=None) + + +def downgrade(): + with op.batch_alter_table('assets', schema=None) as batch_op: + batch_op.drop_column('is_retired') \ No newline at end of file diff --git a/migrations/versions/f1fa035d3afe_initial_schema.py b/migrations/versions/f1fa035d3afe_initial_schema.py new file mode 100644 index 0000000..987c70d --- /dev/null +++ b/migrations/versions/f1fa035d3afe_initial_schema.py @@ -0,0 +1,154 @@ +"""initial schema + +Revision ID: f1fa035d3afe +Revises: +Create Date: 2026-04-23 14:38:17.484705 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f1fa035d3afe' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('communication_parts', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('part_name', sa.String(length=255), nullable=False), + sa.Column('display_name', sa.String(length=255), nullable=True), + sa.Column('mass_t', sa.Numeric(precision=10, scale=4), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('is_deployable', sa.Boolean(), nullable=True), + sa.Column('antenna_type', sa.String(length=120), nullable=True), + sa.Column('deployed_diameter_m', sa.Numeric(precision=10, scale=3), nullable=True), + sa.Column('range_raw', sa.Numeric(precision=18, scale=3), nullable=True), + sa.Column('range_km', sa.Numeric(precision=18, scale=3), nullable=True), + sa.Column('range_au', sa.Numeric(precision=18, scale=6), nullable=True), + sa.Column('range_light_year', sa.Numeric(precision=18, scale=6), nullable=True), + sa.Column('angle_deg', sa.Numeric(precision=10, scale=3), nullable=True), + sa.Column('speed', sa.Numeric(precision=10, scale=3), nullable=True), + sa.Column('idle_power_watt', sa.Numeric(precision=12, scale=4), nullable=True), + sa.Column('idle_power_text', sa.String(length=64), nullable=True), + sa.Column('transmitting_power_watt', sa.Numeric(precision=12, scale=4), nullable=True), + sa.Column('transmitting_power_text', sa.String(length=64), nullable=True), + sa.Column('source', sa.String(length=120), nullable=True), + sa.Column('entry_cost', sa.Integer(), nullable=True), + sa.Column('cost', sa.Integer(), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('rescale_factor', sa.Numeric(precision=8, scale=3), nullable=True), + sa.Column('tweakscale', sa.String(length=64), nullable=True), + sa.Column('is_feeder', sa.Boolean(), nullable=True), + sa.Column('tech_required', sa.String(length=120), nullable=True), + sa.Column('note', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('communication_parts', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_communication_parts_part_name'), ['part_name'], unique=False) + + op.create_table('engine_families', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('engine_name', sa.String(length=255), nullable=False), + sa.Column('part_name', sa.String(length=255), nullable=True), + sa.Column('cycle', sa.String(length=120), nullable=True), + sa.Column('size_m', sa.Numeric(precision=8, scale=3), nullable=True), + sa.Column('entry_cost', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('engine_name') + ) + op.create_table('tank_specs', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tank_name', sa.String(length=255), nullable=False), + sa.Column('fuel_type', sa.String(length=120), nullable=True), + sa.Column('dry_mass_t', sa.Numeric(precision=10, scale=4), nullable=True), + sa.Column('fuel_mass_t', sa.Numeric(precision=10, scale=4), nullable=True), + sa.Column('wet_mass_t', sa.Numeric(precision=10, scale=4), nullable=True), + sa.Column('tank_volume_l', sa.Numeric(precision=12, scale=3), nullable=True), + sa.Column('mass_ratio', sa.Numeric(precision=12, scale=4), nullable=True), + sa.Column('kiloliters_per_ton', sa.Numeric(precision=12, scale=4), nullable=True), + sa.Column('vehicle_name', sa.String(length=120), nullable=True), + sa.Column('source', sa.String(length=120), nullable=True), + sa.Column('note', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('tank_specs', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_tank_specs_tank_name'), ['tank_name'], unique=False) + + op.create_table('vehicle_costs', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('vehicle_name', sa.String(length=255), nullable=False), + sa.Column('launch_price', sa.Integer(), nullable=True), + sa.Column('source', sa.String(length=120), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('vehicle_costs', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_vehicle_costs_vehicle_name'), ['vehicle_name'], unique=False) + + op.create_table('engine_variants', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('family_id', sa.Uuid(), nullable=False), + sa.Column('fuel_type', sa.String(length=120), nullable=False), + sa.Column('work_env', sa.String(length=32), nullable=True), + sa.Column('sl_isp', sa.Numeric(precision=10, scale=3), nullable=True), + sa.Column('vac_isp', sa.Numeric(precision=10, scale=3), nullable=True), + sa.Column('min_thrust_kn', sa.Numeric(precision=12, scale=3), nullable=True), + sa.Column('max_thrust_kn', sa.Numeric(precision=12, scale=3), nullable=True), + sa.Column('mass_t', sa.Numeric(precision=10, scale=4), nullable=True), + sa.Column('twr', sa.Numeric(precision=12, scale=4), nullable=True), + sa.Column('throttle_ratio', sa.Numeric(precision=8, scale=4), nullable=True), + sa.Column('tvc_deg', sa.Numeric(precision=8, scale=3), nullable=True), + sa.Column('price_kd', sa.Integer(), nullable=True), + sa.Column('ignitions', sa.Integer(), nullable=True), + sa.Column('has_unlimited_ignitions', sa.Boolean(), nullable=False), + sa.Column('mod_source', sa.String(length=120), nullable=True), + sa.Column('engine_note', sa.Text(), nullable=True), + sa.Column('config_name', sa.String(length=255), nullable=True), + sa.Column('tech_required', sa.String(length=120), nullable=True), + sa.Column('config_note', sa.Text(), nullable=True), + sa.Column('source_sheet_row', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['family_id'], ['engine_families.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('engine_variants', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_engine_variants_config_name'), ['config_name'], unique=False) + batch_op.create_index(batch_op.f('ix_engine_variants_family_id'), ['family_id'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('engine_variants', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_engine_variants_family_id')) + batch_op.drop_index(batch_op.f('ix_engine_variants_config_name')) + + op.drop_table('engine_variants') + with op.batch_alter_table('vehicle_costs', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_vehicle_costs_vehicle_name')) + + op.drop_table('vehicle_costs') + with op.batch_alter_table('tank_specs', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_tank_specs_tank_name')) + + op.drop_table('tank_specs') + op.drop_table('engine_families') + with op.batch_alter_table('communication_parts', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_communication_parts_part_name')) + + op.drop_table('communication_parts') + # ### end Alembic commands ### diff --git a/migrations/versions/f4f0c9c9426e_add_asset_state_nodes.py b/migrations/versions/f4f0c9c9426e_add_asset_state_nodes.py new file mode 100644 index 0000000..8598096 --- /dev/null +++ b/migrations/versions/f4f0c9c9426e_add_asset_state_nodes.py @@ -0,0 +1,41 @@ +"""add asset state nodes + +Revision ID: f4f0c9c9426e +Revises: 6c2a57f2b1e9 +Create Date: 2026-04-25 21:20:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f4f0c9c9426e' +down_revision = '6c2a57f2b1e9' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'asset_state_nodes', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('entry_id', sa.Uuid(), nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('at_time', sa.DateTime(timezone=True), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['entry_id'], ['asset_log_entries.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + with op.batch_alter_table('asset_state_nodes', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_asset_state_nodes_entry_id'), ['entry_id'], unique=False) + batch_op.create_index(batch_op.f('ix_asset_state_nodes_at_time'), ['at_time'], unique=False) + + +def downgrade(): + with op.batch_alter_table('asset_state_nodes', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_asset_state_nodes_at_time')) + batch_op.drop_index(batch_op.f('ix_asset_state_nodes_entry_id')) + + op.drop_table('asset_state_nodes') \ No newline at end of file diff --git a/project_discovery.md b/project_discovery.md index 082fb08..293572d 100644 --- a/project_discovery.md +++ b/project_discovery.md @@ -236,3 +236,56 @@ 4. 确定前端页面:列表 / 搜索 / 详情 / 编辑 / 导入 5. 再开始初始化项目结构、Docker、数据库迁移、Excel 导入脚本 +## 8. 第二轮落地结论(已按 Excel 实际数据校验) + +### 8.1 唯一键结论 + +- `Engine + Config Name` 在当前工作簿中有 `48` 组重复。 +- 即使补上 `Fuel Type`,仍然还有 `19` 组重复。 +- 这意味着数据库不能直接把自然键硬编码成唯一约束,否则导入会失败。 + +### 8.2 `Engine Database` 的稳定字段边界 + +按同名 `Engine` 聚合后,当前数据呈现出下面的规律: + +- 基本稳定:`Name`、`Cycle`、`Size`、`entry Cost` +- 明显会变化:`Fuel Type`、`Work Env`、`TVC`、`Price KD`、`Classification`、`Note`、`Config Name` + +所以更稳妥的拆分是: + +1. `engine_families` + - `engine_name` + - `part_name` + - `cycle` + - `size_m` + - `entry_cost` +2. `engine_variants` + - 所有配置级字段 + - 使用代理主键 + - 保留原始自然键字段用于展示和导入告警 + +### 8.3 当前实现层面的架构选择 + +- 先采用 **单 Flask 应用**:同时提供 + - Jinja 前端页面 + - JSON API + - PostgreSQL 数据访问层 +- 这样部署到 NAS 时只需要一个应用容器,复杂度最低。 +- 如果后续页面复杂度变高,再把前端拆成独立工程也不迟。 + +### 8.4 已确定的第一批落地内容 + +1. Flask 项目骨架 +2. SQLAlchemy 数据模型 +3. Dockerfile 与 docker-compose 基线 +4. Excel 巡检脚本 +5. Fuel Chart 小工具页面与 API + +### 8.5 下一阶段直接衔接的工作 + +1. 增加数据库迁移目录 +2. 实现 Excel -> ORM 的导入脚本 +3. 先完成 Engine 列表、详情、编辑页 +4. 再补 Communication / Tank / Vehicle Cost 的管理页 + + diff --git a/prototypes/mission-ops/asset-log.html b/prototypes/mission-ops/asset-log.html new file mode 100644 index 0000000..ee98b7e --- /dev/null +++ b/prototypes/mission-ops/asset-log.html @@ -0,0 +1,316 @@ + + + + + + Asset Log Prototype + + + +
+
+ + Mission Ops Prototype + Asset Log + + +
+ +
+
+

Single Asset Detail

+

让单个资产的事件点和状态段在一页里同时可读

+

这页不是普通日志表,而是当前状态、未来计划、历史记录和录入入口的组合页。这里用 XH-02 Taibai 作为样例对象。

+
+
+
+ Simulation Time +

2060-03-12 09:00

+

Current state block and highlighted entry are both interpreted against this clock.

+
+
+
+ Current Status + Transit +
+
+ Next Event + 03-29 +
+
+ Entries + 8 +
+
+
+
+ +
+
+ + + + + +
+
+ Relative labels are derived from selected time + No manual Confirmed / Planned field on the log view +
+
+ +
+
+
+
+
+
+
+

Asset Summary

+

XH-02 Taibai

+

Exploration Mothership · Mars Expedition Program

+
+ In Transit +
+
+
+
Current Location
+
Earth to Mars Transfer
+
+
+
Current Mission
+
Support Mars One Campus
+
+
+
State Window
+
2060-02-26 06:00 → 2060-03-29 18:00
+
+
+
Next Planned Event
+
Mars Arrival · 2060-03-29 18:00
+
+
+
+ Crew Transfer Support + Mars System + Heavy Logistics +
+
+ +
+
+
+

Current Derived State

+

Operational Snapshot

+
+
+
+
+
Status
+
In Transit
+
+
+
Planned Arrival
+
2060-03-29
+
+
+
Transfer Duration
+
31 days
+
+
+
Relative Mode
+
Derived from selected clock
+
+
+
+
+
+ +
+
+
+

History

+

Timeline Entries

+

Event Point 和 State Interval 混排,但视觉上明确区分。

+
+
+ +
+
+
+
+
+
+ 2058-03-15 10:20 +

First module delivered to Star Port

+
+ Event Point +
+

Core transfer spine and habitat ring unloaded for final assembly at Star Port.

+
+
+ +
+
+
+
+
+ 2059-08-06 09:00 +

Commissioned

+
+ Event Point +
+

Flight certification signed. Taibai was cleared for Mars transport duties.

+
+
+ +
+
+
+
+
+ 2059-08-08 04:10 → 2059-10-17 13:40 +

Outbound transfer carrying Mars Expedition 3

+
+ State Interval +
+

Departure from Earth with full crew and descent stack attached. This is the kind of interval that should drive board state during the whole travel window.

+
+
+ +
+
+
+
+
+ 2059-10-17 13:40 +

Mars Arrival

+
+ Event Point +
+

Insertion burn nominal. Crew descent staging began next day.

+
+
+ +
+
+
+
+
+ 2059-10-18 08:00 → 2059-10-19 06:10 +

Mars orbit standby and crew pickup window

+
+ State Interval +
+

Received Mars Expedition 1 return crew after descent vehicle separation.

+
+
+ +
+
+
+
+
+ 2060-02-26 06:00 → 2060-03-29 18:00 +

Support mission transfer to Mars One Campus

+
+ State Interval +
+

Carrying structural truss and greenhouse kit; 105 km/s, 31-day transition. This interval is the one currently lighting up Status Board.

+
+
+ +
+
+
+
+
+ 2060-03-29 18:00 +

Mars Arrival

+
+ Event Point +
+

Planned capture burn and cargo handoff to surface fleet.

+
+
+
+
+
+ + +
+
+ + + \ No newline at end of file diff --git a/prototypes/mission-ops/index.html b/prototypes/mission-ops/index.html new file mode 100644 index 0000000..4019f9f --- /dev/null +++ b/prototypes/mission-ops/index.html @@ -0,0 +1,206 @@ + + + + + + Mission Operations Prototype + + + +
+
+ + KSP Static Prototype + Mission Operations + + +
+ +
+
+

Standalone Review Build

+

载具与设施任务运营原型

+

这是一套独立静态原型,不依赖 Flask 模板渲染。你现在双击这些 HTML 文件,浏览器里看到的就是页面设计本身,而不是 Jinja 源码。

+
+ Asset-first model + Event Point + State Interval + Simulation Time driven + Planned + Current + Past +
+
+
+
+ Prototype Clock +

2060-03-12 09:00

+

原型里所有当前状态、未来事件和时间线高亮,都围绕这个手动设置的 Simulation Time 进行解释。

+
+
+
+ Tracked Assets + 14 +
+
+ Active Missions + 6 +
+
+ Upcoming 30d + 9 +
+
+
+
+ +
+
+ + + + + +
+
+ Current state and next event are always relative to selected clock + Overview now includes recent past and upcoming event lists +
+
+ +
+
+
+
+
+

Mission Center

+

当前正在运行的关键任务

+

首页主区域不再是页面目录,而是更像值班台,优先告诉你当前系统里什么任务正在跑。

+
+
+
+
+
+
+

Loading active operations

+

Preparing mission center feed

+
+
+

This list will update relative to the selected clock.

+
+
+
+ +
+
+
+

Attention Queue

+

接下来需要处理的事项

+

这一栏更接近任务队列,不是简单的未来事件列表。它应该告诉你“值班时下一步要盯什么”。

+
+
+
+
+
+
+

Loading attention queue

+

Preparing next actions

+
+
+

This queue will show near-term actions relative to the selected clock.

+
+
+
+ +
+
+
+

Operational Shortcuts

+

工作面板入口

+

四个详细页面仍然保留,但在首页里它们应该是任务中心的工作入口,而不是首页主内容本身。

+
+
+ +
+
+ + +
+
+ + + \ No newline at end of file diff --git a/prototypes/mission-ops/location-board.html b/prototypes/mission-ops/location-board.html new file mode 100644 index 0000000..60207d7 --- /dev/null +++ b/prototypes/mission-ops/location-board.html @@ -0,0 +1,260 @@ + + + + + + Location Board Prototype + + + +
+
+ + Mission Ops Prototype + Location Board + + +
+ +
+
+

Location-first Board

+

按地点分组看每个系统当前都聚集了什么资产

+

这张板子和 Status Board 是并列关系,不是一个页面里的切换模式。它更适合回答“Earth System 现在有什么、Mars System 现在有什么”。

+
+
+
+ Simulation Time +

2060-03-12 09:00

+

Grouping is based on current location hierarchy. Status still appears on each card so the operational meaning remains visible.

+
+
+
+ Location Groups + 4 +
+
+ Earth System + 3 +
+
+ Mars System + 2 +
+
+
+
+ +
+
+ + + + + +
+
+ + + + + Apply + Reset +
+
+ Grouping: Location hierarchy + Status preserved on each asset card + Supports Station, Port, Surface Site later +
+
+ +
+
+
+
+
+

Location Group

+

Earth System

+

Ports, orbital stations, and near-Earth transfer lanes.

+
+ 3 Assets +
+
+
+
+
+

Lingxiao Station

+

Station

+
+ Docked / On Station +
+

Low Kerbin Orbit. Continuous habitation and logistics hub.

+
+ Next: Expedition 16 Arrival · 2060-03-30 + Current crew handover prep +
+
+
+
+
+

XH-03 Zhuque

+

Shuttle

+
+ At Port +
+

Star Port. Refit phase for Jupiter relay deployment mission.

+
+ Next: Rollout Review · 2060-04-12 + Bay 4 +
+
+
+
+
+

Amalthea Crew Vehicle 4

+

Shuttle

+
+ In Transit +
+

LKO transfer corridor. Planned crew rotation for Lingxiao Station.

+
+ Next: Docking Window · 2060-03-30 + 10 crew manifest +
+
+
+
+ +
+
+
+

Location Group

+

Mars System

+

Orbital operations and deployed surface infrastructure around Mars.

+
+ 2 Assets +
+
+
+
+
+

Mars One Campus Alpha

+

Surface Outpost

+
+ Surface Ops +
+

Primary surface site. Habitat expansion, ISRU staging, and logistics intake point.

+
+ Next: Taibai Supply Window · 2060-03-29 + Needs truss + greenhouse kit +
+
+
+
+
+

XH-02 Taibai

+

Exploration Mothership

+
+ In Transit +
+

Mars transfer lane. Support mission inbound for Mars One Campus surface expansion.

+
+ Next: Mars Arrival · 2060-03-29 + 31-day transfer +
+
+
+
+ +
+
+
+

Location Group

+

Deep Space / Future Hooks

+

Reserved for future hierarchy like Jupiter System, relay chains, and transit corridors.

+
+ Prototype Placeholder +
+
+
+ Location hierarchy will matter + Earth, Star Port, Low Kerbin Orbit, Lingxiao Station should eventually form a parent-child location structure. +
+
+ Facilities are first-class assets + Stations, ports, and surface outposts appear here exactly like ships, which is why the model must be Asset-first. +
+
+
+
+ + +
+
+ + + \ No newline at end of file diff --git a/prototypes/mission-ops/script.js b/prototypes/mission-ops/script.js new file mode 100644 index 0000000..bb90f0b --- /dev/null +++ b/prototypes/mission-ops/script.js @@ -0,0 +1,629 @@ +const SIM_TIME_STORAGE_KEY = "mission-ops-sim-time"; +const DEFAULT_SIM_TIME = "2060-03-12T09:00"; + +const OVERVIEW_EVENTS = [ + { when: "2059-12-27 09:00", title: "XH-02 Taibai parked to Star Port", scope: "Earth System" }, + { when: "2059-12-27 12:00", title: "Mars Expedition 1 crew returned to Earth", scope: "Earth System" }, + { when: "2060-02-26 06:00", title: "XH-02 Taibai departed Star Port for Mars support mission", scope: "Earth System" }, + { when: "2060-03-18 10:00", title: "Tianhe Cargo Pod 7 undock from Lingxiao Station", scope: "Lingxiao Station" }, + { 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" }, +].map((event) => ({ + ...event, + date: parseDateTime(event.when), +})); + +const OVERVIEW_ACTIVE_OPERATIONS = [ + { + asset: "XH-02 Taibai", + assetType: "Exploration Mothership", + location: "Earth to Mars Transfer", + mission: "Support Mars One Campus", + start: "2060-02-26T06:00", + end: "2060-03-29T18:00", + next: "Mars Arrival · 2060-03-29 18:00", + note: "Carrying structural truss, greenhouse kit, and support cargo.", + }, + { + asset: "Lingxiao Station", + assetType: "Station", + location: "Low Kerbin Orbit", + mission: "Continuous habitation and logistics hub", + start: "2059-07-23T00:00", + end: "", + next: "Expedition 16 Arrival · 2060-03-30 08:00", + note: "Crew handover preparation and consumables balancing in progress.", + }, + { + asset: "Tianhe Cargo Pod 7", + assetType: "Cargo Shuttle", + location: "Lingxiao Forward Berth", + mission: "Dry cargo replenishment", + start: "2060-03-01T12:00", + end: "2060-03-18T10:00", + next: "Undock · 2060-03-18 10:00", + note: "Return trash loading remains before departure.", + }, + { + asset: "XH-03 Zhuque", + assetType: "Shuttle", + location: "Star Port", + mission: "Jupiter relay refit", + start: "2060-01-08T09:00", + end: "2060-04-12T14:00", + next: "Rollout Review · 2060-04-12 14:00", + note: "Main engine refurbishment at bay 4 and payload integration queued.", + }, + { + asset: "Mars One Campus Alpha", + assetType: "Surface Outpost", + location: "Mars Surface", + mission: "Habitat expansion and ISRU staging", + start: "2059-10-20T08:00", + end: "", + next: "Taibai Supply Window · 2060-03-29 18:00", + note: "Awaiting new structural truss and greenhouse package delivery.", + }, +]; + +const OVERVIEW_ATTENTION_ITEMS = [ + { + title: "Review Tianhe Cargo Pod 7 undock checklist", + asset: "Tianhe Cargo Pod 7", + priority: "high", + due: "2060-03-18T10:00", + summary: "Finalize return trash manifest and station berth reconfiguration.", + }, + { + title: "Finalize Expedition 16 docking prep", + asset: "Lingxiao Station", + priority: "high", + due: "2060-03-26T18:00", + summary: "Confirm medical bay allocation, handover timeline, and EVA support window.", + }, + { + title: "Prepare XH-02 Taibai Mars arrival burn package", + asset: "XH-02 Taibai", + priority: "high", + due: "2060-03-29T18:00", + summary: "Review capture burn, cargo handoff staging, and surface drop order.", + }, + { + title: "Approve XH-03 Zhuque rollout readiness package", + asset: "XH-03 Zhuque", + priority: "medium", + due: "2060-04-12T14:00", + summary: "Complete avionics validation and relay payload handling plan.", + }, +]; + +const TIMELINE_ASSET_CATALOG = [ + { id: "taibai", name: "XH-02 Taibai", type: "Exploration Mothership", region: "Mars System", note: "Mars support transfer" }, + { 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: "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"], + mars: ["taibai", "campus-alpha"], + crew: ["lingxiao", "amalthea-4", "taibai"], +}; + +let selectedTimelineAssets = new Set(["taibai", "lingxiao", "campus-alpha"]); + +function parseDateTime(value) { + if (!value) { + return null; + } + const normalized = value.includes("T") ? value : value.replace(" ", "T"); + const parsed = new Date(normalized); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function padNumber(value) { + return String(value).padStart(2, "0"); +} + +function toInputValue(date) { + return [ + date.getFullYear(), + "-", + padNumber(date.getMonth() + 1), + "-", + padNumber(date.getDate()), + "T", + padNumber(date.getHours()), + ":", + padNumber(date.getMinutes()), + ].join(""); +} + +function toDisplayValue(date) { + return [ + date.getFullYear(), + "-", + padNumber(date.getMonth() + 1), + "-", + padNumber(date.getDate()), + " ", + padNumber(date.getHours()), + ":", + padNumber(date.getMinutes()), + ].join(""); +} + +function toShortDate(date) { + return [padNumber(date.getMonth() + 1), "-", padNumber(date.getDate())].join(""); +} + +function shiftDays(date, dayOffset) { + const nextDate = new Date(date); + nextDate.setDate(nextDate.getDate() + dayOffset); + return nextDate; +} + +function formatRelativeDayLabel(simDate, targetDate) { + const dayDelta = Math.round((targetDate - simDate) / (1000 * 60 * 60 * 24)); + if (dayDelta < 0) { + return `${Math.abs(dayDelta)}d ago`; + } + if (dayDelta === 0) { + return "Today"; + } + return `in ${dayDelta}d`; +} + +function renderEventFeed(container, events, emptyLabel) { + if (!container) { + return; + } + + if (!events.length) { + container.innerHTML = ` +
+ +

${emptyLabel}

+

Change the simulation time to inspect a different period.

+
+ `; + return; + } + + container.innerHTML = events + .map( + (event) => ` +
+ +

${event.title}

+

${event.scope}

+
+ ` + ) + .join(""); +} + +function renderOverviewOperations(simDate) { + const container = document.getElementById("active-ops-list"); + if (!container) { + return; + } + + const activeOperations = OVERVIEW_ACTIVE_OPERATIONS.filter((item) => { + const start = parseDateTime(item.start); + const end = parseDateTime(item.end || ""); + return start && start <= simDate && (!end || simDate < end); + }); + + if (!activeOperations.length) { + container.innerHTML = ` +
+
+
+

No active operations

+

No State Interval covers the selected clock

+
+
+
+ `; + return; + } + + container.innerHTML = activeOperations + .map( + (item) => ` +
+
+
+

${item.asset}

+

${item.assetType}

+
+ ${item.location} +
+
+
+
Mission
+
${item.mission}
+
+
+
Next Event
+
${item.next}
+
+
+

${item.note}

+
+ ` + ) + .join(""); +} + +function renderAttentionQueue(simDate) { + const container = document.getElementById("attention-queue-list"); + if (!container) { + return; + } + + const queueItems = OVERVIEW_ATTENTION_ITEMS + .map((item) => ({ ...item, dueDate: parseDateTime(item.due) })) + .filter((item) => item.dueDate && item.dueDate >= shiftDays(simDate, -7)) + .sort((left, right) => left.dueDate - right.dueDate) + .slice(0, 4); + + if (!queueItems.length) { + container.innerHTML = ` +
+
+
+

No pending queue items

+

Nothing falls into the current operational window

+
+
+
+ `; + return; + } + + container.innerHTML = queueItems + .map((item) => { + const priorityClass = item.dueDate < simDate ? "overdue" : item.priority; + const priorityLabel = item.dueDate < simDate ? "Overdue" : item.priority; + return ` +
+
+
+

${item.title}

+

${item.asset}

+
+ ${priorityLabel} +
+
+
+
Due
+
${toDisplayValue(item.dueDate)}
+
+
+
Relative
+
${formatRelativeDayLabel(simDate, item.dueDate)}
+
+
+

${item.summary}

+
+ `; + }) + .join(""); +} + +function updateOverview(simDate) { + if (document.body.dataset.page !== "overview") { + return; + } + + const pastEvents = OVERVIEW_EVENTS + .filter((event) => event.date && event.date <= simDate) + .sort((left, right) => right.date - left.date) + .slice(0, 4) + .map((event) => ({ ...event, when: toDisplayValue(event.date) })); + + const futureEvents = OVERVIEW_EVENTS + .filter((event) => event.date && event.date > simDate) + .sort((left, right) => left.date - right.date) + .slice(0, 4) + .map((event) => ({ ...event, when: toDisplayValue(event.date) })); + + renderOverviewOperations(simDate); + renderAttentionQueue(simDate); + renderEventFeed(document.getElementById("recent-past-events"), pastEvents, "No recent past events"); + renderEventFeed(document.getElementById("recent-future-events"), futureEvents, "No upcoming events"); +} + +function updateAssetLog(simDate) { + if (document.body.dataset.page !== "asset-log") { + return; + } + + const entries = Array.from(document.querySelectorAll(".entry[data-start]")); + let currentStateEntry = null; + let nextEventEntry = null; + + entries.forEach((entry) => { + const badge = entry.querySelector("[data-entry-badge]"); + const start = parseDateTime(entry.dataset.start); + const end = parseDateTime(entry.dataset.end || ""); + const kind = entry.dataset.kind; + + if (!badge || !start) { + return; + } + + badge.classList.remove("completed", "planned", "active"); + + if (kind === "state") { + if (start <= simDate && (!end || simDate < end)) { + badge.textContent = "Current Interval"; + badge.classList.add("active"); + currentStateEntry = entry; + } else if (simDate < start) { + badge.textContent = "Upcoming Interval"; + badge.classList.add("planned"); + } else { + badge.textContent = "Past Interval"; + badge.classList.add("completed"); + } + } else if (simDate < start) { + badge.textContent = "Upcoming Event"; + badge.classList.add("planned"); + } else { + badge.textContent = "Past Event"; + badge.classList.add("completed"); + } + + if (start > simDate && (!nextEventEntry || start < parseDateTime(nextEventEntry.dataset.start))) { + nextEventEntry = entry; + } + }); + + const currentStateText = document.querySelector("[data-current-state]"); + const currentStatePill = document.querySelector("[data-current-state-pill]"); + const currentLocation = document.querySelector("[data-current-location]"); + const currentWindow = document.querySelector("[data-current-window]"); + const nextEventText = document.querySelector("[data-next-event]"); + const nextEventShort = document.querySelector("[data-next-event-short]"); + const nextEventDate = document.querySelector("[data-next-event-date]"); + const currentDuration = document.querySelector("[data-current-duration]"); + const relativeMode = document.querySelector("[data-relative-mode]"); + + if (currentStateEntry) { + const start = parseDateTime(currentStateEntry.dataset.start); + const end = parseDateTime(currentStateEntry.dataset.end || ""); + const stateLabel = currentStateEntry.dataset.stateLabel || currentStateEntry.dataset.title; + if (currentStateText) currentStateText.textContent = stateLabel; + if (currentStatePill) currentStatePill.textContent = stateLabel; + if (currentLocation) currentLocation.textContent = currentStateEntry.dataset.location || "Unknown"; + if (currentWindow) currentWindow.textContent = `${toDisplayValue(start)} → ${end ? toDisplayValue(end) : "Open"}`; + if (currentDuration && end) { + const totalDays = Math.round((end - start) / (1000 * 60 * 60 * 24)); + currentDuration.textContent = `${totalDays} days`; + } + if (relativeMode) relativeMode.textContent = "Derived from active State Interval"; + } else { + if (currentStateText) currentStateText.textContent = "No Active Interval"; + if (currentStatePill) currentStatePill.textContent = "No Active Interval"; + if (currentLocation) currentLocation.textContent = "Unknown"; + if (currentWindow) currentWindow.textContent = "No interval covers selected time"; + if (currentDuration) currentDuration.textContent = "-"; + if (relativeMode) relativeMode.textContent = "Derived: no active interval at selected time"; + } + + if (nextEventEntry) { + const nextDate = parseDateTime(nextEventEntry.dataset.start); + const nextLabel = `${nextEventEntry.dataset.title} · ${toDisplayValue(nextDate)}`; + if (nextEventText) nextEventText.textContent = nextLabel; + if (nextEventShort) nextEventShort.textContent = toDisplayValue(nextDate); + if (nextEventDate) nextEventDate.textContent = toShortDate(nextDate); + } else { + if (nextEventText) nextEventText.textContent = "No future event recorded"; + if (nextEventShort) nextEventShort.textContent = "-"; + if (nextEventDate) nextEventDate.textContent = "-"; + } +} + +function getTimelineFilteredAssets() { + const searchInput = document.querySelector("[data-asset-search]"); + const typeFilter = document.querySelector("[data-asset-type-filter]"); + const regionFilter = document.querySelector("[data-asset-region-filter]"); + const searchTerm = (searchInput?.value || "").trim().toLowerCase(); + const selectedType = typeFilter?.value || "all"; + const selectedRegion = regionFilter?.value || "all"; + + return TIMELINE_ASSET_CATALOG.filter((asset) => { + const matchesSearch = !searchTerm || `${asset.name} ${asset.type} ${asset.region} ${asset.note}`.toLowerCase().includes(searchTerm); + const matchesType = selectedType === "all" || asset.type === selectedType; + const matchesRegion = selectedRegion === "all" || asset.region === selectedRegion; + return matchesSearch && matchesType && matchesRegion; + }); +} + +function updateTimelineSelection() { + if (document.body.dataset.page !== "timeline") { + return; + } + + const rows = Array.from(document.querySelectorAll(".timeline-row[data-asset]")); + const selectedAssets = Array.from(selectedTimelineAssets); + const selectedNames = TIMELINE_ASSET_CATALOG.filter((asset) => selectedTimelineAssets.has(asset.id)).map((asset) => asset.name); + + rows.forEach((row) => { + row.classList.toggle("is-hidden", !selectedAssets.includes(row.dataset.asset)); + }); + + const selectedCount = document.querySelector("[data-selected-count]"); + const selectedSummary = document.querySelector("[data-selected-assets]"); + + if (selectedCount) { + selectedCount.textContent = String(selectedAssets.length); + } + + if (selectedSummary) { + selectedSummary.textContent = selectedNames.length ? `Selected: ${selectedNames.join(", ")}` : "Selected: none"; + } +} + +function renderTimelineSelector() { + if (document.body.dataset.page !== "timeline") { + return; + } + + const resultsContainer = document.querySelector("[data-asset-results]"); + const selectedContainer = document.querySelector("[data-selected-asset-list]"); + if (!resultsContainer || !selectedContainer) { + return; + } + + const filteredAssets = getTimelineFilteredAssets(); + const selectedAssetsData = TIMELINE_ASSET_CATALOG.filter((asset) => selectedTimelineAssets.has(asset.id)); + + resultsContainer.innerHTML = filteredAssets.length + ? filteredAssets + .map( + (asset) => ` +
+
+
+

${asset.name}

+

${asset.type}

+
+ ${asset.region} +
+

${asset.note}

+
+ ${selectedTimelineAssets.has(asset.id) ? "Already selected" : "Available to add"} + +
+
+ ` + ) + .join("") + : ` +
+
+
+

No matching assets

+

Try another search or filter combination

+
+
+
+ `; + + selectedContainer.innerHTML = selectedAssetsData.length + ? selectedAssetsData + .map( + (asset) => ` +
+
+
+

${asset.name}

+

${asset.type}

+
+ ${asset.region} +
+
+ ${asset.note} + +
+
+ ` + ) + .join("") + : ` +
+ No assets selected +

Add assets from the search results to build the current timeline focus set.

+
+ `; + + resultsContainer.querySelectorAll("[data-add-asset]").forEach((button) => { + button.addEventListener("click", () => { + selectedTimelineAssets.add(button.dataset.addAsset); + renderTimelineSelector(); + updateTimelineSelection(); + }); + }); + + selectedContainer.querySelectorAll("[data-remove-asset]").forEach((button) => { + button.addEventListener("click", () => { + selectedTimelineAssets.delete(button.dataset.removeAsset); + renderTimelineSelector(); + updateTimelineSelection(); + }); + }); +} + +function setupTimelineSelection() { + if (document.body.dataset.page !== "timeline") { + return; + } + + document.querySelectorAll("[data-asset-search], [data-asset-type-filter], [data-asset-region-filter]").forEach((control) => { + control.addEventListener(control.tagName === "INPUT" ? "input" : "change", renderTimelineSelector); + }); + + document.querySelectorAll("[data-asset-preset]").forEach((button) => { + button.addEventListener("click", () => { + selectedTimelineAssets = new Set(TIMELINE_PRESETS[button.dataset.assetPreset] || []); + renderTimelineSelector(); + updateTimelineSelection(); + }); + }); + + renderTimelineSelector(); + updateTimelineSelection(); +} + +function syncSimulationTime(simDate) { + const inputValue = toInputValue(simDate); + const displayValue = toDisplayValue(simDate); + + document.querySelectorAll("[data-sim-time-input]").forEach((input) => { + if (input.value !== inputValue) { + input.value = inputValue; + } + }); + + document.querySelectorAll("[data-sim-time-display]").forEach((display) => { + display.textContent = displayValue; + }); + + localStorage.setItem(SIM_TIME_STORAGE_KEY, inputValue); + updateOverview(simDate); + updateAssetLog(simDate); +} + +document.addEventListener("DOMContentLoaded", () => { + let simDate = parseDateTime(localStorage.getItem(SIM_TIME_STORAGE_KEY)) || parseDateTime(DEFAULT_SIM_TIME); + + document.querySelectorAll("[data-sim-time-input]").forEach((input) => { + input.addEventListener("input", (event) => { + const nextDate = parseDateTime(event.target.value); + if (!nextDate) { + return; + } + simDate = nextDate; + syncSimulationTime(simDate); + }); + }); + + document.querySelectorAll("[data-time-shift]").forEach((button) => { + button.addEventListener("click", () => { + simDate = shiftDays(simDate, Number(button.dataset.timeShift)); + syncSimulationTime(simDate); + }); + }); + + syncSimulationTime(simDate); + setupTimelineSelection(); +}); \ No newline at end of file diff --git a/prototypes/mission-ops/status-board.html b/prototypes/mission-ops/status-board.html new file mode 100644 index 0000000..db44837 --- /dev/null +++ b/prototypes/mission-ops/status-board.html @@ -0,0 +1,398 @@ + + + + + + Status Board Prototype + + + +
+
+ + Mission Ops Prototype + Status Board + + +
+ +
+
+

Current State Board

+

按状态分组看当前有哪些资产在执行什么任务

+

这个页面只回答一件事:在当前 Simulation Time 下,每个 Asset 正处于哪个状态、在哪里、在做什么,以及下一个关键事件是什么。

+
+
+
+ Simulation Time +

2060-03-12 09:00

+

Current status is derived from active State Interval. Planned events remain visible on the board.

+
+
+
+ Active Assets + 8 +
+
+ In Transit + 2 +
+
+ Upcoming 7d + 3 +
+
+
+
+ +
+
+ + + + + +
+
+ Set Simulation Time +
+
+ + + + + Apply + Reset +
+
+ Grouping: Current State + Location used as secondary filter + Future planned events remain visible +
+
+ +
+
+
+
+
+

State Group

+

In Transit

+

Assets currently travelling between systems or operational nodes.

+
+ 2 Assets +
+ +
+
+
+
+

XH-02 Taibai

+

Exploration Mothership

+
+ Confirmed +
+
+
+
Location
+
Earth to Mars Transfer
+
+
+
Mission
+
Support Mars One Campus
+
+
+
State Window
+
2060-02-26 → 2060-03-29
+
+
+
Next Event
+
Mars Arrival · 2060-03-29
+
+
+

31-day transition carrying structural truss, greenhouse kit, and support consumables for Mars One Campus Alpha.

+
+ Derived from active State Interval + Open asset log +
+
+ +
+
+
+

Amalthea Crew Vehicle 4

+

Shuttle

+
+ Planned +
+
+
+
Location
+
LKO Transfer Corridor
+
+
+
Mission
+
Expedition 16 Crew Rotation
+
+
+
State Window
+
2060-03-28 → 2060-03-30
+
+
+
Next Event
+
Docking Window · 2060-03-30
+
+
+

Planned departure with 10-crew manifest for Lingxiao Station rotation and medical cargo transfer.

+
+ Visible before execution because Planned stays on board + Open asset log +
+
+
+
+ +
+
+
+

State Group

+

Docked / On Station

+

Stationary orbital assets and docked support craft.

+
+ 2 Assets +
+ +
+
+
+
+

Lingxiao Station

+

Station

+
+ Active +
+
+
+
Location
+
Low Kerbin Orbit
+
+
+
Mission
+
Continuous habitation and logistics hub
+
+
+
State Window
+
2059-07-23 → Present
+
+
+
Next Event
+
Expedition 16 Crew Arrival · 2060-03-30
+
+
+

Expedition 15 currently onboard. Crew handover prep and life-support reserve balancing underway.

+
+ Can later host nested docked assets + Open asset log +
+
+ +
+
+
+

Tianhe Cargo Pod 7

+

Cargo Shuttle

+
+ Confirmed +
+
+
+
Location
+
Lingxiao Forward Berth
+
+
+
Mission
+
Dry cargo replenishment
+
+
+
State Window
+
2060-03-01 → 2060-03-18
+
+
+
Next Event
+
Undock · 2060-03-18
+
+
+

Food, EVA packs, filter cartridges, and station spares are already unloaded. Return trash loading remains.

+
+ Useful test case for attached support craft + Open asset log +
+
+
+
+ +
+
+
+

State Group

+

At Port / Surface Ops

+

Idle, refit, maintenance, and deployed surface infrastructure.

+
+ 2 Assets +
+ +
+
+
+
+

XH-03 Zhuque

+

Shuttle

+
+ Confirmed +
+
+
+
Location
+
Star Port
+
+
+
Mission
+
Refit for Jupiter relay deployment
+
+
+
State Window
+
2060-01-08 → 2060-04-12
+
+
+
Next Event
+
Rollout Review · 2060-04-12
+
+
+

Main engine refurbishment at bay 4. Payload integration queued after avionics test completion.

+
+ Example of a non-flight operational state + Open asset log +
+
+ +
+
+
+

Mars One Campus Alpha

+

Surface Outpost

+
+ Active +
+
+
+
Location
+
Mars Surface
+
+
+
Mission
+
Habitat expansion and ISRU staging
+
+
+
State Window
+
2059-10-20 → Present
+
+
+
Next Event
+
Taibai Supply Window · 2060-03-29
+
+
+

Surface crew awaiting structural truss shipment, greenhouse package, and two new power skids.

+
+ Long-duration persistent state example + Open asset log +
+
+
+
+
+ + +
+
+ + + \ No newline at end of file diff --git a/prototypes/mission-ops/styles.css b/prototypes/mission-ops/styles.css new file mode 100644 index 0000000..01000be --- /dev/null +++ b/prototypes/mission-ops/styles.css @@ -0,0 +1,1084 @@ +:root { + --bg-top: #edf5ff; + --bg-bottom: #dfeafb; + --ink-1: #10233d; + --ink-2: #34506f; + --ink-3: #647d99; + --line: rgba(16, 35, 61, 0.1); + --line-strong: rgba(16, 35, 61, 0.16); + --surface: rgba(255, 255, 255, 0.78); + --surface-strong: rgba(255, 255, 255, 0.92); + --surface-tint: rgba(230, 240, 255, 0.76); + --blue-1: #1c73ea; + --blue-2: #0f4fa6; + --blue-3: #dbeaff; + --teal-1: #0e8f89; + --amber-1: #d9942b; + --rose-1: #b24968; + --shadow-lg: 0 24px 60px rgba(26, 53, 98, 0.12); + --shadow-sm: 0 8px 22px rgba(26, 53, 98, 0.08); + --radius-xl: 30px; + --radius-lg: 22px; + --radius-md: 16px; + --radius-pill: 999px; + --content-width: 1480px; + --font-main: "Segoe UI Variable", "Noto Sans SC", "Microsoft YaHei UI", sans-serif; +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + color: var(--ink-1); + font-family: var(--font-main); + background: + radial-gradient(circle at top left, rgba(28, 115, 234, 0.16), transparent 25%), + radial-gradient(circle at top right, rgba(67, 154, 255, 0.13), transparent 26%), + linear-gradient(180deg, var(--bg-top) 0%, var(--bg-bottom) 100%); +} + +a { + color: inherit; +} + +.site-shell { + width: min(calc(100vw - 24px), var(--content-width)); + margin: 0 auto; + padding: 16px 0 40px; +} + +.topbar { + position: sticky; + top: 10px; + z-index: 20; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 12px 16px; + margin-bottom: 18px; + border: 1px solid var(--line); + border-radius: 22px; + backdrop-filter: blur(18px); + background: rgba(255, 255, 255, 0.7); + box-shadow: var(--shadow-sm); +} + +.brand { + display: inline-flex; + flex-direction: column; + gap: 4px; + text-decoration: none; +} + +.brand-kicker { + color: var(--ink-3); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.22em; + text-transform: uppercase; +} + +.brand-name { + font-size: 26px; + font-weight: 800; + line-height: 1; +} + +.topbar-nav { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.topbar-nav a { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 9px 13px; + border: 1px solid var(--line); + border-radius: var(--radius-pill); + background: rgba(255, 255, 255, 0.76); + text-decoration: none; + color: var(--ink-2); + font-size: 14px; + font-weight: 700; +} + +.topbar-nav a.active { + color: white; + border-color: transparent; + background: linear-gradient(135deg, var(--blue-1), var(--blue-2)); +} + +.panel, +.hero, +.metric-card, +.status-card, +.mini-panel, +.timeline-lane { + border: 1px solid var(--line); + box-shadow: var(--shadow-sm); +} + +.hero { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.92fr); + gap: 18px; + padding: 22px; + border-radius: var(--radius-xl); + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.92), rgba(225, 238, 255, 0.78)); +} + +.hero-copy h1, +.section-head h2, +.group-head h2, +.location-head h2, +.entry-head h3, +.timeline-row-label h3 { + margin: 0; +} + +.eyebrow { + margin: 0 0 8px; + color: var(--blue-1); + font-size: 12px; + font-weight: 800; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.hero-copy h1 { + font-size: clamp(32px, 3vw, 48px); + line-height: 1; +} + +.hero-copy p, +.hero-note, +.subtle, +.entry-copy p, +.meta-copy p, +.section-head p { + color: var(--ink-2); + line-height: 1.65; +} + +.hero-side { + display: grid; + gap: 10px; +} + +.time-card { + padding: 18px; + border-radius: var(--radius-lg); + background: linear-gradient(145deg, rgba(233, 243, 255, 0.88), rgba(255, 255, 255, 0.86)); +} + +.time-card .label { + display: block; + color: var(--ink-3); + font-size: 12px; + font-weight: 800; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.time-value { + margin: 10px 0 8px; + color: var(--blue-2); + font-size: 30px; + font-weight: 800; + line-height: 1; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.metric-card { + padding: 14px; + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.84); +} + +.metric-value { + display: block; + margin-top: 4px; + font-size: 26px; + font-weight: 800; + line-height: 1; +} + +.metric-label { + color: var(--ink-3); + font-size: 12px; + font-weight: 700; +} + +.panel { + padding: 18px; + border-radius: var(--radius-lg); + background: var(--surface); + backdrop-filter: blur(12px); +} + +.toolbar { + display: grid; + gap: 12px; + margin-top: 16px; +} + +.toolbar-row { + display: flex; + gap: 10px; + flex-wrap: wrap; + align-items: center; +} + +.clock-field { + display: grid; + gap: 6px; + flex: 1 1 280px; + min-width: min(100%, 280px); +} + +.toolbar input, +.toolbar select, +.composer input, +.composer select, +.composer textarea { + width: 100%; + padding: 11px 12px; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.88); + color: var(--ink-1); + font: inherit; +} + +.toolbar input { + flex: 1 1 250px; +} + +.toolbar-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)) auto auto; + gap: 10px; + align-items: end; +} + +.button, +.ghost-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 42px; + padding: 0 16px; + border-radius: var(--radius-pill); + font-size: 14px; + font-weight: 800; + text-decoration: none; + cursor: default; + white-space: nowrap; +} + +button.button, +button.ghost-button { + font: inherit; + cursor: pointer; +} + +.button { + color: white; + border: 0; + background: linear-gradient(135deg, var(--blue-1), var(--blue-2)); +} + +.ghost-button { + color: var(--ink-2); + border: 1px solid var(--line-strong); + background: rgba(255, 255, 255, 0.8); +} + +.pill-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 10px; + border-radius: var(--radius-pill); + color: var(--blue-2); + background: rgba(28, 115, 234, 0.08); + border: 1px solid rgba(28, 115, 234, 0.12); + font-size: 12px; + font-weight: 700; +} + +.split-layout { + display: grid; + grid-template-columns: minmax(0, 1.45fr) 330px; + gap: 16px; + margin-top: 16px; + align-items: start; +} + +.stack { + display: grid; + gap: 14px; +} + +.section-head, +.group-head, +.location-head, +.asset-summary-head, +.entry-head, +.timeline-toolbar { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + flex-wrap: wrap; +} + +.section-head p, +.group-head p, +.location-head p, +.timeline-toolbar p { + margin: 4px 0 0; +} + +.count-badge, +.status-pill, +.tone-pill { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 7px 10px; + border-radius: var(--radius-pill); + font-size: 12px; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.count-badge { + color: var(--ink-3); + background: rgba(255, 255, 255, 0.84); + border: 1px solid var(--line); +} + +.status-pill { + color: var(--blue-2); + background: rgba(28, 115, 234, 0.1); + border: 1px solid rgba(28, 115, 234, 0.14); +} + +.tone-pill.active { + color: var(--teal-1); + background: rgba(14, 143, 137, 0.12); + border: 1px solid rgba(14, 143, 137, 0.18); +} + +.tone-pill.planned { + color: var(--amber-1); + background: rgba(217, 148, 43, 0.12); + border: 1px solid rgba(217, 148, 43, 0.18); +} + +.tone-pill.completed { + color: var(--ink-2); + background: rgba(100, 125, 153, 0.12); + border: 1px solid rgba(100, 125, 153, 0.16); +} + +.group-stack, +.location-stack, +.upcoming-list, +.warning-list, +.composer, +.entry-list { + display: grid; + gap: 12px; +} + +.status-card, +.mini-panel, +.timeline-row-label { + border-radius: 20px; + background: var(--surface-strong); +} + +.status-card { + display: grid; + gap: 12px; + padding: 16px; +} + +.status-card.blue { + background: linear-gradient(180deg, rgba(220, 234, 255, 0.84), rgba(255, 255, 255, 0.92)); +} + +.status-card.teal { + background: linear-gradient(180deg, rgba(223, 246, 244, 0.84), rgba(255, 255, 255, 0.92)); +} + +.status-card.amber { + background: linear-gradient(180deg, rgba(255, 243, 224, 0.84), rgba(255, 255, 255, 0.92)); +} + +.status-card.soft { + background: linear-gradient(180deg, rgba(240, 245, 251, 0.92), rgba(255, 255, 255, 0.92)); +} + +.card-top { + display: flex; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.card-top h3, +.location-item h3, +.mini-panel h3 { + margin: 0; +} + +.asset-type { + margin: 4px 0 0; + color: var(--ink-3); + font-size: 13px; + font-weight: 700; +} + +.meta-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 12px; +} + +.meta-item dt { + margin: 0; + color: var(--ink-3); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.meta-item dd { + margin: 5px 0 0; + font-weight: 700; + line-height: 1.45; +} + +.summary-text { + margin: 0; + color: var(--ink-2); + line-height: 1.55; +} + +.card-foot, +.location-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; + padding-top: 10px; + border-top: 1px dashed var(--line); + color: var(--ink-3); + font-size: 12px; + font-weight: 700; +} + +.mini-panel { + padding: 16px; +} + +.upcoming-item, +.warning-item, +.location-item { + padding: 14px; + border: 1px solid var(--line); + border-radius: 16px; + background: rgba(255, 255, 255, 0.78); +} + +.upcoming-item time, +.entry-time { + color: var(--blue-1); + font-size: 12px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.warning-item strong { + display: block; + margin-bottom: 6px; +} + +.location-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.ops-list, +.attention-list, +.selector-results-list, +.selected-assets-list { + display: grid; + gap: 12px; +} + +.ops-item, +.attention-item, +.asset-result-card, +.selected-asset-card { + padding: 15px; + border: 1px solid var(--line); + border-radius: 18px; + background: rgba(255, 255, 255, 0.84); +} + +.ops-item, +.attention-item, +.asset-result-card, +.selected-asset-card { + display: grid; + gap: 10px; +} + +.attention-item { + background: linear-gradient(180deg, rgba(255, 248, 237, 0.92), rgba(255, 255, 255, 0.84)); +} + +.priority-chip { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 7px 10px; + border-radius: var(--radius-pill); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.priority-chip.high { + color: #a35a00; + background: rgba(217, 148, 43, 0.14); + border: 1px solid rgba(217, 148, 43, 0.18); +} + +.priority-chip.medium { + color: var(--blue-2); + background: rgba(28, 115, 234, 0.1); + border: 1px solid rgba(28, 115, 234, 0.14); +} + +.priority-chip.overdue { + color: var(--rose-1); + background: rgba(178, 73, 104, 0.12); + border: 1px solid rgba(178, 73, 104, 0.16); +} + +.asset-picker-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.selector-controls-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.preset-chip-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.selector-layout { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(300px, 0.85fr); + gap: 12px; +} + +.compact-head h2 { + font-size: 22px; +} + +.asset-result-foot, +.selected-asset-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; + color: var(--ink-3); + font-size: 12px; + font-weight: 700; +} + +.selector-add, +.selector-remove { + min-height: 36px; + padding: 0 14px; + border-radius: var(--radius-pill); + font: inherit; + font-weight: 800; + cursor: pointer; +} + +.selector-add { + color: white; + border: 0; + background: linear-gradient(135deg, var(--blue-1), var(--blue-2)); +} + +.selector-add[disabled] { + opacity: 0.5; + cursor: default; +} + +.selector-remove { + color: var(--ink-2); + border: 1px solid var(--line-strong); + background: rgba(255, 255, 255, 0.84); +} + +.asset-option { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 18px; + background: rgba(255, 255, 255, 0.82); +} + +.asset-option input { + width: auto; + margin-top: 3px; +} + +.asset-option strong, +.asset-option small { + display: block; +} + +.asset-option small { + margin-top: 4px; + color: var(--ink-3); +} + +.asset-summary-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 280px; + gap: 14px; +} + +.asset-summary-card, +.asset-current-card { + padding: 18px; + border: 1px solid var(--line); + border-radius: 20px; + background: rgba(255, 255, 255, 0.82); +} + +.asset-chip-row { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 14px; +} + +.chip { + display: inline-flex; + align-items: center; + padding: 7px 10px; + border-radius: var(--radius-pill); + background: rgba(28, 115, 234, 0.08); + color: var(--blue-2); + font-size: 12px; + font-weight: 700; +} + +.chip.strong { + background: rgba(28, 115, 234, 0.14); +} + +.entry { + display: grid; + grid-template-columns: 36px minmax(0, 1fr); + gap: 12px; +} + +.entry-rail { + position: relative; + display: flex; + justify-content: center; +} + +.entry-rail::before { + content: ""; + position: absolute; + top: 12px; + bottom: -14px; + width: 2px; + background: rgba(16, 35, 61, 0.1); +} + +.entry-dot { + position: relative; + z-index: 1; + width: 14px; + height: 14px; + margin-top: 6px; + border-radius: 50%; + border: 3px solid rgba(255, 255, 255, 0.94); + background: var(--blue-1); + box-shadow: 0 0 0 2px rgba(28, 115, 234, 0.16); +} + +.entry.state .entry-dot { + background: var(--teal-1); + box-shadow: 0 0 0 2px rgba(14, 143, 137, 0.16); +} + +.entry-copy { + padding: 15px 16px; + border: 1px solid var(--line); + border-radius: 18px; + background: rgba(255, 255, 255, 0.82); +} + +.composer { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.composer .span-2 { + grid-column: span 2; +} + +.composer textarea { + min-height: 140px; + resize: vertical; +} + +.timeline-panel { + overflow: hidden; +} + +.timeline-toolbar { + margin-bottom: 12px; +} + +.legend-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.legend-item { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: var(--radius-pill); + background: rgba(255, 255, 255, 0.84); + border: 1px solid var(--line); + color: var(--ink-2); + font-size: 12px; + font-weight: 700; +} + +.legend-swatch { + width: 12px; + height: 12px; + border-radius: 50%; +} + +.legend-swatch.transit { + background: rgba(28, 115, 234, 0.48); +} + +.legend-swatch.ops { + background: rgba(14, 143, 137, 0.48); +} + +.legend-swatch.hold { + background: rgba(100, 125, 153, 0.44); +} + +.legend-swatch.current { + background: rgba(28, 115, 234, 0.84); +} + +.timeline-scroll { + overflow-x: auto; + padding-bottom: 6px; +} + +.timeline-frame { + min-width: 1280px; +} + +.timeline-scale { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: 12px; + margin-bottom: 8px; +} + +.timeline-scale-strip { + display: grid; + grid-template-columns: repeat(8, minmax(0, 1fr)); + gap: 8px; +} + +.timeline-scale-strip span { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 28px; + color: var(--ink-3); + font-size: 12px; + font-weight: 800; +} + +.timeline-rows { + display: grid; + gap: 14px; +} + +.timeline-row { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: 12px; +} + +.timeline-row.is-hidden { + display: none; +} + +.timeline-row-label { + padding: 14px; +} + +.timeline-row-label p { + margin: 4px 0 0; + color: var(--ink-3); +} + +.timeline-lane { + position: relative; + display: grid; + grid-template-columns: repeat(96, minmax(8px, 1fr)); + gap: 4px; + min-height: 92px; + padding: 16px 6px 30px; + border-radius: 20px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.84), rgba(234, 242, 255, 0.8)); +} + +.timeline-segment { + display: inline-flex; + align-items: center; + justify-content: center; + align-self: center; + min-height: 32px; + padding: 0 10px; + border-radius: var(--radius-pill); + border: 1px solid transparent; + color: var(--ink-1); + font-size: 12px; + font-weight: 800; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.timeline-segment.transit { + background: rgba(28, 115, 234, 0.16); + border-color: rgba(28, 115, 234, 0.2); +} + +.timeline-segment.ops { + background: rgba(14, 143, 137, 0.16); + border-color: rgba(14, 143, 137, 0.2); +} + +.timeline-segment.hold { + background: rgba(100, 125, 153, 0.14); + border-color: rgba(100, 125, 153, 0.18); +} + +.timeline-segment.current { + box-shadow: 0 0 0 2px rgba(28, 115, 234, 0.16); +} + +.timeline-event { + position: relative; + display: flex; + align-items: flex-end; + justify-content: center; +} + +.timeline-event::before { + content: ""; + position: absolute; + top: 10px; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--blue-2); + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.96), 0 0 0 4px rgba(28, 115, 234, 0.14); +} + +.timeline-event span { + position: absolute; + bottom: 0; + transform: translateX(52%); + width: 112px; + color: var(--ink-3); + font-size: 11px; + line-height: 1.35; +} + +.landing-grid { + display: grid; + grid-template-columns: 1.15fr 0.85fr; + gap: 16px; + margin-top: 16px; +} + +.command-layout { + display: grid; + grid-template-columns: minmax(0, 1.5fr) 360px; + gap: 16px; + margin-top: 16px; + align-items: start; +} + +.link-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.shortcut-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.link-card { + display: grid; + gap: 10px; + padding: 18px; + border: 1px solid var(--line); + border-radius: 20px; + background: rgba(255, 255, 255, 0.82); + text-decoration: none; +} + +.link-card h3, +.link-card p, +.info-list p { + margin: 0; +} + +.link-card strong { + color: var(--blue-2); + font-size: 13px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.info-list { + display: grid; + gap: 12px; +} + +@media (max-width: 1200px) { + .hero, + .split-layout, + .landing-grid, + .command-layout, + .asset-summary-grid { + grid-template-columns: 1fr; + } + + .toolbar-grid, + .location-grid, + .link-grid, + .shortcut-grid, + .selector-controls-grid, + .selector-layout { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 860px) { + .site-shell { + width: min(calc(100vw - 16px), var(--content-width)); + } + + .topbar, + .toolbar-row, + .card-top, + .card-foot, + .location-foot, + .group-head, + .location-head, + .entry-head, + .timeline-toolbar { + flex-direction: column; + align-items: flex-start; + } + + .metric-grid, + .toolbar-grid, + .meta-grid, + .location-grid, + .asset-picker-grid, + .link-grid, + .shortcut-grid, + .selector-controls-grid, + .selector-layout, + .composer, + .timeline-row, + .timeline-scale { + grid-template-columns: 1fr; + } + + .composer .span-2 { + grid-column: span 1; + } + + .timeline-event span { + position: static; + transform: none; + width: auto; + margin-top: 24px; + } +} \ No newline at end of file diff --git a/prototypes/mission-ops/timeline-view.html b/prototypes/mission-ops/timeline-view.html new file mode 100644 index 0000000..bd5e5a4 --- /dev/null +++ b/prototypes/mission-ops/timeline-view.html @@ -0,0 +1,281 @@ + + + + + + Timeline View Prototype + + + +
+
+ + Mission Ops Prototype + Timeline View + + +
+ +
+
+

Multi-Asset Range View

+

把多个资产在同一时间范围内的行为画到一张真正可读的时间线里

+

这里是甘特式原型。State Interval 用长条表示,Event Point 用节点表示,当前生效中的状态段会高亮。

+
+
+
+ Simulation Time +

2060-03-12 09:00

+

Current active state segment is highlighted relative to 2060-03-12 09:00.

+
+
+
+ Selected Assets + 3 +
+
+ State Segments + 7 +
+
+ Event Points + 9 +
+
+
+
+ +
+
+ + + + + +
+
+ + + + Apply + Export +
+
+ Transit + Continuous Ops + Hold / Port / Orbit + Current State Highlight +
+
+ Selected: XH-02 Taibai, Lingxiao Station, Mars One Campus Alpha +
+
+ +
+
+
+

Asset Selection

+

用搜索、筛选和已选集合来组织 Timeline 对象

+

如果以后 Asset 很多,就不能把所有对象直接平铺出来。所以这里改成一个更接近“选择工作区”的原型。

+
+
+
+ + + +
+
+ + + + +
+
+
+
+
+

Search Results

+

Matching Assets

+
+
+
+
+
+
+

Loading asset search

+

Preparing searchable asset catalog

+
+
+
+
+
+ +
+
+
+

Selected Assets

+

Timeline Focus Set

+
+
+
+
+ Loading selected assets +
+
+
+
+
+ +
+
+
+

Timeline Canvas

+

Mission Flow Across Assets

+

每个 Asset 一条泳道,节点用于关键事件,条带用于持续状态。

+
+
+ +
+
+
+
+
+ 2059-08 + 2059-09 + 2059-10 + 2059-11 + 2059-12 + 2060-01 + 2060-02 + 2060-03 +
+
+ +
+
+
+

XH-02 Taibai

+

Exploration Mothership

+
+
+
Outbound Transfer
+
Mars Orbit Standby
+
Return Transfer
+
Star Port Stay
+
Support Transit to Mars
+ +
Commissioned
+
Mars Arrival
+
Earth Arrival
+
Next Mars Arrival
+
+
+ +
+
+

Lingxiao Station

+

Station

+
+
+
Continuous Station Operations
+ +
Crew 9 Arrival
+
Expedition 15 Arrival
+
Expedition 16 Arrival
+
+
+ +
+
+

Mars One Campus Alpha

+

Surface Outpost

+
+
+
Surface Expansion and ISRU Staging
+ +
First Crew Descent
+
Taibai Supply Window
+
+
+ + + + + + +
+
+
+
+
+ + + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4c4b0db --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +Flask>=3.1,<4.0 +Flask-SQLAlchemy>=3.1,<4.0 +Flask-Migrate>=4.0,<5.0 +python-dotenv>=1.0,<2.0 +psycopg[binary]>=3.2,<4.0 +openpyxl>=3.1,<4.0 +gunicorn>=23.0,<24.0 diff --git a/scripts/import_log_book.py b/scripts/import_log_book.py new file mode 100644 index 0000000..174055d --- /dev/null +++ b/scripts/import_log_book.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from app import create_app +from app.services.log_book_importer import import_log_book_data + + +def main() -> None: + parser = argparse.ArgumentParser(description="Import mission asset logs from log_book.xlsx.") + parser.add_argument( + "--path", + default=str(PROJECT_ROOT / "log_book.xlsx"), + help="Path to log_book.xlsx.", + ) + args = parser.parse_args() + + app = create_app() + with app.app_context(): + summary = import_log_book_data(args.path) + + print(json.dumps(summary.to_dict(), ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/import_workbook.py b/scripts/import_workbook.py new file mode 100644 index 0000000..5b39b8c --- /dev/null +++ b/scripts/import_workbook.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from app import create_app +from app.services.workbook_importer import import_workbook_data + + +def main() -> None: + parser = argparse.ArgumentParser(description="Import workbook data into the configured database.") + parser.add_argument("--path", default=None, help="Path to the workbook file.") + parser.add_argument( + "--replace", + action="store_true", + help="Clear imported tables before loading workbook data again.", + ) + args = parser.parse_args() + + app = create_app() + with app.app_context(): + workbook_path = args.path or app.config["WORKBOOK_PATH"] + summary = import_workbook_data(workbook_path=workbook_path, replace_existing=args.replace) + + print(json.dumps(summary.to_dict(), ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/inspect_workbook.py b/scripts/inspect_workbook.py new file mode 100644 index 0000000..a619c8d --- /dev/null +++ b/scripts/inspect_workbook.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from app.services.workbook_inspector import summarize_workbook + + +def main() -> None: + parser = argparse.ArgumentParser(description="Inspect the KSP workbook structure.") + parser.add_argument( + "--path", + default=os.getenv("WORKBOOK_PATH", "KSP Engine Tweak Chart.xlsx"), + help="Path to the workbook file.", + ) + args = parser.parse_args() + + summary = summarize_workbook(args.path) + print(json.dumps(summary, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main()