feat(桌面与监控): 完善工作台导航和登录活动定位
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled

- 优化桌面标签、上下文标题、前进后退、导航栏隐藏和原生菜单体验

- 补充登录会话 IP 采集、地理位置回退、管理端展示及数据库迁移

- 更新桌面发布检查、运维文档和前后端测试覆盖
This commit is contained in:
Cheng Zhou
2026-07-13 16:03:20 +08:00
parent b26ebdda02
commit ab59476d10
50 changed files with 2086 additions and 191 deletions
@@ -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")
+30 -3
View File
@@ -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),
},
+6 -3
View File
@@ -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)
+5
View File
@@ -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)
+2 -1
View File
@@ -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)
+4
View File
@@ -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 属地统计不应因运营商不同拆分。"""
+66 -2
View File
@@ -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)
+6
View File
@@ -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 天会话恢复体验,需先确定凭据访问控制策略;通知点击回跳需先定义固定、无敏感信息的动作和目标页面。三项均不是当前发布前置条件。
+7
View File
@@ -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:
+14 -2
View File
@@ -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 或日志。
受限网络建议显式配置:
+60 -5
View File
@@ -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
View File
@@ -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());
}
}
+3 -2
View File
@@ -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");
+1 -1
View File
@@ -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(() => {
+486 -83
View File
@@ -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;
+105 -15
View File
@@ -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);
+1 -1
View File
@@ -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}`;
};
+32
View File
@@ -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,
});
});
});
+30 -1
View File
@@ -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+[");
+62 -5
View File
@@ -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()) {
+7
View File
@@ -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 {
+8 -2
View File
@@ -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;
};
+5
View File
@@ -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;
};
+6 -2
View File
@@ -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)");
+4 -3
View File
@@ -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;
+20 -1
View File
@@ -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"');
+3 -2
View File
@@ -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,
});
};