Compare commits

...

3 Commits

Author SHA1 Message Date
linkong
51ae5e6ec9 release: bump version to 0.27.8 2026-04-20 12:03:09 +08:00
linkong
1cf1f32ddd feat: refine earth hud panel behaviors and news board 2026-04-20 11:23:05 +08:00
linkong
8f3ab88743 release: bump version to 0.27.7 2026-04-16 10:04:14 +08:00
32 changed files with 2362 additions and 523 deletions

View File

@@ -1 +1 @@
0.27.6 0.27.8

View File

@@ -13,6 +13,7 @@ from app.api.v1 import (
collected_data, collected_data,
visualization, visualization,
bgp, bgp,
news,
system_control, system_control,
tv, tv,
) )
@@ -35,3 +36,4 @@ api_router.include_router(system_control.router, prefix="/system", tags=["system
api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"]) api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"])
api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"]) api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"])
api_router.include_router(tv.router, prefix="/tv", tags=["tv"]) api_router.include_router(tv.router, prefix="/tv", tags=["tv"])
api_router.include_router(news.router, prefix="/news", tags=["news"])

View File

@@ -0,0 +1,13 @@
from fastapi import APIRouter, Query
from app.services.earth_news import get_earth_news_payload
router = APIRouter()
@router.get("/earth-feed")
async def get_earth_feed(
lat: float | None = Query(None, description="Current Earth view center latitude"),
lon: float | None = Query(None, description="Current Earth view center longitude"),
):
return await get_earth_news_payload(lat=lat, lon=lon)

View File

@@ -0,0 +1,490 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from datetime import UTC, datetime
from email.utils import parsedate_to_datetime
import hashlib
import html
import re
from typing import Any
from urllib.parse import quote
import xml.etree.ElementTree as ET
import httpx
from bs4 import BeautifulSoup
USER_AGENT = "PlanetEarthNewsBoard/1.0 (+https://planet.local)"
REQUEST_TIMEOUT = 12.0
MAX_ITEMS_PER_SOURCE = 6
MAX_ITEMS_TOTAL = 12
STALE_CACHE_MAX_AGE_SECONDS = 60 * 45
@dataclass(frozen=True)
class RegionProfile:
key: str
label: str
query: str
accent: str
@dataclass(frozen=True)
class NewsFeedSource:
id: str
name: str
region: str
feed_url: str
homepage_url: str
source_type: str = "rss"
priority: int = 100
@dataclass
class ParsedNewsItem:
id: str
title: str
summary: str
url: str
source: str
feed_name: str
feed_region: str
homepage_url: str
published_at: datetime | None
@dataclass
class CachedRegionFeed:
region: str
fetched_at: datetime
items: list[ParsedNewsItem]
sources: list[NewsFeedSource]
REGION_PROFILES: dict[str, RegionProfile] = {
"americas": RegionProfile(
key="americas",
label="美洲焦点",
query='Americas geopolitics OR Latin America OR "United States" OR Canada',
accent="#79d3ff",
),
"europe": RegionProfile(
key="europe",
label="欧洲焦点",
query='Europe geopolitics OR EU OR NATO OR "Eastern Europe"',
accent="#8fd4ff",
),
"middle-east-africa": RegionProfile(
key="middle-east-africa",
label="中东与非洲焦点",
query='"Middle East" OR Africa geopolitics OR Red Sea OR Gulf',
accent="#ffb56a",
),
"asia-pacific": RegionProfile(
key="asia-pacific",
label="亚太焦点",
query='"Asia Pacific" OR Indo-Pacific OR China OR Japan OR Korea OR ASEAN',
accent="#78f2cf",
),
"global": RegionProfile(
key="global",
label="全球焦点",
query='"world news" OR geopolitics OR "global affairs"',
accent="#d6e6ff",
),
}
def _google_news_feed(query: str, *, hl: str, gl: str, ceid: str) -> str:
return (
"https://news.google.com/rss/search?q="
+ quote(query, safe="")
+ f"&hl={hl}&gl={gl}&ceid={ceid}"
)
NEWS_FEED_SOURCES: tuple[NewsFeedSource, ...] = (
NewsFeedSource(
id="bbc-world",
name="BBC World",
region="global",
feed_url="https://feeds.bbci.co.uk/news/world/rss.xml",
homepage_url="https://www.bbc.com/news/world",
priority=10,
),
NewsFeedSource(
id="dw-top",
name="DW Top Stories",
region="europe",
feed_url="https://rss.dw.com/rdf/rss-en-top",
homepage_url="https://www.dw.com/en/top-stories/s-9097",
priority=20,
),
NewsFeedSource(
id="global-scan",
name="Global Monitor / World",
region="global",
feed_url=_google_news_feed(
REGION_PROFILES["global"].query,
hl="en-US",
gl="US",
ceid="US:en",
),
homepage_url="https://news.google.com/",
source_type="aggregated",
priority=30,
),
NewsFeedSource(
id="google-americas",
name="Global Monitor / Americas",
region="americas",
feed_url=_google_news_feed(
REGION_PROFILES["americas"].query,
hl="en-US",
gl="US",
ceid="US:en",
),
homepage_url="https://news.google.com/",
source_type="aggregated",
priority=40,
),
NewsFeedSource(
id="google-europe",
name="Global Monitor / Europe",
region="europe",
feed_url=_google_news_feed(
REGION_PROFILES["europe"].query,
hl="en-GB",
gl="GB",
ceid="GB:en",
),
homepage_url="https://news.google.com/",
source_type="aggregated",
priority=40,
),
NewsFeedSource(
id="google-mea",
name="Global Monitor / MEA",
region="middle-east-africa",
feed_url=_google_news_feed(
REGION_PROFILES["middle-east-africa"].query,
hl="en-US",
gl="US",
ceid="US:en",
),
homepage_url="https://news.google.com/",
source_type="aggregated",
priority=40,
),
NewsFeedSource(
id="google-apac",
name="Global Monitor / APAC",
region="asia-pacific",
feed_url=_google_news_feed(
REGION_PROFILES["asia-pacific"].query,
hl="en-SG",
gl="SG",
ceid="SG:en",
),
homepage_url="https://news.google.com/",
source_type="aggregated",
priority=40,
),
)
_REGION_CACHE: dict[str, CachedRegionFeed] = {}
def determine_focus_region(lat: float | None, lon: float | None) -> str:
if lat is None or lon is None:
return "global"
if -170 <= lon <= -30:
return "americas"
if -30 < lon <= 45:
return "europe" if lat >= 30 else "middle-east-africa"
if 45 < lon <= 150:
return "middle-east-africa" if lat < 10 else "asia-pacific"
return "asia-pacific"
def get_region_profile(region: str) -> RegionProfile:
return REGION_PROFILES.get(region, REGION_PROFILES["global"])
def get_sources_for_region(region: str) -> list[NewsFeedSource]:
return sorted(
[source for source in NEWS_FEED_SOURCES if source.region in {"global", region}],
key=lambda source: (source.priority, source.name),
)
def _strip_html(value: str) -> str:
if not value:
return ""
soup = BeautifulSoup(value, "html.parser")
return re.sub(r"\s+", " ", soup.get_text(" ", strip=True)).strip()
def _truncate(value: str, limit: int = 180) -> str:
text = value.strip()
if len(text) <= limit:
return text
return text[: limit - 1].rstrip() + ""
def _normalize_source_name(raw: str, fallback: str) -> str:
text = html.unescape((raw or "").strip())
if " - " in text:
return text.split(" - ")[-1].strip() or fallback
return text or fallback
def _parse_datetime(raw: str | None) -> datetime | None:
if not raw:
return None
text = raw.strip()
if not text:
return None
for parser in (
lambda value: parsedate_to_datetime(value),
lambda value: datetime.fromisoformat(value.replace("Z", "+00:00")),
):
try:
parsed = parser(text)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC)
except Exception:
continue
return None
def _extract_item_text(element: ET.Element, *names: str) -> str:
for name in names:
node = element.find(name)
if node is not None and node.text:
return node.text.strip()
return ""
def _parse_feed_entries(xml_text: str, source: NewsFeedSource) -> list[ParsedNewsItem]:
root = ET.fromstring(xml_text)
items: list[ParsedNewsItem] = []
rss_items = root.findall("./channel/item")
atom_entries = root.findall("{http://www.w3.org/2005/Atom}entry")
nodes = rss_items or atom_entries
for node in nodes[:MAX_ITEMS_PER_SOURCE]:
if node.tag.endswith("entry"):
title = _extract_item_text(node, "{http://www.w3.org/2005/Atom}title")
summary = _extract_item_text(
node,
"{http://www.w3.org/2005/Atom}summary",
"{http://www.w3.org/2005/Atom}content",
)
link_node = node.find("{http://www.w3.org/2005/Atom}link")
link = link_node.get("href", "").strip() if link_node is not None else ""
published = _extract_item_text(
node,
"{http://www.w3.org/2005/Atom}updated",
"{http://www.w3.org/2005/Atom}published",
)
else:
title = _extract_item_text(node, "title")
summary = _extract_item_text(node, "description", "content")
link = _extract_item_text(node, "link")
published = _extract_item_text(node, "pubDate", "published", "updated")
clean_title = html.unescape(title).strip()
clean_summary = _truncate(_strip_html(summary), 180)
if not clean_title or not link:
continue
item_source = _normalize_source_name(clean_title, source.name)
display_title = clean_title
if source.source_type == "aggregated" and " - " in clean_title:
parts = clean_title.rsplit(" - ", 1)
display_title = parts[0].strip()
item_source = _normalize_source_name(parts[1], source.name)
items.append(
ParsedNewsItem(
id=f"{source.id}:{hashlib.sha1(link.encode('utf-8')).hexdigest()[:12]}",
title=display_title,
summary=clean_summary,
url=link,
source=item_source,
feed_name=source.name,
feed_region=source.region,
homepage_url=source.homepage_url,
published_at=_parse_datetime(published),
)
)
return items
def _serialize_sources(sources: list[NewsFeedSource]) -> list[dict[str, Any]]:
return [
{
"id": source.id,
"name": source.name,
"region": source.region,
"homepage_url": source.homepage_url,
}
for source in sources
]
def _serialize_item(item: ParsedNewsItem, *, active_region: str) -> dict[str, Any]:
published_at = item.published_at
return {
"id": item.id,
"title": item.title,
"summary": item.summary,
"url": item.url,
"source": item.source,
"feed_name": item.feed_name,
"region": item.feed_region,
"homepage_url": item.homepage_url,
"published_at": published_at.isoformat().replace("+00:00", "Z") if published_at else None,
"is_focus_match": item.feed_region == active_region,
}
def _build_payload(
*,
lat: float | None,
lon: float | None,
active_region: str,
items: list[ParsedNewsItem],
sources: list[NewsFeedSource],
errors: list[str],
stale: bool,
generated_at: datetime | None = None,
) -> dict[str, Any]:
profile = get_region_profile(active_region)
timestamp = generated_at or datetime.now(UTC)
return {
"generated_at": timestamp.isoformat().replace("+00:00", "Z"),
"focus": {
"lat": lat,
"lon": lon,
"region": active_region,
"label": profile.label,
"accent": profile.accent,
},
"sources": _serialize_sources(sources),
"items": [_serialize_item(item, active_region=active_region) for item in items],
"errors": errors,
"stale": stale,
}
def _rank_and_trim_items(items: list[ParsedNewsItem], *, active_region: str) -> list[ParsedNewsItem]:
deduped: dict[str, ParsedNewsItem] = {}
for item in items:
key = item.url.strip() or item.title.strip().lower()
if key not in deduped:
deduped[key] = item
return sorted(
deduped.values(),
key=lambda item: (
item.feed_region != active_region,
item.published_at is None,
-(item.published_at.timestamp() if item.published_at else 0),
item.feed_name,
),
)[:MAX_ITEMS_TOTAL]
def _get_cached_region_feed(region: str) -> CachedRegionFeed | None:
cached = _REGION_CACHE.get(region)
if not cached:
return None
age_seconds = (datetime.now(UTC) - cached.fetched_at).total_seconds()
if age_seconds > STALE_CACHE_MAX_AGE_SECONDS:
return None
return cached
def _store_region_cache(region: str, *, items: list[ParsedNewsItem], sources: list[NewsFeedSource]) -> None:
_REGION_CACHE[region] = CachedRegionFeed(
region=region,
fetched_at=datetime.now(UTC),
items=list(items),
sources=list(sources),
)
async def _fetch_source(
client: httpx.AsyncClient,
source: NewsFeedSource,
) -> tuple[NewsFeedSource, list[ParsedNewsItem], str | None]:
try:
response = await client.get(source.feed_url)
response.raise_for_status()
return source, _parse_feed_entries(response.text, source), None
except Exception as exc:
return source, [], str(exc)
async def get_earth_news_payload(lat: float | None = None, lon: float | None = None) -> dict[str, Any]:
active_region = determine_focus_region(lat, lon)
sources = get_sources_for_region(active_region)
errors: list[str] = []
async with httpx.AsyncClient(
timeout=REQUEST_TIMEOUT,
follow_redirects=True,
headers={"User-Agent": USER_AGENT},
) as client:
results = await asyncio.gather(*(_fetch_source(client, source) for source in sources))
fetched_items: list[ParsedNewsItem] = []
for source, items, error in results:
if error:
errors.append(f"{source.name}: {error}")
continue
fetched_items.extend(items)
ranked_items = _rank_and_trim_items(fetched_items, active_region=active_region)
if ranked_items:
_store_region_cache(active_region, items=ranked_items, sources=sources)
return _build_payload(
lat=lat,
lon=lon,
active_region=active_region,
items=ranked_items,
sources=sources,
errors=errors,
stale=False,
)
cached = _get_cached_region_feed(active_region)
if cached:
return _build_payload(
lat=lat,
lon=lon,
active_region=active_region,
items=cached.items,
sources=cached.sources,
errors=errors,
stale=True,
generated_at=cached.fetched_at,
)
return _build_payload(
lat=lat,
lon=lon,
active_region=active_region,
items=[],
sources=sources,
errors=errors,
stale=False,
)

View File

@@ -8,6 +8,35 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合) - `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1` - `bugfix` -> `+0.0.1`
## [0.27.7] — 2026-04-16
## [0.27.8] — 2026-04-20
### 🔧 Improvements
- Earth HUD 共享 `HUDPanel` 默认展开/收缩逻辑继续收口,图例与图层面板统一使用同一套边缘阈值与箭头状态机
- 保持新闻直播面板现有特例折叠行为不变,避免播放器区域被默认折叠逻辑影响
### 🐛 Fixes
- 修复图例与图层面板展开/收缩箭头方向和实际动作不一致的问题
- 修复拖动到屏幕底边附近时初始箭头、拖动中箭头和点击后动作不同步的问题
---
## [0.27.7] — 2026-04-16
### 🔧 Improvements
- 用户管理、数据源配置、电视直播源表格统一接入可折叠操作列,窄宽度下自动收起到下拉菜单,减少操作区挤压
- 电视直播设置改为表格总览 + 弹窗编辑模式,主表内容更紧凑,适合控制台一屏浏览
- Earth TV 面板新增失败源探测与自动回退恢复标记,便于值班时快速识别异常直播源
### 🐛 Fixes
- 修复 Settings 电视直播源新增后取消编辑会残留未保存草稿的问题
- 修复 Settings 删除直播源只改本地状态、刷新后恢复的问题,删除现在会立即持久化
- 修复 Users / DataSources / Settings 表格“备注/状态”和“操作”之间的空白占位列问题
- 修复 `useCollapsedActions` 未释放 `ResizeObserver` 导致的潜在内存泄漏与重复回调问题
---
## [0.27.6] — 2026-04-15 ## [0.27.6] — 2026-04-15
### 🔧 Improvements ### 🔧 Improvements

