浏览代码

Dashboard enhancements: 24h toggle, import/export JSON, delete button, deceased style

- DASHBOARD_24H_TIME env var (default true) controls 12h/24h time input
- Export all persons as JSON (/dashboard/persons/export)
- Export single person as JSON (/dashboard/persons/{id}/export)
- Import persons from JSON file (/dashboard/persons/import)
- Delete button: muted style, leftmost in button group
- Edit and Export buttons: primary/ghost, prominent
- Deceased badge: dark bg (#21262d) with light gray text (#c9d1d9)
- Fix route ordering: all static paths before {person_id} catch-all
- All 102 tests pass
Lukas Goldschmidt 1 月之前
父节点
当前提交
eaab9cf3d0
共有 8 个文件被更改,包括 276 次插入62 次删除
  1. 1 0
      src/astro_mcp/config.py
  2. 157 51
      src/astro_mcp/dashboard.py
  3. 18 2
      static/app.js
  4. 10 0
      static/style.css
  5. 4 3
      templates/person-detail.html
  6. 14 1
      templates/person-form.html
  7. 65 0
      templates/person-import.html
  8. 7 5
      templates/persons.html

+ 1 - 0
src/astro_mcp/config.py

@@ -22,3 +22,4 @@ EPHEMERIS_MCP_URL = os.getenv(
 )
 
 GEONAMES_USERNAME = os.getenv("GEONAMES_USERNAME", "")
+DASHBOARD_24H_TIME = os.getenv("DASHBOARD_24H_TIME", "true").lower() in ("1", "true", "yes", "on")

+ 157 - 51
src/astro_mcp/dashboard.py

@@ -2,7 +2,7 @@
 Dashboard HTTP routes for astro-mcp.
 
 Serves Jinja2 templates for person management.
-All routes are prefixed with /dashboard.
+Route ordering matters: static paths MUST be registered before {person_id} catch-all.
 """
 
 from __future__ import annotations
@@ -14,7 +14,7 @@ from fastapi import APIRouter, Form, Request
 from fastapi.responses import HTMLResponse, RedirectResponse
 
 from . import storage
-from .config import GEONAMES_USERNAME
+from .config import GEONAMES_USERNAME, DASHBOARD_24H_TIME
 from .server import templates
 
 logger = logging.getLogger("astro-mcp.dashboard")
@@ -39,9 +39,7 @@ async def _get_one_person(person_id: str) -> dict[str, Any] | None:
 
 @router.get("/dashboard", response_class=HTMLResponse)
 async def dashboard_home(request: Request):
-    return templates.TemplateResponse(
-        request, "dashboard.html", {}
-    )
+    return templates.TemplateResponse(request, "dashboard.html", {})
 
 
 # ── geonames autocomplete ─────────────────────────────────────────────
@@ -68,7 +66,6 @@ async def geonames_search(q: str, max_rows: int = 8):
         async with httpx.AsyncClient(timeout=5.0) as client:
             resp = await client.get(url, params=params)
             data = resp.json()
-        # Check for GeoNames error responses
         if "status" in data and "message" in data["status"]:
             logger.warning(f"geonames error: {data['status']['message']}")
             return []
@@ -92,19 +89,25 @@ async def geonames_search(q: str, max_rows: int = 8):
         logger.warning(f"geonames search failed: {e}")
         return []
 
+
+# ═══════════════════════════════════════════════════════════════════════
+# STATIC PATHS (must all come BEFORE /dashboard/persons/{person_id})
+# ═══════════════════════════════════════════════════════════════════════
+
+# ── person list ──────────────────────────────────────────────────────
+
 @router.get("/dashboard/persons", response_class=HTMLResponse)
 async def person_list(request: Request):
     persons = await _get_all_persons()
     return templates.TemplateResponse(
         request, "persons.html",
-        {"persons": persons},
+        {"persons": persons, "is_24h": DASHBOARD_24H_TIME},
     )
 
 
 # ── add person (submit) ──────────────────────────────────────────────
-# Must be BEFORE /dashboard/persons/{person_id}
 
-@router.post("/dashboard/persons", response_class=HTMLResponse)
+@router.post("/dashboard/persons")
 async def person_add_submit(
     request: Request,
     name: str = Form(...),
@@ -123,54 +126,166 @@ async def person_add_submit(
     birth_time_known: bool = Form(default=True),
 ):
     await storage.add_person(
-        name=name,
-        birth_datetime=birth_datetime,
-        latitude=latitude,
-        longitude=longitude,
-        elevation=elevation,
-        nickname=nickname or None,
-        birthplace=birthplace or None,
-        alive=alive,
-        private=private,
-        gender=gender or None,
-        description=description or None,
-        notes=notes or None,
-        tz=tz or None,
-        birth_time_known=birth_time_known,
+        name=name, birth_datetime=birth_datetime,
+        latitude=latitude, longitude=longitude, elevation=elevation,
+        nickname=nickname or None, birthplace=birthplace or None,
+        alive=alive, private=private, gender=gender or None,
+        description=description or None, notes=notes or None,
+        tz=tz or None, birth_time_known=birth_time_known,
     )
     return RedirectResponse("/dashboard/persons", status_code=303)
 
 
-# ── add person (form) ────────────────────────────────────────────────
-# Must be BEFORE /dashboard/persons/{person_id} so "new" is not captured
+# ── add person form ──────────────────────────────────────────────────
 
 @router.get("/dashboard/persons/new", response_class=HTMLResponse)
 async def person_add_form(request: Request):
     return templates.TemplateResponse(
         request, "person-form.html",
-        {"person": None, "action": "add"},
+        {"person": None, "action": "add", "is_24h": DASHBOARD_24H_TIME},
+    )
+
+
+# ── export all persons ───────────────────────────────────────────────
+
+@router.get("/dashboard/persons/export")
+async def persons_export():
+    import json as json_mod
+    from fastapi.responses import Response
+    persons = await _get_all_persons()
+    export = []
+    for p in persons:
+        export.append({
+            "name": p.get("name"), "nickname": p.get("nickname"),
+            "birth_datetime": p.get("birth_datetime"),
+            "birthplace": p.get("birthplace"),
+            "latitude": p.get("latitude"), "longitude": p.get("longitude"),
+            "elevation": p.get("elevation"), "alive": p.get("alive"),
+            "private": p.get("private"), "gender": p.get("gender"),
+            "description": p.get("description"), "notes": p.get("notes"),
+            "timezone": p.get("timezone"),
+            "birth_time_known": p.get("birth_time_known"),
+        })
+    content = json_mod.dumps(export, indent=2, ensure_ascii=False)
+    return Response(
+        content=content, media_type="application/json",
+        headers={"Content-Disposition": "attachment; filename=persons.json"},
+    )
+
+
+# ── import form ──────────────────────────────────────────────────────
+
+@router.get("/dashboard/persons/import", response_class=HTMLResponse)
+async def person_import_form(request: Request):
+    return templates.TemplateResponse(
+        request, "person-import.html",
+        {"imported": None, "errors": None},
     )
 
 
+# ── import submit ────────────────────────────────────────────────────
+
+@router.post("/dashboard/persons/import", response_class=HTMLResponse)
+async def person_import_submit(request: Request):
+    import json as json_mod
+    try:
+        form = await request.form()
+        file = form.get("import_file")
+        if not file:
+            return templates.TemplateResponse(
+                request, "person-import.html",
+                {"imported": None, "errors": ["No file uploaded."]},
+            )
+        raw = await file.read()
+        data = json_mod.loads(raw)
+        if isinstance(data, dict):
+            data = [data]
+        imported = 0
+        errors = []
+        for i, entry in enumerate(data):
+            try:
+                name = entry.get("name")
+                bdt = entry.get("birth_datetime")
+                lat = entry.get("latitude")
+                lon = entry.get("longitude")
+                if not name or not bdt or lat is None or lon is None:
+                    errors.append(f"Row {i+1}: missing required fields")
+                    continue
+                await storage.add_person(
+                    name=name, birth_datetime=bdt,
+                    latitude=float(lat), longitude=float(lon),
+                    elevation=float(entry.get("elevation", 0) or 0),
+                    nickname=entry.get("nickname") or None,
+                    birthplace=entry.get("birthplace") or None,
+                    alive=bool(entry.get("alive", True)),
+                    private=bool(entry.get("private", False)),
+                    gender=entry.get("gender") or None,
+                    description=entry.get("description") or None,
+                    notes=entry.get("notes") or None,
+                    tz=entry.get("timezone") or None,
+                    birth_time_known=bool(entry.get("birth_time_known", True)),
+                )
+                imported += 1
+            except Exception as e:
+                errors.append(f"Row {i+1}: {e}")
+        return templates.TemplateResponse(
+            request, "person-import.html",
+            {"imported": imported, "errors": errors},
+        )
+    except Exception as e:
+        return templates.TemplateResponse(
+            request, "person-import.html",
+            {"imported": None, "errors": [f"Import failed: {e}"]},
+        )
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# PARAMETERIZED PATHS ({person_id}) — all static paths registered above
+# ═══════════════════════════════════════════════════════════════════════
+
 # ── person detail ────────────────────────────────────────────────────
 
 @router.get("/dashboard/persons/{person_id:str}", response_class=HTMLResponse)
 async def person_detail(request: Request, person_id: str):
-    if person_id == "new":
-        return RedirectResponse("/dashboard/persons/new", status_code=307)
     person = await _get_one_person(person_id)
     if not person:
         return RedirectResponse("/dashboard/persons", status_code=303)
     return templates.TemplateResponse(
-        request, "person-detail.html",
-        {"person": person},
+        request, "person-detail.html", {"person": person},
+    )
+
+
+# ── person export (single) ───────────────────────────────────────────
+
+@router.get("/dashboard/persons/{person_id:str}/export")
+async def person_export(person_id: str):
+    import json as json_mod
+    from fastapi.responses import Response
+    person = await _get_one_person(person_id)
+    if not person:
+        return RedirectResponse("/dashboard/persons", status_code=303)
+    export = {
+        "name": person.get("name"), "nickname": person.get("nickname"),
+        "birth_datetime": person.get("birth_datetime"),
+        "birthplace": person.get("birthplace"),
+        "latitude": person.get("latitude"), "longitude": person.get("longitude"),
+        "elevation": person.get("elevation"), "alive": person.get("alive"),
+        "private": person.get("private"), "gender": person.get("gender"),
+        "description": person.get("description"), "notes": person.get("notes"),
+        "timezone": person.get("timezone"),
+        "birth_time_known": person.get("birth_time_known"),
+    }
+    content = json_mod.dumps(export, indent=2, ensure_ascii=False)
+    safe_name = person.get("name", person_id).replace(" ", "_")
+    return Response(
+        content=content, media_type="application/json",
+        headers={"Content-Disposition": f"attachment; filename=person_{safe_name}.json"},
     )
 
 
 # ── edit person (submit) ─────────────────────────────────────────────
-# Must be BEFORE /dashboard/persons/{person_id}
 
-@router.post("/dashboard/persons/{person_id}", response_class=HTMLResponse)
+@router.post("/dashboard/persons/{person_id:str}")
 async def person_edit_submit(
     request: Request,
     person_id: str,
@@ -190,41 +305,32 @@ async def person_edit_submit(
     birth_time_known: bool = Form(default=True),
 ):
     await storage.update_person(
-        person_id=person_id,
-        name=name,
-        birth_datetime=birth_datetime,
-        latitude=latitude,
-        longitude=longitude,
-        elevation=elevation,
-        nickname=nickname or None,
-        birthplace=birthplace or None,
-        alive=alive,
-        private=private,
-        gender=gender or None,
-        description=description or None,
-        notes=notes or None,
-        tz=tz or None,
-        birth_time_known=birth_time_known,
+        person_id=person_id, name=name, birth_datetime=birth_datetime,
+        latitude=latitude, longitude=longitude, elevation=elevation,
+        nickname=nickname or None, birthplace=birthplace or None,
+        alive=alive, private=private, gender=gender or None,
+        description=description or None, notes=notes or None,
+        tz=tz or None, birth_time_known=birth_time_known,
     )
     return RedirectResponse(f"/dashboard/persons/{person_id}", status_code=303)
 
 
-# ── edit person (form) ───────────────────────────────────────────────
+# ── edit person form ─────────────────────────────────────────────────
 
-@router.get("/dashboard/persons/{person_id}/edit", response_class=HTMLResponse)
+@router.get("/dashboard/persons/{person_id:str}/edit", response_class=HTMLResponse)
 async def person_edit_form(request: Request, person_id: str):
     person = await _get_one_person(person_id)
     if not person:
         return RedirectResponse("/dashboard/persons", status_code=303)
     return templates.TemplateResponse(
         request, "person-form.html",
-        {"person": person, "action": "edit"},
+        {"person": person, "action": "edit", "is_24h": DASHBOARD_24H_TIME},
     )
 
 
 # ── delete person ────────────────────────────────────────────────────
 
-@router.post("/dashboard/persons/{person_id}/delete", response_class=HTMLResponse)
+@router.post("/dashboard/persons/{person_id:str}/delete")
 async def person_delete(person_id: str):
     await storage.delete_person(person_id)
     return RedirectResponse("/dashboard/persons", status_code=303)

+ 18 - 2
static/app.js

@@ -22,13 +22,29 @@ document.addEventListener('DOMContentLoaded', () => {
         alert('Birth date, time, and timezone are required.');
         return;
       }
+      // Convert 12h to 24h if needed
+      let time24 = time;
+      const ampmSel = document.getElementById('birth_ampm');
+      if (ampmSel) {
+        const ampm = ampmSel.value;
+        const parts = time.split(':');
+        let h = parseInt(parts[0], 10);
+        const m = parts[1] || '00';
+        if (ampm === 'PM' && h < 12) h += 12;
+        if (ampm === 'AM' && h === 12) h = 0;
+        time24 = `${String(h).padStart(2, '0')}:${m}`;
+      }
       // Build ISO string with timezone offset
-      const offset = getTzOffsetMinutes(tz, date, time);
+      const offset = getTzOffsetMinutes(tz, date, time24);
       const sign = offset >= 0 ? '+' : '-';
       const abs = Math.abs(offset);
       const oh = String(Math.floor(abs / 60)).padStart(2, '0');
       const om = String(abs % 60).padStart(2, '0');
-      const iso = `${date}T${time}${sign}${oh}:${om}`;
+      const iso = `${date}T${time24}${sign}${oh}:${om}`;
+      // Remove the original time/ampm fields so they don't conflict
+      const origTime = document.getElementById('birth_time');
+      if (origTime) origTime.removeAttribute('name');
+      if (ampmSel) ampmSel.removeAttribute('name');
       // Add a hidden field with the assembled datetime
       let hidden = document.getElementById('birth_datetime_assembled');
       if (!hidden) {

+ 10 - 0
static/style.css

@@ -73,6 +73,7 @@ tr:hover td { background: #1c2128; }
 }
 .badge-green { background: #238636; color: #fff; }
 .badge-red { background: #da3633; color: #fff; }
+.badge-deceased { background: #21262d; color: #c9d1d9; border: 1px solid #30363d; }
 .badge-gray { background: #30363d; color: #8b949e; }
 .badge-blue { background: #1f6feb; color: #fff; }
 
@@ -95,8 +96,17 @@ tr:hover td { background: #1c2128; }
 .btn-danger:hover { background: #f85149; }
 .btn-ghost { background: transparent; color: #58a6ff; border-color: #30363d; }
 .btn-ghost:hover { background: #1c2128; }
+.btn-delete { background: transparent; color: #6e7681; border-color: transparent; }
+.btn-delete:hover { color: #f85149; background: rgba(248, 81, 73, 0.1); }
 .btn-sm { padding: 0.25rem 0.55rem; font-size: 0.8rem; }
 
+/* ── row actions ───────────────────────────────────────────────────── */
+.row-actions { white-space: nowrap; text-align: right; }
+.row-actions .btn { margin-left: 0.25rem; }
+.row-actions .btn-delete { opacity: 0.4; }
+.row-actions .btn-delete:hover { opacity: 1; }
+tr:hover .row-actions .btn-delete { opacity: 0.7; }
+
 /* ── forms ─────────────────────────────────────────────────────────── */
 .form-group { margin-bottom: 1rem; }
 .form-group label {

+ 4 - 3
templates/person-detail.html

@@ -5,10 +5,11 @@
 
 <div class="toolbar">
   <a href="/dashboard/persons" class="btn btn-ghost">← Back</a>
-  <a href="/dashboard/persons/{{ person.id }}/edit" class="btn btn-primary">Edit</a>
   <form method="post" action="/dashboard/persons/{{ person.id }}/delete" style="display:inline">
-    <button type="submit" class="btn btn-danger" data-confirm="Delete {{ person.name }}?">Delete</button>
+    <button type="submit" class="btn btn-delete" data-confirm="Delete {{ person.name }}?">Delete</button>
   </form>
+  <a href="/dashboard/persons/{{ person.id }}/edit" class="btn btn-primary">Edit</a>
+  <a href="/dashboard/persons/{{ person.id }}/export" class="btn btn-ghost" title="Export as JSON">Export</a>
 </div>
 
 <div class="card">
@@ -33,7 +34,7 @@
       {% if person.alive %}
       <span class="badge badge-green">alive</span>
       {% else %}
-      <span class="badge badge-red">deceased</span>
+      <span class="badge badge-deceased">deceased</span>
       {% endif %}
       {% if person.private %}<span class="badge badge-gray">private</span>{% endif %}
     </span>

+ 14 - 1
templates/person-form.html

@@ -76,9 +76,22 @@
       </div>
       <div class="form-group">
         <label for="birth_time">Birth Time *</label>
+        {% if is_24h | default(true) %}
         <input type="time" id="birth_time" required step="1"
           value="{{ person.birth_datetime[11:16] if person and person.birth_datetime else '' }}">
-        <div class="form-hint">HH:MM (seconds optional)</div>
+        <div class="form-hint">HH:MM (24h format)</div>
+        {% else %}
+        <div style="display:flex;gap:0.5rem;align-items:center">
+          <input type="text" id="birth_time" name="birth_time" required
+            value="{{ person.birth_datetime[11:16] if person and person.birth_datetime else '' }}"
+            placeholder="HH:MM" pattern="[0-9]{1,2}:[0-9]{2}" style="max-width:100px">
+          <select id="birth_ampm" name="birth_ampm" style="width:80px">
+            <option value="AM">AM</option>
+            <option value="PM">PM</option>
+          </select>
+        </div>
+        <div class="form-hint">HH:MM AM/PM (12h format)</div>
+        {% endif %}
       </div>
       <div class="form-group">
         <label for="tz">Timezone *</label>

+ 65 - 0
templates/person-import.html

@@ -0,0 +1,65 @@
+{% extends "base.html" %}
+{% block title %}Import Persons - astro-mcp{% endblock %}
+{% block content %}
+<h1>Import Persons</h1>
+
+<div class="toolbar">
+  <a href="/dashboard/persons" class="btn btn-ghost">← Back to Persons</a>
+</div>
+
+{% if imported is not none %}
+<div class="card">
+  <h2>Import Results</h2>
+  <p><strong>{{ imported }}</strong> person(s) imported successfully.</p>
+  {% if errors %}
+  <h3 style="color:#f85149;margin-top:1rem">Errors</h3>
+  <ul style="color:#f85149;padding-left:1.5rem">
+    {% for err in errors %}
+    <li>{{ err }}</li>
+    {% endfor %}
+  </ul>
+  {% endif %}
+</div>
+{% endif %}
+
+<div class="card">
+  <h2>Upload JSON File</h2>
+  <p class="form-hint" style="margin-bottom:1rem">
+    Upload a JSON file containing person data. The file should be either a single person object
+    or an array of person objects. Each person must have at minimum: <code>name</code>,
+    <code>birth_datetime</code>, <code>latitude</code>, <code>longitude</code>.
+  </p>
+  <form method="post" action="/dashboard/persons/import" enctype="multipart/form-data">
+    <div class="form-group">
+      <label for="import_file">JSON File</label>
+      <input type="file" id="import_file" name="import_file" accept=".json,application/json" required>
+    </div>
+    <div class="form-actions">
+      <button type="submit" class="btn btn-primary">Import</button>
+      <a href="/dashboard/persons" class="btn btn-ghost">Cancel</a>
+    </div>
+  </form>
+</div>
+
+<div class="card">
+  <h2>Expected JSON Format</h2>
+  <pre style="background:#0d1117;padding:1rem;border-radius:6px;overflow-x:auto;font-size:0.8rem;color:#8b949e"><code>[
+  {
+    "name": "John Doe",
+    "nickname": "john",
+    "birth_datetime": "1990-05-15T08:30:00+02:00",
+    "birthplace": "Vienna, Austria",
+    "latitude": 48.2082,
+    "longitude": 16.3738,
+    "elevation": 171,
+    "alive": true,
+    "private": false,
+    "gender": "male",
+    "description": "A sample person",
+    "notes": "Some notes here",
+    "timezone": "Europe/Vienna",
+    "birth_time_known": true
+  }
+]</code></pre>
+</div>
+{% endblock %}

+ 7 - 5
templates/persons.html

@@ -5,12 +5,13 @@
 
 <div class="toolbar">
   <a href="/dashboard/persons/new" class="btn btn-primary">+ Add Person</a>
+  <a href="/dashboard/persons/export" class="btn btn-ghost" title="Export all persons as JSON">Export All</a>
+  <a href="/dashboard/persons/import" class="btn btn-ghost" title="Import persons from JSON">Import</a>
   <input
     class="search-input"
     type="text"
     id="search"
     placeholder="Search name or nickname..."
-    value="{{ q }}"
   >
 </div>
 
@@ -45,7 +46,7 @@
         {% if p.alive %}
         <span class="badge badge-green">alive</span>
         {% else %}
-        <span class="badge badge-red">deceased</span>
+        <span class="badge badge-deceased">deceased</span>
         {% endif %}
         {% if p.private %}
         <span class="badge badge-gray">private</span>
@@ -54,11 +55,12 @@
         <span class="badge badge-gray">time unknown</span>
         {% endif %}
       </td>
-      <td style="white-space:nowrap">
-        <a href="/dashboard/persons/{{ p.id }}/edit" class="btn btn-ghost btn-sm">Edit</a>
+      <td class="row-actions">
         <form method="post" action="/dashboard/persons/{{ p.id }}/delete" style="display:inline">
-          <button type="submit" class="btn btn-danger btn-sm" data-confirm="Delete {{ p.name }}?">Delete</button>
+          <button type="submit" class="btn btn-delete btn-sm" data-confirm="Delete {{ p.name }}?">Delete</button>
         </form>
+        <a href="/dashboard/persons/{{ p.id }}/edit" class="btn btn-primary btn-sm">Edit</a>
+        <a href="/dashboard/persons/{{ p.id }}/export" class="btn btn-ghost btn-sm" title="Export as JSON">Export</a>
       </td>
     </tr>
     {% endfor %}