feat(桌面与监控): 完善工作台导航和登录活动定位
- 优化桌面标签、上下文标题、前进后退、导航栏隐藏和原生菜单体验 - 补充登录会话 IP 采集、地理位置回退、管理端展示及数据库迁移 - 更新桌面发布检查、运维文档和前后端测试覆盖
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
"""Add the server-observed login IP to user login sessions.
|
||||
|
||||
Revision ID: 20260713_01
|
||||
Revises: 20260710_04
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "20260713_01"
|
||||
down_revision = "20260710_04"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"user_login_sessions",
|
||||
sa.Column("login_ip", sa.String(length=45), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("user_login_sessions", "login_ip")
|
||||
@@ -28,6 +28,7 @@ from app.models.permission_metric_snapshot import PermissionMetricSnapshot
|
||||
from app.models.security_access_log import SecurityAccessLog
|
||||
from app.models.user import User
|
||||
from app.services.geo_location_metadata import GeoLocationMetadata, resolve_geo_location_metadata
|
||||
from app.services.ip_geolocation_fallback import resolve_external_ip_locations
|
||||
from app.services.ip_location import IpLocation, resolve_ip_location
|
||||
from app.services.monitoring_retention import get_monitoring_retention_status
|
||||
from app.services.permission_log_writer import get_log_writer
|
||||
@@ -1604,12 +1605,15 @@ async def get_ip_locations(
|
||||
SecurityAccessLog.category,
|
||||
)
|
||||
)
|
||||
permission_rows = permission_result.all()
|
||||
security_rows = security_result.all()
|
||||
buckets: dict[tuple[str, str, str], dict] = {}
|
||||
all_ip_addresses: set[str] = set()
|
||||
all_user_ids: set[str] = set()
|
||||
located_ip_addresses: set[str] = set()
|
||||
private_ip_addresses: set[str] = set()
|
||||
unknown_ip_addresses: set[str] = set()
|
||||
external_fallback_ip_addresses: set[str] = set()
|
||||
total_count = 0
|
||||
allowed_count = 0
|
||||
denied_count = 0
|
||||
@@ -1619,6 +1623,28 @@ async def get_ip_locations(
|
||||
resolved_locations: dict[str, IpLocation] = {}
|
||||
resolved_metadata: dict[str, GeoLocationMetadata] = {}
|
||||
|
||||
source_ip_addresses = list(
|
||||
dict.fromkeys(
|
||||
[str(row[0]) for row in permission_rows if row[0]]
|
||||
+ [str(row[0]) for row in security_rows if row[0]]
|
||||
)
|
||||
)
|
||||
for ip_address in source_ip_addresses:
|
||||
ip_info = resolve_ip_location(ip_address)
|
||||
resolved_locations[ip_address] = ip_info
|
||||
resolved_metadata[ip_address] = resolve_geo_location_metadata(ip_info)
|
||||
missing_coordinate_ips = [
|
||||
ip_address
|
||||
for ip_address, metadata in resolved_metadata.items()
|
||||
if metadata.accuracy_level != "private"
|
||||
and (metadata.longitude is None or metadata.latitude is None)
|
||||
]
|
||||
external_locations = await resolve_external_ip_locations(missing_coordinate_ips)
|
||||
for ip_address, external_location in external_locations.items():
|
||||
resolved_locations[ip_address] = external_location.merge_ip_location(resolved_locations[ip_address])
|
||||
resolved_metadata[ip_address] = external_location.to_metadata()
|
||||
external_fallback_ip_addresses.add(ip_address)
|
||||
|
||||
def normalize_observed_at(value: datetime | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
@@ -1725,7 +1751,7 @@ async def get_ip_locations(
|
||||
if user_id:
|
||||
all_user_ids.add(str(user_id))
|
||||
|
||||
for ip_address, user_id, allowed, event_count, first_seen_at, last_seen_at in permission_result.all():
|
||||
for ip_address, user_id, allowed, event_count, first_seen_at, last_seen_at in permission_rows:
|
||||
add_ip_location_row(
|
||||
ip_address,
|
||||
user_id,
|
||||
@@ -1736,7 +1762,7 @@ async def get_ip_locations(
|
||||
source_kind="permission",
|
||||
)
|
||||
|
||||
for client_ip, user_identifier, auth_status, allowed, severity, category, event_count, first_seen_at, last_seen_at in security_result.all():
|
||||
for client_ip, user_identifier, auth_status, allowed, severity, category, event_count, first_seen_at, last_seen_at in security_rows:
|
||||
user_id = None
|
||||
if auth_status == "AUTHENTICATED" and user_identifier:
|
||||
try:
|
||||
@@ -1837,10 +1863,11 @@ async def get_ip_locations(
|
||||
},
|
||||
"server_location": server_location.to_public_dict() if server_location else None,
|
||||
"data_quality": {
|
||||
"resolver": "ip2region",
|
||||
"resolver": "ip2region+ip2location.io" if external_fallback_ip_addresses else "ip2region",
|
||||
"located_ip_count": located_count,
|
||||
"private_ip_count": private_count,
|
||||
"unknown_ip_count": unknown_count,
|
||||
"external_fallback_ip_count": len(external_fallback_ip_addresses),
|
||||
"location_coverage_rate": round((located_count / public_ip_count) * 100, 1) if public_ip_count else 100.0,
|
||||
"returned_region_count": len(items),
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -10,7 +10,7 @@ from app.crud import user as user_crud
|
||||
from app.crud import member as member_crud
|
||||
from app.utils.pagination import paginate
|
||||
from app.schemas.user import LoginStatus, UserCreate, UserLoginActivityRead, UserRead, UserStatus, UserUpdate
|
||||
from app.services.user_login_sessions import get_login_summaries, list_login_activities
|
||||
from app.services.user_login_sessions import get_login_summaries, list_login_activities, login_activity_payload
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -53,13 +53,16 @@ async def list_users(
|
||||
@router.get("/{user_id}/login-activities", response_model=list[UserLoginActivityRead])
|
||||
async def read_user_login_activities(
|
||||
user_id: uuid.UUID,
|
||||
response: Response,
|
||||
limit: int = Query(default=30, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(require_roles(["ADMIN"])),
|
||||
) -> list[UserLoginActivityRead]:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
if not await user_crud.get_by_id(db, user_id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
return await list_login_activities(db, user_id=user_id, limit=limit)
|
||||
activities = await list_login_activities(db, user_id=user_id, limit=limit)
|
||||
return [login_activity_payload(activity) for activity in activities]
|
||||
|
||||
|
||||
@router.post("/", response_model=UserRead, status_code=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -40,6 +40,11 @@ class Settings(BaseSettings):
|
||||
)
|
||||
MONITORING_PUBLIC_IP_DISCOVERY_TIMEOUT_SECONDS: float = Field(default=2.5, ge=0.5, le=10)
|
||||
MONITORING_SERVER_LOCATION_CACHE_SECONDS: int = Field(default=86400, ge=300, le=604800)
|
||||
MONITORING_IP_GEO_FALLBACK_ENABLED: bool = True
|
||||
MONITORING_IP_GEO_FALLBACK_API_KEY: Optional[str] = None
|
||||
MONITORING_IP_GEO_FALLBACK_TIMEOUT_SECONDS: float = Field(default=2.5, ge=0.5, le=10)
|
||||
MONITORING_IP_GEO_FALLBACK_CACHE_SECONDS: int = Field(default=604800, ge=3600, le=2592000)
|
||||
MONITORING_IP_GEO_FALLBACK_MAX_LOOKUPS: int = Field(default=10, ge=1, le=100)
|
||||
MONITORING_ACCESS_LOG_RETENTION_DAYS: int = Field(default=90, ge=7, le=3650)
|
||||
MONITORING_METRIC_RETENTION_DAYS: int = Field(default=400, ge=30, le=3650)
|
||||
MONITORING_RETENTION_INTERVAL_SECONDS: int = Field(default=86400, ge=60, le=604800)
|
||||
|
||||
@@ -11,7 +11,7 @@ from app.db.base_class import Base
|
||||
|
||||
|
||||
class UserLoginSession(Base):
|
||||
"""Server-side login activity record without storing credentials or raw IPs."""
|
||||
"""Server-side login activity record without storing credentials."""
|
||||
|
||||
__tablename__ = "user_login_sessions"
|
||||
|
||||
@@ -26,6 +26,7 @@ class UserLoginSession(Base):
|
||||
client_platform: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
client_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
client_source: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
login_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||
login_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
@@ -89,10 +89,14 @@ class UserLoginActivityRead(BaseModel):
|
||||
client_type: Literal["web", "desktop"]
|
||||
client_platform: Optional[str] = None
|
||||
client_version: Optional[str] = None
|
||||
client_source: Optional[str] = None
|
||||
login_ip: Optional[str] = None
|
||||
ip_location: Optional[str] = None
|
||||
login_at: datetime
|
||||
last_seen_at: datetime
|
||||
ended_at: Optional[datetime] = None
|
||||
end_reason: Optional[str] = None
|
||||
activity_status: Literal["ONLINE", "OFFLINE", "ENDED"]
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Bounded third-party coordinate fallback for public source IPs.
|
||||
|
||||
IPAddress.my identifies IP2Location.io as its data provider. We use the
|
||||
provider's documented JSON API instead of scraping the public HTML page.
|
||||
Only globally routable addresses are eligible, and results are cached so the
|
||||
monitoring UI does not turn into a per-refresh third-party lookup fan-out.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.geo_location_metadata import GeoLocationMetadata
|
||||
from app.services.ip_location import IpLocation
|
||||
|
||||
logger = logging.getLogger("ctms.ip_geolocation_fallback")
|
||||
|
||||
_API_URL = "https://api.ip2location.io/"
|
||||
_MAX_RESPONSE_BYTES = 64 * 1024
|
||||
_MAX_CACHE_ENTRIES = 4096
|
||||
_NEGATIVE_CACHE_SECONDS = 3600
|
||||
_cache: dict[str, tuple[float, "ExternalIpLocation | None"]] = {}
|
||||
_cache_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def _text(value: Any, limit: int = 160) -> str:
|
||||
candidate = str(value or "").strip()
|
||||
return "" if candidate in {"", "-", "0"} else candidate[:limit]
|
||||
|
||||
|
||||
def _coordinate(value: Any, *, minimum: float, maximum: float) -> float | None:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return number if math.isfinite(number) and minimum <= number <= maximum else None
|
||||
|
||||
|
||||
def _global_ip(value: str | None) -> str | None:
|
||||
try:
|
||||
address = ipaddress.ip_address((value or "").strip())
|
||||
except ValueError:
|
||||
return None
|
||||
return str(address) if address.is_global else None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExternalIpLocation:
|
||||
ip_address: str
|
||||
country: str
|
||||
country_code: str
|
||||
region: str
|
||||
city: str
|
||||
isp: str
|
||||
longitude: float
|
||||
latitude: float
|
||||
|
||||
def merge_ip_location(self, local: IpLocation) -> IpLocation:
|
||||
country = self.country or local.country
|
||||
province = self.region or local.province
|
||||
city = self.city or local.city
|
||||
isp = self.isp or local.isp
|
||||
location = " / ".join(part for part in [country, province, city, isp] if part) or local.location
|
||||
return IpLocation(location=location, country=country, province=province, city=city, isp=isp)
|
||||
|
||||
def to_metadata(self) -> GeoLocationMetadata:
|
||||
return GeoLocationMetadata(
|
||||
country=self.country,
|
||||
country_code=self.country_code,
|
||||
region_code="",
|
||||
longitude=self.longitude,
|
||||
latitude=self.latitude,
|
||||
accuracy_level="city" if self.city else "country",
|
||||
)
|
||||
|
||||
|
||||
def _parse_response(expected_ip: str, payload: Any) -> ExternalIpLocation | None:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
response_ip = _global_ip(_text(payload.get("ip"), 45))
|
||||
if response_ip != expected_ip:
|
||||
return None
|
||||
latitude = _coordinate(payload.get("latitude"), minimum=-90, maximum=90)
|
||||
longitude = _coordinate(payload.get("longitude"), minimum=-180, maximum=180)
|
||||
if latitude is None or longitude is None:
|
||||
return None
|
||||
country_code = _text(payload.get("country_code"), 2).upper()
|
||||
if len(country_code) != 2:
|
||||
country_code = ""
|
||||
return ExternalIpLocation(
|
||||
ip_address=expected_ip,
|
||||
country=_text(payload.get("country_name"), 100),
|
||||
country_code=country_code,
|
||||
region=_text(payload.get("region_name"), 100),
|
||||
city=_text(payload.get("city_name"), 100),
|
||||
isp=_text(payload.get("isp"), 160),
|
||||
longitude=longitude,
|
||||
latitude=latitude,
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_one(
|
||||
client: httpx.AsyncClient,
|
||||
semaphore: asyncio.Semaphore,
|
||||
ip_address: str,
|
||||
) -> ExternalIpLocation | None:
|
||||
async with semaphore:
|
||||
try:
|
||||
response = await client.get(_API_URL, params={"ip": ip_address, "format": "json"})
|
||||
response.raise_for_status()
|
||||
if len(response.content) > _MAX_RESPONSE_BYTES:
|
||||
return None
|
||||
return _parse_response(ip_address, response.json())
|
||||
except (httpx.HTTPError, ValueError):
|
||||
logger.warning("External IP coordinate fallback unavailable")
|
||||
return None
|
||||
|
||||
|
||||
async def resolve_external_ip_locations(ip_addresses: Iterable[str]) -> dict[str, ExternalIpLocation]:
|
||||
if not settings.MONITORING_IP_GEO_FALLBACK_ENABLED or settings.ENV == "test":
|
||||
return {}
|
||||
|
||||
candidates = list(dict.fromkeys(filter(None, (_global_ip(value) for value in ip_addresses))))
|
||||
if not candidates:
|
||||
return {}
|
||||
|
||||
now = time.monotonic()
|
||||
resolved: dict[str, ExternalIpLocation] = {}
|
||||
pending: list[str] = []
|
||||
async with _cache_lock:
|
||||
for ip_address in candidates:
|
||||
cached = _cache.get(ip_address)
|
||||
if cached and cached[0] > now:
|
||||
if cached[1] is not None:
|
||||
resolved[ip_address] = cached[1]
|
||||
continue
|
||||
pending.append(ip_address)
|
||||
|
||||
pending = pending[: settings.MONITORING_IP_GEO_FALLBACK_MAX_LOOKUPS]
|
||||
if not pending:
|
||||
return resolved
|
||||
|
||||
headers = {"Accept": "application/json", "User-Agent": "CTMS-IP-Coordinate-Fallback/1.0"}
|
||||
api_key = (settings.MONITORING_IP_GEO_FALLBACK_API_KEY or "").strip()
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
timeout = httpx.Timeout(settings.MONITORING_IP_GEO_FALLBACK_TIMEOUT_SECONDS)
|
||||
semaphore = asyncio.Semaphore(min(5, len(pending)))
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False, headers=headers) as client:
|
||||
fetched = await asyncio.gather(*(_fetch_one(client, semaphore, item) for item in pending))
|
||||
|
||||
now = time.monotonic()
|
||||
async with _cache_lock:
|
||||
for ip_address, item in zip(pending, fetched):
|
||||
ttl = settings.MONITORING_IP_GEO_FALLBACK_CACHE_SECONDS if item else _NEGATIVE_CACHE_SECONDS
|
||||
_cache[ip_address] = (now + ttl, item)
|
||||
if item is not None:
|
||||
resolved[ip_address] = item
|
||||
while len(_cache) > _MAX_CACHE_ENTRIES:
|
||||
_cache.pop(next(iter(_cache)))
|
||||
return resolved
|
||||
|
||||
|
||||
def reset_ip_geolocation_fallback_cache() -> None:
|
||||
_cache.clear()
|
||||
@@ -21,6 +21,7 @@ import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.geo_location_metadata import resolve_geo_location_metadata
|
||||
from app.services.ip_geolocation_fallback import resolve_external_ip_locations
|
||||
from app.services.ip_location import resolve_ip_location
|
||||
|
||||
logger = logging.getLogger("ctms.monitoring_server_location")
|
||||
@@ -128,6 +129,11 @@ async def resolve_monitoring_server_location(*, force: bool = False) -> Monitori
|
||||
if public_ip:
|
||||
ip_location = resolve_ip_location(public_ip)
|
||||
metadata = resolve_geo_location_metadata(ip_location)
|
||||
if metadata.longitude is None or metadata.latitude is None:
|
||||
external_location = (await resolve_external_ip_locations([public_ip])).get(public_ip)
|
||||
if external_location is not None:
|
||||
ip_location = external_location.merge_ip_location(ip_location)
|
||||
metadata = external_location.to_metadata()
|
||||
if metadata.longitude is not None and metadata.latitude is not None:
|
||||
country = metadata.country or ip_location.country
|
||||
name = " / ".join(
|
||||
|
||||
@@ -20,6 +20,7 @@ from app.models.permission_access_log import PermissionAccessLog
|
||||
from app.models.security_access_log import SecurityAccessLog
|
||||
from app.models.source_location_snapshot import SourceLocationSnapshot
|
||||
from app.services.geo_location_metadata import resolve_geo_location_metadata
|
||||
from app.services.ip_geolocation_fallback import resolve_external_ip_locations
|
||||
from app.services.ip_location import resolve_ip_location
|
||||
|
||||
logger = logging.getLogger("ctms.source_location_aggregator")
|
||||
@@ -149,12 +150,31 @@ async def aggregate_source_location_hour(bucket_start: datetime, bucket_end: dat
|
||||
auth_failure_count=auth_failures,
|
||||
)
|
||||
|
||||
resolved_locations = {
|
||||
ip_address: resolve_ip_location(ip_address)
|
||||
for ip_address, _user_identity in aggregates
|
||||
}
|
||||
resolved_metadata = {
|
||||
ip_address: resolve_geo_location_metadata(ip_info)
|
||||
for ip_address, ip_info in resolved_locations.items()
|
||||
}
|
||||
missing_coordinate_ips = [
|
||||
ip_address
|
||||
for ip_address, metadata in resolved_metadata.items()
|
||||
if metadata.accuracy_level != "private"
|
||||
and (metadata.longitude is None or metadata.latitude is None)
|
||||
]
|
||||
external_locations = await resolve_external_ip_locations(missing_coordinate_ips)
|
||||
for ip_address, external_location in external_locations.items():
|
||||
resolved_locations[ip_address] = external_location.merge_ip_location(resolved_locations[ip_address])
|
||||
resolved_metadata[ip_address] = external_location.to_metadata()
|
||||
|
||||
await session.execute(
|
||||
delete(SourceLocationSnapshot).where(SourceLocationSnapshot.bucket_time == bucket_start)
|
||||
)
|
||||
for row in aggregates.values():
|
||||
ip_info = resolve_ip_location(row["ip_address"])
|
||||
metadata = resolve_geo_location_metadata(ip_info)
|
||||
ip_info = resolved_locations[row["ip_address"]]
|
||||
metadata = resolved_metadata[row["ip_address"]]
|
||||
longitude = metadata.longitude
|
||||
latitude = metadata.latitude
|
||||
country = metadata.country or ip_info.country
|
||||
|
||||
@@ -11,7 +11,9 @@ from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.request_context import resolve_client_ip
|
||||
from app.models.user_login_session import UserLoginSession
|
||||
from app.services.ip_location import resolve_ip_location
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -59,6 +61,35 @@ def session_client_type(payload: dict[str, Any], headers: Any) -> str:
|
||||
return "desktop" if "desktop" in {from_payload, from_header} else "web"
|
||||
|
||||
|
||||
def login_activity_status(session: UserLoginSession, *, reference_time: datetime | None = None) -> str:
|
||||
if session.ended_at is not None:
|
||||
return "ENDED"
|
||||
cutoff = (reference_time or _now()) - timedelta(seconds=settings.USER_SESSION_ONLINE_SECONDS)
|
||||
return "ONLINE" if _as_utc(session.last_seen_at) >= cutoff else "OFFLINE"
|
||||
|
||||
|
||||
def login_activity_payload(
|
||||
session: UserLoginSession,
|
||||
*,
|
||||
reference_time: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
ip_location = resolve_ip_location(session.login_ip) if session.login_ip else None
|
||||
return {
|
||||
"id": session.id,
|
||||
"client_type": session.client_type,
|
||||
"client_platform": session.client_platform,
|
||||
"client_version": session.client_version,
|
||||
"client_source": session.client_source,
|
||||
"login_ip": session.login_ip,
|
||||
"ip_location": ip_location.location if ip_location else None,
|
||||
"login_at": session.login_at,
|
||||
"last_seen_at": session.last_seen_at,
|
||||
"ended_at": session.ended_at,
|
||||
"end_reason": session.end_reason,
|
||||
"activity_status": login_activity_status(session, reference_time=reference_time),
|
||||
}
|
||||
|
||||
|
||||
async def create_login_session(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
@@ -75,6 +106,7 @@ async def create_login_session(
|
||||
client_platform=_text_header(request.headers, "x-ctms-client-platform", 32),
|
||||
client_version=_text_header(request.headers, "x-ctms-client-version", 64),
|
||||
client_source=_text_header(request.headers, "x-ctms-client-source", 32),
|
||||
login_ip=resolve_client_ip(request),
|
||||
login_at=occurred_at,
|
||||
last_seen_at=occurred_at,
|
||||
)
|
||||
@@ -102,6 +134,7 @@ async def touch_login_session(
|
||||
client_platform=_text_header(request.headers, "x-ctms-client-platform", 32),
|
||||
client_version=_text_header(request.headers, "x-ctms-client-version", 64),
|
||||
client_source=_text_header(request.headers, "x-ctms-client-source", 32),
|
||||
login_ip=resolve_client_ip(request),
|
||||
login_at=now,
|
||||
last_seen_at=now,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import pytest
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services import ip_geolocation_fallback
|
||||
from app.services.ip_geolocation_fallback import ExternalIpLocation
|
||||
|
||||
|
||||
def test_ip2location_response_parser_requires_matching_public_ip_and_coordinates():
|
||||
result = ip_geolocation_fallback._parse_response(
|
||||
"8.8.8.8",
|
||||
{
|
||||
"ip": "8.8.8.8",
|
||||
"country_code": "US",
|
||||
"country_name": "United States of America",
|
||||
"region_name": "California",
|
||||
"city_name": "Mountain View",
|
||||
"latitude": 37.38605,
|
||||
"longitude": -122.08385,
|
||||
"isp": "Google LLC",
|
||||
},
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.country_code == "US"
|
||||
assert result.longitude == -122.08385
|
||||
assert ip_geolocation_fallback._parse_response("8.8.8.8", {"ip": "1.1.1.1"}) is None
|
||||
assert ip_geolocation_fallback._global_ip("192.168.1.8") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_external_fallback_caches_public_ip_results(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def fake_fetch(_client, _semaphore, ip_address):
|
||||
calls.append(ip_address)
|
||||
return ExternalIpLocation(
|
||||
ip_address=ip_address,
|
||||
country="United States of America",
|
||||
country_code="US",
|
||||
region="California",
|
||||
city="Mountain View",
|
||||
isp="Google LLC",
|
||||
longitude=-122.08385,
|
||||
latitude=37.38605,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(settings, "ENV", "development")
|
||||
monkeypatch.setattr(settings, "MONITORING_IP_GEO_FALLBACK_ENABLED", True)
|
||||
monkeypatch.setattr(ip_geolocation_fallback, "_fetch_one", fake_fetch)
|
||||
ip_geolocation_fallback.reset_ip_geolocation_fallback_cache()
|
||||
|
||||
first = await ip_geolocation_fallback.resolve_external_ip_locations(["8.8.8.8", "192.168.1.8"])
|
||||
second = await ip_geolocation_fallback.resolve_external_ip_locations(["8.8.8.8"])
|
||||
|
||||
assert first["8.8.8.8"].city == "Mountain View"
|
||||
assert second["8.8.8.8"].latitude == 37.38605
|
||||
assert calls == ["8.8.8.8"]
|
||||
ip_geolocation_fallback.reset_ip_geolocation_fallback_cache()
|
||||
@@ -4,6 +4,7 @@ import pytest
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services import monitoring_server_location
|
||||
from app.services.ip_geolocation_fallback import ExternalIpLocation
|
||||
|
||||
|
||||
def test_public_ip_normalization_rejects_private_addresses():
|
||||
@@ -51,3 +52,45 @@ async def test_server_location_returns_none_instead_of_a_hardcoded_fallback(monk
|
||||
|
||||
assert location is None
|
||||
monitoring_server_location.reset_monitoring_server_location_cache()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_location_uses_external_coordinates_when_local_coordinates_are_missing(monkeypatch):
|
||||
monkeypatch.setattr(settings, "MONITORING_SERVER_PUBLIC_IP", "5.34.216.210")
|
||||
monkeypatch.setattr(
|
||||
monitoring_server_location,
|
||||
"resolve_ip_location",
|
||||
lambda _ip: SimpleNamespace(
|
||||
location="公网",
|
||||
country="",
|
||||
province="",
|
||||
city="",
|
||||
isp="",
|
||||
),
|
||||
)
|
||||
|
||||
async def fake_external_lookup(ip_addresses):
|
||||
assert ip_addresses == ["5.34.216.210"]
|
||||
return {
|
||||
"5.34.216.210": ExternalIpLocation(
|
||||
ip_address="5.34.216.210",
|
||||
country="United States of America",
|
||||
country_code="US",
|
||||
region="California",
|
||||
city="Los Angeles",
|
||||
isp="Example ISP",
|
||||
longitude=-118.2439,
|
||||
latitude=34.05257,
|
||||
)
|
||||
}
|
||||
|
||||
monkeypatch.setattr(monitoring_server_location, "resolve_external_ip_locations", fake_external_lookup)
|
||||
monitoring_server_location.reset_monitoring_server_location_cache()
|
||||
|
||||
location = await monitoring_server_location.resolve_monitoring_server_location()
|
||||
|
||||
assert location is not None
|
||||
assert location.name == "United States of America / California / Los Angeles"
|
||||
assert location.longitude == -118.2439
|
||||
assert location.latitude == 34.05257
|
||||
monitoring_server_location.reset_monitoring_server_location_cache()
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.core.permission_monitor import set_permission_monitor, PermissionMonito
|
||||
from app.api.v1 import permission_monitoring
|
||||
from app.api.v1.system_permissions import list_system_permissions
|
||||
from app.services.monitoring_server_location import MonitoringServerLocation
|
||||
from app.services.ip_geolocation_fallback import ExternalIpLocation
|
||||
|
||||
|
||||
class FakeIpInfo:
|
||||
@@ -583,6 +584,57 @@ async def test_ip_locations_counts_unique_users_per_location(db_session, monkeyp
|
||||
assert sorted(resolved_ips) == ["10.1.1.1", "10.1.1.2", "10.1.1.3"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ip_locations_uses_external_coordinates_only_when_local_coordinates_are_missing(db_session, monkeypatch):
|
||||
await db_session.execute(text("DELETE FROM permission_access_logs"))
|
||||
await db_session.execute(text("DELETE FROM security_access_logs"))
|
||||
await db_session.commit()
|
||||
study_id = uuid.uuid4()
|
||||
user_id = uuid.uuid4()
|
||||
await _seed_permission_log(db_session, study_id, user_id, allowed=True, elapsed_ms=3.2)
|
||||
await db_session.execute(
|
||||
text("UPDATE permission_access_logs SET ip_address = :ip_address"),
|
||||
{"ip_address": "8.8.8.8"},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
monkeypatch.setattr(
|
||||
permission_monitoring,
|
||||
"resolve_ip_location",
|
||||
lambda _ip: FakeIpInfo("", "", isp="", country="未知国家"),
|
||||
)
|
||||
|
||||
async def fake_external_lookup(ip_addresses):
|
||||
assert ip_addresses == ["8.8.8.8"]
|
||||
return {
|
||||
"8.8.8.8": ExternalIpLocation(
|
||||
ip_address="8.8.8.8",
|
||||
country="United States of America",
|
||||
country_code="US",
|
||||
region="California",
|
||||
city="Mountain View",
|
||||
isp="Google LLC",
|
||||
longitude=-122.08385,
|
||||
latitude=37.38605,
|
||||
)
|
||||
}
|
||||
|
||||
monkeypatch.setattr(permission_monitoring, "resolve_external_ip_locations", fake_external_lookup)
|
||||
|
||||
result = await permission_monitoring.get_ip_locations(
|
||||
db=db_session,
|
||||
_=AdminUserStub(),
|
||||
days=7,
|
||||
limit=10,
|
||||
)
|
||||
|
||||
assert result["items"][0]["longitude"] == -122.08385
|
||||
assert result["items"][0]["latitude"] == 37.38605
|
||||
assert result["items"][0]["city"] == "Mountain View"
|
||||
assert result["data_quality"]["external_fallback_ip_count"] == 1
|
||||
assert result["data_quality"]["resolver"] == "ip2region+ip2location.io"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ip_locations_merges_same_region_with_different_isp(db_session, monkeypatch):
|
||||
"""同一省市的 IP 属地统计不应因运营商不同拆分。"""
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -8,7 +9,13 @@ from app.core.config import settings
|
||||
from app.crud import user as user_crud
|
||||
from app.models.user import User, UserStatus
|
||||
from app.models.user_login_session import UserLoginSession
|
||||
from app.services.user_login_sessions import get_login_summaries, session_id_from_payload
|
||||
from app.schemas.user import UserLoginActivityRead
|
||||
from app.services.user_login_sessions import (
|
||||
create_login_session,
|
||||
get_login_summaries,
|
||||
login_activity_payload,
|
||||
session_id_from_payload,
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_session_id_is_stable_without_storing_a_token():
|
||||
@@ -28,6 +35,63 @@ def test_session_heartbeat_returns_server_observed_client_ip():
|
||||
assert '"client_ip": resolve_client_ip(request)' in auth_source
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_session_stores_server_observed_login_ip(db_session):
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
email="login-ip@example.com",
|
||||
password_hash="hash",
|
||||
full_name="Login IP",
|
||||
clinical_department="IT",
|
||||
status=UserStatus.ACTIVE,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
request = SimpleNamespace(
|
||||
headers={
|
||||
"x-ctms-client-type": "desktop",
|
||||
"x-ctms-client-platform": "macos",
|
||||
"x-ctms-client-version": "0.1.0",
|
||||
},
|
||||
client=SimpleNamespace(host="203.0.113.18"),
|
||||
)
|
||||
|
||||
session = await create_login_session(
|
||||
db_session,
|
||||
session_id=uuid.uuid4(),
|
||||
user_id=user.id,
|
||||
request=request,
|
||||
)
|
||||
|
||||
assert session.login_ip == "203.0.113.18"
|
||||
assert session.client_type == "desktop"
|
||||
|
||||
|
||||
def test_login_activity_payload_uses_server_authoritative_status_and_local_ip_location(monkeypatch):
|
||||
now = datetime.now(timezone.utc)
|
||||
monkeypatch.setattr(settings, "USER_SESSION_ONLINE_SECONDS", 300)
|
||||
session = UserLoginSession(
|
||||
id=uuid.uuid4(),
|
||||
user_id=uuid.uuid4(),
|
||||
client_type="web",
|
||||
login_ip="127.0.0.1",
|
||||
login_at=now - timedelta(minutes=10),
|
||||
last_seen_at=now - timedelta(seconds=30),
|
||||
)
|
||||
|
||||
online = login_activity_payload(session, reference_time=now)
|
||||
assert online["activity_status"] == "ONLINE"
|
||||
assert online["login_ip"] == "127.0.0.1"
|
||||
assert online["ip_location"] == "本机"
|
||||
assert UserLoginActivityRead.model_validate(online).activity_status == "ONLINE"
|
||||
|
||||
session.last_seen_at = now - timedelta(minutes=6)
|
||||
assert login_activity_payload(session, reference_time=now)["activity_status"] == "OFFLINE"
|
||||
|
||||
session.ended_at = now - timedelta(minutes=1)
|
||||
assert login_activity_payload(session, reference_time=now)["activity_status"] == "ENDED"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_summary_marks_recent_unended_sessions_online(db_session, monkeypatch):
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
@@ -38,8 +38,14 @@ services:
|
||||
MONITORING_RETENTION_INTERVAL_SECONDS: ${MONITORING_RETENTION_INTERVAL_SECONDS:-86400}
|
||||
USER_LOGIN_ACTIVITY_RETENTION_DAYS: ${USER_LOGIN_ACTIVITY_RETENTION_DAYS:-180}
|
||||
USER_SESSION_ONLINE_SECONDS: ${USER_SESSION_ONLINE_SECONDS:-300}
|
||||
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-127.0.0.1/32,::1/128,172.16.0.0/12,192.168.0.0/16}
|
||||
MONITORING_SERVER_PUBLIC_IP: ${MONITORING_SERVER_PUBLIC_IP:-}
|
||||
MONITORING_PUBLIC_IP_DISCOVERY_URLS: ${MONITORING_PUBLIC_IP_DISCOVERY_URLS:-https://api64.ipify.org,https://icanhazip.com}
|
||||
MONITORING_IP_GEO_FALLBACK_ENABLED: ${MONITORING_IP_GEO_FALLBACK_ENABLED:-true}
|
||||
MONITORING_IP_GEO_FALLBACK_API_KEY: ${MONITORING_IP_GEO_FALLBACK_API_KEY:-}
|
||||
MONITORING_IP_GEO_FALLBACK_TIMEOUT_SECONDS: ${MONITORING_IP_GEO_FALLBACK_TIMEOUT_SECONDS:-2.5}
|
||||
MONITORING_IP_GEO_FALLBACK_CACHE_SECONDS: ${MONITORING_IP_GEO_FALLBACK_CACHE_SECONDS:-604800}
|
||||
MONITORING_IP_GEO_FALLBACK_MAX_LOOKUPS: ${MONITORING_IP_GEO_FALLBACK_MAX_LOOKUPS:-10}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -47,7 +47,11 @@ npm run desktop:build:app
|
||||
- [ ] Tauri capability 不包含 shell 权限、持久文件系统 scope 或宽泛目录读写。
|
||||
- [ ] 文件系统与 opener scope 只允许 `$TEMP/ctms-desktop/**`。
|
||||
- [ ] 单实例插件先于其他桌面插件注册。
|
||||
- [ ] Tauri command 白名单仅包含凭据和更新命令。
|
||||
- [ ] macOS 首个顶层 submenu 为应用菜单,包含关于、设置、服务、隐藏和退出;文件菜单保持独立。
|
||||
- [ ] macOS 红色按钮保持真正关闭窗口的系统语义,Dock/Finder reopen 事件在需要时重建、显示并聚焦主窗口。
|
||||
- [ ] 桌面快捷键只通过受控 command 同步到原生菜单,不向 WebView 开放原始窗口或菜单控制权限。
|
||||
- [ ] 桌面主题支持跟随系统、明亮和暗黑,并通过受控 command 同步窗口外观。
|
||||
- [ ] Tauri command 白名单仅包含凭据、更新和已审计的桌面 UI 窄命令。
|
||||
- [ ] 前端源码不通过 query string 传递 token。
|
||||
- [ ] `ctms_token` 只允许由 `secureSessionStorage` 处理。
|
||||
- [ ] 登录表单密码不写入 `localStorage` 或 `sessionStorage`;Web 端只使用浏览器凭据管理能力,Desktop 端只使用系统凭据库。
|
||||
@@ -91,6 +95,10 @@ npm run desktop:build:app
|
||||
| 系统通知拒绝 | 不适用 | 必测 | 开关回退,提示系统权限未开启 |
|
||||
| 通知领取与 ack | 不适用 | 必测 | 显示成功后 ack;失败等待租约重试 |
|
||||
| 单实例重复启动 | 不适用 | 必测 | 恢复、显示并聚焦主窗口 |
|
||||
| 关闭窗口后 Dock 重开 | 不适用 | 必测 | 红色关闭按钮真正关闭主窗口;点击 Dock 后重建、显示并聚焦 |
|
||||
| 原生应用菜单 | 不适用 | 必测 | CTMS、文件、编辑、显示、导航、窗口和帮助菜单顺序及职责符合 macOS 习惯 |
|
||||
| 自定义快捷键同步 | 不适用 | 必测 | 设置修改后原生菜单立即显示并响应新的后退、前进和刷新快捷键 |
|
||||
| 跟随系统主题 | 不适用 | 必测 | 系统明暗模式变化时 WebView、标题栏和系统控件同步更新 |
|
||||
| 自动更新检查 | 不适用 | 必测 | release 通道按当前 CTMS origin 派生清单 |
|
||||
| 更新稍后提醒 | 不适用 | 必测 | 同版本 24 小时内不重复提示 |
|
||||
| 更新安装失败 | 不适用 | 必测 | 不打断业务录入,显示可排障错误 |
|
||||
@@ -107,6 +115,8 @@ npm run desktop:build:app
|
||||
- [ ] 通知开关显示 OS 权限状态。
|
||||
- [ ] 手动检查更新能反馈“已是最新版本”、未启用更新或检查失败。
|
||||
- [ ] 关键弹窗、表单、按钮在最小窗口尺寸 `1180x760` 下不重叠、不溢出。
|
||||
- [ ] “设置…”位于 macOS CTMS 应用菜单并使用 `⌘,`;退出位于应用菜单而不是文件菜单。
|
||||
- [ ] “显示”和“导航”菜单中的快捷键与桌面偏好保存值一致。
|
||||
- [ ] 更新弹窗只显示版本、发布日期和通用 release notes,不展示 token、下载链接或业务详情。
|
||||
|
||||
## 5. 不允许项
|
||||
@@ -231,3 +241,17 @@ npm run desktop:build:app
|
||||
|
||||
- 在真实 macOS `.app` 中以 `1180x760` 检查登录页、服务器设置、个人中心、系统偏好和更新弹窗无重叠、无横向溢出。
|
||||
- 在真实系统通知权限拒绝流程中确认权限状态提示、开关回退和错误提示与 OS 状态一致。
|
||||
|
||||
## 11. 2026-07-13 macOS 原生体验适配记录
|
||||
|
||||
本轮继续保留 Tauri 和共享 Vue 前端,不引入 Swift 业务 UI,也不改变 Windows 内测边界:
|
||||
|
||||
- macOS 新增标准 CTMS 应用菜单,将关于、设置、服务、隐藏和退出等项目收敛到首个应用 submenu;文件、编辑、显示、导航、窗口和帮助菜单保持独立。
|
||||
- macOS 红色关闭按钮保持真正关闭主窗口的系统语义;Tauri `RunEvent::Reopen` 在 Dock/Finder 再激活时从受控配置重建、显示并聚焦主窗口,重复启动继续复用同一恢复 helper。
|
||||
- 新增受控 `desktop_menu_set_shortcuts` command,只接受后退、前进和刷新三项经过双层校验的快捷键,并同步原生菜单 accelerator。
|
||||
- 桌面主题新增“跟随系统”,WebView 监听 `prefers-color-scheme`,受控 `desktop_window_set_theme` command 同步 Tauri 窗口外观;未向 WebView 增加原始窗口权限。
|
||||
- `desktop:release:check` 已增加应用菜单、Dock reopen、关闭后重建、快捷键同步、系统主题和新增 command 白名单约束。
|
||||
|
||||
真实 `.app` 仍需人工验证:红色按钮真正关闭主窗口后 Dock 重建、菜单顺序、个人中心自身关闭按钮、编辑菜单文本输入行为、自定义快捷键即时同步,以及系统明暗模式切换时的标题栏一致性。
|
||||
|
||||
本轮未引入以下条件性 Swift 能力:Quick Look 需先确认附件审阅频率和 Windows 降级语义;Touch ID 会改变现有 30 天会话恢复体验,需先确定凭据访问控制策略;通知点击回跳需先定义固定、无敏感信息的动作和目标页面。三项均不是当前发布前置条件。
|
||||
|
||||
@@ -30,12 +30,19 @@ The runtime contract currently provides:
|
||||
- file picker/save/open adapters
|
||||
- desktop system notification adapters
|
||||
- desktop updater adapters
|
||||
- native desktop application menu and shortcut synchronization
|
||||
- desktop window lifecycle and system appearance synchronization
|
||||
|
||||
Business modules must use this public runtime entry point. They must not inspect
|
||||
Tauri globals or import Tauri packages directly. Native files, notifications,
|
||||
secure session storage, and automatic updates must remain behind
|
||||
`frontend/src/runtime/` and explicit capability flags.
|
||||
|
||||
Desktop menu accelerators and window appearance are synchronized through narrow
|
||||
Tauri commands owned by `frontend/src/runtime/`. macOS close/reopen behavior is
|
||||
handled by the Tauri event loop so a genuinely closed main window can be rebuilt from the Dock;
|
||||
these concerns must not move into Vue business modules.
|
||||
|
||||
## Version Change
|
||||
|
||||
Update every client manifest with one command:
|
||||
|
||||
@@ -33,13 +33,19 @@ docker compose ps
|
||||
MONITORING_ACCESS_LOG_RETENTION_DAYS=90
|
||||
MONITORING_METRIC_RETENTION_DAYS=400
|
||||
MONITORING_RETENTION_INTERVAL_SECONDS=86400
|
||||
MONITORING_IP_GEO_FALLBACK_ENABLED=true
|
||||
MONITORING_IP_GEO_FALLBACK_TIMEOUT_SECONDS=2.5
|
||||
MONITORING_IP_GEO_FALLBACK_CACHE_SECONDS=604800
|
||||
MONITORING_IP_GEO_FALLBACK_MAX_LOOKUPS=10
|
||||
USER_LOGIN_ACTIVITY_RETENTION_DAYS=180
|
||||
USER_SESSION_ONLINE_SECONDS=300
|
||||
```
|
||||
|
||||
访问日志留存范围为 7–3650 天,指标留存范围为 30–3650 天,清理周期范围为 60–604800 秒。修改后需重启后端。正式环境部署新版本前必须先执行 Alembic migration。
|
||||
|
||||
账号管理页的“在线”状态由服务端会话心跳计算:最近 `USER_SESSION_ONLINE_SECONDS` 秒内成功心跳且未退出的会话视为在线。登录记录仅保存客户端类型、版本、登录/最近活动/退出时间;不保存 Token、密码或原始 IP。
|
||||
账号管理页的“在线”状态由服务端会话心跳计算:最近 `USER_SESSION_ONLINE_SECONDS` 秒内成功心跳且未退出的会话视为在线。登录记录保存客户端类型、版本、服务端观测到的登录 IP、登录/最近活动/退出时间;IP 属地由本地离线数据库按需解析,不发送到外部服务。登录记录接口仅限系统管理员访问并禁止 HTTP 缓存,不保存 Token 或密码,并与登录活动一起按 `USER_LOGIN_ACTIVITY_RETENTION_DAYS` 清理。
|
||||
|
||||
登录 IP 只接受可信反向代理提供的转发地址。Docker Compose 默认信任容器常用的 `172.16.0.0/12` 和 `192.168.0.0/16` 内部网段;其他部署必须通过 `TRUSTED_PROXY_CIDRS` 明确配置实际代理网段,不得直接信任任意来源的 `X-Forwarded-For`。
|
||||
|
||||
## 来源地图服务器位置
|
||||
|
||||
@@ -49,7 +55,13 @@ USER_SESSION_ONLINE_SECONDS=300
|
||||
2. `FRONTEND_PUBLIC_URL` 主机名解析出的公网 IP。
|
||||
3. `MONITORING_PUBLIC_IP_DISCOVERY_URLS` 配置的公网出口 IP 查询服务。
|
||||
|
||||
默认公网查询服务为 `https://api64.ipify.org,https://icanhazip.com`,单次超时 2.5 秒。解析成功后通过本地 ip2region 数据库确定属地,再由后端转换为地图坐标;解析失败时 API 返回 `server_location: null`,地图不会使用任意默认城市代替。
|
||||
默认公网查询服务为 `https://api64.ipify.org,https://icanhazip.com`,单次超时 2.5 秒。解析成功后通过本地 ip2region 数据库确定属地并转换为地图坐标;本地库没有坐标时复用下述 IP2Location.io 受控兜底。两条链路均失败时 API 返回 `server_location: null`,地图不会使用任意默认城市代替。默认打开全球视图,以便服务器与访问来源分属不同国家时仍能完整显示飞线。
|
||||
|
||||
## 访问来源坐标兜底
|
||||
|
||||
访问来源始终先使用本地 ip2region 和内置行政区质心解析。只有公网 IP 已识别、但本地链路无法得到地图坐标时,后端才调用 IPAddress.my 使用的 IP2Location.io 官方 JSON API `https://api.ip2location.io/` 补充经纬度;私网、回环、链路本地和无效地址不会发送给第三方。
|
||||
|
||||
兜底默认启用,单次请求最多查询 10 个尚未缓存的公网 IP,最多并发 5 个请求,超时 2.5 秒。成功结果缓存 7 天,失败结果缓存 1 小时;第三方不可用时继续返回本地解析结果,不影响访问来源接口。可设置 `MONITORING_IP_GEO_FALLBACK_ENABLED=false` 完全禁用外部查询。无密钥模式受服务方每日额度限制;如需配置 API Key,使用 `MONITORING_IP_GEO_FALLBACK_API_KEY`,后端通过 `Authorization: Bearer` 发送,禁止把密钥写入 URL 或日志。
|
||||
|
||||
受限网络建议显式配置:
|
||||
|
||||
|
||||
@@ -174,18 +174,56 @@ const verifyRustBoundary = async () => {
|
||||
singleInstanceIndex,
|
||||
dialogIndex > singleInstanceIndex ? dialogIndex : singleInstanceIndex + 600,
|
||||
);
|
||||
const restoreWindowStart = libSource.indexOf("fn restore_main_window");
|
||||
const restoreWindowEnd = libSource.indexOf("fn is_allowed_shortcut_key", restoreWindowStart);
|
||||
const restoreWindowSource = libSource.slice(restoreWindowStart, restoreWindowEnd);
|
||||
const restoreWindowTokens = ['get_webview_window("main")', "window.unminimize()", "window.show()", "window.set_focus()"];
|
||||
assert(restoreWindowStart >= 0, "Desktop runtime must define a shared main-window restore helper.");
|
||||
assert(
|
||||
singleInstanceSource.includes("restore_main_window(app)"),
|
||||
"Single-instance duplicate launch handler must use the shared main-window restore helper.",
|
||||
);
|
||||
for (const token of restoreWindowTokens) {
|
||||
assert(singleInstanceSource.includes(token), `Single-instance duplicate launch handler must call ${token}.`);
|
||||
assert(restoreWindowSource.includes(token), `Main-window restore helper must call ${token}.`);
|
||||
}
|
||||
assert(
|
||||
singleInstanceSource.indexOf("window.unminimize()") < singleInstanceSource.indexOf("window.show()") &&
|
||||
singleInstanceSource.indexOf("window.show()") < singleInstanceSource.indexOf("window.set_focus()"),
|
||||
"Single-instance duplicate launch handler must restore, show, then focus the main window.",
|
||||
restoreWindowSource.indexOf("window.unminimize()") < restoreWindowSource.indexOf("window.show()") &&
|
||||
restoreWindowSource.indexOf("window.show()") < restoreWindowSource.indexOf("window.set_focus()"),
|
||||
"Main-window restore helper must restore, show, then focus the main window.",
|
||||
);
|
||||
assert(libSource.includes("RunEvent::Reopen"), "macOS Dock reopen events must restore the main window.");
|
||||
assert(libSource.includes("WebviewWindowBuilder::from_config"), "Dock reopen must rebuild a main window that was actually closed.");
|
||||
assert(libSource.includes('find(|config| config.label == "main")'), "Main-window rebuild must use only the configured main window.");
|
||||
assert(!libSource.includes("WindowEvent::CloseRequested"), "The macOS red close button must keep its native close-window semantics.");
|
||||
assert(!libSource.includes("api.prevent_close()"), "The macOS red close button must not be converted into a hide action.");
|
||||
assert(!libSource.includes("window.hide()"), "The macOS red close button must not hide the main window.");
|
||||
|
||||
const appMenuIndex = libSource.indexOf('package.name.clone()');
|
||||
const fileMenuIndex = libSource.indexOf('"文件"');
|
||||
assert(appMenuIndex >= 0 && appMenuIndex < fileMenuIndex, "macOS application menu must precede the File menu.");
|
||||
for (const token of [
|
||||
'Some("关于 CTMS")',
|
||||
"short_version: Some(String::new())",
|
||||
"icon: handle.default_window_icon().cloned()",
|
||||
'"ctms.desktop.preferences"',
|
||||
"PredefinedMenuItem::services",
|
||||
"PredefinedMenuItem::hide",
|
||||
"PredefinedMenuItem::hide_others",
|
||||
"PredefinedMenuItem::show_all",
|
||||
"PredefinedMenuItem::quit",
|
||||
"WINDOW_SUBMENU_ID",
|
||||
"HELP_SUBMENU_ID",
|
||||
"TOGGLE_SIDEBAR_COMMAND_ID",
|
||||
'"显示/隐藏导航栏"',
|
||||
]) {
|
||||
assert(libSource.includes(token), `Desktop menu must include ${token}.`);
|
||||
}
|
||||
|
||||
const handlerSource = libSource.match(/generate_handler!\s*\\?\[([\s\S]*?)\]/)?.[1] || "";
|
||||
const commands = handlerSource.match(/[a-z_]+::[a-z_]+/g) || [];
|
||||
const commands = handlerSource
|
||||
.split(",")
|
||||
.map((command) => command.trim())
|
||||
.filter(Boolean);
|
||||
const allowedCommands = [
|
||||
"credentials::credential_get",
|
||||
"credentials::credential_set",
|
||||
@@ -195,6 +233,8 @@ const verifyRustBoundary = async () => {
|
||||
"credentials::login_credential_delete",
|
||||
"updates::desktop_update_check",
|
||||
"updates::desktop_update_install",
|
||||
"desktop_menu_set_shortcuts",
|
||||
"desktop_window_set_theme",
|
||||
];
|
||||
const unexpected = commands.filter((command) => !allowedCommands.includes(command));
|
||||
const missing = allowedCommands.filter((command) => !commands.includes(command));
|
||||
@@ -202,6 +242,20 @@ const verifyRustBoundary = async () => {
|
||||
assert(missing.length === 0, `Missing expected Tauri commands: ${missing.join(", ") || "<none>"}.`);
|
||||
};
|
||||
|
||||
const verifyDesktopUiNativeSync = async () => {
|
||||
const menuSource = await readFile(resolve(sourceDir, "runtime/desktopMenu.ts"), "utf8");
|
||||
const preferencesSource = await readFile(resolve(sourceDir, "runtime/desktopUiPreferences.ts"), "utf8");
|
||||
const appSource = await readFile(resolve(sourceDir, "App.vue"), "utf8");
|
||||
|
||||
assert(menuSource.includes('invoke("desktop_menu_set_shortcuts"'), "Desktop shortcuts must sync through the controlled Tauri command.");
|
||||
assert(menuSource.includes("DESKTOP_SHORTCUTS_CHANGED_EVENT"), "Native menu shortcuts must track preference changes.");
|
||||
assert(preferencesSource.includes('"system" | "light" | "dark"'), "Desktop theme must support following the system appearance.");
|
||||
assert(preferencesSource.includes('invoke("desktop_window_set_theme"'), "Desktop window theme must sync through the controlled Tauri command.");
|
||||
assert(preferencesSource.includes('matchMedia("(prefers-color-scheme: dark)")'), "System theme changes must update the WebView appearance.");
|
||||
assert(appSource.includes("initializeDesktopThemePreference()"), "Desktop theme synchronization must initialize at app startup.");
|
||||
assert(appSource.includes("initializeDesktopMenuShortcutSync()"), "Native menu shortcut synchronization must initialize at app startup.");
|
||||
};
|
||||
|
||||
const verifySourceSafety = async () => {
|
||||
const sourceExtensions = new Set([".ts", ".tsx", ".vue", ".js", ".jsx", ".rs"]);
|
||||
const files = [
|
||||
@@ -358,6 +412,7 @@ const verifyWorkflowGates = async () => {
|
||||
await verifyTauriConfig();
|
||||
await verifyCapabilities();
|
||||
await verifyRustBoundary();
|
||||
await verifyDesktopUiNativeSync();
|
||||
await verifySourceSafety();
|
||||
await verifyNotificationBoundary();
|
||||
await verifySessionBoundary();
|
||||
|
||||
+290
-31
@@ -1,15 +1,90 @@
|
||||
mod credentials;
|
||||
mod updates;
|
||||
|
||||
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu};
|
||||
use tauri::{Emitter, Manager, Runtime};
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde::Deserialize;
|
||||
use tauri::menu::{
|
||||
AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID,
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::RunEvent;
|
||||
use tauri::{Emitter, Manager, Runtime, Theme, WebviewWindow, WebviewWindowBuilder};
|
||||
|
||||
const DESKTOP_MENU_COMMAND_EVENT: &str = "ctms:desktop-menu-command";
|
||||
const VIEW_MENU_ID: &str = "ctms.menu.view";
|
||||
const NAVIGATION_MENU_ID: &str = "ctms.menu.navigation";
|
||||
const REFRESH_COMMAND_ID: &str = "ctms.desktop.refresh";
|
||||
const TOGGLE_SIDEBAR_COMMAND_ID: &str = "ctms.desktop.toggleSidebar";
|
||||
const BACK_COMMAND_ID: &str = "ctms.desktop.back";
|
||||
const FORWARD_COMMAND_ID: &str = "ctms.desktop.forward";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DesktopMenuShortcuts {
|
||||
back: String,
|
||||
forward: String,
|
||||
refresh: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum DesktopThemePreference {
|
||||
System,
|
||||
Light,
|
||||
Dark,
|
||||
}
|
||||
|
||||
fn desktop_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>> {
|
||||
let package = handle.package_info();
|
||||
let config = handle.config();
|
||||
let about = AboutMetadata {
|
||||
name: Some(package.name.clone()),
|
||||
version: Some(package.version.to_string()),
|
||||
// macOS falls back to CFBundleVersion when this value is omitted, which
|
||||
// renders duplicated text such as `Version 0.1.0 (0.1.0)`.
|
||||
#[cfg(target_os = "macos")]
|
||||
short_version: Some(String::new()),
|
||||
copyright: config.bundle.copyright.clone(),
|
||||
authors: config
|
||||
.bundle
|
||||
.publisher
|
||||
.clone()
|
||||
.map(|publisher| vec![publisher]),
|
||||
// Supplying the icon explicitly keeps the native About panel from
|
||||
// falling back to the generic macOS application/folder icon.
|
||||
icon: handle.default_window_icon().cloned(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Menu::with_items(
|
||||
handle,
|
||||
&[
|
||||
#[cfg(target_os = "macos")]
|
||||
&Submenu::with_items(
|
||||
handle,
|
||||
package.name.clone(),
|
||||
true,
|
||||
&[
|
||||
&PredefinedMenuItem::about(handle, Some("关于 CTMS"), Some(about.clone()))?,
|
||||
&PredefinedMenuItem::separator(handle)?,
|
||||
&MenuItem::with_id(
|
||||
handle,
|
||||
"ctms.desktop.preferences",
|
||||
"设置…",
|
||||
true,
|
||||
Some("CmdOrCtrl+,"),
|
||||
)?,
|
||||
&PredefinedMenuItem::separator(handle)?,
|
||||
&PredefinedMenuItem::services(handle, None)?,
|
||||
&PredefinedMenuItem::separator(handle)?,
|
||||
&PredefinedMenuItem::hide(handle, None)?,
|
||||
&PredefinedMenuItem::hide_others(handle, None)?,
|
||||
&PredefinedMenuItem::show_all(handle, None)?,
|
||||
&PredefinedMenuItem::separator(handle)?,
|
||||
&PredefinedMenuItem::quit(handle, None)?,
|
||||
],
|
||||
)?,
|
||||
&Submenu::with_items(
|
||||
handle,
|
||||
"文件",
|
||||
@@ -22,15 +97,17 @@ fn desktop_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<
|
||||
true,
|
||||
Some("CmdOrCtrl+K"),
|
||||
)?,
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
&MenuItem::with_id(
|
||||
handle,
|
||||
"ctms.desktop.serverSettings",
|
||||
"服务器设置",
|
||||
"ctms.desktop.preferences",
|
||||
"设置",
|
||||
true,
|
||||
None::<&str>,
|
||||
Some("CmdOrCtrl+,"),
|
||||
)?,
|
||||
&PredefinedMenuItem::separator(handle)?,
|
||||
&PredefinedMenuItem::close_window(handle, None)?,
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
&PredefinedMenuItem::quit(handle, None)?,
|
||||
],
|
||||
)?,
|
||||
@@ -48,44 +125,49 @@ fn desktop_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<
|
||||
&PredefinedMenuItem::select_all(handle, None)?,
|
||||
],
|
||||
)?,
|
||||
&Submenu::with_items(
|
||||
&Submenu::with_id_and_items(
|
||||
handle,
|
||||
"视图",
|
||||
VIEW_MENU_ID,
|
||||
"显示",
|
||||
true,
|
||||
&[
|
||||
&MenuItem::with_id(
|
||||
handle,
|
||||
"ctms.desktop.refresh",
|
||||
REFRESH_COMMAND_ID,
|
||||
"刷新当前视图",
|
||||
true,
|
||||
Some("CmdOrCtrl+R"),
|
||||
)?,
|
||||
&MenuItem::with_id(
|
||||
handle,
|
||||
TOGGLE_SIDEBAR_COMMAND_ID,
|
||||
"显示/隐藏导航栏",
|
||||
true,
|
||||
None::<&str>,
|
||||
)?,
|
||||
&PredefinedMenuItem::separator(handle)?,
|
||||
&PredefinedMenuItem::fullscreen(handle, None)?,
|
||||
],
|
||||
)?,
|
||||
&Submenu::with_items(
|
||||
&Submenu::with_id_and_items(
|
||||
handle,
|
||||
NAVIGATION_MENU_ID,
|
||||
"导航",
|
||||
true,
|
||||
&[
|
||||
&MenuItem::with_id(handle, BACK_COMMAND_ID, "后退", true, Some("CmdOrCtrl+["))?,
|
||||
&MenuItem::with_id(
|
||||
handle,
|
||||
"ctms.desktop.back",
|
||||
"返回",
|
||||
true,
|
||||
None::<&str>,
|
||||
)?,
|
||||
&MenuItem::with_id(
|
||||
handle,
|
||||
"ctms.desktop.forward",
|
||||
FORWARD_COMMAND_ID,
|
||||
"前进",
|
||||
true,
|
||||
None::<&str>,
|
||||
Some("CmdOrCtrl+]"),
|
||||
)?,
|
||||
],
|
||||
)?,
|
||||
&Submenu::with_items(
|
||||
&Submenu::with_id_and_items(
|
||||
handle,
|
||||
WINDOW_SUBMENU_ID,
|
||||
"窗口",
|
||||
true,
|
||||
&[
|
||||
@@ -94,17 +176,20 @@ fn desktop_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<
|
||||
&PredefinedMenuItem::close_window(handle, None)?,
|
||||
],
|
||||
)?,
|
||||
&Submenu::with_items(
|
||||
&Submenu::with_id_and_items(
|
||||
handle,
|
||||
HELP_SUBMENU_ID,
|
||||
"帮助",
|
||||
true,
|
||||
&[
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
&PredefinedMenuItem::about(handle, Some("关于 CTMS"), Some(about))?,
|
||||
&MenuItem::with_id(
|
||||
handle,
|
||||
"ctms.desktop.preferences",
|
||||
"桌面偏好",
|
||||
"ctms.desktop.help",
|
||||
"CTMS 命令与导航",
|
||||
true,
|
||||
Some("CmdOrCtrl+,"),
|
||||
None::<&str>,
|
||||
)?,
|
||||
],
|
||||
)?,
|
||||
@@ -118,8 +203,148 @@ fn emit_menu_command<R: Runtime>(app: &tauri::AppHandle<R>, command: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_main_window<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<(), String> {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let config = app
|
||||
.config()
|
||||
.app
|
||||
.windows
|
||||
.iter()
|
||||
.find(|config| config.label == "main")
|
||||
.cloned()
|
||||
.ok_or_else(|| "未找到主窗口配置".to_string())?;
|
||||
let window = WebviewWindowBuilder::from_config(app, &config)
|
||||
.map_err(|error| format!("读取主窗口配置失败:{error}"))?
|
||||
.build()
|
||||
.map_err(|error| format!("重建主窗口失败:{error}"))?;
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_allowed_shortcut_key(value: &str) -> bool {
|
||||
matches!(
|
||||
value,
|
||||
"[" | "]"
|
||||
| ","
|
||||
| "."
|
||||
| "/"
|
||||
| ";"
|
||||
| "="
|
||||
| "-"
|
||||
| "ArrowLeft"
|
||||
| "ArrowRight"
|
||||
| "ArrowUp"
|
||||
| "ArrowDown"
|
||||
| "Home"
|
||||
| "End"
|
||||
| "PageUp"
|
||||
| "PageDown"
|
||||
) || (value.len() == 1
|
||||
&& value
|
||||
.as_bytes()
|
||||
.first()
|
||||
.is_some_and(|value| value.is_ascii_uppercase() || value.is_ascii_digit()))
|
||||
}
|
||||
|
||||
fn normalize_menu_shortcut(value: &str) -> Result<String, String> {
|
||||
const RESERVED: &[&str] = &[
|
||||
"Mod+K",
|
||||
"Mod+,",
|
||||
"Mod+W",
|
||||
"Mod+Q",
|
||||
"Mod+C",
|
||||
"Mod+V",
|
||||
"Mod+X",
|
||||
"Mod+A",
|
||||
"Mod+Z",
|
||||
"Mod+Shift+Z",
|
||||
];
|
||||
|
||||
if RESERVED.contains(&value) {
|
||||
return Err("快捷键与系统或应用保留快捷键冲突".to_string());
|
||||
}
|
||||
|
||||
let parts = value.split('+').collect::<Vec<_>>();
|
||||
if parts.len() < 2 || parts[0] != "Mod" {
|
||||
return Err("快捷键必须以 Mod 开头".to_string());
|
||||
}
|
||||
|
||||
let key = parts.last().copied().unwrap_or_default();
|
||||
if !is_allowed_shortcut_key(key) {
|
||||
return Err("快捷键按键不受支持".to_string());
|
||||
}
|
||||
|
||||
let mut modifiers = HashSet::new();
|
||||
for modifier in &parts[1..parts.len() - 1] {
|
||||
if !matches!(*modifier, "Shift" | "Alt") || !modifiers.insert(*modifier) {
|
||||
return Err("快捷键修饰键不受支持或重复".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(format!("CmdOrCtrl+{}", parts[1..].join("+")))
|
||||
}
|
||||
|
||||
fn set_menu_accelerator<R: Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
submenu_id: &str,
|
||||
item_id: &str,
|
||||
accelerator: &str,
|
||||
) -> Result<(), String> {
|
||||
let menu = app.menu().ok_or_else(|| "桌面菜单尚未初始化".to_string())?;
|
||||
let submenu = menu
|
||||
.get(submenu_id)
|
||||
.and_then(|item| item.as_submenu().cloned())
|
||||
.ok_or_else(|| format!("未找到桌面菜单:{submenu_id}"))?;
|
||||
let item = submenu
|
||||
.get(item_id)
|
||||
.and_then(|item| item.as_menuitem().cloned())
|
||||
.ok_or_else(|| format!("未找到桌面菜单项:{item_id}"))?;
|
||||
item.set_accelerator(Some(accelerator))
|
||||
.map_err(|error| format!("更新桌面菜单快捷键失败:{error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn desktop_menu_set_shortcuts<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
shortcuts: DesktopMenuShortcuts,
|
||||
) -> Result<(), String> {
|
||||
let back = normalize_menu_shortcut(&shortcuts.back)?;
|
||||
let forward = normalize_menu_shortcut(&shortcuts.forward)?;
|
||||
let refresh = normalize_menu_shortcut(&shortcuts.refresh)?;
|
||||
if HashSet::from([back.as_str(), forward.as_str(), refresh.as_str()]).len() != 3 {
|
||||
return Err("桌面快捷键不能重复".to_string());
|
||||
}
|
||||
|
||||
set_menu_accelerator(&app, NAVIGATION_MENU_ID, BACK_COMMAND_ID, &back)?;
|
||||
set_menu_accelerator(&app, NAVIGATION_MENU_ID, FORWARD_COMMAND_ID, &forward)?;
|
||||
set_menu_accelerator(&app, VIEW_MENU_ID, REFRESH_COMMAND_ID, &refresh)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn desktop_window_set_theme<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
theme: DesktopThemePreference,
|
||||
) -> Result<(), String> {
|
||||
let theme = match theme {
|
||||
DesktopThemePreference::System => None,
|
||||
DesktopThemePreference::Light => Some(Theme::Light),
|
||||
DesktopThemePreference::Dark => Some(Theme::Dark),
|
||||
};
|
||||
window
|
||||
.set_theme(theme)
|
||||
.map_err(|error| format!("更新桌面窗口主题失败:{error}"))
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
let app = tauri::Builder::default()
|
||||
.menu(desktop_menu)
|
||||
.on_menu_event(|app, event| {
|
||||
let command = event.id().as_ref();
|
||||
@@ -128,11 +353,7 @@ pub fn run() {
|
||||
}
|
||||
})
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
let _ = restore_main_window(app);
|
||||
}))
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
@@ -149,7 +370,45 @@ pub fn run() {
|
||||
credentials::login_credential_delete,
|
||||
updates::desktop_update_check,
|
||||
updates::desktop_update_install,
|
||||
desktop_menu_set_shortcuts,
|
||||
desktop_window_set_theme,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running CTMS desktop application");
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building CTMS desktop application");
|
||||
|
||||
app.run(|app, event| {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let RunEvent::Reopen { .. } = event {
|
||||
if let Err(error) = restore_main_window(app) {
|
||||
eprintln!("{error}");
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = (app, event);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_menu_shortcut;
|
||||
|
||||
#[test]
|
||||
fn converts_runtime_shortcuts_to_native_accelerators() {
|
||||
assert_eq!(
|
||||
normalize_menu_shortcut("Mod+Shift+[").unwrap(),
|
||||
"CmdOrCtrl+Shift+["
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_menu_shortcut("Mod+Alt+ArrowLeft").unwrap(),
|
||||
"CmdOrCtrl+Alt+ArrowLeft"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_reserved_or_malformed_shortcuts() {
|
||||
assert!(normalize_menu_shortcut("Mod+Q").is_err());
|
||||
assert!(normalize_menu_shortcut("Ctrl+R").is_err());
|
||||
assert!(normalize_menu_shortcut("Mod+Shift+Shift+R").is_err());
|
||||
assert!(normalize_menu_shortcut("Mod+F12").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,13 +10,14 @@
|
||||
import { initSessionManager } from "./session/sessionManager";
|
||||
import { initDesktopNotificationManager } from "./session/desktopNotificationManager";
|
||||
import { initDesktopUpdateManager } from "./session/desktopUpdateManager";
|
||||
import { applyDesktopThemePreference, isTauriRuntime } from "./runtime";
|
||||
import { initializeDesktopMenuShortcutSync, initializeDesktopThemePreference, isTauriRuntime } from "./runtime";
|
||||
import SessionTimeoutPrompt from "./components/SessionTimeoutPrompt.vue";
|
||||
|
||||
const isDesktopRuntime = isTauriRuntime();
|
||||
|
||||
if (isDesktopRuntime) {
|
||||
applyDesktopThemePreference();
|
||||
initializeDesktopThemePreference();
|
||||
initializeDesktopMenuShortcutSync();
|
||||
document.body.classList.add("is-desktop-runtime");
|
||||
} else {
|
||||
document.body.classList.remove("is-desktop-runtime");
|
||||
|
||||
@@ -22,7 +22,7 @@ export const updateUser = (
|
||||
export const deleteUser = (userId: string) =>
|
||||
apiDelete<void>(`/api/v1/users/${userId}`, { suppressErrorMessage: true });
|
||||
|
||||
export const fetchUserLoginActivities = (userId: string, limit = 30) =>
|
||||
export const fetchUserLoginActivities = (userId: string, limit = 100) =>
|
||||
apiGet<UserLoginActivity[]>(`/api/v1/users/${userId}/login-activities`, {
|
||||
params: { limit },
|
||||
});
|
||||
|
||||
@@ -14,6 +14,12 @@ describe("account connection status", () => {
|
||||
expect(component).toContain("当前账号与连接状态");
|
||||
expect(component).toContain("API 延迟");
|
||||
expect(component).toContain("会话状态");
|
||||
expect(component).toContain("useStudyStore");
|
||||
expect(component).toContain("getProjectRole(study.currentStudy, study.currentStudyRole)");
|
||||
expect(component).toContain('isSystemAdmin(auth.user) ? "系统角色" : "当前项目角色"');
|
||||
expect(component).toContain("projectRole.value ? displayRoleLabel(projectRole.value) : \"未选择项目\"");
|
||||
expect(component).toContain("`${study.currentStudy.name} · ${roleLabel.value}`");
|
||||
expect(component).not.toContain('auth.user?.is_admin ? "系统管理员" : "普通账号"');
|
||||
expect(component).toContain("mode === 'network'");
|
||||
expect(component).toContain("当前 IP");
|
||||
expect(component).toContain("clientIpLabel");
|
||||
|
||||
@@ -59,8 +59,8 @@
|
||||
|
||||
<dl class="connection-details-grid">
|
||||
<div>
|
||||
<dt>角色</dt>
|
||||
<dd>{{ roleLabel }}</dd>
|
||||
<dt>{{ roleFieldLabel }}</dt>
|
||||
<dd :title="roleContextTitle">{{ roleLabel }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>客户端</dt>
|
||||
@@ -91,11 +91,14 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { useSessionStore } from "../store/session";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { clientRuntime, getAppMetadata, isTauriRuntime } from "../runtime";
|
||||
import { IDLE_TIMEOUT_MINUTES } from "../session/sessionManager";
|
||||
import { parseJwtExp } from "../session/jwt";
|
||||
import { useConnectionStatus } from "../composables/useConnectionStatus";
|
||||
import { useRoleTemplateMeta } from "../composables/useRoleTemplateMeta";
|
||||
import { evaluateConnectionSecurity } from "../utils/connectionSecurity";
|
||||
import { getProjectRole, isSystemAdmin } from "../utils/roles";
|
||||
|
||||
withDefaults(defineProps<{ mode?: "indicator" | "details" | "network" }>(), {
|
||||
mode: "indicator",
|
||||
@@ -103,8 +106,10 @@ withDefaults(defineProps<{ mode?: "indicator" | "details" | "network" }>(), {
|
||||
|
||||
const auth = useAuthStore();
|
||||
const session = useSessionStore();
|
||||
const study = useStudyStore();
|
||||
const isDesktop = isTauriRuntime();
|
||||
const appMetadata = getAppMetadata();
|
||||
const { roleLabel: displayRoleLabel, loadRoleTemplates } = useRoleTemplateMeta();
|
||||
const {
|
||||
status,
|
||||
statusLabel,
|
||||
@@ -125,7 +130,17 @@ const securityCheckExpanded = ref(true);
|
||||
const userDisplayName = computed(
|
||||
() => auth.user?.full_name || auth.user?.username || auth.user?.email || "当前用户",
|
||||
);
|
||||
const roleLabel = computed(() => (auth.user?.is_admin ? "系统管理员" : "普通账号"));
|
||||
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
|
||||
const roleFieldLabel = computed(() => (isSystemAdmin(auth.user) ? "系统角色" : "当前项目角色"));
|
||||
const roleLabel = computed(() => {
|
||||
if (isSystemAdmin(auth.user)) return "系统管理员";
|
||||
return projectRole.value ? displayRoleLabel(projectRole.value) : "未选择项目";
|
||||
});
|
||||
const roleContextTitle = computed(() => {
|
||||
if (isSystemAdmin(auth.user)) return roleLabel.value;
|
||||
if (!study.currentStudy) return "选择项目后显示该项目内的具体角色";
|
||||
return `${study.currentStudy.name} · ${roleLabel.value}`;
|
||||
});
|
||||
const clientLabel = computed(() => {
|
||||
if (!isDesktop) return "网页端";
|
||||
const platform = appMetadata.platform === "macos" ? "macOS" : appMetadata.platform || "桌面系统";
|
||||
@@ -195,6 +210,9 @@ const runSecurityCheck = async () => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (!isSystemAdmin(auth.user)) {
|
||||
void loadRoleTemplates().catch(() => {});
|
||||
}
|
||||
stopConnectionMonitor = startConnectionMonitor();
|
||||
nowTs.value = Date.now();
|
||||
sessionClockTimer = window.setInterval(() => {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<template>
|
||||
<div class="desktop-workbench" @click="closeWorkspaceTabMenu">
|
||||
<div
|
||||
class="desktop-workbench"
|
||||
:class="{
|
||||
'is-sidebar-hidden': !desktopSidebarVisible,
|
||||
'is-macos': desktopMetadata.platform === 'macos',
|
||||
}"
|
||||
@click="closeWorkspaceTabMenu"
|
||||
>
|
||||
<aside class="desktop-sidebar">
|
||||
<header class="sidebar-head">
|
||||
<div class="sidebar-title-row">
|
||||
@@ -117,9 +124,54 @@
|
||||
<section class="desktop-main">
|
||||
<header class="desktop-toolbar">
|
||||
<div class="toolbar-left" data-tauri-drag-region>
|
||||
<button
|
||||
class="toolbar-nav-button sidebar-toggle-button"
|
||||
type="button"
|
||||
:title="desktopSidebarToggleLabel"
|
||||
:aria-label="desktopSidebarToggleLabel"
|
||||
:aria-pressed="!desktopSidebarVisible"
|
||||
@click="toggleDesktopSidebar"
|
||||
>
|
||||
<el-icon><Fold v-if="desktopSidebarVisible" /><Expand v-else /></el-icon>
|
||||
</button>
|
||||
<div class="workspace-history-controls" aria-label="当前任务导航">
|
||||
<button
|
||||
class="toolbar-nav-button"
|
||||
type="button"
|
||||
title="后退(当前任务)"
|
||||
:disabled="!canNavigateWorkspaceTabBack"
|
||||
@click="navigateCurrentWorkspaceTabHistory(-1)"
|
||||
>
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-nav-button"
|
||||
type="button"
|
||||
title="前进(当前任务)"
|
||||
:disabled="!canNavigateWorkspaceTabForward"
|
||||
@click="navigateCurrentWorkspaceTabHistory(1)"
|
||||
>
|
||||
<el-icon><ArrowRight /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
<span class="toolbar-history-divider" aria-hidden="true"></span>
|
||||
<div class="toolbar-context" data-tauri-drag-region>
|
||||
<div class="toolbar-title" data-tauri-drag-region>{{ desktopToolbarTitle }}</div>
|
||||
<div class="toolbar-subtitle" data-tauri-drag-region>{{ desktopToolbarSubtitle }}</div>
|
||||
<div class="toolbar-subtitle" :title="desktopToolbarSubtitle" data-tauri-drag-region>
|
||||
<template v-for="(item, index) in desktopToolbarContextItems" :key="`${item.label}:${item.path || index}`">
|
||||
<span v-if="index" class="toolbar-context-separator" aria-hidden="true">·</span>
|
||||
<button
|
||||
v-if="item.path"
|
||||
class="toolbar-context-link"
|
||||
type="button"
|
||||
:title="`返回${item.label}`"
|
||||
@click="navigateToolbarContext(item.path)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<span v-else>{{ item.label }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -274,7 +326,7 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="workspaceTabs.length" ref="workspaceTabsElement" class="workspace-tabs">
|
||||
<div v-if="workspaceTabs.length > 1" class="workspace-tabs">
|
||||
<div
|
||||
class="workspace-tabs-list"
|
||||
:style="{ gridTemplateColumns: `repeat(${visibleWorkspaceTabs.length}, minmax(0, 238px))` }"
|
||||
@@ -306,33 +358,47 @@
|
||||
</div>
|
||||
|
||||
<el-dropdown
|
||||
v-if="overflowWorkspaceTabs.length"
|
||||
v-if="hasWorkspaceTabsOverflow"
|
||||
class="workspace-tabs-overflow"
|
||||
popper-class="workspace-tabs-overflow-popper"
|
||||
trigger="click"
|
||||
placement="bottom-end"
|
||||
@command="navigateOverflowWorkspaceTab"
|
||||
@visible-change="workspaceTabsOverflowOpen = $event"
|
||||
@visible-change="handleWorkspaceTabsOverflowVisibleChange"
|
||||
>
|
||||
<button
|
||||
class="workspace-tabs-overflow-trigger"
|
||||
:class="{ 'is-open': workspaceTabsOverflowOpen }"
|
||||
type="button"
|
||||
:aria-label="`查看其余 ${overflowWorkspaceTabs.length} 个标签`"
|
||||
:aria-label="`查看全部 ${workspaceTabs.length} 个任务`"
|
||||
>
|
||||
<span>更多</span>
|
||||
<span class="workspace-tabs-overflow-count">{{ overflowWorkspaceTabs.length }}</span>
|
||||
<span>全部任务</span>
|
||||
<span class="workspace-tabs-overflow-count">{{ workspaceTabs.length }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</button>
|
||||
<template #dropdown>
|
||||
<div class="workspace-tabs-overflow-menu">
|
||||
<div class="workspace-tabs-overflow-header">
|
||||
<span>其他标签</span>
|
||||
<span>{{ overflowWorkspaceTabs.length }} 个</span>
|
||||
<span>全部任务</span>
|
||||
<span>{{ workspaceTabs.length }} 个</span>
|
||||
</div>
|
||||
<div class="workspace-tabs-overflow-search" @click.stop @keydown.stop>
|
||||
<el-icon><Search /></el-icon>
|
||||
<input
|
||||
v-model="workspaceTabsSearchQuery"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
aria-label="搜索已打开任务"
|
||||
placeholder="搜索已打开任务"
|
||||
/>
|
||||
</div>
|
||||
<div class="workspace-tabs-overflow-actions">
|
||||
<button type="button" @click.stop="closeOtherWorkspaceTabs(activeMenu)">关闭其他</button>
|
||||
<button type="button" @click.stop="closeAllWorkspaceTabs">关闭全部</button>
|
||||
</div>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="item in overflowWorkspaceTabs"
|
||||
v-for="item in filteredWorkspaceTabs"
|
||||
:key="item.path"
|
||||
:command="item.path"
|
||||
:class="{ 'is-current': activeMenu === item.path }"
|
||||
@@ -351,6 +417,7 @@
|
||||
×
|
||||
</button>
|
||||
</el-dropdown-item>
|
||||
<li v-if="!filteredWorkspaceTabs.length" class="workspace-tabs-overflow-empty">没有匹配的任务</li>
|
||||
</el-dropdown-menu>
|
||||
</div>
|
||||
</template>
|
||||
@@ -432,6 +499,8 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Bell,
|
||||
Box,
|
||||
Calendar,
|
||||
@@ -441,8 +510,10 @@ import {
|
||||
DataAnalysis,
|
||||
Document,
|
||||
Download,
|
||||
Expand,
|
||||
Files,
|
||||
Flag,
|
||||
Fold,
|
||||
Folder,
|
||||
House,
|
||||
Key,
|
||||
@@ -488,7 +559,9 @@ import {
|
||||
getDesktopServerUrl,
|
||||
listenDesktopMenuCommand,
|
||||
readDesktopFavoriteRoutes,
|
||||
readDesktopSidebarVisible,
|
||||
readDesktopShortcutPreferences,
|
||||
setDesktopSidebarVisible,
|
||||
toggleDesktopFavoriteRoute,
|
||||
type DesktopRoutePreference,
|
||||
type DesktopShortcutPreferences,
|
||||
@@ -511,6 +584,7 @@ import {
|
||||
type LayoutNavigationItem,
|
||||
} from "./layout/navigation";
|
||||
import {
|
||||
canMoveWorkspaceTabHistory,
|
||||
createWorkspaceTabHistory,
|
||||
currentWorkspaceTabPath,
|
||||
moveWorkspaceTabHistory,
|
||||
@@ -518,6 +592,12 @@ import {
|
||||
replaceWorkspaceTabPath,
|
||||
type WorkspaceTabHistory,
|
||||
} from "./layout/workspaceTabHistory";
|
||||
import {
|
||||
buildWorkspaceTabTitle,
|
||||
buildWorkspaceToolbarTitle,
|
||||
resolveWorkspaceTabContextTitle,
|
||||
type WorkspaceTabContext,
|
||||
} from "./layout/workspaceTabTitle";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const study = useStudyStore();
|
||||
@@ -525,10 +605,8 @@ const router = useRouter();
|
||||
const route = useRoute();
|
||||
const desktopMetadata = getAppMetadata();
|
||||
const DESKTOP_WORKSPACE_TAB_CACHE_MAX = 12;
|
||||
const DESKTOP_WORKSPACE_TAB_MIN_WIDTH = 130;
|
||||
const DESKTOP_WORKSPACE_TAB_GAP = 6;
|
||||
const DESKTOP_WORKSPACE_TABS_HORIZONTAL_PADDING = 20;
|
||||
const DESKTOP_WORKSPACE_TABS_OVERFLOW_WIDTH = 106;
|
||||
const DESKTOP_WORKSPACE_TABS_OVERFLOW_THRESHOLD = 5;
|
||||
const DESKTOP_WORKSPACE_TABS_RECENT_VISIBLE_COUNT = 4;
|
||||
const DESKTOP_ADMIN_SECTION_LABEL = "系统管理";
|
||||
const isAdmin = computed(() => !!auth.user?.is_admin);
|
||||
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
|
||||
@@ -554,11 +632,12 @@ const desktopUpdateStatus = ref<DesktopUpdateStatusSnapshot>(getDesktopUpdateSta
|
||||
const desktopActivities = ref<DesktopActivityItem[]>(getDesktopActivities());
|
||||
const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes());
|
||||
const desktopShortcutPreferences = ref<DesktopShortcutPreferences>(readDesktopShortcutPreferences());
|
||||
const desktopSidebarVisible = ref(readDesktopSidebarVisible());
|
||||
const workspaceTabs = ref<DesktopRoutePreference[]>([]);
|
||||
const workspaceTabHistories = ref<Record<string, WorkspaceTabHistory>>({});
|
||||
const workspaceTabsElement = ref<HTMLElement | null>(null);
|
||||
const workspaceTabsAvailableWidth = ref(0);
|
||||
const workspaceTabContexts = ref<Record<string, WorkspaceTabContext>>({});
|
||||
const workspaceTabsOverflowOpen = ref(false);
|
||||
const workspaceTabsSearchQuery = ref("");
|
||||
const draggingWorkspaceTabPath = ref("");
|
||||
const lastClosedWorkspaceTab = ref<DesktopRoutePreference | null>(null);
|
||||
const lastClosedWorkspaceTabHistory = ref<WorkspaceTabHistory | null>(null);
|
||||
@@ -580,7 +659,6 @@ const headerReminderStats = ref({ overdueAes: 0, overdueMonitoringIssues: 0 });
|
||||
let desktopMenuUnlisten: (() => void) | undefined;
|
||||
let desktopUpdateStatusUnlisten: (() => void) | undefined;
|
||||
let desktopActivityUnlisten: (() => void) | undefined;
|
||||
let workspaceTabsResizeObserver: ResizeObserver | undefined;
|
||||
|
||||
const iconComponentMap = {
|
||||
audit: Document,
|
||||
@@ -659,6 +737,7 @@ const userDisplayInitial = computed(() => {
|
||||
return source.charAt(0).toUpperCase();
|
||||
});
|
||||
const desktopProjectSectionLabel = computed(() => study.currentStudy?.name || TEXT.menu.currentProject);
|
||||
const desktopSidebarToggleLabel = computed(() => (desktopSidebarVisible.value ? "隐藏导航栏" : "显示导航栏"));
|
||||
const projectEntryMenuLabel = computed(() => (study.currentStudy ? "返回项目选择" : "选择项目"));
|
||||
const desktopConnectionLabel = computed(() => (desktopServerUrl.value ? "已连接" : "未配置"));
|
||||
const desktopConnectionClass = computed(() => (desktopServerUrl.value ? "is-connected" : "is-warning"));
|
||||
@@ -700,39 +779,60 @@ const formatDesktopActivityTime = (value: string) => {
|
||||
return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
|
||||
};
|
||||
|
||||
const currentWorkspaceContext = computed<WorkspaceTabContext | null>(() => {
|
||||
const storedContext = workspaceTabContexts.value[activeMenu.value];
|
||||
const pageTitle = resolveWorkspaceTabContextTitle({
|
||||
liveContextTitle: study.viewContext?.pageTitle,
|
||||
storedContext,
|
||||
routePath: route.path,
|
||||
});
|
||||
if (!pageTitle) return null;
|
||||
const storedContextMatchesRoute = storedContext?.routePath === route.path;
|
||||
return {
|
||||
routePath: route.path,
|
||||
pageTitle,
|
||||
objectType: study.viewContext?.objectType || (storedContextMatchesRoute ? storedContext.objectType : undefined),
|
||||
};
|
||||
});
|
||||
const currentDesktopRoutePreference = computed(() => {
|
||||
const item = desktopNavigationItems.value.find((navItem) => navItem.path === activeMenu.value);
|
||||
if (!item) return null;
|
||||
return { path: item.path, title: item.label, group: item.group };
|
||||
});
|
||||
const visibleWorkspaceTabCapacity = computed(() => {
|
||||
const tabCount = workspaceTabs.value.length;
|
||||
const availableWidth = workspaceTabsAvailableWidth.value;
|
||||
if (!tabCount || availableWidth <= 0) return tabCount;
|
||||
const maxWithoutOverflow = Math.max(
|
||||
1,
|
||||
Math.floor((availableWidth + DESKTOP_WORKSPACE_TAB_GAP) / (DESKTOP_WORKSPACE_TAB_MIN_WIDTH + DESKTOP_WORKSPACE_TAB_GAP)),
|
||||
);
|
||||
if (tabCount <= maxWithoutOverflow) return tabCount;
|
||||
return Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
(availableWidth - DESKTOP_WORKSPACE_TABS_OVERFLOW_WIDTH) /
|
||||
(DESKTOP_WORKSPACE_TAB_MIN_WIDTH + DESKTOP_WORKSPACE_TAB_GAP),
|
||||
),
|
||||
);
|
||||
return {
|
||||
path: item.path,
|
||||
title: buildWorkspaceTabTitle({
|
||||
moduleTitle: item.label,
|
||||
routeTitle: typeof route.meta?.title === "string" ? route.meta.title : undefined,
|
||||
contextTitle: currentWorkspaceContext.value?.pageTitle,
|
||||
}),
|
||||
group: item.group,
|
||||
};
|
||||
});
|
||||
const workspaceTabsByRecentUse = computed(() =>
|
||||
workspaceTabs.value
|
||||
.map((item, index) => ({ item, index }))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.item.updatedAt.localeCompare(right.item.updatedAt) ||
|
||||
Number(left.item.path === activeMenu.value) - Number(right.item.path === activeMenu.value) ||
|
||||
left.index - right.index,
|
||||
)
|
||||
.map(({ item }) => item),
|
||||
);
|
||||
const hasWorkspaceTabsOverflow = computed(
|
||||
() => workspaceTabs.value.length > DESKTOP_WORKSPACE_TABS_OVERFLOW_THRESHOLD,
|
||||
);
|
||||
const visibleWorkspaceTabs = computed(() => {
|
||||
const capacity = visibleWorkspaceTabCapacity.value;
|
||||
if (workspaceTabs.value.length <= capacity) return workspaceTabs.value;
|
||||
const firstTabs = workspaceTabs.value.slice(0, capacity);
|
||||
const activeTab = workspaceTabs.value.find((item) => item.path === activeMenu.value);
|
||||
if (!activeTab || firstTabs.some((item) => item.path === activeTab.path)) return firstTabs;
|
||||
return [...firstTabs.slice(0, -1), activeTab];
|
||||
if (!hasWorkspaceTabsOverflow.value) return workspaceTabs.value;
|
||||
const recentVisiblePaths = new Set(
|
||||
workspaceTabsByRecentUse.value.slice(-DESKTOP_WORKSPACE_TABS_RECENT_VISIBLE_COUNT).map((item) => item.path),
|
||||
);
|
||||
return workspaceTabs.value.filter((item) => recentVisiblePaths.has(item.path));
|
||||
});
|
||||
const overflowWorkspaceTabs = computed(() => {
|
||||
const visiblePaths = new Set(visibleWorkspaceTabs.value.map((item) => item.path));
|
||||
return workspaceTabs.value.filter((item) => !visiblePaths.has(item.path));
|
||||
const filteredWorkspaceTabs = computed(() => {
|
||||
const query = workspaceTabsSearchQuery.value.trim().toLocaleLowerCase();
|
||||
const recentFirst = [...workspaceTabsByRecentUse.value].reverse();
|
||||
if (!query) return recentFirst;
|
||||
return recentFirst.filter((item) => `${item.title} ${item.path}`.toLocaleLowerCase().includes(query));
|
||||
});
|
||||
const currentRouteFavorited = computed(() => {
|
||||
const current = currentDesktopRoutePreference.value;
|
||||
@@ -740,25 +840,42 @@ const currentRouteFavorited = computed(() => {
|
||||
});
|
||||
const desktopToolbarTitle = computed(() => {
|
||||
const current = currentDesktopRoutePreference.value;
|
||||
return current?.title || String(route.meta?.title || TEXT.common.appName);
|
||||
if (!current) return String(route.meta?.title || TEXT.common.appName);
|
||||
return buildWorkspaceToolbarTitle({
|
||||
moduleTitle: desktopNavigationItems.value.find((item) => item.path === current.path)?.label || current.title,
|
||||
routeTitle: typeof route.meta?.title === "string" ? route.meta.title : undefined,
|
||||
contextTitle: currentWorkspaceContext.value?.pageTitle,
|
||||
contextObjectType: currentWorkspaceContext.value?.objectType,
|
||||
});
|
||||
});
|
||||
|
||||
const desktopToolbarSubtitle = computed(() => {
|
||||
const desktopToolbarContextItems = computed<Array<{ label: string; path?: string }>>(() => {
|
||||
const current = currentDesktopRoutePreference.value;
|
||||
if (!current) return TEXT.common.appName;
|
||||
if (!current) return [{ label: TEXT.common.appName }];
|
||||
const moduleItem = desktopNavigationItems.value.find((item) => item.path === current.path);
|
||||
const items: Array<{ label: string; path?: string }> = [];
|
||||
const isAdminRoute = adminDesktopNavigationItems.value.some((item) => item.path === current.path);
|
||||
if (isAdminRoute) {
|
||||
const section = current.group && current.group !== TEXT.menu.admin ? current.group : "";
|
||||
return [DESKTOP_ADMIN_SECTION_LABEL, section].filter(Boolean).join(" / ");
|
||||
}
|
||||
const isProjectRoute = projectDesktopNavigationItems.value.some((item) => item.path === current.path);
|
||||
if (isProjectRoute) {
|
||||
items.push({ label: DESKTOP_ADMIN_SECTION_LABEL });
|
||||
if (section) items.push({ label: section });
|
||||
} else if (projectDesktopNavigationItems.value.some((item) => item.path === current.path)) {
|
||||
const section = current.group && current.group !== TEXT.menu.currentProject ? current.group : "";
|
||||
const projectName = study.currentStudy?.name || TEXT.menu.currentProject;
|
||||
return [projectName, section].filter(Boolean).join(" / ");
|
||||
items.push({ label: projectName });
|
||||
if (section) items.push({ label: section });
|
||||
} else {
|
||||
items.push({ label: current.group || TEXT.common.appName });
|
||||
}
|
||||
return current.group || TEXT.common.appName;
|
||||
if (currentWorkspaceContext.value && moduleItem && !items.some((item) => item.label === moduleItem.label)) {
|
||||
items.push({ label: moduleItem.label, path: moduleItem.path });
|
||||
}
|
||||
return items;
|
||||
});
|
||||
const desktopToolbarSubtitle = computed(() => desktopToolbarContextItems.value.map((item) => item.label).join(" · "));
|
||||
const activeWorkspaceTabHistory = computed(() => workspaceTabHistories.value[activeMenu.value]);
|
||||
const canNavigateWorkspaceTabBack = computed(() => canMoveWorkspaceTabHistory(activeWorkspaceTabHistory.value, -1));
|
||||
const canNavigateWorkspaceTabForward = computed(() => canMoveWorkspaceTabHistory(activeWorkspaceTabHistory.value, 1));
|
||||
|
||||
const canReadRiskIssueAes = computed(() => !isAdminContext.value && canAccessProjectPath("/risk-issues/sae"));
|
||||
const canReadMonitoringIssues = computed(() => !isAdminContext.value && canAccessProjectPath("/risk-issues/monitoring-visits"));
|
||||
@@ -823,6 +940,13 @@ const removeWorkspaceTabHistory = (tabPath: string) => {
|
||||
workspaceTabHistories.value = next;
|
||||
};
|
||||
|
||||
const removeWorkspaceTabContext = (tabPath: string) => {
|
||||
if (!workspaceTabContexts.value[tabPath]) return;
|
||||
const next = { ...workspaceTabContexts.value };
|
||||
delete next[tabPath];
|
||||
workspaceTabContexts.value = next;
|
||||
};
|
||||
|
||||
const recordWorkspaceTabRoute = (tabPath: string, fullPath: string) => {
|
||||
setWorkspaceTabHistory(tabPath, recordWorkspaceTabPath(workspaceTabHistories.value[tabPath], fullPath));
|
||||
};
|
||||
@@ -849,27 +973,18 @@ const navigateCurrentWorkspaceTabHistory = (offset: -1 | 1) => {
|
||||
void router.push(movement.path);
|
||||
};
|
||||
|
||||
const navigateToolbarContext = (path: string) => {
|
||||
if (route.path === path) return;
|
||||
void router.push(path);
|
||||
};
|
||||
|
||||
const navigateOverflowWorkspaceTab = (path: string) => {
|
||||
navigateWorkspaceTab(path);
|
||||
};
|
||||
|
||||
const updateWorkspaceTabsAvailableWidth = () => {
|
||||
const element = workspaceTabsElement.value;
|
||||
workspaceTabsAvailableWidth.value = element
|
||||
? Math.max(0, element.clientWidth - DESKTOP_WORKSPACE_TABS_HORIZONTAL_PADDING)
|
||||
: 0;
|
||||
};
|
||||
|
||||
const observeWorkspaceTabsElement = (element: HTMLElement | null) => {
|
||||
workspaceTabsResizeObserver?.disconnect();
|
||||
if (!element) {
|
||||
workspaceTabsAvailableWidth.value = 0;
|
||||
return;
|
||||
}
|
||||
updateWorkspaceTabsAvailableWidth();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
workspaceTabsResizeObserver ||= new ResizeObserver(updateWorkspaceTabsAvailableWidth);
|
||||
workspaceTabsResizeObserver.observe(element);
|
||||
const handleWorkspaceTabsOverflowVisibleChange = (visible: boolean) => {
|
||||
workspaceTabsOverflowOpen.value = visible;
|
||||
if (!visible) workspaceTabsSearchQuery.value = "";
|
||||
};
|
||||
|
||||
const closeWorkspaceTabMenu = () => {
|
||||
@@ -932,6 +1047,7 @@ const closeWorkspaceTab = (path: string) => {
|
||||
}
|
||||
workspaceTabs.value = workspaceTabs.value.filter((tab) => tab.path !== path);
|
||||
removeWorkspaceTabHistory(path);
|
||||
removeWorkspaceTabContext(path);
|
||||
if (activeMenu.value !== path) return;
|
||||
const next =
|
||||
workspaceTabs.value[index] ||
|
||||
@@ -955,10 +1071,36 @@ const closeOtherWorkspaceTabs = (path: string) => {
|
||||
workspaceTabs.value = [target];
|
||||
const targetHistory = workspaceTabHistories.value[path];
|
||||
workspaceTabHistories.value = targetHistory ? { [path]: targetHistory } : {};
|
||||
const targetContext = workspaceTabContexts.value[path];
|
||||
workspaceTabContexts.value = targetContext ? { [path]: targetContext } : {};
|
||||
if (activeMenu.value !== path) void router.push(workspaceTabDestination(path));
|
||||
closeWorkspaceTabMenu();
|
||||
};
|
||||
|
||||
const closeAllWorkspaceTabs = () => {
|
||||
const activeTab = workspaceTabs.value.find((tab) => tab.path === activeMenu.value);
|
||||
if (activeTab) {
|
||||
lastClosedWorkspaceTab.value = activeTab;
|
||||
lastClosedWorkspaceTabHistory.value = workspaceTabHistories.value[activeTab.path] || createWorkspaceTabHistory(activeTab.path);
|
||||
}
|
||||
workspaceTabs.value = [];
|
||||
workspaceTabHistories.value = {};
|
||||
workspaceTabContexts.value = {};
|
||||
workspaceTabsSearchQuery.value = "";
|
||||
closeWorkspaceTabMenu();
|
||||
|
||||
const fallbackPath = workspaceTabFallbackPath.value;
|
||||
if (activeMenu.value === fallbackPath) {
|
||||
const fallbackTab = currentDesktopRoutePreference.value;
|
||||
if (fallbackTab) {
|
||||
addWorkspaceTab(fallbackTab);
|
||||
recordWorkspaceTabRoute(fallbackTab.path, route.fullPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
void router.push(fallbackPath);
|
||||
};
|
||||
|
||||
const restoreLastClosedWorkspaceTab = () => {
|
||||
const tab = lastClosedWorkspaceTab.value;
|
||||
if (!tab) return;
|
||||
@@ -989,10 +1131,15 @@ const openDesktopPreferences = () => {
|
||||
desktopPreferencesVisible.value = true;
|
||||
};
|
||||
|
||||
const toggleDesktopSidebar = () => {
|
||||
desktopSidebarVisible.value = setDesktopSidebarVisible(!desktopSidebarVisible.value);
|
||||
};
|
||||
|
||||
const openDesktopProjectEntry = async () => {
|
||||
study.clearCurrentStudy();
|
||||
workspaceTabs.value = [];
|
||||
workspaceTabHistories.value = {};
|
||||
workspaceTabContexts.value = {};
|
||||
lastClosedWorkspaceTab.value = null;
|
||||
lastClosedWorkspaceTabHistory.value = null;
|
||||
await router.push("/desktop/project-entry");
|
||||
@@ -1011,7 +1158,7 @@ const refreshCurrentDesktopView = () => {
|
||||
};
|
||||
|
||||
const handleDesktopMenuCommand = (command: string) => {
|
||||
if (command === "ctms.desktop.commandPalette") {
|
||||
if (command === "ctms.desktop.commandPalette" || command === "ctms.desktop.help") {
|
||||
openCommandPalette();
|
||||
return;
|
||||
}
|
||||
@@ -1033,6 +1180,10 @@ const handleDesktopMenuCommand = (command: string) => {
|
||||
}
|
||||
if (command === "ctms.desktop.forward") {
|
||||
navigateCurrentWorkspaceTabHistory(1);
|
||||
return;
|
||||
}
|
||||
if (command === "ctms.desktop.toggleSidebar") {
|
||||
toggleDesktopSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1236,11 +1387,14 @@ const beforeProfileDialogClose = async (done: () => void) => {
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
(fullPath, previousFullPath) => {
|
||||
study.setViewContext(null);
|
||||
const previousRoute = previousFullPath ? router.resolve(previousFullPath) : null;
|
||||
if (!previousRoute || previousRoute.path !== route.path) {
|
||||
study.setViewContext(null);
|
||||
}
|
||||
const current = currentDesktopRoutePreference.value;
|
||||
if (current) {
|
||||
addWorkspaceTab(current);
|
||||
const previousTabPath = previousFullPath ? getActiveLayoutPath(router.resolve(previousFullPath).path) : "";
|
||||
const previousTabPath = previousRoute ? getActiveLayoutPath(previousRoute.path) : "";
|
||||
const replacesCurrentTabEntry =
|
||||
previousTabPath === current.path && Boolean((window.history.state as { replaced?: boolean } | null)?.replaced);
|
||||
if (replacesCurrentTabEntry) {
|
||||
@@ -1266,12 +1420,27 @@ watch(
|
||||
() => refreshDesktopRoutePreferences(),
|
||||
);
|
||||
|
||||
watch(workspaceTabsElement, (element) => observeWorkspaceTabsElement(element), { flush: "post" });
|
||||
watch(
|
||||
() => [study.viewContext?.pageTitle, study.viewContext?.objectType] as const,
|
||||
([pageTitle, objectType]) => {
|
||||
if (!pageTitle) return;
|
||||
workspaceTabContexts.value = {
|
||||
...workspaceTabContexts.value,
|
||||
[activeMenu.value]: {
|
||||
routePath: route.path,
|
||||
pageTitle,
|
||||
objectType,
|
||||
},
|
||||
};
|
||||
const current = currentDesktopRoutePreference.value;
|
||||
if (current) addWorkspaceTab(current);
|
||||
},
|
||||
{ flush: "sync" },
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
|
||||
window.addEventListener(DESKTOP_SHORTCUTS_CHANGED_EVENT, updateDesktopShortcutPreferences);
|
||||
window.addEventListener("resize", updateWorkspaceTabsAvailableWidth);
|
||||
clearLegacyDesktopRouteHistoryPreference();
|
||||
const current = currentDesktopRoutePreference.value;
|
||||
if (current) {
|
||||
@@ -1304,8 +1473,6 @@ onMounted(async () => {
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
|
||||
window.removeEventListener(DESKTOP_SHORTCUTS_CHANGED_EVENT, updateDesktopShortcutPreferences);
|
||||
window.removeEventListener("resize", updateWorkspaceTabsAvailableWidth);
|
||||
workspaceTabsResizeObserver?.disconnect();
|
||||
desktopMenuUnlisten?.();
|
||||
desktopUpdateStatusUnlisten?.();
|
||||
desktopActivityUnlisten?.();
|
||||
@@ -1348,6 +1515,15 @@ useDesktopShortcuts(
|
||||
overflow: hidden;
|
||||
background: #eef2f6;
|
||||
color: #142033;
|
||||
transition: grid-template-columns 180ms ease;
|
||||
}
|
||||
|
||||
.desktop-workbench.is-sidebar-hidden {
|
||||
grid-template-columns: 0 minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.desktop-workbench.is-macos.is-sidebar-hidden .desktop-toolbar {
|
||||
padding-left: 88px;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
@@ -1372,6 +1548,18 @@ useDesktopShortcuts(
|
||||
flex-direction: column;
|
||||
background: #f8fafc;
|
||||
border-right: 1px solid #d7e0ea;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
visibility: visible;
|
||||
transition: opacity 140ms ease, transform 180ms ease, visibility 180ms ease;
|
||||
}
|
||||
|
||||
.desktop-workbench.is-sidebar-hidden .desktop-sidebar {
|
||||
border-right-color: transparent;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateX(-12px);
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.sidebar-head {
|
||||
@@ -1601,6 +1789,7 @@ useDesktopShortcuts(
|
||||
|
||||
.toolbar-left {
|
||||
align-self: stretch;
|
||||
gap: 10px;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -1612,6 +1801,10 @@ useDesktopShortcuts(
|
||||
|
||||
.toolbar-right,
|
||||
.toolbar-right :deep(*),
|
||||
.sidebar-toggle-button,
|
||||
.workspace-history-controls,
|
||||
.workspace-history-controls *,
|
||||
.toolbar-context-link,
|
||||
.desktop-sidebar button,
|
||||
.desktop-sidebar :deep(.el-dropdown),
|
||||
.workspace-tabs,
|
||||
@@ -1621,6 +1814,51 @@ useDesktopShortcuts(
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.workspace-history-controls {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toolbar-nav-button {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #52667d;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.toolbar-nav-button:hover:not(:disabled),
|
||||
.toolbar-nav-button:focus-visible:not(:disabled) {
|
||||
border-color: #d3dee9;
|
||||
background: #edf3f8;
|
||||
color: #183756;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.toolbar-nav-button:disabled {
|
||||
color: #aeb9c7;
|
||||
cursor: default;
|
||||
opacity: 0.56;
|
||||
}
|
||||
|
||||
.toolbar-history-divider {
|
||||
flex: 0 0 1px;
|
||||
width: 1px;
|
||||
height: 22px;
|
||||
background: #d7e0ea;
|
||||
}
|
||||
|
||||
.toolbar-left[data-tauri-drag-region],
|
||||
.toolbar-context[data-tauri-drag-region],
|
||||
.toolbar-title[data-tauri-drag-region],
|
||||
@@ -1630,6 +1868,7 @@ useDesktopShortcuts(
|
||||
|
||||
.toolbar-context {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
align-content: center;
|
||||
gap: 2px;
|
||||
@@ -1653,12 +1892,44 @@ useDesktopShortcuts(
|
||||
}
|
||||
|
||||
.toolbar-subtitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #66778d;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.toolbar-context-separator {
|
||||
flex: 0 0 auto;
|
||||
color: #9aa8b8;
|
||||
}
|
||||
|
||||
.toolbar-context-link {
|
||||
appearance: none;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar-context-link:hover,
|
||||
.toolbar-context-link:focus-visible {
|
||||
color: #183756;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.command-trigger,
|
||||
.update-notice,
|
||||
.connection-button,
|
||||
@@ -2033,7 +2304,7 @@ useDesktopShortcuts(
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
padding: 7px 10px;
|
||||
padding: 0 10px;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid #dfe7f0;
|
||||
background: #f4f7fa;
|
||||
@@ -2048,6 +2319,7 @@ useDesktopShortcuts(
|
||||
}
|
||||
|
||||
.workspace-tab {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
@@ -2073,6 +2345,7 @@ useDesktopShortcuts(
|
||||
border-color: #aebfd1;
|
||||
background: #eaf1f8;
|
||||
color: #183756;
|
||||
box-shadow: inset 0 -2px #315f89;
|
||||
}
|
||||
|
||||
.workspace-tab.dragging {
|
||||
@@ -2108,6 +2381,14 @@ useDesktopShortcuts(
|
||||
background: transparent;
|
||||
color: #7b8da3;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease, background-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.workspace-tab:hover .tab-close,
|
||||
.workspace-tab.active .tab-close,
|
||||
.tab-close:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
@@ -2117,7 +2398,7 @@ useDesktopShortcuts(
|
||||
|
||||
.workspace-tabs-overflow {
|
||||
display: flex;
|
||||
flex: 0 0 106px;
|
||||
flex: 0 0 132px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -2126,7 +2407,7 @@ useDesktopShortcuts(
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 106px;
|
||||
width: 132px;
|
||||
height: 30px;
|
||||
gap: 6px;
|
||||
padding: 0 9px 0 11px;
|
||||
@@ -2181,7 +2462,7 @@ useDesktopShortcuts(
|
||||
}
|
||||
|
||||
.workspace-tabs-overflow-menu {
|
||||
width: 252px;
|
||||
width: 292px;
|
||||
}
|
||||
|
||||
.workspace-tabs-overflow-header {
|
||||
@@ -2202,6 +2483,64 @@ useDesktopShortcuts(
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.workspace-tabs-overflow-search {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
margin: 8px 8px 6px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid #d6e0eb;
|
||||
border-radius: 7px;
|
||||
background: #f8fafc;
|
||||
color: #8292a6;
|
||||
}
|
||||
|
||||
.workspace-tabs-overflow-search input {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: #223349;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.workspace-tabs-overflow-search input::placeholder {
|
||||
color: #96a4b5;
|
||||
}
|
||||
|
||||
.workspace-tabs-overflow-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
padding: 0 8px 6px;
|
||||
border-bottom: 1px solid #e5ebf2;
|
||||
}
|
||||
|
||||
.workspace-tabs-overflow-actions button {
|
||||
appearance: none;
|
||||
min-height: 26px;
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #52667d;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.workspace-tabs-overflow-actions button:hover,
|
||||
.workspace-tabs-overflow-actions button:focus-visible {
|
||||
background: #eef4fb;
|
||||
color: #183756;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:global(.workspace-tabs-overflow-popper .el-dropdown-menu) {
|
||||
min-width: 0;
|
||||
max-height: min(360px, calc(100vh - 120px));
|
||||
@@ -2233,6 +2572,16 @@ useDesktopShortcuts(
|
||||
color: #183756;
|
||||
}
|
||||
|
||||
.workspace-tabs-overflow-empty {
|
||||
display: flex;
|
||||
min-height: 72px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #8292a6;
|
||||
font-size: 12px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.workspace-tabs-overflow-label {
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
@@ -2431,6 +2780,30 @@ useDesktopShortcuts(
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .toolbar-nav-button) {
|
||||
color: #aebed0;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .toolbar-nav-button:hover:not(:disabled)),
|
||||
:global([data-ctms-theme="dark"] .toolbar-nav-button:focus-visible:not(:disabled)) {
|
||||
border-color: #334155;
|
||||
background: #1f2d3d;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .toolbar-nav-button:disabled) {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .toolbar-history-divider) {
|
||||
background: #334155;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .toolbar-context-link:hover),
|
||||
:global([data-ctms-theme="dark"] .toolbar-context-link:focus-visible) {
|
||||
color: #bfdbfe;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .study-switcher-trigger),
|
||||
:global([data-ctms-theme="dark"] .command-trigger),
|
||||
:global([data-ctms-theme="dark"] .update-notice),
|
||||
@@ -2505,6 +2878,30 @@ useDesktopShortcuts(
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-search) {
|
||||
border-color: #334155;
|
||||
background: #111827;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-search input) {
|
||||
color: #e5edf7;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-actions) {
|
||||
border-bottom-color: #334155;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-actions button) {
|
||||
color: #aebed0;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-actions button:hover),
|
||||
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-actions button:focus-visible) {
|
||||
background: #243247;
|
||||
color: #bfdbfe;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-popper .el-dropdown-menu__item) {
|
||||
color: #dbe5f1;
|
||||
}
|
||||
@@ -2520,6 +2917,10 @@ useDesktopShortcuts(
|
||||
background: #6f87a1;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-empty) {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-close) {
|
||||
color: #94a3b8;
|
||||
}
|
||||
@@ -2739,7 +3140,7 @@ useDesktopShortcuts(
|
||||
|
||||
.workspace-tabs {
|
||||
gap: 8px;
|
||||
padding: 9px 14px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.workspace-tab {
|
||||
@@ -2779,6 +3180,8 @@ useDesktopShortcuts(
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.icon-button,
|
||||
.desktop-workbench,
|
||||
.desktop-sidebar,
|
||||
.desktop-route-enter-active,
|
||||
.desktop-route-leave-active {
|
||||
transition-duration: 1ms !important;
|
||||
|
||||
@@ -37,6 +37,8 @@ describe("desktop layout shell", () => {
|
||||
expect(source).toContain("<DesktopLayout v-if=\"isDesktop\" />");
|
||||
expect(source).toContain("<WebLayout v-else />");
|
||||
expect(app).toContain("const isDesktopRuntime = isTauriRuntime();");
|
||||
expect(app).toContain("initializeDesktopThemePreference();");
|
||||
expect(app).toContain("initializeDesktopMenuShortcutSync();");
|
||||
expect(app).toContain('document.body.classList.add("is-desktop-runtime")');
|
||||
expect(app).toContain('document.body.classList.remove("is-desktop-runtime")');
|
||||
});
|
||||
@@ -98,9 +100,14 @@ describe("desktop layout shell", () => {
|
||||
expect(source).toContain("desktopToolbarSubtitle");
|
||||
expect(source).not.toContain("desktopBreadcrumbs");
|
||||
expect(source).not.toContain("breadcrumb-chip");
|
||||
expect(source).not.toContain("history-controls");
|
||||
expect(source).not.toContain('title="后退"');
|
||||
expect(source).not.toContain('title="前进"');
|
||||
expect(source).toContain('class="workspace-history-controls"');
|
||||
expect(source).toContain('title="后退(当前任务)"');
|
||||
expect(source).toContain('title="前进(当前任务)"');
|
||||
expect(source).toContain(':disabled="!canNavigateWorkspaceTabBack"');
|
||||
expect(source).toContain(':disabled="!canNavigateWorkspaceTabForward"');
|
||||
expect(source).toContain('class="toolbar-history-divider"');
|
||||
expect(source).toContain('v-for="(item, index) in desktopToolbarContextItems"');
|
||||
expect(source).toContain('class="toolbar-context-link"');
|
||||
expect(source).not.toContain('title="刷新当前视图"');
|
||||
expect(source).not.toContain("const sidebarTitle");
|
||||
expect(source).not.toContain('title="搜索命令"');
|
||||
@@ -135,6 +142,29 @@ describe("desktop layout shell", () => {
|
||||
expect(layout).not.toContain(["@tauri-apps", "api", "window"].join("/"));
|
||||
});
|
||||
|
||||
it("uses a native macOS application menu and rebuilds a closed main window from the Dock", () => {
|
||||
const tauriLib = readTauriLibSource();
|
||||
|
||||
expect(tauriLib.indexOf("package.name.clone()")).toBeLessThan(tauriLib.indexOf('"文件"'));
|
||||
expect(tauriLib).toContain('Some("关于 CTMS")');
|
||||
expect(tauriLib).toContain("short_version: Some(String::new())");
|
||||
expect(tauriLib).toContain("icon: handle.default_window_icon().cloned()");
|
||||
expect(tauriLib).toContain('"ctms.desktop.preferences"');
|
||||
expect(tauriLib).toContain("PredefinedMenuItem::services");
|
||||
expect(tauriLib).toContain("PredefinedMenuItem::hide_others");
|
||||
expect(tauriLib).toContain("PredefinedMenuItem::show_all");
|
||||
expect(tauriLib).toContain("WINDOW_SUBMENU_ID");
|
||||
expect(tauriLib).toContain("HELP_SUBMENU_ID");
|
||||
expect(tauriLib).toContain("TOGGLE_SIDEBAR_COMMAND_ID");
|
||||
expect(tauriLib).toContain('"显示/隐藏导航栏"');
|
||||
expect(tauriLib).toContain("RunEvent::Reopen");
|
||||
expect(tauriLib).toContain("WebviewWindowBuilder::from_config");
|
||||
expect(tauriLib).toContain('find(|config| config.label == "main")');
|
||||
expect(tauriLib).not.toContain("WindowEvent::CloseRequested");
|
||||
expect(tauriLib).not.toContain("api.prevent_close()");
|
||||
expect(tauriLib).not.toContain("window.hide()");
|
||||
});
|
||||
|
||||
it("keeps the desktop sidebar dense enough for navigation-heavy screens", () => {
|
||||
const layout = readDesktopLayoutSource();
|
||||
const sidebarHeadStart = layout.indexOf('<header class="sidebar-head">');
|
||||
@@ -169,6 +199,24 @@ describe("desktop layout shell", () => {
|
||||
expect(layout).not.toContain("padding: 44px 12px 12px;");
|
||||
});
|
||||
|
||||
it("persists a fully hidden desktop sidebar with a macOS-safe restore control", () => {
|
||||
const layout = readDesktopLayoutSource();
|
||||
|
||||
expect(layout).toContain("const desktopSidebarVisible = ref(readDesktopSidebarVisible());");
|
||||
expect(layout).toContain("desktopSidebarVisible.value = setDesktopSidebarVisible(!desktopSidebarVisible.value);");
|
||||
expect(layout).toContain("'is-sidebar-hidden': !desktopSidebarVisible");
|
||||
expect(layout).toContain("'is-macos': desktopMetadata.platform === 'macos'");
|
||||
expect(layout).toContain('class="toolbar-nav-button sidebar-toggle-button"');
|
||||
expect(layout).toContain(':title="desktopSidebarToggleLabel"');
|
||||
expect(layout).toContain('<Fold v-if="desktopSidebarVisible" /><Expand v-else />');
|
||||
expect(layout).toContain('command === "ctms.desktop.toggleSidebar"');
|
||||
expect(layout).toContain(".desktop-workbench.is-sidebar-hidden {");
|
||||
expect(layout).toContain("grid-template-columns: 0 minmax(0, 1fr);");
|
||||
expect(layout).toContain(".desktop-workbench.is-macos.is-sidebar-hidden .desktop-toolbar {");
|
||||
expect(layout).toContain("padding-left: 88px;");
|
||||
expect(layout).toContain("pointer-events: none;");
|
||||
});
|
||||
|
||||
it("renders desktop preferences as a split settings panel", () => {
|
||||
const preferences = readDesktopPreferencesSource();
|
||||
const desktopLayout = readDesktopLayoutSource();
|
||||
@@ -183,6 +231,10 @@ describe("desktop layout shell", () => {
|
||||
expect(preferences).toContain("scrollbar-gutter: stable;");
|
||||
expect(preferences).toContain('{ id: "connection", label: "连接"');
|
||||
expect(preferences).toContain('{ id: "appearance", label: "外观"');
|
||||
expect(preferences).toContain('{ value: "system" as const, label: "跟随系统"');
|
||||
expect(preferences).toContain("grid-template-columns: repeat(3, minmax(0, 1fr));");
|
||||
expect(preferences).toContain("width: min(100%, 336px);");
|
||||
expect(preferences).toContain("white-space: nowrap;");
|
||||
expect(preferences).toContain('{ id: "shortcuts", label: "快捷键"');
|
||||
expect(preferences).toContain('{ id: "notifications", label: "通知"');
|
||||
expect(preferences).toContain('{ id: "updates", label: "更新"');
|
||||
@@ -249,7 +301,7 @@ describe("desktop layout shell", () => {
|
||||
|
||||
it("keeps workspace tabs stable and draggable like browser tabs", () => {
|
||||
const source = readDesktopLayoutSource();
|
||||
const tabsStart = source.indexOf('<div v-if="workspaceTabs.length" ref="workspaceTabsElement" class="workspace-tabs">');
|
||||
const tabsStart = source.indexOf('<div v-if="workspaceTabs.length > 1" class="workspace-tabs">');
|
||||
const tabsEnd = source.indexOf('<main class="desktop-content"', tabsStart);
|
||||
const tabsTemplate = source.slice(tabsStart, tabsEnd);
|
||||
|
||||
@@ -292,6 +344,8 @@ describe("desktop layout shell", () => {
|
||||
expect(source).toContain("replaceWorkspaceTabRoute(current.path, fullPath);");
|
||||
expect(source).toContain("currentWorkspaceTabPath(workspaceTabHistories.value[tabPath], tabPath)");
|
||||
expect(source).toContain("moveWorkspaceTabHistory(workspaceTabHistories.value[tabPath], offset)");
|
||||
expect(source).toContain("canMoveWorkspaceTabHistory(activeWorkspaceTabHistory.value, -1)");
|
||||
expect(source).toContain("canMoveWorkspaceTabHistory(activeWorkspaceTabHistory.value, 1)");
|
||||
expect(source).toContain("navigateBack: () => navigateCurrentWorkspaceTabHistory(-1)");
|
||||
expect(source).toContain("navigateForward: () => navigateCurrentWorkspaceTabHistory(1)");
|
||||
expect(source).toContain('id: "desktop:tab-back"');
|
||||
@@ -300,6 +354,25 @@ describe("desktop layout shell", () => {
|
||||
expect(source).not.toContain("navigateForward: () => router.forward()");
|
||||
});
|
||||
|
||||
it("updates a detail workspace tab after its business context has loaded", () => {
|
||||
const source = readDesktopLayoutSource();
|
||||
|
||||
expect(source).toContain("buildWorkspaceTabTitle,");
|
||||
expect(source).toContain("buildWorkspaceToolbarTitle,");
|
||||
expect(source).toContain("resolveWorkspaceTabContextTitle,");
|
||||
expect(source).toContain('} from "./layout/workspaceTabTitle";');
|
||||
expect(source).toContain("const workspaceTabContexts = ref<Record<string, WorkspaceTabContext>>({});");
|
||||
expect(source).toContain("title: buildWorkspaceTabTitle({");
|
||||
expect(source).toContain("const pageTitle = resolveWorkspaceTabContextTitle({");
|
||||
expect(source).toContain("() => [study.viewContext?.pageTitle, study.viewContext?.objectType] as const,");
|
||||
expect(source).toContain("[activeMenu.value]: {");
|
||||
expect(source).toContain("([pageTitle, objectType]) => {");
|
||||
expect(source).toContain("if (current) addWorkspaceTab(current);");
|
||||
expect(source).toContain('{ flush: "sync" },');
|
||||
expect(source).toContain("if (!previousRoute || previousRoute.path !== route.path) {");
|
||||
expect(source).toContain("const removeWorkspaceTabContext = (tabPath: string) => {");
|
||||
});
|
||||
|
||||
it("allows desktop shortcuts to be customized from system preferences", () => {
|
||||
const preferences = readDesktopPreferencesSource();
|
||||
const tauriLib = readTauriLibSource();
|
||||
@@ -313,30 +386,45 @@ describe("desktop layout shell", () => {
|
||||
expect(preferences).toContain('window.addEventListener("pointerdown", cancelDesktopShortcutCaptureOnPointerDown, { capture: true })');
|
||||
expect(preferences).toContain("event.stopImmediatePropagation()");
|
||||
expect(preferences).toContain("恢复默认");
|
||||
expect(tauriLib).not.toContain('Some("CmdOrCtrl+R")');
|
||||
expect(tauriLib).not.toContain('Some("CmdOrCtrl+[")');
|
||||
expect(tauriLib).not.toContain('Some("CmdOrCtrl+]")');
|
||||
expect(tauriLib).toContain('Some("CmdOrCtrl+R")');
|
||||
expect(tauriLib).toContain('Some("CmdOrCtrl+[")');
|
||||
expect(tauriLib).toContain('Some("CmdOrCtrl+]")');
|
||||
expect(tauriLib).toContain("desktop_menu_set_shortcuts");
|
||||
expect(tauriLib).toContain("set_menu_accelerator");
|
||||
});
|
||||
|
||||
it("moves overflowing workspace tabs into a fixed dropdown without horizontal scrolling", () => {
|
||||
it("shows the four most recently opened tasks with a searchable all-tasks menu after five tabs", () => {
|
||||
const source = readDesktopLayoutSource();
|
||||
const tabsStart = source.indexOf('<div v-if="workspaceTabs.length" ref="workspaceTabsElement" class="workspace-tabs">');
|
||||
const tabsStart = source.indexOf('<div v-if="workspaceTabs.length > 1" class="workspace-tabs">');
|
||||
const tabsEnd = source.indexOf('<main class="desktop-content"', tabsStart);
|
||||
const tabsTemplate = source.slice(tabsStart, tabsEnd);
|
||||
|
||||
expect(tabsTemplate).toContain('v-for="item in visibleWorkspaceTabs"');
|
||||
expect(tabsTemplate).toContain('v-if="overflowWorkspaceTabs.length"');
|
||||
expect(tabsTemplate).toContain('v-if="hasWorkspaceTabsOverflow"');
|
||||
expect(tabsTemplate).toContain('popper-class="workspace-tabs-overflow-popper"');
|
||||
expect(tabsTemplate).toContain('{{ overflowWorkspaceTabs.length }}');
|
||||
expect(tabsTemplate).toContain('<span>全部任务</span>');
|
||||
expect(tabsTemplate).toContain('{{ workspaceTabs.length }}');
|
||||
expect(tabsTemplate).toContain('@command="navigateOverflowWorkspaceTab"');
|
||||
expect(tabsTemplate).toContain('gridTemplateColumns: `repeat(${visibleWorkspaceTabs.length}, minmax(0, 238px))`');
|
||||
expect(tabsTemplate).toContain('class="workspace-tabs-overflow-header"');
|
||||
expect(source).toContain("const visibleWorkspaceTabCapacity = computed(() => {");
|
||||
expect(tabsTemplate).toContain('v-model="workspaceTabsSearchQuery"');
|
||||
expect(tabsTemplate).toContain('v-for="item in filteredWorkspaceTabs"');
|
||||
expect(tabsTemplate).toContain('@click.stop="closeOtherWorkspaceTabs(activeMenu)"');
|
||||
expect(tabsTemplate).toContain('@click.stop="closeAllWorkspaceTabs"');
|
||||
expect(source).toContain("const DESKTOP_WORKSPACE_TABS_OVERFLOW_THRESHOLD = 5;");
|
||||
expect(source).toContain("const DESKTOP_WORKSPACE_TABS_RECENT_VISIBLE_COUNT = 4;");
|
||||
expect(source).toContain("const workspaceTabsByRecentUse = computed(() =>");
|
||||
expect(source).toContain("const visibleWorkspaceTabs = computed(() => {");
|
||||
expect(source).toContain("const overflowWorkspaceTabs = computed(() => {");
|
||||
expect(source).toContain('workspaceTabsResizeObserver ||= new ResizeObserver(updateWorkspaceTabsAvailableWidth);');
|
||||
expect(source).toContain("const DESKTOP_WORKSPACE_TABS_OVERFLOW_WIDTH = 106;");
|
||||
expect(source).toContain("const recentVisiblePaths = new Set(");
|
||||
expect(source).toContain("workspaceTabsByRecentUse.value.slice(-DESKTOP_WORKSPACE_TABS_RECENT_VISIBLE_COUNT)");
|
||||
expect(source).toContain("return workspaceTabs.value.filter((item) => recentVisiblePaths.has(item.path));");
|
||||
expect(source).toContain("const filteredWorkspaceTabs = computed(() => {");
|
||||
expect(source).toContain("const closeAllWorkspaceTabs = () => {");
|
||||
expect(source).not.toContain("visibleWorkspaceTabCapacity");
|
||||
expect(source).not.toContain("workspaceTabsResizeObserver");
|
||||
expect(source).toContain(".workspace-tabs-list {\n display: grid;");
|
||||
expect(source).toContain("padding: 0 10px;");
|
||||
expect(source).toContain("padding: 0 14px;");
|
||||
expect(source).toContain("overflow: hidden;");
|
||||
expect(source).not.toContain("overflow-x: auto;");
|
||||
});
|
||||
@@ -584,6 +672,8 @@ describe("desktop layout shell", () => {
|
||||
expect(profile).toContain("overflow: hidden;");
|
||||
expect(profile).not.toContain("overflow: auto;");
|
||||
expect(profile).toContain("overflow-wrap: anywhere;");
|
||||
expect(profile).toContain(".dialog-close:hover");
|
||||
expect(profile).toContain(".dialog-close :deep(.el-icon)");
|
||||
expect(preferences).toContain("flex-wrap: wrap;");
|
||||
expect(preferences).toContain("overflow-wrap: anywhere;");
|
||||
});
|
||||
|
||||
@@ -183,6 +183,7 @@ describe("PermissionAccessLogs", () => {
|
||||
expect(source).toContain("loadIpRankingData");
|
||||
expect(source).toContain("fetchAccessRowsForIpRanking");
|
||||
expect(source).toContain("buildIpRankingRows");
|
||||
expect(source).toContain("const IP_RANKING_LIMIT = 10;");
|
||||
expect(source).toContain('width="min(1180px, 96vw)"');
|
||||
expect(source).toContain("ip-rank-grid");
|
||||
expect(source).toContain("ip-rank-card");
|
||||
|
||||
@@ -325,7 +325,7 @@ const MetricIconSpeed = () => h("svg", { viewBox: "0 0 24 24", fill: "none", str
|
||||
const REALTIME_POLL_INTERVAL_MS = 30_000;
|
||||
const ACCESS_LOG_EXPORT_PAGE_SIZE = 200;
|
||||
const IP_RANKING_PAGE_SIZE = 200;
|
||||
const IP_RANKING_LIMIT = 20;
|
||||
const IP_RANKING_LIMIT = 10;
|
||||
|
||||
const TIME_RANGE_PRESETS = [
|
||||
{ value: "all", label: "全部", hours: null },
|
||||
|
||||
@@ -73,8 +73,11 @@ describe("PermissionIpLocations", () => {
|
||||
it("uses backend coordinates and keeps unmapped locations in the ranking", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('const mapView = ref<"china" | "flow">("flow");');
|
||||
expect(source).toContain("item.longitude");
|
||||
expect(source).toContain("item.latitude");
|
||||
expect(source).toContain("external_fallback_ip_count");
|
||||
expect(source).toContain("外部补全");
|
||||
expect(source).toContain("chinaRankRows");
|
||||
expect(source).toContain('全部(保留期 ${periodMetadata.value.retention_days} 天)');
|
||||
expect(source).not.toContain('from "./worldMapSource"');
|
||||
|
||||
@@ -45,6 +45,9 @@
|
||||
<el-tag v-if="dataQuality && dataQuality.unknown_ip_count" type="warning" effect="plain" round size="small">
|
||||
定位 {{ dataQuality.location_coverage_rate }}%
|
||||
</el-tag>
|
||||
<el-tag v-if="dataQuality?.external_fallback_ip_count" type="info" effect="plain" round size="small">
|
||||
外部补全 {{ dataQuality.external_fallback_ip_count }} IP
|
||||
</el-tag>
|
||||
<el-tooltip content="全屏预览" placement="top">
|
||||
<el-button class="map-fullscreen-button" size="small" circle aria-label="全屏预览" @click="openFullscreenPreview">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3H3v5"/><path d="M16 3h5v5"/><path d="M21 16v5h-5"/><path d="M3 16v5h5"/><path d="M3 3l6 6"/><path d="M21 3l-6 6"/><path d="M21 21l-6-6"/><path d="M3 21l6-6"/></svg>
|
||||
@@ -205,7 +208,7 @@ const getIpLocationsDays = (preset: TimeRangePreset): number | undefined => {
|
||||
};
|
||||
return map[preset];
|
||||
};
|
||||
const mapView = ref<"china" | "flow">("china");
|
||||
const mapView = ref<"china" | "flow">("flow");
|
||||
const mapMetric = ref<"traffic" | "risk">("traffic");
|
||||
const items = ref<IpLocationStatItem[]>([]);
|
||||
const summary = ref<IpLocationsResponse["summary"] | null>(null);
|
||||
|
||||
@@ -786,7 +786,7 @@ const refreshCurrentDesktopView = () => {
|
||||
};
|
||||
|
||||
const handleDesktopMenuCommand = (command: string) => {
|
||||
if (command === "ctms.desktop.commandPalette") {
|
||||
if (command === "ctms.desktop.commandPalette" || command === "ctms.desktop.help") {
|
||||
openCommandPalette();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildWorkspaceTabTitle,
|
||||
buildWorkspaceToolbarTitle,
|
||||
resolveWorkspaceTabContextTitle,
|
||||
} from "./workspaceTabTitle";
|
||||
|
||||
describe("workspace tab title", () => {
|
||||
it("keeps the module title when no detail context is available", () => {
|
||||
expect(buildWorkspaceTabTitle({ moduleTitle: "参与者管理", routeTitle: "参与者详情" })).toBe("参与者管理");
|
||||
});
|
||||
|
||||
it("combines the detail object type with its loaded identifier", () => {
|
||||
expect(
|
||||
buildWorkspaceTabTitle({
|
||||
moduleTitle: "参与者管理",
|
||||
routeTitle: "参与者详情",
|
||||
contextTitle: "SUBJ-001",
|
||||
}),
|
||||
).toBe("参与者 · SUBJ-001");
|
||||
});
|
||||
|
||||
it("uses a non-detail route title as the context prefix", () => {
|
||||
expect(
|
||||
buildWorkspaceTabTitle({
|
||||
moduleTitle: "文件版本管理",
|
||||
routeTitle: "文件版本管理",
|
||||
contextTitle: "知情同意书",
|
||||
}),
|
||||
).toBe("文件版本管理 · 知情同意书");
|
||||
});
|
||||
|
||||
it("avoids duplicate fallback titles", () => {
|
||||
expect(
|
||||
buildWorkspaceTabTitle({
|
||||
moduleTitle: "设备管理",
|
||||
routeTitle: "设备详情",
|
||||
contextTitle: "设备详情",
|
||||
}),
|
||||
).toBe("设备详情");
|
||||
});
|
||||
|
||||
it("keeps a loaded context when the same detail route temporarily clears its live context", () => {
|
||||
expect(
|
||||
resolveWorkspaceTabContextTitle({
|
||||
routePath: "/subjects/subject-1",
|
||||
storedContext: { routePath: "/subjects/subject-1", pageTitle: "SUBJ-001" },
|
||||
}),
|
||||
).toBe("SUBJ-001");
|
||||
});
|
||||
|
||||
it("does not leak a stored title into another detail route", () => {
|
||||
expect(
|
||||
resolveWorkspaceTabContextTitle({
|
||||
routePath: "/subjects/subject-2",
|
||||
storedContext: { routePath: "/subjects/subject-1", pageTitle: "SUBJ-001" },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the business object type in the desktop toolbar", () => {
|
||||
expect(
|
||||
buildWorkspaceToolbarTitle({
|
||||
moduleTitle: "文件版本管理",
|
||||
routeTitle: "文件版本管理",
|
||||
contextTitle: "11",
|
||||
contextObjectType: "SOP",
|
||||
}),
|
||||
).toBe("SOP · 11");
|
||||
});
|
||||
|
||||
it("derives a concise object label from a detail route", () => {
|
||||
expect(
|
||||
buildWorkspaceToolbarTitle({
|
||||
moduleTitle: "参与者管理",
|
||||
routeTitle: "参与者详情",
|
||||
contextTitle: "S01",
|
||||
}),
|
||||
).toBe("参与者 · S01");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
interface WorkspaceTabTitleInput {
|
||||
moduleTitle: string;
|
||||
routeTitle?: string;
|
||||
contextTitle?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceTabContext {
|
||||
routePath: string;
|
||||
pageTitle: string;
|
||||
objectType?: string;
|
||||
}
|
||||
|
||||
interface WorkspaceTabContextTitleInput {
|
||||
liveContextTitle?: string;
|
||||
storedContext?: WorkspaceTabContext;
|
||||
routePath: string;
|
||||
}
|
||||
|
||||
export const resolveWorkspaceTabContextTitle = ({
|
||||
liveContextTitle,
|
||||
storedContext,
|
||||
routePath,
|
||||
}: WorkspaceTabContextTitleInput) =>
|
||||
liveContextTitle || (storedContext?.routePath === routePath ? storedContext.pageTitle : undefined);
|
||||
|
||||
interface WorkspaceToolbarTitleInput extends WorkspaceTabTitleInput {
|
||||
contextObjectType?: string;
|
||||
}
|
||||
|
||||
export const buildWorkspaceToolbarTitle = ({
|
||||
moduleTitle,
|
||||
routeTitle,
|
||||
contextTitle,
|
||||
contextObjectType,
|
||||
}: WorkspaceToolbarTitleInput) => {
|
||||
const contextLabel = contextTitle?.trim();
|
||||
if (!contextLabel) return moduleTitle.trim();
|
||||
const objectLabel = contextObjectType?.trim() || routeTitle?.replace(/详情$/, "").trim() || moduleTitle.trim();
|
||||
if (!objectLabel || contextLabel === objectLabel) return contextLabel;
|
||||
return `${objectLabel} · ${contextLabel}`;
|
||||
};
|
||||
|
||||
export const buildWorkspaceTabTitle = ({ moduleTitle, routeTitle, contextTitle }: WorkspaceTabTitleInput) => {
|
||||
const moduleLabel = moduleTitle.trim();
|
||||
const routeLabel = routeTitle?.trim() || moduleLabel;
|
||||
const contextLabel = contextTitle?.trim();
|
||||
if (!contextLabel || contextLabel === moduleLabel) return moduleLabel;
|
||||
if (contextLabel === routeLabel) return routeLabel;
|
||||
|
||||
const objectLabel = routeLabel.replace(/详情$/, "").trim() || moduleLabel;
|
||||
if (contextLabel === objectLabel) return routeLabel;
|
||||
return `${objectLabel} · ${contextLabel}`;
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_DESKTOP_SHORTCUTS } from "./desktopUiPreferences";
|
||||
import { syncDesktopMenuShortcuts } from "./desktopMenu";
|
||||
|
||||
const invokeMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: invokeMock,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
invokeMock.mockReset();
|
||||
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
|
||||
});
|
||||
|
||||
describe("desktop native menu", () => {
|
||||
it("does not invoke desktop commands in the web runtime", async () => {
|
||||
await syncDesktopMenuShortcuts(DEFAULT_DESKTOP_SHORTCUTS);
|
||||
|
||||
expect(invokeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("synchronizes validated shortcut preferences through the controlled command", async () => {
|
||||
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
|
||||
|
||||
await syncDesktopMenuShortcuts(DEFAULT_DESKTOP_SHORTCUTS);
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith("desktop_menu_set_shortcuts", {
|
||||
shortcuts: DEFAULT_DESKTOP_SHORTCUTS,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,46 @@
|
||||
import { isTauriRuntime } from "./platform";
|
||||
import {
|
||||
DESKTOP_SHORTCUTS_CHANGED_EVENT,
|
||||
readDesktopShortcutPreferences,
|
||||
type DesktopShortcutPreferences,
|
||||
} from "./desktopUiPreferences";
|
||||
|
||||
export const DESKTOP_MENU_COMMAND_EVENT = "ctms:desktop-menu-command";
|
||||
|
||||
export type DesktopMenuCommand =
|
||||
| "ctms.desktop.commandPalette"
|
||||
| "ctms.desktop.help"
|
||||
| "ctms.desktop.preferences"
|
||||
| "ctms.desktop.serverSettings"
|
||||
| "ctms.desktop.refresh"
|
||||
| "ctms.desktop.back"
|
||||
| "ctms.desktop.forward";
|
||||
| "ctms.desktop.forward"
|
||||
| "ctms.desktop.toggleSidebar";
|
||||
|
||||
type Unlisten = () => void;
|
||||
|
||||
let shortcutSyncInitialized = false;
|
||||
|
||||
export const syncDesktopMenuShortcuts = async (shortcuts: DesktopShortcutPreferences): Promise<void> => {
|
||||
if (!isTauriRuntime()) return;
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
await invoke("desktop_menu_set_shortcuts", { shortcuts });
|
||||
};
|
||||
|
||||
export const initializeDesktopMenuShortcutSync = (): void => {
|
||||
if (shortcutSyncInitialized || !isTauriRuntime() || typeof window === "undefined") return;
|
||||
shortcutSyncInitialized = true;
|
||||
|
||||
const sync = (event?: Event) => {
|
||||
const shortcuts =
|
||||
(event as CustomEvent<DesktopShortcutPreferences> | undefined)?.detail || readDesktopShortcutPreferences();
|
||||
void syncDesktopMenuShortcuts(shortcuts).catch(() => {});
|
||||
};
|
||||
|
||||
window.addEventListener(DESKTOP_SHORTCUTS_CHANGED_EVENT, sync);
|
||||
sync();
|
||||
};
|
||||
|
||||
export const listenDesktopMenuCommand = async (
|
||||
handler: (command: DesktopMenuCommand | string) => void,
|
||||
): Promise<Unlisten> => {
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_DESKTOP_SHORTCUTS,
|
||||
DESKTOP_FAVORITE_ROUTES_KEY,
|
||||
DESKTOP_SHORTCUTS_KEY,
|
||||
DESKTOP_SIDEBAR_VISIBLE_KEY,
|
||||
DESKTOP_THEME_KEY,
|
||||
applyDesktopThemePreference,
|
||||
clearLegacyDesktopRouteHistoryPreference,
|
||||
desktopShortcutFromKeyboardEvent,
|
||||
readDesktopFavoriteRoutes,
|
||||
readDesktopShortcutPreferences,
|
||||
readDesktopSidebarVisible,
|
||||
readDesktopThemePreference,
|
||||
resetDesktopShortcutPreferences,
|
||||
resolveDesktopShortcutAction,
|
||||
setDesktopShortcutPreference,
|
||||
setDesktopSidebarVisible,
|
||||
setDesktopThemePreference,
|
||||
toggleDesktopFavoriteRoute,
|
||||
} from "./desktopUiPreferences";
|
||||
|
||||
const invokeMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: invokeMock,
|
||||
}));
|
||||
|
||||
const installLocalStorageMock = () => {
|
||||
const store = new Map<string, string>();
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
@@ -32,11 +41,18 @@ const installLocalStorageMock = () => {
|
||||
|
||||
describe("desktop UI preferences", () => {
|
||||
beforeEach(() => {
|
||||
invokeMock.mockReset();
|
||||
installLocalStorageMock();
|
||||
document.documentElement.removeAttribute("data-ctms-theme");
|
||||
document.documentElement.removeAttribute("data-ctms-theme-preference");
|
||||
document.documentElement.style.colorScheme = "";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("stores only route metadata for desktop favorites", () => {
|
||||
toggleDesktopFavoriteRoute({ path: "/subjects", title: "受试者", group: "当前项目" });
|
||||
|
||||
@@ -76,7 +92,11 @@ describe("desktop UI preferences", () => {
|
||||
});
|
||||
|
||||
it("stores and applies only the desktop theme enum", () => {
|
||||
expect(readDesktopThemePreference()).toBe("light");
|
||||
expect(readDesktopThemePreference()).toBe("system");
|
||||
|
||||
applyDesktopThemePreference();
|
||||
expect(document.documentElement.getAttribute("data-ctms-theme")).toBe("light");
|
||||
expect(document.documentElement.getAttribute("data-ctms-theme-preference")).toBe("system");
|
||||
|
||||
setDesktopThemePreference("dark");
|
||||
|
||||
@@ -89,6 +109,37 @@ describe("desktop UI preferences", () => {
|
||||
expect(document.documentElement.getAttribute("data-ctms-theme")).toBe("light");
|
||||
});
|
||||
|
||||
it("persists the desktop sidebar visibility without storing business data", () => {
|
||||
expect(readDesktopSidebarVisible()).toBe(true);
|
||||
|
||||
expect(setDesktopSidebarVisible(false)).toBe(false);
|
||||
expect(readDesktopSidebarVisible()).toBe(false);
|
||||
expect(window.localStorage.getItem(DESKTOP_SIDEBAR_VISIBLE_KEY)).toBe("false");
|
||||
|
||||
expect(setDesktopSidebarVisible(true)).toBe(true);
|
||||
expect(readDesktopSidebarVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves the system desktop theme through prefers-color-scheme", () => {
|
||||
vi.stubGlobal("matchMedia", vi.fn(() => ({ matches: true })));
|
||||
|
||||
applyDesktopThemePreference("system");
|
||||
|
||||
expect(document.documentElement.getAttribute("data-ctms-theme")).toBe("dark");
|
||||
expect(document.documentElement.getAttribute("data-ctms-theme-preference")).toBe("system");
|
||||
expect(document.documentElement.style.colorScheme).toBe("dark");
|
||||
});
|
||||
|
||||
it("synchronizes the selected theme through the controlled desktop command", async () => {
|
||||
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
|
||||
|
||||
setDesktopThemePreference("system");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(invokeMock).toHaveBeenCalledWith("desktop_window_set_theme", { theme: "system" });
|
||||
});
|
||||
});
|
||||
|
||||
it("stores validated non-conflicting desktop shortcuts", () => {
|
||||
const result = setDesktopShortcutPreference("back", "Mod+Shift+[");
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { isTauriRuntime } from "./platform";
|
||||
|
||||
export const DESKTOP_FAVORITE_ROUTES_KEY = "ctms_desktop_favorite_routes";
|
||||
export const DESKTOP_THEME_KEY = "ctms_desktop_theme";
|
||||
export const DESKTOP_THEME_CHANGED_EVENT = "ctms:desktop-theme-changed";
|
||||
export const DESKTOP_SHORTCUTS_KEY = "ctms_desktop_shortcuts";
|
||||
export const DESKTOP_SHORTCUTS_CHANGED_EVENT = "ctms:desktop-shortcuts-changed";
|
||||
export const DESKTOP_SIDEBAR_VISIBLE_KEY = "ctms_desktop_sidebar_visible";
|
||||
|
||||
export type DesktopThemePreference = "light" | "dark";
|
||||
export type DesktopThemePreference = "system" | "light" | "dark";
|
||||
export type ResolvedDesktopTheme = "light" | "dark";
|
||||
export type DesktopShortcutAction = "back" | "forward" | "refresh";
|
||||
export type DesktopShortcutPreferences = Record<DesktopShortcutAction, string>;
|
||||
|
||||
@@ -22,7 +26,7 @@ export interface DesktopRoutePreference {
|
||||
}
|
||||
|
||||
const MAX_FAVORITE_ROUTES = 12;
|
||||
const DEFAULT_DESKTOP_THEME: DesktopThemePreference = "light";
|
||||
const DEFAULT_DESKTOP_THEME: DesktopThemePreference = "system";
|
||||
const DESKTOP_THEME_ATTRIBUTE = "data-ctms-theme";
|
||||
const LEGACY_DESKTOP_ROUTE_HISTORY_KEY = "ctms_desktop_recent_routes";
|
||||
const DESKTOP_SHORTCUT_ACTIONS: DesktopShortcutAction[] = ["back", "forward", "refresh"];
|
||||
@@ -75,7 +79,25 @@ const writeRoutePreferences = (key: string, routes: DesktopRoutePreference[]) =>
|
||||
};
|
||||
|
||||
const sanitizeDesktopThemePreference = (value: unknown): DesktopThemePreference =>
|
||||
value === "dark" ? "dark" : DEFAULT_DESKTOP_THEME;
|
||||
value === "light" || value === "dark" || value === "system" ? value : DEFAULT_DESKTOP_THEME;
|
||||
|
||||
const resolveSystemTheme = (): ResolvedDesktopTheme => {
|
||||
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
return "light";
|
||||
};
|
||||
|
||||
const resolveDesktopTheme = (theme: DesktopThemePreference): ResolvedDesktopTheme =>
|
||||
theme === "system" ? resolveSystemTheme() : theme;
|
||||
|
||||
const syncDesktopWindowTheme = async (theme: DesktopThemePreference): Promise<void> => {
|
||||
if (!isTauriRuntime()) return;
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
await invoke("desktop_window_set_theme", { theme });
|
||||
};
|
||||
|
||||
let desktopThemeListenerInitialized = false;
|
||||
|
||||
const sanitizeDesktopShortcut = (value: unknown): string | null => {
|
||||
if (typeof value !== "string") return null;
|
||||
@@ -188,15 +210,50 @@ export const readDesktopThemePreference = (): DesktopThemePreference => {
|
||||
return sanitizeDesktopThemePreference(window.localStorage.getItem(DESKTOP_THEME_KEY));
|
||||
};
|
||||
|
||||
export const readDesktopSidebarVisible = (): boolean => {
|
||||
if (!isStorageAvailable()) return true;
|
||||
return window.localStorage.getItem(DESKTOP_SIDEBAR_VISIBLE_KEY) !== "false";
|
||||
};
|
||||
|
||||
export const setDesktopSidebarVisible = (visible: boolean): boolean => {
|
||||
if (isStorageAvailable()) {
|
||||
window.localStorage.setItem(DESKTOP_SIDEBAR_VISIBLE_KEY, visible ? "true" : "false");
|
||||
}
|
||||
return visible;
|
||||
};
|
||||
|
||||
export const applyDesktopThemePreference = (theme: DesktopThemePreference = readDesktopThemePreference()): DesktopThemePreference => {
|
||||
const nextTheme = sanitizeDesktopThemePreference(theme);
|
||||
const resolvedTheme = resolveDesktopTheme(nextTheme);
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.setAttribute(DESKTOP_THEME_ATTRIBUTE, nextTheme);
|
||||
document.documentElement.style.colorScheme = nextTheme;
|
||||
document.documentElement.setAttribute(DESKTOP_THEME_ATTRIBUTE, resolvedTheme);
|
||||
document.documentElement.setAttribute("data-ctms-theme-preference", nextTheme);
|
||||
document.documentElement.style.colorScheme = resolvedTheme;
|
||||
}
|
||||
void syncDesktopWindowTheme(nextTheme).catch(() => {});
|
||||
return nextTheme;
|
||||
};
|
||||
|
||||
export const initializeDesktopThemePreference = (): DesktopThemePreference => {
|
||||
const theme = applyDesktopThemePreference();
|
||||
if (
|
||||
desktopThemeListenerInitialized ||
|
||||
typeof window === "undefined" ||
|
||||
typeof window.matchMedia !== "function"
|
||||
) {
|
||||
return theme;
|
||||
}
|
||||
|
||||
desktopThemeListenerInitialized = true;
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
mediaQuery.addEventListener("change", () => {
|
||||
if (readDesktopThemePreference() === "system") {
|
||||
applyDesktopThemePreference("system");
|
||||
}
|
||||
});
|
||||
return theme;
|
||||
};
|
||||
|
||||
export const setDesktopThemePreference = (theme: DesktopThemePreference): DesktopThemePreference => {
|
||||
const nextTheme = sanitizeDesktopThemePreference(theme);
|
||||
if (isStorageAvailable()) {
|
||||
|
||||
@@ -13,30 +13,37 @@ export {
|
||||
DEFAULT_DESKTOP_SHORTCUTS,
|
||||
DESKTOP_SHORTCUTS_CHANGED_EVENT,
|
||||
DESKTOP_SHORTCUTS_KEY,
|
||||
DESKTOP_SIDEBAR_VISIBLE_KEY,
|
||||
DESKTOP_THEME_CHANGED_EVENT,
|
||||
DESKTOP_THEME_KEY,
|
||||
applyDesktopThemePreference,
|
||||
clearLegacyDesktopRouteHistoryPreference,
|
||||
desktopShortcutFromKeyboardEvent,
|
||||
formatDesktopShortcut,
|
||||
initializeDesktopThemePreference,
|
||||
isDesktopFavoriteRoute,
|
||||
readDesktopFavoriteRoutes,
|
||||
readDesktopShortcutPreferences,
|
||||
readDesktopSidebarVisible,
|
||||
readDesktopThemePreference,
|
||||
resetDesktopShortcutPreferences,
|
||||
resolveDesktopShortcutAction,
|
||||
setDesktopShortcutPreference,
|
||||
setDesktopSidebarVisible,
|
||||
setDesktopThemePreference,
|
||||
toggleDesktopFavoriteRoute,
|
||||
type DesktopRoutePreference,
|
||||
type DesktopShortcutAction,
|
||||
type DesktopShortcutPreferences,
|
||||
type DesktopThemePreference,
|
||||
type ResolvedDesktopTheme,
|
||||
type SetDesktopShortcutResult,
|
||||
} from "./desktopUiPreferences";
|
||||
export {
|
||||
DESKTOP_MENU_COMMAND_EVENT,
|
||||
initializeDesktopMenuShortcutSync,
|
||||
listenDesktopMenuCommand,
|
||||
syncDesktopMenuShortcuts,
|
||||
type DesktopMenuCommand,
|
||||
} from "./desktopMenu";
|
||||
export {
|
||||
|
||||
@@ -4,6 +4,12 @@ import { fetchStudies } from "../api/studies";
|
||||
import { fetchMyApiEndpointPermissions } from "../api/projectPermissions";
|
||||
import type { Study } from "../types/api";
|
||||
|
||||
export interface StudyViewContext {
|
||||
siteName?: string;
|
||||
pageTitle?: string;
|
||||
objectType?: string;
|
||||
}
|
||||
|
||||
const STUDY_KEY = "ctms_current_study";
|
||||
const STUDY_ROLE_KEY = "ctms_current_study_role";
|
||||
const SITE_KEY = "ctms_current_site";
|
||||
@@ -13,7 +19,7 @@ export const useStudyStore = defineStore("study", () => {
|
||||
const currentStudy = ref<Study | null>(null);
|
||||
const currentStudyRole = ref<string | null>(null);
|
||||
const currentSite = ref<any | null>(null);
|
||||
const viewContext = ref<{ siteName?: string; pageTitle?: string } | null>(null);
|
||||
const viewContext = ref<StudyViewContext | null>(null);
|
||||
const currentPermissions = ref<any | null>(null);
|
||||
const normalizeUserKey = (value: string) => value.trim().toLowerCase();
|
||||
|
||||
@@ -229,7 +235,7 @@ export const useStudyStore = defineStore("study", () => {
|
||||
}
|
||||
};
|
||||
|
||||
const setViewContext = (ctx: { siteName?: string; pageTitle?: string } | null) => {
|
||||
const setViewContext = (ctx: StudyViewContext | null) => {
|
||||
viewContext.value = ctx;
|
||||
};
|
||||
|
||||
|
||||
@@ -65,10 +65,14 @@ export interface UserLoginActivity {
|
||||
client_type: "web" | "desktop";
|
||||
client_platform?: string | null;
|
||||
client_version?: string | null;
|
||||
client_source?: string | null;
|
||||
login_ip?: string | null;
|
||||
ip_location?: string | null;
|
||||
login_at: string;
|
||||
last_seen_at: string;
|
||||
ended_at?: string | null;
|
||||
end_reason?: string | null;
|
||||
activity_status?: "ONLINE" | "OFFLINE" | "ENDED";
|
||||
}
|
||||
|
||||
export type UserMeResponse = UserInfo;
|
||||
@@ -689,6 +693,7 @@ export interface IpLocationsResponse {
|
||||
located_ip_count: number;
|
||||
private_ip_count: number;
|
||||
unknown_ip_count: number;
|
||||
external_fallback_ip_count?: number;
|
||||
location_coverage_rate: number;
|
||||
returned_region_count: number;
|
||||
};
|
||||
|
||||
@@ -313,6 +313,7 @@ const desktopUpdateStatus = ref<DesktopUpdateStatusSnapshot>(getDesktopUpdateSta
|
||||
let updateStatusUnlisten: (() => void) | undefined;
|
||||
let connectionPendingTimer: number | undefined;
|
||||
const themeOptions = [
|
||||
{ value: "system" as const, label: "跟随系统", icon: Monitor },
|
||||
{ value: "light" as const, label: "明亮", icon: Sunny },
|
||||
{ value: "dark" as const, label: "暗黑", icon: Moon },
|
||||
];
|
||||
@@ -1358,7 +1359,9 @@ h3 {
|
||||
* ===================================================== */
|
||||
.theme-segmented {
|
||||
display: inline-grid;
|
||||
grid-template-columns: repeat(2, minmax(72px, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
flex: 0 1 336px;
|
||||
width: min(100%, 336px);
|
||||
gap: 3px;
|
||||
padding: 3px;
|
||||
border: 1px solid var(--pref-border-card);
|
||||
@@ -1372,7 +1375,7 @@ h3 {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-width: 72px;
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
@@ -1383,6 +1386,7 @@ h3 {
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
color 140ms ease,
|
||||
|
||||
@@ -24,6 +24,10 @@ describe("DesktopProjectEntry", () => {
|
||||
expect(source).toContain("project-cards-grid");
|
||||
expect(source).toContain("card-action-bar");
|
||||
expect(source).toContain("进入项目工作空间");
|
||||
expect(source).toContain('v-if="isAdmin || project.role_in_study"');
|
||||
expect(source).toContain('<span class="meta-label">我的角色</span>');
|
||||
expect(source).toContain('isAdmin.value ? "系统管理员" : project.role_in_study || "未分配"');
|
||||
expect(source).not.toContain('project.role_in_study && !isAdmin');
|
||||
expect(source).toContain('router.push(isAdmin.value ? "/admin/users" : "/admin/projects")');
|
||||
expect(source).toContain("studyStore.clearCurrentStudy()");
|
||||
expect(source).toContain("studyStore.setCurrentStudy(pmProject)");
|
||||
|
||||
@@ -109,9 +109,9 @@
|
||||
<span class="meta-label">申办方</span>
|
||||
<span class="meta-val">{{ project.sponsor }}</span>
|
||||
</div>
|
||||
<div v-if="project.role_in_study && !isAdmin" class="meta-item">
|
||||
<span class="meta-label">分配角色</span>
|
||||
<span class="meta-val">{{ project.role_in_study }}</span>
|
||||
<div v-if="isAdmin || project.role_in_study" class="meta-item">
|
||||
<span class="meta-label">我的角色</span>
|
||||
<span class="meta-val" :title="projectRoleLabel(project)">{{ projectRoleLabel(project) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -180,6 +180,7 @@ const entrySubtitle = computed(() =>
|
||||
|
||||
const statusLabel = (status: string | undefined) =>
|
||||
status ? TEXT.enums.projectStatus[status as keyof typeof TEXT.enums.projectStatus] || status : "未知";
|
||||
const projectRoleLabel = (project: Study) => isAdmin.value ? "系统管理员" : project.role_in_study || "未分配";
|
||||
|
||||
const loadProjects = async () => {
|
||||
loading.value = true;
|
||||
|
||||
@@ -316,10 +316,29 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
|
||||
}
|
||||
|
||||
.dialog-close {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-top: -4px;
|
||||
color: #718096;
|
||||
padding: 0;
|
||||
border: 1px solid #d7dee8;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.dialog-close:hover,
|
||||
.dialog-close:focus-visible {
|
||||
border-color: #b8c4d3;
|
||||
background: #eef2f7;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.dialog-close :deep(.el-icon) {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.title {
|
||||
|
||||
@@ -15,6 +15,10 @@ describe("WebWorkbenchEntry", () => {
|
||||
expect(source).toContain("换项目先回工作台");
|
||||
expect(source).toContain("项目内不直接切换项目");
|
||||
expect(source).toContain("项目工作区");
|
||||
expect(source).toContain('v-if="isAdmin || project.role_in_study"');
|
||||
expect(source).toContain('<span class="meta-label">我的角色</span>');
|
||||
expect(source).toContain('isAdmin.value ? "系统管理员" : project.role_in_study || "未分配"');
|
||||
expect(source).not.toContain('project.role_in_study && !isAdmin');
|
||||
expect(source).toContain("fetchStudies()");
|
||||
expect(source).toContain('projects.value.some((project) => project.role_in_study === "PM")');
|
||||
expect(source).toContain('v-if="canEnterManagement"');
|
||||
|
||||
@@ -134,9 +134,9 @@
|
||||
<span class="meta-label">申办方</span>
|
||||
<span class="meta-val" :title="project.sponsor">{{ project.sponsor }}</span>
|
||||
</div>
|
||||
<div v-if="project.role_in_study && !isAdmin" class="meta-item">
|
||||
<div v-if="isAdmin || project.role_in_study" class="meta-item">
|
||||
<span class="meta-label">我的角色</span>
|
||||
<span class="meta-val" :title="project.role_in_study">{{ project.role_in_study }}</span>
|
||||
<span class="meta-val" :title="projectRoleLabel(project)">{{ projectRoleLabel(project) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -219,6 +219,7 @@ const managementActionLabel = computed(() => canEnterManagement.value ? "进入
|
||||
|
||||
const statusLabel = (status: string | undefined) =>
|
||||
status ? TEXT.enums.projectStatus[status as keyof typeof TEXT.enums.projectStatus] || status : "未知";
|
||||
const projectRoleLabel = (project: Study) => isAdmin.value ? "系统管理员" : project.role_in_study || "未分配";
|
||||
|
||||
const loadProjects = async () => {
|
||||
loading.value = true;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
v-model="visibleProxy"
|
||||
title="登录记录"
|
||||
direction="rtl"
|
||||
size="min(520px, 92vw)"
|
||||
size="min(760px, 96vw)"
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="login-activity-drawer">
|
||||
@@ -18,28 +18,50 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="activities" class="login-activity-table" empty-text="暂无登录记录">
|
||||
<div class="activity-view-toolbar">
|
||||
<div>
|
||||
<strong>{{ activityViewMode === 'source' ? '最近登录来源' : '全部登录会话' }}</strong>
|
||||
<span v-if="activityViewMode === 'all'">展示最近 100 条原始登录会话。</span>
|
||||
</div>
|
||||
<el-segmented v-model="activityViewMode" :options="activityViewOptions" size="small" />
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="displayActivities" class="login-activity-table" empty-text="暂无登录记录">
|
||||
<el-table-column label="状态" width="102">
|
||||
<template #default="scope">
|
||||
<span class="status-badge compact" :class="activityStatusClass(scope.row)">
|
||||
<span class="status-badge-dot"></span>
|
||||
<span>{{ activityStatusLabel(scope.row) }}</span>
|
||||
</span>
|
||||
<el-tooltip :content="activityStatusDescription(scope.row)" placement="top">
|
||||
<span class="status-badge compact" :class="activityStatusClass(scope.row)">
|
||||
<span class="status-badge-dot"></span>
|
||||
<span>{{ activityStatusLabel(scope.row) }}</span>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="客户端" min-width="130">
|
||||
<el-table-column label="客户端" min-width="150">
|
||||
<template #default="scope">
|
||||
<div class="client-cell">
|
||||
<strong>{{ scope.row.client_type === 'desktop' ? '桌面端' : '网页端' }}</strong>
|
||||
<span>{{ clientDescription(scope.row) }}</span>
|
||||
<el-tag v-if="scope.row.grouped_count > 1" type="info" effect="plain" size="small">
|
||||
合并 {{ scope.row.grouped_count }} 条
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="登录 / 最近活动" min-width="160">
|
||||
<el-table-column label="登录 IP(位置)" min-width="190">
|
||||
<template #default="scope">
|
||||
<div class="source-cell">
|
||||
<strong>{{ scope.row.login_ip || 'IP 未记录' }}</strong>
|
||||
<span>{{ scope.row.ip_location || '--' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="登录 / 最近活动" min-width="190">
|
||||
<template #default="scope">
|
||||
<div class="time-cell">
|
||||
<span>登录 {{ displayDateTime(scope.row.login_at) }}</span>
|
||||
<span>活动 {{ displayDateTime(scope.row.last_seen_at) }}</span>
|
||||
<span v-if="scope.row.ended_at">退出 {{ displayDateTime(scope.row.ended_at) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -63,22 +85,70 @@ const emit = defineEmits<{ "update:modelValue": [value: boolean] }>();
|
||||
|
||||
const activities = ref<UserLoginActivity[]>([]);
|
||||
const loading = ref(false);
|
||||
const activityViewMode = ref<"source" | "all">("source");
|
||||
const activityViewOptions = [
|
||||
{ label: "最近来源", value: "source" },
|
||||
{ label: "全部会话", value: "all" },
|
||||
];
|
||||
const visibleProxy = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit("update:modelValue", value),
|
||||
});
|
||||
|
||||
const isRecent = (value: string) => Date.now() - new Date(value).getTime() <= 5 * 60 * 1000;
|
||||
type DisplayLoginActivity = UserLoginActivity & { grouped_count: number };
|
||||
|
||||
const resolvedActivityStatus = (item: UserLoginActivity) => {
|
||||
if (item.activity_status) return item.activity_status;
|
||||
if (item.ended_at) return "ENDED";
|
||||
return Date.now() - new Date(item.last_seen_at).getTime() <= 5 * 60 * 1000 ? "ONLINE" : "OFFLINE";
|
||||
};
|
||||
const activityStatusLabel = (item: UserLoginActivity) => {
|
||||
if (item.ended_at) return "已退出";
|
||||
return isRecent(item.last_seen_at) ? "在线" : "已离线";
|
||||
const status = resolvedActivityStatus(item);
|
||||
if (status === "ENDED") return "已退出";
|
||||
return status === "ONLINE" ? "在线" : "已离线";
|
||||
};
|
||||
const activityStatusClass = (item: UserLoginActivity) => {
|
||||
if (item.ended_at) return "is-ended";
|
||||
return isRecent(item.last_seen_at) ? "is-online" : "is-offline";
|
||||
const status = resolvedActivityStatus(item);
|
||||
if (status === "ENDED") return "is-ended";
|
||||
return status === "ONLINE" ? "is-online" : "is-offline";
|
||||
};
|
||||
const activityStatusDescription = (item: UserLoginActivity) => {
|
||||
const status = resolvedActivityStatus(item);
|
||||
if (status === "ENDED") return "服务端已收到明确的退出请求";
|
||||
if (status === "ONLINE") return "最近心跳仍在服务端在线判定时间内";
|
||||
return "未明确退出,但最近心跳已超过服务端在线判定时间";
|
||||
};
|
||||
const clientDescription = (item: UserLoginActivity) =>
|
||||
[item.client_platform, item.client_version].filter(Boolean).join(" · ") || "--";
|
||||
const activitySourceKey = (item: UserLoginActivity) => {
|
||||
if (!item.login_ip) return `session:${item.id}`;
|
||||
return [item.login_ip, item.client_type, item.client_platform || "", item.client_source || ""].join("|");
|
||||
};
|
||||
const sourceActivities = computed<DisplayLoginActivity[]>(() => {
|
||||
const rows: DisplayLoginActivity[] = [];
|
||||
const groupedHistory = new Map<string, DisplayLoginActivity>();
|
||||
activities.value.forEach((item) => {
|
||||
const row: DisplayLoginActivity = { ...item, grouped_count: 1 };
|
||||
if (resolvedActivityStatus(item) === "ONLINE") {
|
||||
rows.push(row);
|
||||
return;
|
||||
}
|
||||
const key = activitySourceKey(item);
|
||||
const existing = groupedHistory.get(key);
|
||||
if (existing) {
|
||||
existing.grouped_count += 1;
|
||||
return;
|
||||
}
|
||||
groupedHistory.set(key, row);
|
||||
rows.push(row);
|
||||
});
|
||||
return rows;
|
||||
});
|
||||
const displayActivities = computed<DisplayLoginActivity[]>(() =>
|
||||
activityViewMode.value === "source"
|
||||
? sourceActivities.value
|
||||
: activities.value.map((item) => ({ ...item, grouped_count: 1 })),
|
||||
);
|
||||
|
||||
const loadActivities = async () => {
|
||||
if (!props.user) return;
|
||||
@@ -96,7 +166,10 @@ const loadActivities = async () => {
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(isOpen) => {
|
||||
if (isOpen) void loadActivities();
|
||||
if (isOpen) {
|
||||
activityViewMode.value = "source";
|
||||
void loadActivities();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
@@ -209,6 +282,32 @@ watch(
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.activity-view-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.activity-view-toolbar > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.activity-view-toolbar strong {
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.activity-view-toolbar span {
|
||||
color: #64748b;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.login-activity-table :deep(.el-table__header-wrapper) th {
|
||||
background-color: #f8fafc !important;
|
||||
color: #475569 !important;
|
||||
@@ -224,6 +323,7 @@ watch(
|
||||
}
|
||||
|
||||
.client-cell,
|
||||
.source-cell,
|
||||
.time-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -237,12 +337,35 @@ watch(
|
||||
}
|
||||
|
||||
.client-cell span,
|
||||
.source-cell span,
|
||||
.time-cell span {
|
||||
color: #64748b;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.client-cell .el-tag {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.source-cell strong {
|
||||
color: #334155;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.time-cell span {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.activity-view-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.activity-view-toolbar :deep(.el-segmented) {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { resolve } from "node:path";
|
||||
const readSource = (path: string) => readFileSync(resolve(__dirname, path), "utf8");
|
||||
|
||||
describe("admin user login status", () => {
|
||||
it("shows server-provided session state and opens a privacy-safe activity drawer", () => {
|
||||
it("shows server-provided session state with login source details and grouped history", () => {
|
||||
const users = readSource("./Users.vue");
|
||||
const drawer = readSource("./UserLoginActivitiesDrawer.vue");
|
||||
|
||||
@@ -15,8 +15,21 @@ describe("admin user login status", () => {
|
||||
expect(users).toContain("openLoginActivities");
|
||||
expect(users).toContain("UserLoginActivitiesDrawer");
|
||||
expect(drawer).toContain("登录记录");
|
||||
expect(drawer).not.toContain("不会展示 Token、密码或原始 IP");
|
||||
expect(drawer).toContain("登录 IP(位置)");
|
||||
expect(drawer).toContain("scope.row.login_ip");
|
||||
expect(drawer).toContain("scope.row.ip_location");
|
||||
expect(drawer).toContain("activity_status");
|
||||
expect(drawer).toContain("resolvedActivityStatus");
|
||||
expect(drawer).toContain("最近来源");
|
||||
expect(drawer).toContain("全部会话");
|
||||
expect(drawer).toContain("activitySourceKey");
|
||||
expect(drawer).toContain("grouped_count");
|
||||
expect(drawer).toContain("scope.row.ended_at");
|
||||
expect(drawer).not.toContain("const isRecent");
|
||||
expect(drawer).not.toContain("access_token");
|
||||
|
||||
const usersApi = readSource("../../api/users.ts");
|
||||
expect(usersApi).toContain("limit = 100");
|
||||
});
|
||||
|
||||
it("offers responsive account and login-status filters with clear reset feedback", () => {
|
||||
|
||||
@@ -93,6 +93,7 @@ describe("DocumentDetail breadcrumbs", () => {
|
||||
expect(source).toContain("study.setViewContext({");
|
||||
expect(source).toContain("siteName: displaySite(detail.site_id)");
|
||||
expect(source).toContain("pageTitle: detail.title || TEXT.modules.fileVersionManagement.title");
|
||||
expect(source).toContain("objectType: detail.doc_type ? displayText(detail.doc_type, TEXT.enums.documentType) : undefined");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -478,6 +478,7 @@ const syncBreadcrumbContext = () => {
|
||||
study.setViewContext({
|
||||
siteName: displaySite(detail.site_id),
|
||||
pageTitle: detail.title || TEXT.modules.fileVersionManagement.title,
|
||||
objectType: detail.doc_type ? displayText(detail.doc_type, TEXT.enums.documentType) : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user