View File

@@ -0,0 +1,165 @@
# HUD Panel Component Plan
## Goal
Unify Earth HUD panels into a reusable component layer so new panels can share:
- a consistent shell
- a consistent header
- a consistent action-button system
- a consistent body and collapse pattern
## Scope
Target panels:
- `tv-panel`
- `news-panel`
- `legend`
- `layer-panel`
- `earth-stats`
- `info-card`
- settings modal header/actions
## Component Model
### Base shell
- `.hud-panel`
- `.hud-panel--compact`
- `.hud-panel--media`
- `.hud-panel--collapsed`
- `.hud-panel-hidden`
- `.hud-panel.is-dragging`
- `.hud-panel.is-layout-animating`
### Header
- `.hud-panel__header`
- `.hud-panel__title-group`
- `.hud-panel__title`
- `.hud-panel__subtitle`
- `.hud-panel__chip`
- `.hud-panel__actions`
Header baseline rule:
- Header title styling is fixed by the component layer and should not drift per panel
- Title font size, font weight, letter spacing, line height, text color, and vertical alignment come from the shared header tokens and structure
- Header divider, border treatment, inner spacing, and title-to-actions alignment are part of the same shared baseline
- Panel-specific header differences should be limited to explicit variants such as `compact` or `media`, or token overrides with documented intent
- “Looks close enough” local header overrides should be treated as temporary compatibility code and removed during migration
### Actions
- `.hud-panel__action`
- `.hud-panel__action--icon`
- `.hud-panel__action--collapse`
- `.hud-panel__action--close`
- `.hud-panel__action--refresh`
- `.hud-panel__action--external`
Action-button baseline rule:
- Header action buttons must have one fixed default style baseline across all HUD panels
- Default width behavior, padding, icon size, radius, alignment, hover, and active feedback all come from `.hud-panel__action`
- Panel-specific differences must be expressed through explicit variants or token overrides, not ad-hoc local button rewrites
- `close` buttons are part of the same default action system and must not silently fall back to a separate legacy box model
### Body
- `.hud-panel__body`
- `.hud-panel__body--scroll`
- `.hud-panel__body--collapsible`
### Collapse behavior
- `.hud-panel--collapsed`
- `.hud-panel--expand-up`
- `.hud-panel--expand-down`
Adaptive collapse / expand rule:
- HUD panels support two expansion directions:
- top-to-bottom expansion
- bottom-to-top expansion
- Expansion direction should be decided at runtime from available viewport space rather than hardcoded per panel
- Use:
- `d` = available distance from the header anchor to the viewport bottom edge
- `h` = expected expanded panel height
- buffer = `20px`
- Collapsed-state direction rule:
- if `d > h + 20px`, the next action direction is `expand-up`
- if `d <= h + 20px`, the next action direction is `expand-down`
- To avoid jitter around the threshold, the shared controller should keep a small hysteresis band:
- if the current direction is already `up`, keep it until `d <= h`
- if the current direction is already `down`, keep it until `d > h + 20px`
- The opposite edge is still a safety guard:
- if the chosen side cannot fit at all, fall back to the other side if it can fit
- if neither side fully fits, choose the side with more space and let the body scroll
- If neither direction fully fits, choose the direction with more available space and let the body scroll
- Collapse icon direction must match the active expansion direction so the icon always describes the real open/close motion
- The collapse icon describes the next action, not the current state
- This mapping is fixed component behavior and must not drift per panel:
- collapsed + expand-down => `expand_more`
- expanded + expand-down => `expand_less`
- collapsed + expand-up => `expand_less`
- expanded + expand-up => `expand_more`
- Panels must not combine icon-name swapping with extra CSS rotation for the same collapse control
- Expansion direction and icon direction must come from one shared source of truth in the component controller
- The direction decision should be recomputed when opening, resizing the viewport, or restoring a dragged panel near another edge
## Tokens
Promote panel differences into CSS variables instead of duplicating selectors:
- `--hud-panel-padding`
- `--hud-header-padding`
- `--hud-header-gap`
- `--hud-action-padding`
- `--hud-action-gap`
- `--hud-action-icon-size`
- `--hud-body-gap`
- `--hud-body-max-height`
- `--hud-chip-radius`
- `--hud-title-font-size`
- `--hud-title-font-weight`
- `--hud-title-letter-spacing`
- `--hud-title-line-height`
- `--hud-title-color`
- `--hud-header-border-color`
- `--hud-header-divider-opacity`
- `--hud-expand-direction`
## Migration Order
1. Build the shared component layer in `frontend/public/earth/css/hud.css`
2. Migrate `tv-panel` and `news-panel` first as the reference implementation
3. Migrate `legend` and `layer-panel` into a compact variant
4. Migrate `earth-stats` and `info-card`
5. Align settings modal header/actions with the same action system
6. Remove legacy one-off button selectors after verification
## Guardrails
- Do not change panel behavior and data flow during the first pass
- Keep old class names temporarily as compatibility hooks
- Prefer variable overrides over per-panel reimplementation
- Treat header action-button default styling as fixed component API, not per-panel design space
- Treat header title typography, border, and divider styling as fixed component API, not per-panel design space
- Treat collapse direction as a component behavior contract, not a one-off panel trick
- Treat collapse icon semantics as a component behavior contract, not a per-panel visual preference
- Verify header alignment and drag/collapse behavior after each migration batch
## First Implementation Batch
Batch 1 should only do:
- shared header structure
- shared action-button system
- shared title typography and header border/divider baseline
- shared collapsible body pattern
- adaptive collapse direction logic and direction-aware collapse icons
- migration of `tv-panel` and `news-panel`
That keeps risk low while giving the rest of the HUD a stable target to migrate toward.

View File

@@ -16,12 +16,14 @@
## Current Version ## Current Version
- `main` 当前主线历史推导到:`0.16.5` - `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.27.6` - `dev` 当前开发分支历史推导到:`0.27.8`
## Timeline ## Timeline
| Version | Type | Branch | Commit | Summary | | Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `0.27.8` | bugfix | `dev` | `pending` | 统一 Earth HUD 默认折叠逻辑,修复图例与图层面板箭头和底边阈值行为 |
| `0.27.7` | bugfix | `dev` | `pending` | 修复电视直播源编辑持久化问题,清理表格空白占位列并统一可折叠操作列 |
| `0.27.6` | improvement | `dev` | `pending` | BGP/用户表格滚动条修复Playground 响应式按钮与输入框收起优化 | | `0.27.6` | improvement | `dev` | `pending` | BGP/用户表格滚动条修复Playground 响应式按钮与输入框收起优化 |
| `0.27.5` | bugfix | `dev` | `pending` | 统一控制台自定义滚动条,修复 alerts/BGP 响应式滚动与采集进度完成态显示 | | `0.27.5` | bugfix | `dev` | `pending` | 统一控制台自定义滚动条,修复 alerts/BGP 响应式滚动与采集进度完成态显示 |
| `0.27.4` | improvement | `dev` | — | info-card 懒加载动态挂载,页面初始不再有隐藏节点 | | `0.27.4` | improvement | `dev` | — | info-card 懒加载动态挂载,页面初始不再有隐藏节点 |

View File

@@ -1,6 +1,6 @@
{ {
"name": "planet-frontend", "name": "planet-frontend",
"version": "0.27.6", "version": "0.27.8",
"private": true, "private": true,
"packageManager": "bun@1", "packageManager": "bun@1",
"dependencies": { "dependencies": {

View File

@@ -19,6 +19,7 @@
--hud-font-size: calc(0.88rem * var(--hud-scale)); --hud-font-size: calc(0.88rem * var(--hud-scale));
--hud-font-size-sm: calc(0.75rem * var(--hud-scale)); --hud-font-size-sm: calc(0.75rem * var(--hud-scale));
--hud-title-size: calc(1.02rem * var(--hud-scale)); --hud-title-size: calc(1.02rem * var(--hud-scale));
--hud-panel-header-title-size: calc(0.82rem * var(--hud-scale));
--hud-kicker-size: calc(0.68rem * var(--hud-scale)); --hud-kicker-size: calc(0.68rem * var(--hud-scale));
--hud-surface-top: rgba(17, 31, 53, 0.84); --hud-surface-top: rgba(17, 31, 53, 0.84);
--hud-surface-bottom: rgba(7, 17, 31, 0.76); --hud-surface-bottom: rgba(7, 17, 31, 0.76);

View File

@@ -28,20 +28,22 @@
.stats-kicker { .stats-kicker {
color: var(--hud-text-soft); color: var(--hud-text-soft);
font-size: calc(0.64rem * var(--hud-scale)); font-size: var(--hud-panel-header-title-size);
letter-spacing: 0.16em; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.01em;
line-height: 1.2;
} }
/* Reuse hud-panel-close — just override size to match kicker line */
.stats-drag-bar .hud-panel-close { .stats-drag-bar .hud-panel-close {
width: calc(20px * var(--hud-scale)); align-self: auto;
height: calc(20px * var(--hud-scale)); width: auto;
min-width: calc(20px * var(--hud-scale)); height: auto;
min-width: 0;
padding: calc(7px * var(--hud-scale));
} }
.stats-drag-bar .hud-panel-close .material-symbols-rounded { .stats-drag-bar .hud-panel-close .material-symbols-rounded {
font-size: calc(12px * var(--hud-scale)); font-size: calc(16px * var(--hud-scale));
} }
/* ── 2-column KPI grid ────────────────────────────────────────── */ /* ── 2-column KPI grid ────────────────────────────────────────── */

View File

@@ -65,22 +65,6 @@
line-height: 1.2; line-height: 1.2;
} }
.hud-panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--hud-gap-sm);
margin-bottom: var(--hud-gap-sm);
padding-bottom: var(--hud-gap-sm);
border-bottom: 1px solid var(--hud-line);
}
.hud-panel-header .hud-panel-title {
margin-bottom: 0;
color: var(--hud-text-soft);
font-size: calc(0.82rem * var(--hud-scale));
}
.hud-panel-drag-handle { .hud-panel-drag-handle {
cursor: grab; cursor: grab;
user-select: none; user-select: none;
@@ -90,19 +74,72 @@
cursor: grabbing; cursor: grabbing;
} }
.hud-panel__header,
.hud-panel-header {
--hud-header-padding: 0 0 var(--hud-gap-sm);
--hud-header-gap: var(--hud-gap-sm);
--hud-header-border-color: var(--hud-line);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--hud-header-gap);
margin-bottom: var(--hud-gap-sm);
padding: var(--hud-header-padding);
border-bottom: 1px solid var(--hud-header-border-color);
}
.hud-panel__title-group {
display: flex;
align-items: center;
gap: var(--hud-gap-xs);
min-width: 0;
flex: 1 1 auto;
}
.hud-panel__title,
.hud-panel__header .hud-panel-title,
.hud-panel-header .hud-panel-title {
margin: 0;
color: var(--hud-text-soft);
font-size: var(--hud-panel-header-title-size);
font-weight: 600;
letter-spacing: 0.01em;
line-height: 1.2;
}
.hud-panel__subtitle {
color: var(--hud-text-soft);
font-size: calc(0.7rem * var(--hud-scale));
line-height: 1.4;
}
.hud-panel__chip {
flex: 0 0 auto;
}
.hud-panel__actions {
display: inline-flex;
align-items: center;
gap: var(--hud-gap-xs);
flex-shrink: 0;
}
.hud-panel__action,
.hud-panel-close { .hud-panel-close {
align-self: flex-start; --hud-action-padding: calc(7px * var(--hud-scale));
width: calc(var(--hud-title-size) * 1.24); --hud-action-icon-size: calc(16px * var(--hud-scale));
height: calc(var(--hud-title-size) * 1.24);
min-width: calc(var(--hud-title-size) * 1.24);
padding: 0;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: calc(4px * var(--hud-scale)); border-radius: calc(4px * var(--hud-scale));
background: transparent; background: transparent;
color: var(--hud-text-muted); color: var(--hud-text-muted);
padding: var(--hud-action-padding);
width: auto;
height: auto;
min-width: 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
align-self: auto;
cursor: pointer; cursor: pointer;
transition: transition:
background 0.18s ease, background 0.18s ease,
@@ -112,17 +149,51 @@
opacity 0.18s ease; opacity 0.18s ease;
} }
.hud-panel__action .material-symbols-rounded,
.hud-panel-close .material-symbols-rounded { .hud-panel-close .material-symbols-rounded {
font-size: calc(var(--hud-title-size) * 0.8); font-size: var(--hud-action-icon-size);
line-height: 1; line-height: 1;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
pointer-events: none;
} }
.hud-panel-close:hover { .hud-panel__action:hover:not(:disabled),
.hud-panel-close:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
border-color: rgba(225, 239, 255, 0.14); border-color: rgba(225, 239, 255, 0.14);
color: var(--hud-accent-strong); color: var(--hud-accent-strong);
} }
.hud-panel__action:disabled,
.hud-panel-close:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.hud-panel__body {
position: relative;
z-index: 1;
}
.hud-panel__body--collapsible {
--hud-body-collapse-gap: var(--hud-gap-sm);
--hud-body-max-height: 1000px;
overflow: hidden;
opacity: 1;
max-height: var(--hud-body-max-height);
transition:
max-height 0.26s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.2s ease,
margin 0.22s ease;
}
.hud-panel--collapsed .hud-panel__body--collapsible {
max-height: 0;
opacity: 0;
pointer-events: none;
margin-top: calc(-1 * var(--hud-body-collapse-gap));
}
.hud-panel.is-dragging { .hud-panel.is-dragging {
transition: none !important; transition: none !important;
box-shadow: box-shadow:
@@ -313,10 +384,22 @@
.earth-settings-title { .earth-settings-title {
margin: 4px 0 0; margin: 4px 0 0;
color: var(--hud-title); color: var(--hud-title);
font-size: var(--hud-panel-header-title-size);
font-weight: 600;
line-height: 1.2;
} }
.earth-settings-close { .earth-settings-close {
margin-top: 4px; margin-top: 4px;
align-self: auto;
width: auto;
height: auto;
min-width: 0;
padding: calc(7px * var(--hud-scale));
}
.earth-settings-close .material-symbols-rounded {
font-size: calc(16px * var(--hud-scale));
} }
.earth-settings-content { .earth-settings-content {

View File

@@ -160,7 +160,7 @@
.info-card-header h3 { .info-card-header h3 {
flex: 1; flex: 1;
margin: 0; margin: 0;
font-size: calc(0.92rem * var(--hud-scale)); font-size: var(--hud-panel-header-title-size);
color: var(--hud-title); color: var(--hud-title);
font-weight: 600; font-weight: 600;
white-space: nowrap; white-space: nowrap;
@@ -170,6 +170,15 @@
.info-card-close { .info-card-close {
flex-shrink: 0; flex-shrink: 0;
align-self: auto;
width: auto;
height: auto;
min-width: 0;
padding: calc(7px * var(--hud-scale));
}
.info-card-close .material-symbols-rounded {
font-size: calc(16px * var(--hud-scale));
} }
.info-card-content { .info-card-content {

View File

@@ -39,7 +39,7 @@
flex: 1 1 auto; flex: 1 1 auto;
margin: 0; margin: 0;
color: var(--hud-text-soft); color: var(--hud-text-soft);
font-size: calc(0.82rem * var(--hud-scale)); font-size: var(--hud-panel-header-title-size);
font-weight: 600; font-weight: 600;
letter-spacing: 0.04em; letter-spacing: 0.04em;
line-height: 1.2; line-height: 1.2;
@@ -51,38 +51,33 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: calc(22px * var(--hud-scale)); padding: calc(7px * var(--hud-scale));
height: calc(22px * var(--hud-scale)); border: 1px solid transparent;
min-width: calc(22px * var(--hud-scale));
padding: 0;
border: none;
border-radius: calc(4px * var(--hud-scale)); border-radius: calc(4px * var(--hud-scale));
background: transparent; background: transparent;
color: var(--hud-text-muted); color: var(--hud-text-muted);
cursor: pointer; cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
transition: background 0.14s ease, color 0.14s ease; transition:
background 0.18s ease,
border-color 0.18s ease,
color 0.18s ease,
transform 0.18s ease,
opacity 0.18s ease;
} }
.layer-panel-btn:hover { .layer-panel-btn:hover {
background: rgba(255, 255, 255, 0.07); background: rgba(255, 255, 255, 0.08);
color: var(--hud-text); border-color: rgba(225, 239, 255, 0.14);
color: var(--hud-accent-strong);
} }
.layer-panel-btn .material-symbols-rounded { .layer-panel-btn .material-symbols-rounded {
font-size: calc(14px * var(--hud-scale)); font-size: calc(16px * var(--hud-scale));
line-height: 1; line-height: 1;
pointer-events: none; pointer-events: none;
transition: transform 0.22s ease; transition: color 0.18s ease;
} font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
/* Chevron展开时朝上可折叠折叠时朝下可展开 */
.layer-panel-btn .material-symbols-rounded {
transform: rotate(180deg);
}
.layer-panel--collapsed .layer-panel-btn .material-symbols-rounded {
transform: rotate(0deg);
} }
/* ── Search bar ───────────────────────────────────────────────── */ /* ── Search bar ───────────────────────────────────────────────── */

View File

@@ -27,38 +27,38 @@
cursor: grabbing; cursor: grabbing;
} }
/* ── Mode tabs ────────────────────────────────────────────────── */ /* ── Current mode label ───────────────────────────────────────── */
.legend-tabs { .legend-current {
display: flex; display: flex;
gap: calc(2px * var(--hud-scale)); align-items: center;
gap: calc(6px * var(--hud-scale));
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
} }
.legend-tab { .legend-title {
padding: calc(3px * var(--hud-scale)) calc(7px * var(--hud-scale)); flex: 0 0 auto;
border-radius: calc(4px * var(--hud-scale)); color: var(--hud-text-soft);
border: 1px solid transparent; font-size: var(--hud-panel-header-title-size);
background: transparent; font-weight: 600;
color: var(--hud-text-muted); letter-spacing: 0.01em;
font-size: calc(0.68rem * var(--hud-scale)); line-height: 1.2;
font-family: inherit;
letter-spacing: 0.08em;
cursor: pointer;
transition: background 0.14s ease, color 0.14s ease, border-color 0.14s ease;
white-space: nowrap; white-space: nowrap;
} }
.legend-tab:hover { .legend-current-label {
background: rgba(255, 255, 255, 0.06); display: inline-flex;
color: var(--hud-text); align-items: center;
} min-width: 0;
padding: calc(3px * var(--hud-scale)) calc(7px * var(--hud-scale));
.legend-tab--active { border-radius: calc(4px * var(--hud-scale));
border: 1px solid rgba(120, 180, 255, 0.2);
background: rgba(120, 180, 255, 0.12); background: rgba(120, 180, 255, 0.12);
border-color: rgba(120, 180, 255, 0.2);
color: var(--hud-accent-strong); color: var(--hud-accent-strong);
font-size: calc(0.68rem * var(--hud-scale));
letter-spacing: 0.08em;
white-space: nowrap;
} }
/* ── Bar action buttons ───────────────────────────────────────── */ /* ── Bar action buttons ───────────────────────────────────────── */
@@ -66,7 +66,7 @@
.legend-bar-actions { .legend-bar-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: calc(2px * var(--hud-scale)); gap: var(--hud-gap-xs);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -74,54 +74,17 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: calc(20px * var(--hud-scale));
height: calc(20px * var(--hud-scale));
min-width: calc(20px * var(--hud-scale));
padding: 0;
border: none;
border-radius: calc(4px * var(--hud-scale));
background: transparent;
color: var(--hud-text-muted);
cursor: pointer; cursor: pointer;
transition: background 0.14s ease, color 0.14s ease;
}
.legend-bar-btn:hover {
background: rgba(255, 255, 255, 0.07);
color: var(--hud-text);
} }
.legend-bar-btn .material-symbols-rounded { .legend-bar-btn .material-symbols-rounded {
font-size: calc(13px * var(--hud-scale));
line-height: 1;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
pointer-events: none;
}
/* Collapse chevron */
#legend-collapse .material-symbols-rounded {
transition: transform 0.22s ease;
}
.legend--collapsed #legend-collapse .material-symbols-rounded {
transform: rotate(180deg);
} }
/* ── Collapsible list body ────────────────────────────────────── */ /* ── Collapsible list body ────────────────────────────────────── */
.legend-body { .legend-body {
max-height: calc(220px * var(--hud-scale)); --hud-body-collapse-gap: calc(4px * var(--hud-scale));
overflow: hidden; --hud-body-max-height: calc(220px * var(--hud-scale));
transition:
max-height 0.26s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.2s ease;
opacity: 1;
}
.legend--collapsed .legend-body {
max-height: 0;
opacity: 0;
pointer-events: none;
} }
/* ── Item list ────────────────────────────────────────────────── */ /* ── Item list ────────────────────────────────────────────────── */

View File

@@ -0,0 +1,211 @@
/* news-panel.css */
.hud-panel-news {
--hud-body-max-height: calc(580px * var(--hud-scale));
top: calc(214px * var(--hud-scale));
right: var(--hud-offset);
width: calc(420px * var(--hud-scale));
min-width: calc(320px * var(--hud-scale));
max-width: calc(100vw - 32px);
padding: calc(12px * var(--hud-scale));
display: flex;
flex-direction: column;
gap: var(--hud-gap-sm);
z-index: 17;
}
.news-panel-header {
align-items: center;
gap: var(--hud-gap-xs);
margin-bottom: 0;
}
.news-panel-header-copy {
flex: 1 1 auto;
min-width: 0;
}
.news-panel-title-row {
display: flex;
align-items: center;
gap: calc(8px * var(--hud-scale));
min-width: 0;
}
.news-panel-title {
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.news-region-chip {
--news-accent: #d6e6ff;
border: 1px solid color-mix(in srgb, var(--news-accent) 46%, transparent);
border-radius: 999px;
padding: calc(3px * var(--hud-scale)) calc(8px * var(--hud-scale));
color: color-mix(in srgb, var(--news-accent) 82%, white);
background: color-mix(in srgb, var(--news-accent) 12%, transparent);
font-size: calc(0.62rem * var(--hud-scale));
letter-spacing: 0.08em;
text-transform: uppercase;
}
.news-panel-subtitle {
color: var(--hud-text-soft);
font-size: calc(0.7rem * var(--hud-scale));
}
.news-panel-body {
display: flex;
flex-direction: column;
gap: var(--hud-gap-sm);
--hud-body-collapse-gap: var(--hud-gap-sm);
}
.news-panel-focus {
display: grid;
grid-template-columns: 1fr auto;
gap: calc(10px * var(--hud-scale));
padding: calc(12px * var(--hud-scale));
border-radius: calc(16px * var(--hud-scale));
background:
radial-gradient(circle at 16% 18%, rgba(123, 205, 255, 0.12), transparent 36%),
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(104, 166, 232, 0.04));
border: 1px solid rgba(205, 231, 255, 0.08);
}
.news-focus-kicker,
.news-board-status {
color: var(--hud-text-soft);
font-size: calc(0.66rem * var(--hud-scale));
letter-spacing: 0.08em;
text-transform: uppercase;
}
.news-focus-label {
margin-top: calc(4px * var(--hud-scale));
color: var(--hud-text);
font-size: calc(1rem * var(--hud-scale));
font-weight: 600;
}
.news-focus-coords {
margin-top: calc(3px * var(--hud-scale));
color: var(--hud-text-muted);
font-size: calc(0.74rem * var(--hud-scale));
}
.news-source-count {
align-self: start;
color: var(--hud-accent-strong);
font-size: calc(0.72rem * var(--hud-scale));
}
.news-board {
display: flex;
flex-direction: column;
gap: var(--hud-gap-sm);
}
.news-board-list {
display: flex;
flex-direction: column;
gap: calc(8px * var(--hud-scale));
max-height: calc(420px * var(--hud-scale));
overflow-y: auto;
padding-right: calc(4px * var(--hud-scale));
scrollbar-width: thin;
scrollbar-color: rgba(160, 220, 255, 0.36) transparent;
}
.news-board-list::-webkit-scrollbar {
width: 6px;
}
.news-board-list::-webkit-scrollbar-track {
background: transparent;
}
.news-board-list::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(210, 237, 255, 0.24), rgba(110, 176, 255, 0.28));
border-radius: 999px;
}
.news-story-card {
display: flex;
flex-direction: column;
gap: calc(8px * var(--hud-scale));
text-decoration: none;
padding: calc(12px * var(--hud-scale));
border-radius: calc(16px * var(--hud-scale));
border: 1px solid rgba(201, 225, 247, 0.08);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(92, 151, 218, 0.03));
transition:
border-color 0.18s ease,
background 0.18s ease,
transform 0.18s ease;
}
.news-story-card:hover {
border-color: rgba(214, 235, 255, 0.16);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(92, 151, 218, 0.06));
transform: translateY(-1px);
}
.news-story-card--focus {
border-color: rgba(127, 219, 255, 0.22);
box-shadow: 0 0 0 1px rgba(122, 214, 255, 0.08) inset;
}
.news-story-meta,
.news-story-tags {
display: flex;
align-items: center;
justify-content: space-between;
gap: calc(8px * var(--hud-scale));
flex-wrap: wrap;
}
.news-story-source,
.news-story-time,
.news-story-tag {
color: var(--hud-text-soft);
font-size: calc(0.66rem * var(--hud-scale));
}
.news-story-source {
color: var(--hud-accent-strong);
}
.news-story-title {
color: var(--hud-text);
font-size: calc(0.9rem * var(--hud-scale));
font-weight: 600;
line-height: 1.4;
}
.news-story-summary {
color: var(--hud-text-muted);
font-size: calc(0.74rem * var(--hud-scale));
line-height: 1.45;
}
.news-story-tag {
border-radius: 999px;
padding: calc(3px * var(--hud-scale)) calc(7px * var(--hud-scale));
background: rgba(255, 255, 255, 0.04);
}
.news-board-empty {
color: var(--hud-text-muted);
font-size: calc(0.82rem * var(--hud-scale));
line-height: 1.5;
padding: calc(16px * var(--hud-scale)) calc(4px * var(--hud-scale));
}
.earth-app.layout-expanded .hud-panel-news:not([data-dragged="true"]) {
transform: translate(calc(100% - var(--hud-offset)), calc(-100% + var(--hud-offset)));
}

View File

@@ -14,15 +14,11 @@
} }
/* header 内嵌 select + actions */ /* header 内嵌 select + actions */
.hud-panel-tv .hud-panel-header { .hud-panel-tv .hud-panel__header {
align-items: center; align-items: center;
gap: var(--hud-gap-xs); gap: var(--hud-gap-xs);
} }
.hud-panel-tv .hud-panel-header .hud-panel-close {
align-self: center;
}
.tv-panel-header-title { .tv-panel-header-title {
flex: 0 0 auto; flex: 0 0 auto;
white-space: nowrap; white-space: nowrap;
@@ -30,8 +26,9 @@
} }
/* select / action 在 drag-handle 内,恢复正常指针 */ /* select / action 在 drag-handle 内,恢复正常指针 */
.hud-panel-tv .hud-panel-header .tv-panel-select, .hud-panel-tv .hud-panel__header .tv-panel-select,
.hud-panel-tv .hud-panel-header .tv-panel-action { .hud-panel-tv .hud-panel__header .hud-panel__action,
.hud-panel-tv .hud-panel__header .hud-panel-close {
cursor: pointer; cursor: pointer;
user-select: auto; user-select: auto;
} }
@@ -63,58 +60,6 @@
color: #eef5fc; color: #eef5fc;
} }
.tv-panel-actions {
display: flex;
gap: var(--hud-gap-xs);
}
.tv-panel-action {
border: 1px solid transparent;
border-radius: calc(4px * var(--hud-scale));
background: transparent;
color: var(--hud-text-muted);
padding: calc(7px * var(--hud-scale));
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition:
background 0.18s ease,
border-color 0.18s ease,
color 0.18s ease,
transform 0.18s ease,
opacity 0.18s ease;
}
.tv-panel-action--icon .material-symbols-rounded {
font-size: calc(16px * var(--hud-scale));
line-height: 1;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
pointer-events: none;
}
.tv-panel-action:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(225, 239, 255, 0.14);
color: var(--hud-accent-strong);
}
.tv-panel-action:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* toggle 箭头:展开时朝下,折叠时朝上 */
.tv-panel-meta-toggle .material-symbols-rounded {
transition: transform 0.22s ease;
transform: rotate(0deg);
}
.tv-panel-meta-toggle.is-collapsed .material-symbols-rounded {
transform: rotate(180deg);
}
/* meta wrap折叠时用负 margin 抵消 flex gap无死区 */
.tv-panel-meta-wrap { .tv-panel-meta-wrap {
overflow: hidden; overflow: hidden;
max-height: calc(120px * var(--hud-scale)); max-height: calc(120px * var(--hud-scale));

View File

@@ -35,6 +35,7 @@
<link rel="stylesheet" href="css/legend.css"> <link rel="stylesheet" href="css/legend.css">
<link rel="stylesheet" href="css/earth-stats.css"> <link rel="stylesheet" href="css/earth-stats.css">
<link rel="stylesheet" href="css/tv-panel.css"> <link rel="stylesheet" href="css/tv-panel.css">
<link rel="stylesheet" href="css/news-panel.css">
<link rel="stylesheet" href="css/layer-panel.css"> <link rel="stylesheet" href="css/layer-panel.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@400;500;600&display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@400;500;600&display=swap">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,500,0,0"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,500,0,0">
@@ -61,7 +62,7 @@
<span class="material-symbols-rounded layer-panel-icon">layers</span> <span class="material-symbols-rounded layer-panel-icon">layers</span>
<span class="layer-panel-title">图层</span> <span class="layer-panel-title">图层</span>
<button id="layer-panel-collapse" class="layer-panel-btn" type="button" aria-label="折叠图层列表" title="折叠"> <button id="layer-panel-collapse" class="layer-panel-btn" type="button" aria-label="折叠图层列表" title="折叠">
<span class="material-symbols-rounded">expand_more</span> <span class="material-symbols-rounded">expand_less</span>
</button> </button>
</div> </div>
@@ -171,6 +172,12 @@
</span> </span>
<span class="tooltip earth-toolbar-tooltip">打开新闻直播</span> <span class="tooltip earth-toolbar-tooltip">打开新闻直播</span>
</button> </button>
<button id="toggle-news" class="floating-btn liquid-glass-surface earth-toolbar-btn active" title="全球态势新闻">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">newspaper</span>
</span>
<span class="tooltip earth-toolbar-tooltip">打开态势新闻</span>
</button>
<button id="reload-data" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重新加载数据"> <button id="reload-data" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重新加载数据">
<span class="icon" aria-hidden="true"> <span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">refresh</span> <span class="material-symbols-rounded">refresh</span>
@@ -217,24 +224,23 @@
<div id="legend" class="hud-panel hud-panel-legend hud-panel-draggable" data-panel-key="legend"> <div id="legend" class="hud-panel hud-panel-legend hud-panel-draggable" data-panel-key="legend">
<!-- Drag bar: mode tabs + collapse + close --> <!-- Drag bar: current mode + collapse + close -->
<div class="legend-bar hud-panel-drag-handle"> <div class="legend-bar hud-panel-drag-handle">
<div class="legend-tabs" id="legend-tabs"> <div class="legend-current" id="legend-current">
<button class="legend-tab legend-tab--active" data-legend-mode="cables">海缆</button> <span class="legend-title">图例</span>
<button class="legend-tab" data-legend-mode="satellites">卫星</button> <span id="legend-current-label" class="legend-current-label">海缆</span>
<button class="legend-tab" data-legend-mode="bgp">BGP</button>
</div> </div>
<div class="legend-bar-actions"> <div class="legend-bar-actions">
<button id="legend-collapse" class="legend-bar-btn" title="折叠"> <button id="legend-collapse" class="legend-bar-btn hud-panel__action hud-panel__action--collapse" title="折叠">
<span class="material-symbols-rounded">expand_less</span> <span class="material-symbols-rounded">expand_less</span>
</button> </button>
<button class="legend-bar-btn hud-panel-close" type="button" data-close-panel="legend" aria-label="关闭图例"> <button class="legend-bar-btn hud-panel__action hud-panel__action--close hud-panel-close" type="button" data-close-panel="legend" aria-label="关闭图例">
<span class="material-symbols-rounded">close</span> <span class="material-symbols-rounded">close</span>
</button> </button>
</div> </div>
</div> </div>
<!-- Collapsible list --> <!-- Collapsible list -->
<div id="legend-body" class="legend-body"> <div id="legend-body" class="legend-body hud-panel__body hud-panel__body--collapsible">
<div class="legend-list"></div> <div class="legend-list"></div>
</div> </div>
</div> </div>
@@ -289,23 +295,25 @@
</div> </div>
<div id="tv-panel" class="hud-panel hud-panel-tv hud-panel-draggable" data-panel-key="tv-panel" data-drag-self="true"> <div id="tv-panel" class="hud-panel hud-panel-tv hud-panel-draggable" data-panel-key="tv-panel" data-drag-self="true">
<div class="hud-panel-header hud-panel-drag-handle"> <div class="hud-panel__header hud-panel-drag-handle">
<span class="hud-panel-title tv-panel-header-title">新闻直播</span> <div class="hud-panel__title-group">
<span class="hud-panel-title hud-panel__title tv-panel-header-title">新闻直播</span>
</div>
<select id="tv-source-select" class="tv-panel-select" aria-label="选择新闻直播源"></select> <select id="tv-source-select" class="tv-panel-select" aria-label="选择新闻直播源"></select>
<div class="tv-panel-actions"> <div class="hud-panel__actions">
<button id="tv-refresh" class="tv-panel-action tv-panel-action--icon" type="button" title="刷新直播源" aria-label="刷新直播源"> <button id="tv-refresh" class="hud-panel__action hud-panel__action--refresh" type="button" title="刷新直播源" aria-label="刷新直播源">
<span class="material-symbols-rounded">refresh</span> <span class="material-symbols-rounded">refresh</span>
</button> </button>
<button id="tv-open-external" class="tv-panel-action tv-panel-action--icon" type="button" title="访问官网" aria-label="访问官网"> <button id="tv-open-external" class="hud-panel__action hud-panel__action--external" type="button" title="访问官网" aria-label="访问官网">
<span class="material-symbols-rounded">open_in_new</span> <span class="material-symbols-rounded">open_in_new</span>
</button> </button>
<button id="tv-meta-toggle" class="tv-panel-action tv-panel-action--icon tv-panel-meta-toggle" type="button" title="频道信息" aria-label="频道信息"> <button id="tv-meta-toggle" class="hud-panel__action hud-panel__action--collapse tv-panel-meta-toggle" type="button" title="折叠新闻直播内容" aria-label="折叠新闻直播内容">
<span class="material-symbols-rounded">expand_more</span> <span class="material-symbols-rounded">expand_less</span>
</button>
<button class="hud-panel-close hud-panel__action hud-panel__action--close" type="button" data-close-panel="tv-panel" aria-label="关闭电视直播">
<span class="material-symbols-rounded">close</span>
</button> </button>
</div> </div>
<button class="hud-panel-close" type="button" data-close-panel="tv-panel" aria-label="关闭电视直播">
<span class="material-symbols-rounded">close</span>
</button>
</div> </div>
<div class="tv-panel-meta-wrap" id="tv-meta-wrap"> <div class="tv-panel-meta-wrap" id="tv-meta-wrap">
<div class="tv-panel-meta" id="tv-panel-meta"> <div class="tv-panel-meta" id="tv-panel-meta">
@@ -335,6 +343,51 @@
<div class="tv-panel-edge" data-edge="bl"></div> <div class="tv-panel-edge" data-edge="bl"></div>
</div> </div>
<div id="news-panel" class="hud-panel hud-panel-news hud-panel-draggable hud-panel--expand-down" data-panel-key="news-panel">
<div class="hud-panel__header hud-panel-drag-handle">
<div class="news-panel-header-copy hud-panel__title-group">
<div class="news-panel-title-row">
<span class="hud-panel-title hud-panel__title news-panel-title">全球态势聚合</span>
<span id="news-region-chip" class="news-region-chip hud-panel__chip">global</span>
</div>
</div>
<div class="hud-panel__actions">
<button id="news-refresh" class="hud-panel__action hud-panel__action--refresh" type="button" title="刷新新闻源" aria-label="刷新新闻源">
<span class="material-symbols-rounded">refresh</span>
</button>
<button id="news-open-external" class="hud-panel__action hud-panel__action--external" type="button" title="打开源站" aria-label="打开源站">
<span class="material-symbols-rounded">open_in_new</span>
</button>
<button id="news-collapse" class="hud-panel__action hud-panel__action--collapse news-panel-collapse" type="button" title="折叠新闻面板" aria-label="折叠新闻面板">
<span class="material-symbols-rounded">expand_less</span>
</button>
<button class="hud-panel__action hud-panel__action--close hud-panel-close" type="button" data-close-panel="news-panel" aria-label="关闭态势新闻">
<span class="material-symbols-rounded">close</span>
</button>
</div>
</div>
<div id="news-panel-body" class="news-panel-body hud-panel__body hud-panel__body--collapsible">
<div class="news-panel-subtitle">跟随地球正面视角自动切换区域新闻</div>
<div class="news-panel-focus">
<div>
<div class="news-focus-kicker">当前关注区域</div>
<div id="news-focus-label" class="news-focus-label">全球焦点</div>
<div id="news-focus-coords" class="news-focus-coords">跟随当前视角自动聚焦</div>
</div>
<div id="news-source-count" class="news-source-count">0 路聚合源</div>
</div>
<div class="news-board">
<div id="news-board-status" class="news-board-status">正在准备全球态势新闻...</div>
<div id="news-board-list" class="news-board-list"></div>
<div id="news-board-empty" class="news-board-empty" hidden>正在准备全球态势新闻聚合源...</div>
</div>
</div>
<a id="news-feed-anchor" hidden rel="noreferrer noopener" target="_blank"></a>
</div>
<div id="loading" class="earth-loading"> <div id="loading" class="earth-loading">
<div id="loading-spinner" class="earth-loading-spinner"></div> <div id="loading-spinner" class="earth-loading-spinner"></div>
<div id="loading-title" class="earth-loading-title earth-loading-text">正在初始化全球态势数据...</div> <div id="loading-title" class="earth-loading-title earth-loading-text">正在初始化全球态势数据...</div>
@@ -398,6 +451,16 @@
<span class="earth-settings-switch-track"></span> <span class="earth-settings-switch-track"></span>
</span> </span>
</label> </label>
<label class="earth-settings-item" for="toggle-view-news">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">态势新闻</span>
<span class="earth-settings-item-subtitle">控制跟随视角切换的全球态势 RSS 看板显示</span>
</div>
<span class="earth-settings-switch">
<input id="toggle-view-news" type="checkbox" data-settings-panel="news-panel" checked>
<span class="earth-settings-switch-track"></span>
</span>
</label>
</div> </div>
</section> </section>
</div> </div>

View File

@@ -18,6 +18,12 @@ import {
import { getShowCables } from "./cables.js"; import { getShowCables } from "./cables.js";
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js"; import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
import { ensureTVPanelReady } from "./tv.js"; import { ensureTVPanelReady } from "./tv.js";
import { createHUDPanel } from "./hud-panels.js";
import {
ensureNewsPanelReady,
setNewsPanelVisible,
updateNewsToggleUI,
} from "./news.js";
export let autoRotate = true; export let autoRotate = true;
export let zoomLevel = 1.0; export let zoomLevel = 1.0;
@@ -31,6 +37,7 @@ const HUD_PANEL_IDS = [
"legend", "legend",
"earth-stats", "earth-stats",
"tv-panel", "tv-panel",
"news-panel",
"layer-toggles", "layer-toggles",
]; ];
const DRAGGABLE_PANEL_SELECTOR = ".hud-panel-draggable"; const DRAGGABLE_PANEL_SELECTOR = ".hud-panel-draggable";
@@ -97,6 +104,14 @@ function setHudPanelVisibility(panelId, visible) {
}); });
} }
} }
if (panelId === "news-panel") {
updateNewsToggleUI(visible);
if (visible) {
ensureNewsPanelReady().catch((error) => {
console.error("初始化态势新闻面板失败:", error);
});
}
}
} }
function syncSettingsToggle(panelId, visible) { function syncSettingsToggle(panelId, visible) {
@@ -230,7 +245,7 @@ function setupDraggableHudPanels() {
}; };
bindListener(handle, "pointerdown", (event) => { bindListener(handle, "pointerdown", (event) => {
if (event.target.closest(".hud-panel-close, .layer-panel-btn, .info-card-close, .tv-panel-select, .tv-panel-action, .tv-panel-player, .tv-panel-edge, .legend-bar-btn")) return; if (event.target.closest(".hud-panel-close, .hud-panel__action, .layer-panel-btn, .info-card-close, .tv-panel-select, .tv-panel-player, .tv-panel-edge, .legend-bar-btn, .news-story-card")) return;
isDragging = true; isDragging = true;
startPointerX = event.clientX; startPointerX = event.clientX;
startPointerY = event.clientY; startPointerY = event.clientY;
@@ -617,13 +632,20 @@ function setupLayerPanel() {
const emptyState = document.getElementById("layer-panel-empty"); const emptyState = document.getElementById("layer-panel-empty");
if (!panel) return; if (!panel) return;
const layerPanel = createHUDPanel({
panel,
header: ".layer-panel-header",
body: "#layer-panel-body",
collapseBtn,
collapsedClass: "layer-panel--collapsed",
preferredDirection: "down",
expandLabel: "展开图层列表",
collapseLabel: "折叠图层列表",
});
bindListener(collapseBtn, "click", (e) => { bindListener(collapseBtn, "click", (e) => {
e.stopPropagation(); e.stopPropagation();
const isCollapsed = panel.classList.toggle("layer-panel--collapsed"); layerPanel.setCollapsed(!layerPanel.isCollapsed());
collapseBtn.title = isCollapsed ? "展开" : "折叠";
collapseBtn.setAttribute("aria-label", isCollapsed ? "展开图层列表" : "折叠图层列表");
const icon = collapseBtn.querySelector(".material-symbols-rounded");
if (icon) icon.textContent = isCollapsed ? "expand_less" : "expand_more";
}); });
if (searchInput) { if (searchInput) {
@@ -801,6 +823,14 @@ function setupTerrainControls() {
console.error("初始化电视直播面板失败:", error); console.error("初始化电视直播面板失败:", error);
}); });
} }
const newsVisible = !document.getElementById("news-panel")?.classList.contains("hud-panel-hidden");
updateNewsToggleUI(newsVisible);
setNewsPanelVisible(newsVisible);
if (newsVisible) {
ensureNewsPanelReady().catch((error) => {
console.error("初始化态势新闻面板失败:", error);
});
}
updateLayoutUI(container); updateLayoutUI(container);
} }

View File

@@ -0,0 +1,233 @@
const DEFAULT_COLLAPSED_CLASS = "hud-panel--collapsed";
const DEFAULT_HIDDEN_CLASS = "hud-panel-hidden";
function getHudScale() {
const rootStyle = getComputedStyle(document.documentElement);
const scale = parseFloat(rootStyle.getPropertyValue("--hud-scale"));
return Number.isFinite(scale) && scale > 0 ? scale : 1;
}
function getEdgeFlipThresholdPx() {
const rootStyle = getComputedStyle(document.documentElement);
const hudOffset = parseFloat(rootStyle.getPropertyValue("--hud-offset"));
if (Number.isFinite(hudOffset) && hudOffset > 0) {
return hudOffset;
}
return 20 * getHudScale();
}
function clampExpandDirection(direction) {
return direction === "up" ? "up" : "down";
}
function resolveElement(target, root = document) {
if (!target) return null;
if (target instanceof HTMLElement) return target;
if (typeof target === "string") {
return root.querySelector(target);
}
return null;
}
function pickExpandDirection({
panelRect,
preferredDirection,
}) {
const spaceBelow = Math.max(0, window.innerHeight - panelRect.bottom);
const edgeFlipThresholdPx = getEdgeFlipThresholdPx();
const preferred = clampExpandDirection(preferredDirection);
// Pure edge-threshold contract:
// - d < threshold => "up" family
// - d >= threshold => "down" family
// Do not pre-flip early based on expanded height.
if (spaceBelow <= edgeFlipThresholdPx) return "up";
if (spaceBelow > edgeFlipThresholdPx) return "down";
return preferred;
}
function getCollapseButtonState({ collapsed, direction, expandLabel, collapseLabel }) {
// The arrow always describes the next action and must stay aligned with the
// real expansion direction chosen by the controller. Panels should not add
// their own extra CSS rotation on top of this mapping.
if (collapsed) {
return {
title: expandLabel,
icon: direction === "up" ? "expand_less" : "expand_more",
};
}
return {
title: collapseLabel,
icon: direction === "up" ? "expand_more" : "expand_less",
};
}
export function createHUDPanel({
panel,
header,
body,
collapseBtn,
bodyCollapsedClass = "",
preferredDirection = "down",
collapsedClass = DEFAULT_COLLAPSED_CLASS,
hiddenClass = DEFAULT_HIDDEN_CLASS,
expandLabel = "展开",
collapseLabel = "折叠",
}) {
const panelEl = resolveElement(panel);
const headerEl = resolveElement(header, panelEl ?? document);
const bodyEl = resolveElement(body, panelEl ?? document);
const collapseBtnEl = resolveElement(collapseBtn, panelEl ?? document);
if (!(panelEl instanceof HTMLElement) || !(headerEl instanceof HTMLElement) || !(bodyEl instanceof HTMLElement)) {
return {
panel: panelEl,
header: headerEl,
body: bodyEl,
collapseBtn: collapseBtnEl,
setCollapsed() {},
setVisible() {},
syncLayout() {},
destroy() {},
isCollapsed() {
return false;
},
isVisible() {
return false;
},
getExpandDirection() {
return clampExpandDirection(preferredDirection);
},
};
}
let currentDirection = clampExpandDirection(preferredDirection);
const shouldAnchorBottomDuringToggle = () =>
panelEl.dataset.dragged === "true" && typeof panelEl.style.top === "string" && panelEl.style.top !== "";
const compensateTopForBottomAnchor = (beforeBottom) => {
if (!shouldAnchorBottomDuringToggle()) return;
const afterBottom = panelEl.getBoundingClientRect().bottom;
const delta = afterBottom - beforeBottom;
if (delta === 0) return;
panelEl.style.top = `${parseFloat(panelEl.style.top) - delta}px`;
};
const syncDirection = () => {
const expandedHeight = Math.max(bodyEl.scrollHeight, bodyEl.getBoundingClientRect().height);
const nextDirection = pickExpandDirection({
panelRect: panelEl.getBoundingClientRect(),
preferredDirection,
});
currentDirection = nextDirection;
panelEl.classList.toggle("hud-panel--expand-up", nextDirection === "up");
panelEl.classList.toggle("hud-panel--expand-down", nextDirection !== "up");
panelEl.dataset.expandDirection = nextDirection;
};
const syncButton = () => {
if (!(collapseBtnEl instanceof HTMLElement)) return;
const iconEl = collapseBtnEl.querySelector(".material-symbols-rounded");
const { title, icon } = getCollapseButtonState({
collapsed: panelEl.classList.contains(collapsedClass),
direction: currentDirection,
expandLabel,
collapseLabel,
});
collapseBtnEl.title = title;
collapseBtnEl.setAttribute("aria-label", title);
collapseBtnEl.dataset.expandDirection = currentDirection;
if (iconEl) {
iconEl.textContent = icon;
}
};
const syncLayout = () => {
syncDirection();
syncButton();
};
const setCollapsed = (collapsed) => {
syncDirection();
const nextCollapsed = Boolean(collapsed);
const shouldCompensate = currentDirection === "up" && shouldAnchorBottomDuringToggle();
const bottomBefore = shouldCompensate ? panelEl.getBoundingClientRect().bottom : 0;
if (shouldCompensate) {
bodyEl.style.transition = "none";
}
panelEl.classList.toggle(collapsedClass, nextCollapsed);
if (bodyCollapsedClass) {
bodyEl.classList.toggle(bodyCollapsedClass, nextCollapsed);
}
if (shouldCompensate) {
void panelEl.offsetHeight;
compensateTopForBottomAnchor(bottomBefore);
requestAnimationFrame(() => {
bodyEl.style.transition = "";
});
}
syncButton();
};
const setVisible = (visible) => {
panelEl.classList.toggle(hiddenClass, !visible);
if (visible) {
syncLayout();
}
};
const handleViewportChange = () => {
if (!panelEl.classList.contains(hiddenClass)) {
syncLayout();
}
};
const handlePointerMove = () => {
if (!panelEl.classList.contains(hiddenClass) && panelEl.classList.contains("is-dragging")) {
syncLayout();
}
};
window.addEventListener("resize", handleViewportChange);
document.addEventListener("pointerup", handleViewportChange);
document.addEventListener("pointermove", handlePointerMove);
syncLayout();
requestAnimationFrame(syncLayout);
return {
panel: panelEl,
header: headerEl,
body: bodyEl,
collapseBtn: collapseBtnEl,
setCollapsed,
setVisible,
syncLayout,
destroy() {
window.removeEventListener("resize", handleViewportChange);
document.removeEventListener("pointerup", handleViewportChange);
document.removeEventListener("pointermove", handlePointerMove);
},
isCollapsed() {
return panelEl.classList.contains(collapsedClass);
},
isVisible() {
return !panelEl.classList.contains(hiddenClass);
},
getExpandDirection() {
return currentDirection;
},
};
}
export function setupCollapsibleHudPanel(options) {
return createHUDPanel(options);
}

View File

@@ -1,3 +1,5 @@
import { createHUDPanel } from "./hud-panels.js";
const LEGEND_MODES = { const LEGEND_MODES = {
cables: { title: "海缆" }, cables: { title: "海缆" },
satellites: { title: "卫星" }, satellites: { title: "卫星" },
@@ -5,6 +7,7 @@ const LEGEND_MODES = {
}; };
let currentLegendMode = "cables"; let currentLegendMode = "cables";
let legendPanel = null;
let legendItemsByMode = { let legendItemsByMode = {
cables: [], cables: [],
satellites: [], satellites: [],
@@ -12,34 +15,33 @@ let legendItemsByMode = {
}; };
export function initLegend() { export function initLegend() {
// Tab click → switch mode
const tabsEl = document.getElementById("legend-tabs");
if (tabsEl) {
tabsEl.addEventListener("click", (e) => {
const btn = e.target.closest(".legend-tab");
if (!btn) return;
const mode = btn.dataset.legendMode;
if (mode) setLegendMode(mode);
});
}
// Collapse toggle
const collapseBtn = document.getElementById("legend-collapse"); const collapseBtn = document.getElementById("legend-collapse");
const legend = document.getElementById("legend"); const legend = document.getElementById("legend");
if (collapseBtn && legend) { if (collapseBtn && legend) {
legendPanel = createHUDPanel({
panel: legend,
header: ".legend-bar",
body: "#legend-body",
collapseBtn,
preferredDirection: "down",
expandLabel: "展开图例",
collapseLabel: "折叠图例",
});
collapseBtn.addEventListener("click", (e) => { collapseBtn.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
legend.classList.toggle("legend--collapsed"); legendPanel?.setCollapsed(!(legendPanel?.isCollapsed() ?? false));
}); });
} }
syncCurrentLabel(currentLegendMode);
renderLegend(currentLegendMode); renderLegend(currentLegendMode);
} }
export function setLegendMode(mode) { export function setLegendMode(mode) {
const nextMode = LEGEND_MODES[mode] ? mode : "cables"; const nextMode = LEGEND_MODES[mode] ? mode : "cables";
currentLegendMode = nextMode; currentLegendMode = nextMode;
syncTabs(nextMode); syncCurrentLabel(nextMode);
renderLegend(nextMode); renderLegend(nextMode);
} }
@@ -59,11 +61,10 @@ export function setLegendItems(mode, items) {
} }
} }
function syncTabs(mode) { function syncCurrentLabel(mode) {
const tabs = document.querySelectorAll("#legend-tabs .legend-tab"); const labelEl = document.getElementById("legend-current-label");
tabs.forEach((tab) => { if (!labelEl) return;
tab.classList.toggle("legend-tab--active", tab.dataset.legendMode === mode); labelEl.textContent = LEGEND_MODES[mode]?.title || LEGEND_MODES.cables.title;
});
} }
function renderLegend(mode) { function renderLegend(mode) {

View File

@@ -127,6 +127,7 @@ import {
} from "./legend.js"; } from "./legend.js";
import { mountBrand } from "./brand.js"; import { mountBrand } from "./brand.js";
import { initTVPanel } from "./tv.js"; import { initTVPanel } from "./tv.js";
import { initNewsPanel, updateNewsViewFocus } from "./news.js";
export let scene; export let scene;
export let camera; export let camera;
@@ -173,6 +174,7 @@ const scratchCableCenter = new THREE.Vector3();
const scratchCableDirection = new THREE.Vector3(); const scratchCableDirection = new THREE.Vector3();
const scratchBGPDirection = new THREE.Vector3(); const scratchBGPDirection = new THREE.Vector3();
const scratchBGPWorldPosition = new THREE.Vector3(); const scratchBGPWorldPosition = new THREE.Vector3();
const scratchViewCenterWorld = new THREE.Vector3();
const cleanupFns = []; const cleanupFns = [];
const DRAG_SMOOTHING_FACTOR = 0.18; const DRAG_SMOOTHING_FACTOR = 0.18;
@@ -194,6 +196,8 @@ const HUD_INTERACTIVE_SELECTORS = [
"#earth-stats *", "#earth-stats *",
"#tv-panel", "#tv-panel",
"#tv-panel *", "#tv-panel *",
"#news-panel",
"#news-panel *",
]; ];
function bindListener(target, eventName, handler, options) { function bindListener(target, eventName, handler, options) {
@@ -871,6 +875,20 @@ function updateStatsSummary() {
}); });
} }
function getCurrentViewCenterCoords() {
const earth = getEarth();
if (!earth || !camera) return null;
scratchViewCenterWorld
.copy(camera.position)
.sub(earth.position)
.normalize()
.multiplyScalar(CONFIG.earthRadius);
earth.worldToLocal(scratchViewCenterWorld);
return vector3ToLatLon(scratchViewCenterWorld);
}
window.addEventListener("error", (event) => { window.addEventListener("error", (event) => {
console.error("全局错误:", event.error); console.error("全局错误:", event.error);
}); });
@@ -889,6 +907,7 @@ export function init() {
const brandRoot = document.getElementById("brand-root"); const brandRoot = document.getElementById("brand-root");
mountBrand(brandRoot, HUD_CONFIG.brandLanguage); mountBrand(brandRoot, HUD_CONFIG.brandLanguage);
initTVPanel(); initTVPanel();
initNewsPanel();
scene = new THREE.Scene(); scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera( camera = new THREE.PerspectiveCamera(
@@ -1693,6 +1712,7 @@ function animate() {
updateSatellitePositions(deltaTime); updateSatellitePositions(deltaTime);
updateBreathingPhase(deltaTime); updateBreathingPhase(deltaTime);
updateRelatedSatelliteHighlights(); updateRelatedSatelliteHighlights();
updateNewsViewFocus(getCurrentViewCenterCoords());
const satPositions = getSatellitePositions(); const satPositions = getSatellitePositions();
if ( if (

View File

@@ -0,0 +1,350 @@
import { showStatusMessage } from "./ui.js";
import { createHUDPanel } from "./hud-panels.js";
const EARTH_NEWS_API = "/api/v1/news/earth-feed";
const FOCUS_UPDATE_INTERVAL_MS = 4000;
const DATA_REFRESH_INTERVAL_MS = 180000;
const MIN_REGION_SWITCH_INTERVAL_MS = 2500;
const REQUEST_TIMEOUT_MS = 15000;
let initialized = false;
let refreshPromise = null;
let payload = null;
let lastFocus = null;
let lastFetchAt = 0;
let lastRegionSwitchAt = 0;
let newsPanel = null;
function getElements() {
return {
panel: document.getElementById("news-panel"),
toggleBtn: document.getElementById("toggle-news"),
refreshBtn: document.getElementById("news-refresh"),
openBtn: document.getElementById("news-open-external"),
collapseBtn: document.getElementById("news-collapse"),
status: document.getElementById("news-board-status"),
focusLabel: document.getElementById("news-focus-label"),
focusCoords: document.getElementById("news-focus-coords"),
sourceCount: document.getElementById("news-source-count"),
regionChip: document.getElementById("news-region-chip"),
board: document.getElementById("news-board-list"),
empty: document.getElementById("news-board-empty"),
feedAnchor: document.getElementById("news-feed-anchor"),
};
}
function formatCoord(value, positiveLabel, negativeLabel) {
const abs = Math.abs(value).toFixed(1);
return `${abs}°${value >= 0 ? positiveLabel : negativeLabel}`;
}
function formatRelativeTime(raw) {
if (!raw) return "刚刚同步";
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return "刚刚同步";
const diff = Date.now() - date.getTime();
const minutes = Math.max(1, Math.round(diff / 60000));
if (minutes < 60) return `${minutes} 分钟前`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours} 小时前`;
const days = Math.round(hours / 24);
return `${days} 天前`;
}
export function updateNewsToggleUI(visible) {
const { toggleBtn } = getElements();
if (!toggleBtn) return;
toggleBtn.classList.toggle("active", visible);
const tooltip = toggleBtn.querySelector(".earth-toolbar-tooltip");
if (tooltip) {
tooltip.textContent = visible ? "关闭态势新闻" : "打开态势新闻";
}
}
function syncSettingsToggle(visible) {
const input = document.querySelector('[data-settings-panel="news-panel"]');
if (input instanceof HTMLInputElement) {
input.checked = visible;
}
}
export function setNewsPanelVisible(visible) {
const { panel } = getElements();
if (!panel) return;
newsPanel?.setVisible(visible);
updateNewsToggleUI(visible);
syncSettingsToggle(visible);
}
function renderEmptyState(message) {
const { board, empty, status, openBtn } = getElements();
if (board) board.innerHTML = "";
if (empty) {
empty.hidden = false;
empty.textContent = message;
}
if (status) {
status.textContent = "等待聚合新闻源";
}
if (openBtn) openBtn.disabled = true;
}
function renderPayload(nextPayload) {
payload = nextPayload;
const {
board,
empty,
status,
focusLabel,
focusCoords,
sourceCount,
regionChip,
openBtn,
feedAnchor,
} = getElements();
if (!board || !status || !focusLabel || !focusCoords || !sourceCount || !regionChip) {
return;
}
const items = Array.isArray(nextPayload?.items) ? nextPayload.items : [];
const sources = Array.isArray(nextPayload?.sources) ? nextPayload.sources : [];
const focus = nextPayload?.focus || {};
focusLabel.textContent = focus.label || "全球焦点";
regionChip.textContent = focus.region || "global";
regionChip.style.setProperty("--news-accent", focus.accent || "#d6e6ff");
if (typeof focus.lat === "number" && typeof focus.lon === "number") {
focusCoords.textContent = `${formatCoord(focus.lat, "N", "S")} · ${formatCoord(focus.lon, "E", "W")}`;
} else {
focusCoords.textContent = "跟随当前视角自动聚焦";
}
sourceCount.textContent = `${sources.length} 路聚合源`;
if (nextPayload?.stale) {
status.textContent = `当前显示最近一次可用新闻缓存,共 ${items.length}`;
} else {
status.textContent = nextPayload?.errors?.length
? `已聚合 ${items.length} 条,部分源不可用`
: `已聚合 ${items.length} 条态势新闻`;
}
if (feedAnchor) {
const matchedSource = sources.find((source) => source.region === focus.region) || sources[0];
feedAnchor.href = matchedSource?.homepage_url || "https://news.google.com/";
}
if (openBtn) {
openBtn.disabled = !feedAnchor?.href;
}
if (items.length === 0) {
board.innerHTML = "";
if (empty) {
empty.hidden = false;
empty.textContent = "当前未拉到可用新闻,请稍后刷新或切换视角区域。";
}
return;
}
if (empty) empty.hidden = true;
board.innerHTML = items
.map((item) => {
const cardClass = item.is_focus_match
? "news-story-card news-story-card--focus"
: "news-story-card";
const summary = item.summary
? `<div class="news-story-summary">${item.summary}</div>`
: "";
return `
<a class="${cardClass}" href="${item.url}" target="_blank" rel="noreferrer noopener">
<div class="news-story-meta">
<span class="news-story-source">${item.source}</span>
<span class="news-story-time">${formatRelativeTime(item.published_at)}</span>
</div>
<div class="news-story-title">${item.title}</div>
${summary}
<div class="news-story-tags">
<span class="news-story-tag">${item.region}</span>
<span class="news-story-tag">${item.feed_name}</span>
</div>
</a>
`;
})
.join("");
}
async function fetchNews(lat, lon) {
const url = new URL(EARTH_NEWS_API, window.location.origin);
if (typeof lat === "number") url.searchParams.set("lat", lat.toFixed(4));
if (typeof lon === "number") url.searchParams.set("lon", lon.toFixed(4));
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const response = await fetch(url.toString(), {
cache: "no-store",
signal: controller.signal,
}).finally(() => {
window.clearTimeout(timeoutId);
});
if (!response.ok) {
throw new Error(`新闻源请求失败: ${response.status}`);
}
return response.json();
}
async function refreshNews(lat, lon, { silent = false } = {}) {
if (refreshPromise) return refreshPromise;
const { status } = getElements();
if (status) {
status.textContent = "正在同步全球态势新闻...";
}
refreshPromise = fetchNews(lat, lon)
.then((nextPayload) => {
renderPayload(nextPayload);
lastFetchAt = Date.now();
if (Array.isArray(nextPayload?.items) && nextPayload.items.length === 0) {
const { status } = getElements();
if (status) {
status.textContent = "当前区域暂无可用新闻,已完成一次聚合尝试";
}
}
return nextPayload;
})
.catch((error) => {
console.error("加载 Earth RSS 新闻失败:", error);
const message = error?.name === "AbortError"
? "新闻聚合请求超时,请稍后重试"
: `新闻聚合暂时不可用: ${error?.message || "未知错误"}`;
if (!payload) {
renderEmptyState(message);
} else if (!silent) {
showStatusMessage("态势新闻同步失败", "error");
}
throw error;
})
.finally(() => {
refreshPromise = null;
});
return refreshPromise;
}
function shouldRefreshForFocus(lat, lon, region) {
const now = Date.now();
if (!lastFocus) return true;
if (region !== lastFocus.region && now - lastRegionSwitchAt > MIN_REGION_SWITCH_INTERVAL_MS) {
lastRegionSwitchAt = now;
return true;
}
if (now - lastFetchAt > DATA_REFRESH_INTERVAL_MS) return true;
if (now - (lastFocus.updatedAt || 0) < FOCUS_UPDATE_INTERVAL_MS) return false;
const latDrift = Math.abs((lat || 0) - (lastFocus.lat || 0));
const lonDrift = Math.abs((lon || 0) - (lastFocus.lon || 0));
return latDrift >= 18 || lonDrift >= 25;
}
function inferRegion(lat, lon) {
if (typeof lat !== "number" || typeof lon !== "number") return "global";
if (lon >= -170 && lon <= -30) return "americas";
if (lon > -30 && lon <= 45) return lat >= 30 ? "europe" : "middle-east-africa";
if (lon > 45 && lon <= 150) return lat < 10 ? "middle-east-africa" : "asia-pacific";
return "asia-pacific";
}
function openCurrentSourceHomepage() {
const { feedAnchor } = getElements();
if (feedAnchor?.href) {
window.open(feedAnchor.href, "_blank", "noopener,noreferrer");
}
}
function setCollapsed(collapsed) {
newsPanel?.setCollapsed(collapsed);
}
export function updateNewsViewFocus(coords) {
if (!initialized) return;
if (!coords || typeof coords.lat !== "number" || typeof coords.lon !== "number") return;
const region = inferRegion(coords.lat, coords.lon);
const nextFocus = {
lat: coords.lat,
lon: coords.lon,
region,
updatedAt: Date.now(),
};
const shouldRefresh = shouldRefreshForFocus(coords.lat, coords.lon, region);
lastFocus = nextFocus;
if (shouldRefresh) {
refreshNews(coords.lat, coords.lon, { silent: true }).catch(() => {});
}
}
export async function ensureNewsPanelReady() {
if (!initialized) {
initNewsPanel();
}
if (!payload) {
await refreshNews(lastFocus?.lat, lastFocus?.lon);
}
return payload;
}
export function initNewsPanel() {
if (initialized) return;
initialized = true;
const { panel, toggleBtn, refreshBtn, openBtn, collapseBtn } = getElements();
if (panel && collapseBtn) {
newsPanel = createHUDPanel({
panel,
header: ".hud-panel__header",
body: "#news-panel-body",
collapseBtn,
preferredDirection: "down",
expandLabel: "展开新闻面板",
collapseLabel: "折叠新闻面板",
});
}
updateNewsToggleUI(true);
syncSettingsToggle(true);
renderEmptyState("正在准备全球态势新闻聚合源...");
toggleBtn?.addEventListener("click", async () => {
const nextVisible = !(newsPanel?.isVisible() ?? false);
setNewsPanelVisible(nextVisible);
if (nextVisible) {
try {
await ensureNewsPanelReady();
} catch {
// surface already handled
}
}
});
refreshBtn?.addEventListener("click", async () => {
try {
await refreshNews(lastFocus?.lat, lastFocus?.lon);
showStatusMessage("态势新闻已刷新", "info");
} catch {
showStatusMessage("态势新闻刷新失败", "error");
}
});
openBtn?.addEventListener("click", openCurrentSourceHomepage);
collapseBtn?.addEventListener("click", (event) => {
event.stopPropagation();
setCollapsed(!newsPanel?.isCollapsed());
});
refreshNews(undefined, undefined, { silent: true }).catch(() => {});
}

View File

@@ -1,5 +1,6 @@
import Hls from "hls.js"; import Hls from "hls.js";
import { showStatusMessage } from "./ui.js"; import { showStatusMessage } from "./ui.js";
import { createHUDPanel } from "./hud-panels.js";
const TV_STREAMS_API = "/api/v1/tv/streams"; const TV_STREAMS_API = "/api/v1/tv/streams";
const TV_PROXY_API = "/api/v1/tv/proxy"; const TV_PROXY_API = "/api/v1/tv/proxy";
@@ -21,8 +22,12 @@ let refreshPromise = null;
let hlsPlayer = null; let hlsPlayer = null;
let hlsRecoveryAttempts = 0; let hlsRecoveryAttempts = 0;
let metaAutoCollapseTimer = null; let metaAutoCollapseTimer = null;
let tvPanel = null;
const failedSourceIds = new Set();
let probeTimer = null;
const META_AUTO_COLLAPSE_DELAY = 2500; const META_AUTO_COLLAPSE_DELAY = 2500;
const PROBE_INTERVAL_MS = 2 * 60 * 1000;
const HLS_MAX_RECOVERY_ATTEMPTS = 3; const HLS_MAX_RECOVERY_ATTEMPTS = 3;
const HLS_RETRY_CONFIG = { const HLS_RETRY_CONFIG = {
@@ -55,36 +60,7 @@ function getElements() {
} }
function setMetaCollapsed(collapsed) { function setMetaCollapsed(collapsed) {
const { metaWrap, metaToggle, panel } = getElements(); tvPanel?.setCollapsed(collapsed);
if (!metaWrap) return;
const isDragged = panel?.dataset.dragged === "true" && panel.style.top;
if (isDragged) {
// Top-anchored panel: toggle instantly and compensate top so the player
// (panel bottom) stays visually fixed.
const bottomBefore = panel.getBoundingClientRect().bottom;
metaWrap.style.transition = "none";
metaWrap.classList.toggle("is-collapsed", collapsed);
metaToggle?.classList.toggle("is-collapsed", collapsed);
// Force synchronous reflow to get updated panel height
void panel.offsetHeight;
const delta = panel.getBoundingClientRect().bottom - bottomBefore;
if (delta !== 0) {
panel.style.top = `${parseFloat(panel.style.top) - delta}px`;
}
// Restore CSS transition after this paint
requestAnimationFrame(() => {
metaWrap.style.transition = "";
});
} else {
metaWrap.classList.toggle("is-collapsed", collapsed);
metaToggle?.classList.toggle("is-collapsed", collapsed);
}
} }
function autoExpandMeta() { function autoExpandMeta() {
@@ -206,7 +182,7 @@ function syncSettingsToggle(visible) {
function setPanelVisible(visible) { function setPanelVisible(visible) {
const { panel } = getElements(); const { panel } = getElements();
if (!panel) return; if (!panel) return;
panel.classList.toggle("hud-panel-hidden", !visible); tvPanel?.setVisible(visible);
updateToggleButton(visible); updateToggleButton(visible);
syncSettingsToggle(visible); syncSettingsToggle(visible);
} }
@@ -392,7 +368,7 @@ function attachVideoSource(video, source) {
} }
} }
if (!showEmbeddedFallback(source)) { if (!showEmbeddedFallback(source) && !tryFallbackSource()) {
setPanelMessage(TV_STATUS_MESSAGE.videoError); setPanelMessage(TV_STATUS_MESSAGE.videoError);
} }
}); });
@@ -425,6 +401,59 @@ function findSourceById(sourceId) {
return tvPayload?.sources?.find((source) => source.id === sourceId) || null; return tvPayload?.sources?.find((source) => source.id === sourceId) || null;
} }
function markSourceFailed(sourceId) {
if (!sourceId) return;
failedSourceIds.add(sourceId);
renderSourceOptions();
if (!probeTimer) {
probeTimer = setInterval(probeFailedSources, PROBE_INTERVAL_MS);
}
}
function clearSourceFailed(sourceId) {
if (!failedSourceIds.has(sourceId)) return;
failedSourceIds.delete(sourceId);
renderSourceOptions();
if (failedSourceIds.size === 0 && probeTimer) {
clearInterval(probeTimer);
probeTimer = null;
}
}
async function probeFailedSources() {
if (failedSourceIds.size === 0) {
clearInterval(probeTimer);
probeTimer = null;
return;
}
for (const sourceId of [...failedSourceIds]) {
const source = findSourceById(sourceId);
if (!source) { failedSourceIds.delete(sourceId); continue; }
const probeUrl = source.stream_url || source.embed_url;
if (!probeUrl) continue;
try {
const resp = await fetch(probeUrl, {
method: "HEAD",
signal: AbortSignal.timeout(5000),
});
if (resp.ok) clearSourceFailed(sourceId);
} catch {
// 仍然失效,保持标记
}
}
}
function tryFallbackSource() {
const fallback = tvPayload?.fallback_source;
if (!fallback || fallback.id === currentSourceId) return false;
markSourceFailed(currentSourceId);
currentSourceId = fallback.id;
const { select } = getElements();
if (select) select.value = currentSourceId;
renderSource(fallback);
return true;
}
function getCurrentSource() { function getCurrentSource() {
return findSourceById(currentSourceId); return findSourceById(currentSourceId);
} }
@@ -487,10 +516,11 @@ function renderSourceOptions() {
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
sources.forEach((source) => { sources.forEach((source) => {
const marker = source.id === tvPayload?.default_source_id ? " · 默认" : ""; const defaultMark = source.id === tvPayload?.default_source_id ? " · 默认" : "";
const failMark = failedSourceIds.has(source.id) ? " ⚠" : "";
const option = document.createElement("option"); const option = document.createElement("option");
option.value = source.id; option.value = source.id;
option.textContent = `${source.name}${marker}`; option.textContent = `${source.name}${defaultMark}${failMark}`;
fragment.appendChild(option); fragment.appendChild(option);
}); });
@@ -622,15 +652,28 @@ export function initTVPanel() {
if (initialized) return; if (initialized) return;
initialized = true; initialized = true;
const { select, refreshBtn, iframe, video, toggleBtn, panel } = getElements(); const { select, refreshBtn, iframe, video, toggleBtn, panel, metaToggle } = getElements();
updateToggleButton(!panel?.classList.contains("hud-panel-hidden")); if (panel && metaToggle) {
syncSettingsToggle(!panel?.classList.contains("hud-panel-hidden")); tvPanel = createHUDPanel({
panel,
header: ".hud-panel__header",
body: "#tv-meta-wrap",
collapseBtn: metaToggle,
bodyCollapsedClass: "is-collapsed",
preferredDirection: "up",
expandLabel: "展开新闻直播信息",
collapseLabel: "折叠新闻直播信息",
});
}
updateToggleButton(tvPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
syncSettingsToggle(tvPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
toggleBtn?.addEventListener("click", async (event) => { toggleBtn?.addEventListener("click", async (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
const nextVisible = panel?.classList.contains("hud-panel-hidden") ?? true; const nextVisible = !(tvPanel?.isVisible() ?? false);
setPanelVisible(nextVisible); setPanelVisible(nextVisible);
if (nextVisible) { if (nextVisible) {
await ensureTVPanelReady(); await ensureTVPanelReady();
@@ -647,10 +690,9 @@ export function initTVPanel() {
renderSource(findSourceById(currentSourceId)); renderSource(findSourceById(currentSourceId));
}); });
const { metaToggle } = getElements();
metaToggle?.addEventListener("click", () => { metaToggle?.addEventListener("click", () => {
clearTimeout(metaAutoCollapseTimer); clearTimeout(metaAutoCollapseTimer);
const isNowCollapsed = !metaToggle.classList.contains("is-collapsed"); const isNowCollapsed = !(tvPanel?.isCollapsed() ?? false);
setMetaCollapsed(isNowCollapsed); setMetaCollapsed(isNowCollapsed);
}); });
@@ -660,17 +702,19 @@ export function initTVPanel() {
iframe?.addEventListener("load", () => { iframe?.addEventListener("load", () => {
if (iframe.hidden) return; if (iframe.hidden) return;
clearSourceFailed(currentSourceId);
setPanelMessage(TV_STATUS_MESSAGE.iframeReady); setPanelMessage(TV_STATUS_MESSAGE.iframeReady);
}); });
video?.addEventListener("loadedmetadata", () => { video?.addEventListener("loadedmetadata", () => {
if (video.hidden) return; if (video.hidden) return;
clearSourceFailed(currentSourceId);
setPanelMessage(TV_STATUS_MESSAGE.videoReady); setPanelMessage(TV_STATUS_MESSAGE.videoReady);
}); });
video?.addEventListener("error", () => { video?.addEventListener("error", () => {
const currentSource = getCurrentSource(); const currentSource = getCurrentSource();
if (!showEmbeddedFallback(currentSource)) { if (!showEmbeddedFallback(currentSource) && !tryFallbackSource()) {
setPanelMessage(TV_STATUS_MESSAGE.videoError); setPanelMessage(TV_STATUS_MESSAGE.videoError);
} }
}); });

View File

@@ -0,0 +1,26 @@
import type { MenuProps } from 'antd'
import type { ReactNode } from 'react'
import { Button, Dropdown } from 'antd'
import { MoreOutlined } from '@ant-design/icons'
interface Props {
collapsed: boolean
items: MenuProps['items']
children: ReactNode
}
/** onCell style for action columns — prevents overflow ellipsis and text wrapping */
export const actionCellProps = {
style: { whiteSpace: 'nowrap' as const, textOverflow: 'clip' as const },
}
export function TableActions({ collapsed, items, children }: Props) {
if (collapsed) {
return (
<Dropdown trigger={['click']} menu={{ items }}>
<Button type="text" size="small" icon={<MoreOutlined />} />
</Dropdown>
)
}
return <div style={{ display: 'inline-flex', gap: 4 }}>{children}</div>
}

View File

@@ -1 +1,2 @@
export { useCollapsedActions } from './useCollapsedActions'
export { useWebSocket } from './useWebSocket' export { useWebSocket } from './useWebSocket'

View File

@@ -0,0 +1,39 @@
import { useCallback, useEffect, useRef, useState } from 'react'
/**
* 监听容器宽度,宽时展开操作按钮,窄时收入 Dropdown。
* @param threshold 折叠阈值px默认 700
* @returns [collapsed, callbackRef]
*/
export function useCollapsedActions(threshold = 700) {
const [collapsed, setCollapsed] = useState(false)
const observerRef = useRef<ResizeObserver | null>(null)
const elementRef = useRef<HTMLElement | null>(null)
const ref = useCallback(
(el: HTMLElement | null) => {
observerRef.current?.disconnect()
observerRef.current = null
elementRef.current = el
if (!el || typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(([entry]) => {
setCollapsed(entry.contentRect.width < threshold)
})
observer.observe(el)
observerRef.current = observer
},
[threshold],
)
useEffect(() => {
return () => {
observerRef.current?.disconnect()
observerRef.current = null
elementRef.current = null
}
}, [])
return [collapsed, ref] as const
}

View File

@@ -2575,34 +2575,25 @@ body {
max-height: none !important; max-height: none !important;
} }
.settings-tv-toolbar {
.settings-tv-edit-modal .ant-modal-content {
overflow: hidden;
}
.settings-tv-edit-modal__body {
display: flex; display: flex;
align-items: flex-end; flex-direction: column;
justify-content: space-between; height: min(80vh, 640px);
gap: 16px; min-height: 0;
flex-wrap: wrap; padding: 16px 0 0 24px;
} }
.settings-tv-toolbar__controls { .settings-tv-edit-modal__scroll {
display: flex; flex: 1 1 auto;
flex-wrap: wrap; min-height: 0;
gap: 16px; padding-right: 20px;
align-items: flex-end;
} }
.settings-tv-toolbar__actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.settings-tv-field {
display: grid;
gap: 8px;
}
.data-list-workspace { .data-list-workspace {
min-height: 0; min-height: 0;

View File

@@ -1,4 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useCollapsedActions } from '../../hooks'
import { TableActions, actionCellProps } from '../../components/TableActions/TableActions'
import { import {
Table, Tag, Space, Button, Form, Input, Select, Progress, Checkbox, message, Modal, Table, Tag, Space, Button, Form, Input, Select, Progress, Checkbox, message, Modal,
Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber, Row, Col, Card Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber, Row, Col, Card
@@ -213,6 +215,8 @@ function DataSources() {
const customTableRegionRef = useRef<HTMLDivElement | null>(null) const customTableRegionRef = useRef<HTMLDivElement | null>(null)
const [builtinTableHeight, setBuiltinTableHeight] = useState(360) const [builtinTableHeight, setBuiltinTableHeight] = useState(360)
const [customTableHeight, setCustomTableHeight] = useState(360) const [customTableHeight, setCustomTableHeight] = useState(360)
const [builtinActionsCollapsed, builtinContainerRef] = useCollapsedActions()
const [customActionsCollapsed, customContainerRef] = useCollapsedActions()
const [form] = Form.useForm() const [form] = Form.useForm()
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
@@ -940,10 +944,29 @@ function DataSources() {
{ {
title: '操作', title: '操作',
key: 'action', key: 'action',
width: 200,
fixed: 'right' as const, fixed: 'right' as const,
width: builtinActionsCollapsed ? 40 : 164,
onCell: () => actionCellProps,
render: (_: unknown, record: BuiltInDataSource) => ( render: (_: unknown, record: BuiltInDataSource) => (
<Space size="small"> <TableActions
collapsed={builtinActionsCollapsed}
items={[
{
key: 'trigger',
label: '触发',
icon: <SyncOutlined />,
disabled: !record.is_active,
onClick: () => handleTrigger(record.id),
},
{
key: 'toggle',
label: record.is_active ? '禁用' : '启用',
icon: record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />,
danger: record.is_active,
onClick: () => handleToggle(record.id, record.is_active),
},
]}
>
<Button <Button
type="link" type="link"
size="small" size="small"
@@ -963,7 +986,7 @@ function DataSources() {
> >
{record.is_active ? '禁用' : '启用'} {record.is_active ? '禁用' : '启用'}
</Button> </Button>
</Space> </TableActions>
), ),
}, },
] ]
@@ -1001,30 +1024,56 @@ function DataSources() {
{ {
title: '操作', title: '操作',
key: 'action', key: 'action',
width: 150,
fixed: 'right' as const, fixed: 'right' as const,
width: customActionsCollapsed ? 40 : 228,
onCell: () => actionCellProps,
render: (_: unknown, record: CustomDataSource) => ( render: (_: unknown, record: CustomDataSource) => (
<Space size="small"> <TableActions
<Tooltip title="编辑"> collapsed={customActionsCollapsed}
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openDrawer(record)} /> items={[
</Tooltip> {
<Tooltip title={record.is_active ? '禁用' : '启用'}> key: 'edit',
<Button label: '编辑',
type="link" icon: <EditOutlined />,
size="small" onClick: () => openDrawer(record),
icon={record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />} },
onClick={() => handleToggleCustom(record.id, record.is_active)} {
/> key: 'toggle',
</Tooltip> label: record.is_active ? '禁用' : '启用',
<Popconfirm icon: record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />,
title="确定删除此配置?" danger: record.is_active,
onConfirm={() => handleDelete(record.id)} onClick: () => handleToggleCustom(record.id, record.is_active),
},
{ type: 'divider' },
{
key: 'delete',
label: '删除',
icon: <DeleteOutlined />,
danger: true,
onClick: () => {
Modal.confirm({
title: '确定删除此配置?',
onOk: () => handleDelete(record.id),
})
},
},
]}
>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openDrawer(record)}></Button>
<Button
type="link"
size="small"
icon={record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
danger={record.is_active}
style={record.is_active ? undefined : { color: '#52c41a' }}
onClick={() => handleToggleCustom(record.id, record.is_active)}
> >
<Tooltip title="删除"> {record.is_active ? '禁用' : '启用'}
<Button type="link" size="small" danger icon={<DeleteOutlined />} /> </Button>
</Tooltip> <Popconfirm title="确定删除此配置?" onConfirm={() => handleDelete(record.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm> </Popconfirm>
</Space> </TableActions>
), ),
}, },
] ]
@@ -1034,7 +1083,7 @@ function DataSources() {
key: 'builtin', key: 'builtin',
label: '内置数据源', label: '内置数据源',
children: ( children: (
<div className="page-shell__body data-source-builtin-tab"> <div className="page-shell__body data-source-builtin-tab" ref={builtinContainerRef}>
<div className="data-source-bulk-toolbar"> <div className="data-source-bulk-toolbar">
<div className="data-source-bulk-toolbar__meta"> <div className="data-source-bulk-toolbar__meta">
<div className="data-source-bulk-toolbar__title"></div> <div className="data-source-bulk-toolbar__title"></div>
@@ -1120,7 +1169,7 @@ function DataSources() {
</span> </span>
), ),
children: ( children: (
<div className="page-shell__body data-source-custom-tab"> <div className="page-shell__body data-source-custom-tab" ref={customContainerRef}>
<div className="data-source-custom-toolbar"> <div className="data-source-custom-toolbar">
<Button type="primary" icon={<PlusOutlined />} onClick={() => openDrawer()}> <Button type="primary" icon={<PlusOutlined />} onClick={() => openDrawer()}>

View File

@@ -1,4 +1,7 @@
import { useEffect, useRef, useState, type ReactNode } from 'react' import { useEffect, useRef, useState, type ReactNode } from 'react'
import { useCollapsedActions } from '../../hooks'
import { TableActions, actionCellProps } from '../../components/TableActions/TableActions'
import { CheckCircleOutlined, DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
import { import {
Button, Button,
Card, Card,
@@ -6,11 +9,13 @@ import {
Input, Input,
InputNumber, InputNumber,
message, message,
Modal,
Select, Select,
Switch, Switch,
Table, Table,
Tabs, Tabs,
Tag, Tag,
Tooltip,
Typography, Typography,
} from 'antd' } from 'antd'
import axios from 'axios' import axios from 'axios'
@@ -108,11 +113,14 @@ function Settings() {
const [securitySettings, setSecuritySettings] = useState<SecuritySettings | null>(null) const [securitySettings, setSecuritySettings] = useState<SecuritySettings | null>(null)
const [tvSettings, setTvSettings] = useState<TVSettings | null>(null) const [tvSettings, setTvSettings] = useState<TVSettings | null>(null)
const [savingTvSettings, setSavingTvSettings] = useState(false) const [savingTvSettings, setSavingTvSettings] = useState(false)
const [editingSource, setEditingSource] = useState<TVStreamSource | null>(null)
const [tvActionsCollapsed, tvTableRef] = useCollapsedActions(780)
const collectorTableRegionRef = useRef<HTMLDivElement | null>(null) const collectorTableRegionRef = useRef<HTMLDivElement | null>(null)
const [collectorTableHeight, setCollectorTableHeight] = useState(360) const [collectorTableHeight, setCollectorTableHeight] = useState(360)
const [systemForm] = Form.useForm<SystemSettings>() const [systemForm] = Form.useForm<SystemSettings>()
const [notificationForm] = Form.useForm<NotificationSettings>() const [notificationForm] = Form.useForm<NotificationSettings>()
const [securityForm] = Form.useForm<SecuritySettings>() const [securityForm] = Form.useForm<SecuritySettings>()
const [tvEditForm] = Form.useForm<TVStreamSource>()
const fetchSettings = async () => { const fetchSettings = async () => {
try { try {
@@ -206,90 +214,95 @@ function Settings() {
} }
} }
const updateTvSetting = <K extends keyof TVSettings>(field: K, value: TVSettings[K]) => { const setDefaultSource = (sourceId: string) => {
setTvSettings((prev) => (prev ? { ...prev, [field]: value } : prev)) if (!tvSettings) return
} const next = { ...tvSettings, default_source_id: sourceId }
setTvSettings(next)
const updateTvSourceField = <K extends keyof TVStreamSource>( saveTvSettings(next)
sourceId: string,
field: K,
value: TVStreamSource[K]
) => {
setTvSettings((prev) => {
if (!prev) return prev
const nextSources = prev.sources.map((source) => {
if (field === 'is_fallback' && value === true) {
return { ...source, is_fallback: source.id === sourceId }
}
if (source.id === sourceId) {
return { ...source, [field]: value }
}
return source
})
const nextDefaultSourceId =
field === 'is_enabled' && value === false && prev.default_source_id === sourceId
? nextSources.find((source) => source.id !== sourceId && source.is_enabled)?.id || ''
: prev.default_source_id
return {
...prev,
default_source_id: nextDefaultSourceId,
sources: nextSources,
}
})
} }
const addTvSource = () => { const addTvSource = () => {
setTvSettings((prev) => { const nextIndex = (tvSettings?.sources.length || 0) + 1
if (!prev) return prev const newSource: TVStreamSource = {
const nextIndex = prev.sources.length + 1 id: `manual-tv-${Date.now()}`,
const newSource: TVStreamSource = { name: `新闻直播源 ${nextIndex}`,
id: `manual-tv-${Date.now()}`, provider: 'Manual',
name: `新闻直播源 ${nextIndex}`, region: 'Global',
provider: 'Manual', language: 'und',
region: 'Global', source_type: 'iframe',
language: 'und', embed_url: '',
source_type: 'iframe', stream_url: '',
embed_url: '', homepage_url: '',
stream_url: '', poster_url: '',
homepage_url: '', youtube_video_id: '',
poster_url: '', youtube_channel: '',
youtube_video_id: '', is_enabled: true,
youtube_channel: '', is_fallback: false,
is_enabled: true, sort_order: nextIndex * 10,
is_fallback: false, collector_source: null,
sort_order: nextIndex * 10, notes: '',
collector_source: null, }
notes: '', setEditingSource(newSource)
} tvEditForm.setFieldsValue(newSource)
return {
...prev,
sources: [...prev.sources, newSource],
}
})
} }
const removeTvSource = (sourceId: string) => { const confirmEditSource = async () => {
setTvSettings((prev) => { if (!editingSource || !tvSettings) return
if (!prev) return prev const values = tvEditForm.getFieldsValue()
const nextSources = prev.sources.filter((source) => source.id !== sourceId) const nextSources = tvSettings.sources
const nextDefaultSourceId = .map((source) => {
prev.default_source_id === sourceId ? nextSources[0]?.id || '' : prev.default_source_id if (source.id === editingSource.id) return { ...source, ...values }
return { if (values.is_fallback) return { ...source, is_fallback: false }
...prev, return source
default_source_id: nextDefaultSourceId, })
sources: nextSources,
if (!tvSettings.sources.some((source) => source.id === editingSource.id)) {
nextSources.push({
...editingSource,
...values,
})
if (values.is_fallback) {
for (let index = 0; index < nextSources.length - 1; index += 1) {
nextSources[index] = { ...nextSources[index], is_fallback: false }
}
} }
}) }
const nextDefaultSourceId =
values.is_enabled === false && tvSettings.default_source_id === editingSource.id
? nextSources.find((s) => s.id !== editingSource.id && s.is_enabled)?.id || ''
: tvSettings.default_source_id
const next = { ...tvSettings, default_source_id: nextDefaultSourceId, sources: nextSources }
setTvSettings(next)
setEditingSource(null)
await saveTvSettings(next)
} }
const saveTvSettings = async () => { const removeTvSource = async (sourceId: string) => {
if (!tvSettings) return if (!tvSettings) return
const nextSources = tvSettings.sources.filter((source) => source.id !== sourceId)
const nextDefaultSourceId =
tvSettings.default_source_id === sourceId ? nextSources[0]?.id || '' : tvSettings.default_source_id
const next = {
...tvSettings,
default_source_id: nextDefaultSourceId,
sources: nextSources,
}
setTvSettings(next)
if (editingSource?.id === sourceId) {
setEditingSource(null)
}
await saveTvSettings(next)
}
const saveTvSettings = async (next?: TVSettings) => {
const toSave = next ?? tvSettings
if (!toSave) return
try { try {
setSavingTvSettings(true) setSavingTvSettings(true)
await axios.put('/api/v1/settings/tv', tvSettings) await axios.put('/api/v1/settings/tv', toSave)
message.success('电视直播配置已保存') message.success('电视直播配置已保存')
await fetchSettings() await fetchSettings()
} catch (error) { } catch (error) {
@@ -402,105 +415,39 @@ function Settings() {
const tvSourceColumns = [ const tvSourceColumns = [
{ {
title: '频道', title: '频道',
dataIndex: 'name',
key: 'name', key: 'name',
width: 220, width: 180,
render: (_: string, record: TVStreamSource) => ( render: (_: unknown, record: TVStreamSource) => (
<div style={{ display: 'grid', gap: 8 }}> <div>
<Input value={record.name} onChange={(event) => updateTvSourceField(record.id, 'name', event.target.value)} /> <div style={{ fontWeight: 500 }}>{record.name}</div>
<Input <Text type="secondary" style={{ fontSize: 12 }}>{record.provider}</Text>
value={record.provider}
placeholder="提供方"
onChange={(event) => updateTvSourceField(record.id, 'provider', event.target.value)}
/>
</div> </div>
), ),
}, },
{ {
title: '区域 / 语言', title: '区域 / 语言',
key: 'locale', key: 'locale',
width: 160, width: 130,
render: (_: unknown, record: TVStreamSource) => ( render: (_: unknown, record: TVStreamSource) => (
<div style={{ display: 'grid', gap: 8 }}> <Text type="secondary">{record.region} · {record.language}</Text>
<Input
value={record.region}
placeholder="区域"
onChange={(event) => updateTvSourceField(record.id, 'region', event.target.value)}
/>
<Input
value={record.language}
placeholder="语言"
onChange={(event) => updateTvSourceField(record.id, 'language', event.target.value)}
/>
</div>
), ),
}, },
{ {
title: '类型', title: '类型',
dataIndex: 'source_type', dataIndex: 'source_type',
key: 'source_type', key: 'source_type',
width: 120, width: 90,
render: (value: TVStreamSource['source_type'], record: TVStreamSource) => ( render: (value: string) => <Tag>{value}</Tag>,
<Select
value={value}
style={{ width: '100%' }}
onChange={(nextValue) => updateTvSourceField(record.id, 'source_type', nextValue)}
options={[
{ value: 'iframe', label: 'iframe' },
{ value: 'hls', label: 'hls' },
{ value: 'video', label: 'video' },
{ value: 'youtube', label: 'youtube' },
{ value: 'external', label: 'external' },
]}
/>
),
},
{
title: '播放地址',
key: 'urls',
width: 320,
render: (_: unknown, record: TVStreamSource) => (
<div style={{ display: 'grid', gap: 8 }}>
<Input
value={record.embed_url}
placeholder="嵌入地址 / iframe 地址"
onChange={(event) => updateTvSourceField(record.id, 'embed_url', event.target.value)}
/>
<Input
value={record.stream_url}
placeholder="流地址 / HLS 地址"
onChange={(event) => updateTvSourceField(record.id, 'stream_url', event.target.value)}
/>
<Input
value={record.youtube_video_id}
placeholder="YouTube 视频 ID可选"
onChange={(event) => updateTvSourceField(record.id, 'youtube_video_id', event.target.value)}
/>
<Input
value={record.youtube_channel}
placeholder="YouTube 频道 Handle / URL可选"
onChange={(event) => updateTvSourceField(record.id, 'youtube_channel', event.target.value)}
/>
</div>
),
},
{
title: '官网',
dataIndex: 'homepage_url',
key: 'homepage_url',
width: 220,
render: (value: string, record: TVStreamSource) => (
<Input value={value} onChange={(event) => updateTvSourceField(record.id, 'homepage_url', event.target.value)} />
),
}, },
{ {
title: '状态', title: '状态',
key: 'status', key: 'status',
width: 110, width: 130,
render: (_: unknown, record: TVStreamSource) => ( render: (_: unknown, record: TVStreamSource) => (
<div style={{ display: 'grid', gap: 8 }}> <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' as const }}>
<Switch checked={record.is_enabled} onChange={(checked) => updateTvSourceField(record.id, 'is_enabled', checked)} /> <Tag color={record.is_enabled ? 'success' : 'default'}>{record.is_enabled ? '启用' : '禁用'}</Tag>
<Switch checked={record.is_fallback} onChange={(checked) => updateTvSourceField(record.id, 'is_fallback', checked)} /> {record.id === tvSettings?.default_source_id && <Tag color="gold"></Tag>}
{record.is_fallback && <Tag color="blue"></Tag>}
</div> </div>
), ),
}, },
@@ -508,20 +455,82 @@ function Settings() {
title: '备注', title: '备注',
dataIndex: 'notes', dataIndex: 'notes',
key: 'notes', key: 'notes',
width: 220, width: 200,
render: (value: string, record: TVStreamSource) => ( ellipsis: true,
<Input value={value} onChange={(event) => updateTvSourceField(record.id, 'notes', event.target.value)} /> render: (value: string) => <Text type="secondary">{value || '—'}</Text>,
),
}, },
{ {
title: '操作', title: '操作',
key: 'action', key: 'action',
width: 90,
fixed: 'right' as const, fixed: 'right' as const,
width: tvActionsCollapsed ? 40 : 258,
onCell: () => actionCellProps,
render: (_: unknown, record: TVStreamSource) => ( render: (_: unknown, record: TVStreamSource) => (
<Button danger onClick={() => removeTvSource(record.id)} disabled={record.id === tvSettings?.default_source_id}> <TableActions
collapsed={tvActionsCollapsed}
</Button> items={[
{
key: 'default',
label: '设为默认',
icon: <CheckCircleOutlined />,
disabled: record.id === tvSettings?.default_source_id,
onClick: () => setDefaultSource(record.id),
},
{
key: 'edit',
label: '编辑',
icon: <EditOutlined />,
onClick: () => {
setEditingSource(record)
tvEditForm.setFieldsValue(record)
},
},
{ type: 'divider' },
{
key: 'delete',
label: '删除',
icon: <DeleteOutlined />,
danger: true,
disabled: record.id === tvSettings?.default_source_id,
onClick: () => {
void removeTvSource(record.id)
},
},
]}
>
<Button
type="link"
size="small"
icon={<CheckCircleOutlined />}
disabled={record.id === tvSettings?.default_source_id}
onClick={() => setDefaultSource(record.id)}
>
</Button>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => {
setEditingSource(record)
tvEditForm.setFieldsValue(record)
}}
>
</Button>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
disabled={record.id === tvSettings?.default_source_id}
onClick={() => {
void removeTvSource(record.id)
}}
>
</Button>
</TableActions>
), ),
}, },
] ]
@@ -612,51 +621,111 @@ function Settings() {
key: 'tv', key: 'tv',
label: '电视直播', label: '电视直播',
children: ( children: (
<div className="settings-pane"> <div className="settings-pane" ref={tvTableRef}>
<Card className="settings-panel-card settings-panel-card--table" loading={loading}> <Card
<div className="settings-panel-scroll" style={{ display: 'grid', gap: 16 }}> className="settings-panel-card settings-panel-card--table"
<div className="settings-tv-toolbar"> loading={loading}
<div className="settings-tv-toolbar__controls"> styles={{ body: { padding: 0 } }}
<div className="settings-tv-field"> >
<Text type="secondary"></Text> <TableScrollRegion
<Select className="data-source-table-region"
value={tvSettings?.default_source_id} style={{ flex: '1 1 auto', minHeight: 0 }}
style={{ minWidth: 260 }} >
options={(tvSettings?.sources || []).map((source) => ({ <Table
value: source.id, rowKey="id"
label: source.name, columns={tvSourceColumns}
}))} dataSource={tvSettings?.sources || []}
onChange={(value) => updateTvSetting('default_source_id', value)} pagination={false}
/> scroll={{ x: 'max-content', y: 420 }}
</div> tableLayout="fixed"
<div className="settings-tv-field"> size="small"
<Text type="secondary">退</Text> />
<Switch </TableScrollRegion>
checked={tvSettings?.auto_fallback || false} <Tooltip title="新增直播源">
onChange={(checked) => updateTvSetting('auto_fallback', checked)} <Button
/> type="text"
</div> icon={<PlusOutlined />}
</div> onClick={addTvSource}
<div className="settings-tv-toolbar__actions"> style={{ width: '100%', borderRadius: 0, borderTop: '1px solid rgba(0,0,0,0.06)' }}
<Button onClick={addTvSource}></Button> />
<Button type="primary" loading={savingTvSettings} onClick={saveTvSettings}> </Tooltip>
</Button>
</div>
</div>
<TableScrollRegion className="data-source-table-region">
<Table
rowKey="id"
columns={tvSourceColumns}
dataSource={tvSettings?.sources || []}
pagination={false}
scroll={{ x: 1500, y: 420 }}
tableLayout="fixed"
size="small"
/>
</TableScrollRegion>
</div>
</Card> </Card>
<Modal
title={editingSource?.id.startsWith('manual-tv-') ? '新增直播源' : '编辑直播源'}
open={editingSource !== null}
onOk={confirmEditSource}
onCancel={() => {
setEditingSource(null)
tvEditForm.resetFields()
}}
okText="保存"
okButtonProps={{ loading: savingTvSettings }}
cancelText="取消"
width={560}
centered
destroyOnHidden
className="settings-tv-edit-modal"
styles={{ body: { padding: 0 } }}
>
<div className="settings-tv-edit-modal__body">
<Scrollbar className="settings-tv-edit-modal__scroll">
<Form form={tvEditForm} layout="vertical" style={{ paddingBottom: 16 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name="name" label="频道名称" rules={[{ required: true, message: '请输入频道名称' }]}>
<Input />
</Form.Item>
<Form.Item name="provider" label="提供方">
<Input />
</Form.Item>
<Form.Item name="region" label="区域">
<Input />
</Form.Item>
<Form.Item name="language" label="语言">
<Input />
</Form.Item>
<Form.Item name="source_type" label="类型">
<Select options={[
{ value: 'iframe', label: 'iframe' },
{ value: 'hls', label: 'HLS' },
{ value: 'video', label: 'video' },
{ value: 'youtube', label: 'YouTube' },
{ value: 'external', label: 'external仅外部打开' },
]} />
</Form.Item>
<Form.Item name="sort_order" label="排序">
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
</div>
<Form.Item name="embed_url" label="嵌入地址 / iframe 地址">
<Input />
</Form.Item>
<Form.Item name="stream_url" label="流地址 / HLS 地址">
<Input />
</Form.Item>
<Form.Item name="youtube_video_id" label="YouTube 视频 ID">
<Input />
</Form.Item>
<Form.Item name="youtube_channel" label="YouTube 频道 Handle / URL">
<Input />
</Form.Item>
<Form.Item name="homepage_url" label="官网地址">
<Input />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input />
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name="is_enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="is_fallback" label="设为备用源" valuePropName="checked">
<Switch />
</Form.Item>
</div>
</Form>
</Scrollbar>
</div>
</Modal>
</div> </div>
), ),
}, },

View File

@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Table, Button, Tag, Space, message, Modal, Form, Input, Select } from 'antd' import { Table, Button, Tag, message, Modal, Form, Input, Select } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons' import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'
import { useCollapsedActions } from '../../hooks'
import { TableActions, actionCellProps } from '../../components/TableActions/TableActions'
import axios from 'axios' import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout' import AppLayout from '../../components/AppLayout/AppLayout'
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion' import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
@@ -20,6 +22,7 @@ function Users() {
const [modalVisible, setModalVisible] = useState(false) const [modalVisible, setModalVisible] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null) const [editingUser, setEditingUser] = useState<User | null>(null)
const [form] = Form.useForm() const [form] = Form.useForm()
const [actionsCollapsed, containerRef] = useCollapsedActions()
const fetchUsers = async () => { const fetchUsers = async () => {
setLoading(true) setLoading(true)
@@ -107,12 +110,21 @@ function Users() {
{ {
title: '操作', title: '操作',
key: 'action', key: 'action',
width: 180, fixed: 'right' as const,
width: actionsCollapsed ? 56 : 172,
onCell: () => actionCellProps,
render: (_: unknown, record: User) => ( render: (_: unknown, record: User) => (
<Space> <TableActions
<Button type="link" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button> collapsed={actionsCollapsed}
<Button type="link" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}></Button> items={[
</Space> { key: 'edit', label: '编辑', icon: <EditOutlined />, onClick: () => handleEdit(record) },
{ type: 'divider' },
{ key: 'delete', label: '删除', icon: <DeleteOutlined />, danger: true, onClick: () => handleDelete(record.id) },
]}
>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button>
<Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}></Button>
</TableActions>
), ),
}, },
] ]
@@ -124,15 +136,16 @@ function Users() {
<h2 style={{ margin: 0 }}></h2> <h2 style={{ margin: 0 }}></h2>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}></Button> <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}></Button>
</div> </div>
<div className="page-shell__body"> <div className="page-shell__body" ref={containerRef}>
<TableScrollRegion className="data-source-table-region users-table-region"> <TableScrollRegion className="data-source-table-region users-table-region">
<Table <Table
columns={columns} columns={columns}
dataSource={users} dataSource={users}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
scroll={{ x: 960, y: 10000 }} scroll={{ x: 'max-content' }}
pagination={false} pagination={false}
size="small"
tableLayout="fixed" tableLayout="fixed"
/> />
</TableScrollRegion> </TableScrollRegion>

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "planet" name = "planet"
version = "0.27.6" version = "0.27.8"
description = "智能星球计划 - 态势感知系统" description = "智能星球计划 - 态势感知系统"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [

2
uv.lock generated
View File

@@ -475,7 +475,7 @@ wheels = [
[[package]] [[package]]
name = "planet" name = "planet"
version = "0.27.6" version = "0.27.8"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },