feat(desktop): implement phase 2 native capabilities
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
"""add desktop phase 2 notification and client metadata state
|
||||
|
||||
Revision ID: 20260630_02
|
||||
Revises: 20260630_01
|
||||
Create Date: 2026-06-30 21:30:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "20260630_02"
|
||||
down_revision: Union[str, None] = "20260630_01"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("security_access_logs", sa.Column("client_type", sa.String(16), nullable=True))
|
||||
op.add_column("security_access_logs", sa.Column("client_version", sa.String(32), nullable=True))
|
||||
op.add_column("security_access_logs", sa.Column("client_platform", sa.String(16), nullable=True))
|
||||
op.add_column("security_access_logs", sa.Column("build_channel", sa.String(16), nullable=True))
|
||||
op.add_column("security_access_logs", sa.Column("build_commit", sa.String(64), nullable=True))
|
||||
op.create_index(
|
||||
"ix_security_log_client_created",
|
||||
"security_access_logs",
|
||||
["client_type", "client_version", "created_at"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"desktop_notification_subscriptions",
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("enabled", sa.Boolean(), server_default=sa.false(), nullable=False),
|
||||
sa.Column("enabled_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("user_id"),
|
||||
)
|
||||
op.create_table(
|
||||
"desktop_notification_deliveries",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("distribution_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("claim_token", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["distribution_id"], ["distributions.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"user_id",
|
||||
"distribution_id",
|
||||
name="uq_desktop_notification_user_distribution",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_desktop_notification_claim",
|
||||
"desktop_notification_deliveries",
|
||||
["user_id", "delivered_at", "claimed_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_desktop_notification_claim", table_name="desktop_notification_deliveries")
|
||||
op.drop_table("desktop_notification_deliveries")
|
||||
op.drop_table("desktop_notification_subscriptions")
|
||||
op.drop_index("ix_security_log_client_created", table_name="security_access_logs")
|
||||
for column in ("build_commit", "build_channel", "client_platform", "client_version", "client_type"):
|
||||
op.drop_column("security_access_logs", column)
|
||||
@@ -346,13 +346,10 @@ async def preview_attachment(
|
||||
|
||||
|
||||
async def _authorize_global(request: Request, db: AsyncSession, study_id: uuid.UUID):
|
||||
token = None
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if auth_header and auth_header.lower().startswith("bearer "):
|
||||
token = auth_header.split(" ", 1)[1]
|
||||
if not token:
|
||||
token = request.query_params.get("token")
|
||||
if not token:
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录")
|
||||
payload = decode_token(token)
|
||||
user = await user_crud.get_by_id(db, uuid.UUID(str(payload.get("sub"))))
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session
|
||||
from app.schemas.notification import (
|
||||
DesktopNotificationAckRequest,
|
||||
DesktopNotificationClaimRequest,
|
||||
DesktopNotificationClaimResponse,
|
||||
DesktopNotificationSubscriptionRead,
|
||||
DesktopNotificationSubscriptionUpdate,
|
||||
)
|
||||
from app.services import desktop_notification_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/subscription", response_model=DesktopNotificationSubscriptionRead)
|
||||
async def read_subscription(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> DesktopNotificationSubscriptionRead:
|
||||
subscription = await desktop_notification_service.get_subscription(db, current_user.id)
|
||||
return DesktopNotificationSubscriptionRead(
|
||||
enabled=bool(subscription and subscription.enabled),
|
||||
enabled_at=subscription.enabled_at if subscription else None,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/subscription", response_model=DesktopNotificationSubscriptionRead)
|
||||
async def update_subscription(
|
||||
payload: DesktopNotificationSubscriptionUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> DesktopNotificationSubscriptionRead:
|
||||
subscription = await desktop_notification_service.set_subscription(
|
||||
db, current_user.id, payload.enabled
|
||||
)
|
||||
return DesktopNotificationSubscriptionRead(
|
||||
enabled=subscription.enabled,
|
||||
enabled_at=subscription.enabled_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/claim", response_model=DesktopNotificationClaimResponse)
|
||||
async def claim_notifications(
|
||||
payload: DesktopNotificationClaimRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> DesktopNotificationClaimResponse:
|
||||
if payload.limit < 1 or payload.limit > 50:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="limit 必须在 1 到 50 之间")
|
||||
claim_token, lease_expires_at, items = await desktop_notification_service.claim_notifications(
|
||||
db, current_user.id, payload.limit
|
||||
)
|
||||
return DesktopNotificationClaimResponse(
|
||||
claim_token=claim_token,
|
||||
lease_expires_at=lease_expires_at,
|
||||
items=items,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/ack", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def acknowledge_notifications(
|
||||
payload: DesktopNotificationAckRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
await desktop_notification_service.acknowledge_notifications(
|
||||
db,
|
||||
current_user.id,
|
||||
payload.claim_token,
|
||||
payload.delivered_ids,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{distribution_id}/read", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def mark_notification_read(
|
||||
distribution_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
await desktop_notification_service.mark_notification_read(
|
||||
db, current_user.id, distribution_id
|
||||
)
|
||||
@@ -14,7 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, is_system_admin
|
||||
from app.core.deps import get_current_user, get_db_session, is_system_admin, list_active_pm_study_ids
|
||||
from app.core.permission_monitor import (
|
||||
CACHE_HIT_RATE_HEALTH_MIN_ACCESSES,
|
||||
CACHE_METRICS_WINDOW_SECONDS,
|
||||
@@ -43,6 +43,11 @@ class MonitoringScope:
|
||||
async def resolve_monitoring_scope(db: AsyncSession, current_user) -> MonitoringScope:
|
||||
if is_system_admin(current_user):
|
||||
return MonitoringScope(is_admin=True, study_ids=set())
|
||||
user_id = getattr(current_user, "id", None)
|
||||
if user_id:
|
||||
study_ids = await list_active_pm_study_ids(db, user_id)
|
||||
if study_ids:
|
||||
return MonitoringScope(is_admin=False, study_ids=study_ids)
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
|
||||
|
||||
@@ -479,6 +484,8 @@ async def get_security_access_logs(
|
||||
_=Depends(get_current_user),
|
||||
status_min: Optional[int] = Query(None, ge=100, le=599),
|
||||
auth_status: Optional[str] = Query(None),
|
||||
client_type: Optional[str] = Query(None),
|
||||
client_version: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
) -> dict:
|
||||
@@ -486,11 +493,17 @@ async def get_security_access_logs(
|
||||
scope = await resolve_monitoring_scope(db, _)
|
||||
if not scope.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
client_type = client_type if isinstance(client_type, str) else None
|
||||
client_version = client_version if isinstance(client_version, str) else None
|
||||
conditions = []
|
||||
if status_min is not None:
|
||||
conditions.append(SecurityAccessLog.status_code >= status_min)
|
||||
if auth_status:
|
||||
conditions.append(SecurityAccessLog.auth_status == auth_status)
|
||||
if client_type:
|
||||
conditions.append(SecurityAccessLog.client_type == client_type)
|
||||
if client_version:
|
||||
conditions.append(SecurityAccessLog.client_version == client_version)
|
||||
|
||||
query = select(SecurityAccessLog)
|
||||
count_query = select(func.count()).select_from(SecurityAccessLog)
|
||||
@@ -545,6 +558,11 @@ async def get_security_access_logs(
|
||||
"ip_city": ip_location.city,
|
||||
"ip_isp": ip_location.isp,
|
||||
"user_agent": log.user_agent,
|
||||
"client_type": log.client_type,
|
||||
"client_version": log.client_version,
|
||||
"client_platform": log.client_platform,
|
||||
"build_channel": log.build_channel,
|
||||
"build_commit": log.build_commit,
|
||||
"auth_status": log.auth_status,
|
||||
"user_identifier": log.user_identifier,
|
||||
"account_label": _security_account_label(log.auth_status, log.user_identifier, user_names),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import auth, users, admin_email_settings, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles
|
||||
from app.api.v1 import auth, users, admin_email_settings, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, desktop_notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles
|
||||
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -10,6 +10,7 @@ api_router.include_router(users.router, prefix="/users", tags=["users"])
|
||||
api_router.include_router(studies.router, prefix="/studies", tags=["studies"])
|
||||
api_router.include_router(overview.router, prefix="/studies/{study_id}", tags=["overview"])
|
||||
api_router.include_router(notifications.router, prefix="/studies/{study_id}", tags=["notifications"])
|
||||
api_router.include_router(desktop_notifications.router, prefix="/desktop-notifications", tags=["desktop-notifications"])
|
||||
api_router.include_router(sites.router, prefix="/studies/{study_id}/sites", tags=["sites"])
|
||||
api_router.include_router(members.router, prefix="/studies/{study_id}/members", tags=["study-members"])
|
||||
api_router.include_router(api_permissions.router, tags=["api-permissions"])
|
||||
|
||||
@@ -26,6 +26,10 @@ class Settings(BaseSettings):
|
||||
LOGIN_CHALLENGE_MAX_ACTIVE: int = 1000
|
||||
SETTINGS_ENCRYPTION_KEY: Optional[str] = None
|
||||
FRONTEND_PUBLIC_URL: str = "http://localhost:8888"
|
||||
CORS_ALLOWED_ORIGINS: str = (
|
||||
"http://localhost:8888,http://localhost:5173,"
|
||||
"tauri://localhost,http://tauri.localhost"
|
||||
)
|
||||
IP2REGION_XDB_PATH: Optional[str] = None
|
||||
IP2REGION_IPV6_XDB_PATH: Optional[str] = None
|
||||
|
||||
@@ -36,3 +40,11 @@ def get_settings() -> Settings:
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def get_cors_allowed_origins() -> list[str]:
|
||||
return [
|
||||
origin.strip()
|
||||
for origin in settings.CORS_ALLOWED_ORIGINS.split(",")
|
||||
if origin.strip()
|
||||
]
|
||||
|
||||
@@ -42,4 +42,8 @@ from app.models.permission_access_log import PermissionAccessLog # noqa: F401
|
||||
from app.models.permission_metric_snapshot import PermissionMetricSnapshot # noqa: F401
|
||||
from app.models.permission_template import PermissionTemplate, PermissionTemplateVersion # noqa: F401
|
||||
from app.models.security_access_log import SecurityAccessLog # noqa: F401
|
||||
from app.models.desktop_notification import ( # noqa: F401
|
||||
DesktopNotificationDelivery,
|
||||
DesktopNotificationSubscription,
|
||||
)
|
||||
from app.models.email_settings import EmailVerificationCode, SystemEmailSettings # noqa: F401
|
||||
|
||||
+17
-3
@@ -10,7 +10,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.api.v1.router import api_router
|
||||
from app.core.config import settings
|
||||
from app.core.config import get_cors_allowed_origins, settings
|
||||
from app.core.exceptions import register_exception_handlers
|
||||
from app.core.login_crypto import validate_login_crypto_configuration
|
||||
from app.crud.user import ensure_admin_exists
|
||||
@@ -116,10 +116,19 @@ def create_app() -> FastAPI:
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_origins=get_cors_allowed_origins(),
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_headers=[
|
||||
"Accept",
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-CTMS-Client-Type",
|
||||
"X-CTMS-Client-Version",
|
||||
"X-CTMS-Client-Platform",
|
||||
"X-CTMS-Build-Channel",
|
||||
"X-CTMS-Build-Commit",
|
||||
],
|
||||
)
|
||||
|
||||
@app.middleware("http")
|
||||
@@ -269,6 +278,11 @@ def _enqueue_security_access_log(request, path: str, status_code: int, started_a
|
||||
"elapsed_ms": round((time.perf_counter() - started_at) * 1000, 2),
|
||||
"client_ip": _resolve_client_ip(request),
|
||||
"user_agent": request.headers.get("user-agent"),
|
||||
"client_type": request.headers.get("x-ctms-client-type"),
|
||||
"client_version": request.headers.get("x-ctms-client-version"),
|
||||
"client_platform": request.headers.get("x-ctms-client-platform"),
|
||||
"build_channel": request.headers.get("x-ctms-build-channel"),
|
||||
"build_commit": request.headers.get("x-ctms-build-commit"),
|
||||
"auth_status": auth_status,
|
||||
"user_identifier": user_identifier,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class DesktopNotificationSubscription(Base):
|
||||
__tablename__ = "desktop_notification_subscriptions"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
enabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class DesktopNotificationDelivery(Base):
|
||||
__tablename__ = "desktop_notification_deliveries"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "distribution_id", name="uq_desktop_notification_user_distribution"),
|
||||
Index("ix_desktop_notification_claim", "user_id", "delivered_at", "claimed_at"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
distribution_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("distributions.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
claim_token: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
||||
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
@@ -30,6 +30,11 @@ class SecurityAccessLog(Base):
|
||||
elapsed_ms: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
client_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
client_type: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
|
||||
client_version: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
|
||||
client_platform: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
|
||||
build_channel: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
|
||||
build_commit: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
auth_status: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
user_identifier: Mapped[Optional[str]] = mapped_column(String(80), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
@@ -16,5 +16,33 @@ class NotificationItem(BaseModel):
|
||||
change_summary: str | None = None
|
||||
effective_at: datetime | None = None
|
||||
created_at: datetime
|
||||
study_id: uuid.UUID | None = None
|
||||
study_name: str | None = None
|
||||
delivered_at: datetime | None = None
|
||||
read_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class DesktopNotificationSubscriptionRead(BaseModel):
|
||||
enabled: bool
|
||||
enabled_at: datetime | None = None
|
||||
|
||||
|
||||
class DesktopNotificationSubscriptionUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class DesktopNotificationClaimRequest(BaseModel):
|
||||
limit: int = 20
|
||||
|
||||
|
||||
class DesktopNotificationClaimResponse(BaseModel):
|
||||
claim_token: uuid.UUID | None = None
|
||||
lease_expires_at: datetime | None = None
|
||||
items: list[NotificationItem]
|
||||
|
||||
|
||||
class DesktopNotificationAckRequest(BaseModel):
|
||||
claim_token: uuid.UUID
|
||||
delivered_ids: list[uuid.UUID]
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import and_, exists, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.desktop_notification import (
|
||||
DesktopNotificationDelivery,
|
||||
DesktopNotificationSubscription,
|
||||
)
|
||||
from app.models.distribution import Distribution, DistributionStatus, DistributionTargetType
|
||||
from app.models.document import Document
|
||||
from app.models.document_version import DocumentVersion
|
||||
from app.models.study import Study
|
||||
from app.models.study_member import StudyMember
|
||||
from app.schemas.notification import NotificationItem
|
||||
|
||||
CLAIM_LEASE = timedelta(minutes=5)
|
||||
|
||||
|
||||
async def get_subscription(db: AsyncSession, user_id: uuid.UUID) -> DesktopNotificationSubscription | None:
|
||||
return await db.get(DesktopNotificationSubscription, user_id)
|
||||
|
||||
|
||||
async def set_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
enabled: bool,
|
||||
) -> DesktopNotificationSubscription:
|
||||
subscription = await get_subscription(db, user_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
if subscription is None:
|
||||
subscription = DesktopNotificationSubscription(
|
||||
user_id=user_id,
|
||||
enabled=enabled,
|
||||
enabled_at=now if enabled else None,
|
||||
)
|
||||
db.add(subscription)
|
||||
elif subscription.enabled != enabled:
|
||||
subscription.enabled = enabled
|
||||
subscription.enabled_at = now if enabled else None
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
return subscription
|
||||
|
||||
|
||||
def _eligible_query(user_id: uuid.UUID, enabled_at: datetime, lease_cutoff: datetime):
|
||||
role_target_exists = exists(
|
||||
select(StudyMember.id).where(
|
||||
StudyMember.study_id == Document.trial_id,
|
||||
StudyMember.user_id == user_id,
|
||||
StudyMember.is_active.is_(True),
|
||||
StudyMember.role_in_study == Distribution.target_id,
|
||||
)
|
||||
)
|
||||
target_matches = or_(
|
||||
and_(
|
||||
Distribution.target_type == DistributionTargetType.USER,
|
||||
Distribution.target_id == str(user_id),
|
||||
),
|
||||
and_(
|
||||
Distribution.target_type == DistributionTargetType.ROLE,
|
||||
role_target_exists,
|
||||
),
|
||||
)
|
||||
return (
|
||||
select(
|
||||
Distribution,
|
||||
Document,
|
||||
DocumentVersion,
|
||||
Study,
|
||||
DesktopNotificationDelivery,
|
||||
)
|
||||
.join(Document, Distribution.document_id == Document.id)
|
||||
.join(DocumentVersion, Distribution.version_id == DocumentVersion.id)
|
||||
.join(Study, Document.trial_id == Study.id)
|
||||
.outerjoin(
|
||||
DesktopNotificationDelivery,
|
||||
and_(
|
||||
DesktopNotificationDelivery.distribution_id == Distribution.id,
|
||||
DesktopNotificationDelivery.user_id == user_id,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
Distribution.status == DistributionStatus.ACTIVE,
|
||||
Distribution.created_at >= enabled_at,
|
||||
target_matches,
|
||||
or_(
|
||||
DesktopNotificationDelivery.id.is_(None),
|
||||
and_(
|
||||
DesktopNotificationDelivery.delivered_at.is_(None),
|
||||
or_(
|
||||
DesktopNotificationDelivery.claimed_at.is_(None),
|
||||
DesktopNotificationDelivery.claimed_at < lease_cutoff,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.order_by(Distribution.created_at.asc())
|
||||
.with_for_update(of=Distribution, skip_locked=True)
|
||||
)
|
||||
|
||||
|
||||
async def claim_notifications(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
limit: int,
|
||||
) -> tuple[uuid.UUID | None, datetime | None, list[NotificationItem]]:
|
||||
subscription = await get_subscription(db, user_id)
|
||||
if not subscription or not subscription.enabled or not subscription.enabled_at:
|
||||
return None, None, []
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
token = uuid.uuid4()
|
||||
rows = (
|
||||
await db.execute(
|
||||
_eligible_query(user_id, subscription.enabled_at, now - CLAIM_LEASE).limit(max(1, min(limit, 50)))
|
||||
)
|
||||
).all()
|
||||
items: list[NotificationItem] = []
|
||||
for distribution, document, version, study, delivery in rows:
|
||||
if delivery is None:
|
||||
delivery = DesktopNotificationDelivery(
|
||||
user_id=user_id,
|
||||
distribution_id=distribution.id,
|
||||
)
|
||||
db.add(delivery)
|
||||
delivery.claim_token = token
|
||||
delivery.claimed_at = now
|
||||
items.append(
|
||||
NotificationItem(
|
||||
id=distribution.id,
|
||||
document_id=document.id,
|
||||
version_id=version.id,
|
||||
document_title=document.title,
|
||||
document_no=document.doc_no,
|
||||
version_no=version.version_no,
|
||||
change_summary=version.change_summary,
|
||||
effective_at=version.effective_at,
|
||||
created_at=distribution.created_at,
|
||||
study_id=study.id,
|
||||
study_name=study.name,
|
||||
delivered_at=delivery.delivered_at,
|
||||
read_at=delivery.read_at,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
if not items:
|
||||
return None, None, []
|
||||
return token, now + CLAIM_LEASE, items
|
||||
|
||||
|
||||
async def acknowledge_notifications(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
claim_token: uuid.UUID,
|
||||
delivered_ids: list[uuid.UUID],
|
||||
) -> None:
|
||||
if delivered_ids:
|
||||
await db.execute(
|
||||
update(DesktopNotificationDelivery)
|
||||
.where(
|
||||
DesktopNotificationDelivery.user_id == user_id,
|
||||
DesktopNotificationDelivery.claim_token == claim_token,
|
||||
DesktopNotificationDelivery.distribution_id.in_(delivered_ids),
|
||||
)
|
||||
.values(delivered_at=datetime.now(timezone.utc))
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def mark_notification_read(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
distribution_id: uuid.UUID,
|
||||
) -> None:
|
||||
delivery = (
|
||||
await db.execute(
|
||||
select(DesktopNotificationDelivery).where(
|
||||
DesktopNotificationDelivery.user_id == user_id,
|
||||
DesktopNotificationDelivery.distribution_id == distribution_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if delivery is None:
|
||||
delivery = DesktopNotificationDelivery(
|
||||
user_id=user_id,
|
||||
distribution_id=distribution_id,
|
||||
)
|
||||
db.add(delivery)
|
||||
delivery.read_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
@@ -10,7 +10,7 @@ from typing import Iterable
|
||||
import aiofiles
|
||||
from fastapi import HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import delete as sa_delete, or_, select, update as sa_update
|
||||
from sqlalchemy import and_, delete as sa_delete, or_, select, update as sa_update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_cra_site_scope
|
||||
@@ -30,6 +30,8 @@ from app.models.audit_log import AuditLog
|
||||
from app.models.distribution import Distribution, DistributionStatus, DistributionTargetType
|
||||
from app.models.document import Document, DocumentScopeType, DocumentStatus
|
||||
from app.models.document_version import DocumentVersion, DocumentVersionStatus
|
||||
from app.models.desktop_notification import DesktopNotificationDelivery
|
||||
from app.models.study import Study
|
||||
from app.schemas.acknowledgement import AcknowledgementCreate
|
||||
from app.schemas.distribution import DistributionCreate, DistributionRead, DistributionStats
|
||||
from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary, DocumentUpdate
|
||||
@@ -778,9 +780,20 @@ async def list_distribution_notifications(
|
||||
DocumentVersion.version_no,
|
||||
DocumentVersion.change_summary,
|
||||
DocumentVersion.effective_at,
|
||||
Study.name.label("study_name"),
|
||||
DesktopNotificationDelivery.delivered_at,
|
||||
DesktopNotificationDelivery.read_at,
|
||||
)
|
||||
.join(DocumentVersion, Distribution.version_id == DocumentVersion.id)
|
||||
.join(Document, Distribution.document_id == Document.id)
|
||||
.join(Study, Document.trial_id == Study.id)
|
||||
.outerjoin(
|
||||
DesktopNotificationDelivery,
|
||||
and_(
|
||||
DesktopNotificationDelivery.distribution_id == Distribution.id,
|
||||
DesktopNotificationDelivery.user_id == current_user.id,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
Document.trial_id == study_id,
|
||||
Distribution.status == DistributionStatus.ACTIVE,
|
||||
@@ -804,6 +817,10 @@ async def list_distribution_notifications(
|
||||
change_summary=row.change_summary,
|
||||
effective_at=row.effective_at,
|
||||
created_at=row.created_at,
|
||||
study_id=study_id,
|
||||
study_name=row.study_name,
|
||||
delivered_at=row.delivered_at,
|
||||
read_at=row.read_at,
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
@@ -76,6 +76,11 @@ class SecurityAccessLogWriter:
|
||||
elapsed_ms=entry["elapsed_ms"],
|
||||
client_ip=entry.get("client_ip"),
|
||||
user_agent=entry.get("user_agent"),
|
||||
client_type=entry.get("client_type"),
|
||||
client_version=entry.get("client_version"),
|
||||
client_platform=entry.get("client_platform"),
|
||||
build_channel=entry.get("build_channel"),
|
||||
build_commit=entry.get("build_commit"),
|
||||
auth_status=entry["auth_status"],
|
||||
user_identifier=entry.get("user_identifier"),
|
||||
created_at=entry.get("created_at", datetime.now(timezone.utc)),
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
状态: `snapshot`
|
||||
适用范围: `storage-persistence`
|
||||
最后更新: `2026-02-27`
|
||||
最后更新: `2026-06-30`
|
||||
|
||||
- 扫描目录: `/Users/zcc/MyCTMS/ctms-project/frontend/src`
|
||||
- 总发现数: `46`
|
||||
- 高风险: `4` / 中风险: `0` / 低风险: `42`
|
||||
- 总发现数: `36`
|
||||
- 高风险: `4` / 中风险: `0` / 低风险: `32`
|
||||
|
||||
> 2026-06-30 第二阶段更新:认证 token 已迁移到 `frontend/src/runtime/secureSessionStorage.ts`;
|
||||
> 业务入口不再直接从 `localStorage` 读取 `ctms_token`,附件下载不再使用 query-token。
|
||||
|
||||
## 明细
|
||||
|
||||
@@ -18,13 +21,6 @@
|
||||
| high | business-draft | `frontend/src/views/admin/ProjectDetail.vue` | 2984 | `localStorage.removeItem` | `storageKey.value` | 立项配置草稿本地兜底,需显式提示未落库 |
|
||||
| low | ui-preference | `frontend/src/components/Layout.vue` | 227 | `localStorage.getItem` | `"ctms_sidebar_collapsed"` | UI偏好/上下文缓存 |
|
||||
| low | ui-preference | `frontend/src/components/Layout.vue` | 390 | `localStorage.setItem` | `"ctms_sidebar_collapsed"` | UI偏好/上下文缓存 |
|
||||
| low | auth-session | `frontend/src/components/ThreadList.vue` | 72 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) |
|
||||
| low | auth-session | `frontend/src/components/attachments/AttachmentList.vue` | 132 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) |
|
||||
| low | auth-session | `frontend/src/components/attachments/AttachmentList.vue` | 137 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) |
|
||||
| low | auth-session | `frontend/src/components/attachments/AttachmentList.vue` | 168 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) |
|
||||
| low | auth-session | `frontend/src/components/fees/FeeAttachmentPanel.vue` | 169 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) |
|
||||
| low | auth-session | `frontend/src/components/fees/FeeAttachmentPanel.vue` | 174 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) |
|
||||
| low | auth-session | `frontend/src/components/fees/FeeAttachmentPanel.vue` | 223 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) |
|
||||
| low | auth-session | `frontend/src/session/sessionManager.ts` | 33 | `localStorage.setItem` | `"ctms_auth_broadcast"` | 认证/会话数据(非业务主数据) |
|
||||
| low | auth-session | `frontend/src/session/sessionManager.ts` | 181 | `sessionStorage.removeItem` | `LOGOUT_REASON_STORAGE_KEY` | 认证/会话数据(非业务主数据) |
|
||||
| low | auth-session | `frontend/src/session/sessionManager.ts` | 184 | `sessionStorage.setItem` | `LOGOUT_REASON_STORAGE_KEY` | 认证/会话数据(非业务主数据) |
|
||||
@@ -49,9 +45,6 @@
|
||||
| low | ui-preference | `frontend/src/store/study.ts` | 167 | `localStorage.removeItem` | `STUDY_ROLE_KEY` | UI偏好/上下文缓存 |
|
||||
| low | ui-preference | `frontend/src/store/study.ts` | 174 | `localStorage.setItem` | `SITE_KEY` | UI偏好/上下文缓存 |
|
||||
| low | ui-preference | `frontend/src/store/study.ts` | 176 | `localStorage.removeItem` | `SITE_KEY` | UI偏好/上下文缓存 |
|
||||
| low | auth-session | `frontend/src/utils/auth.ts` | 4 | `localStorage.getItem` | `TOKEN_KEY` | 认证/会话数据(非业务主数据) |
|
||||
| low | auth-session | `frontend/src/utils/auth.ts` | 7 | `localStorage.setItem` | `TOKEN_KEY` | 认证/会话数据(非业务主数据) |
|
||||
| low | auth-session | `frontend/src/utils/auth.ts` | 11 | `localStorage.removeItem` | `TOKEN_KEY` | 认证/会话数据(非业务主数据) |
|
||||
| low | auth-session | `frontend/src/utils/auth.ts` | 17 | `localStorage.setItem` | `CREDENTIAL_KEY` | 认证/会话数据(非业务主数据) |
|
||||
| low | auth-session | `frontend/src/utils/auth.ts` | 24 | `localStorage.getItem` | `CREDENTIAL_KEY` | 认证/会话数据(非业务主数据) |
|
||||
| low | auth-session | `frontend/src/utils/auth.ts` | 38 | `localStorage.removeItem` | `CREDENTIAL_KEY` | 认证/会话数据(非业务主数据) |
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# CTMS 桌面端第二阶段执行设计
|
||||
|
||||
## 范围与边界
|
||||
|
||||
第二阶段基于已合入 `dev` 的 Tauri 基线推进。每个评审单元从最新 `dev` 创建短期 `codex/*` 分支,合入后删除,不建立长期桌面主线。
|
||||
|
||||
本阶段只增强在线桌面客户端能力:
|
||||
|
||||
- 安全运行时与诊断。
|
||||
- 原生文件选择、保存、打开。
|
||||
- 服务端持久系统通知。
|
||||
- 签名更新、发布流水线与 Windows 打包准备。
|
||||
|
||||
本阶段明确不实现离线登录、离线缓存、本地业务数据库、本地业务队列、后台业务同步或本地权限裁决。所有业务数据、权限、审计和认证判定仍以 FastAPI 后端为准。
|
||||
|
||||
## 运行时边界
|
||||
|
||||
共享业务模块只能通过 `frontend/src/runtime/` 使用平台能力。非 runtime 模块不得直接导入 Tauri API。
|
||||
|
||||
`ClientRuntime` 第二阶段能力包括:
|
||||
|
||||
- `secureSessionStorage`
|
||||
- `files`
|
||||
- `notifications`
|
||||
- `updates`
|
||||
- `capabilities.secureSessionStorage/nativeFiles/systemNotifications/automaticUpdates`
|
||||
|
||||
能力标志只在对应运行时可用或初始化成功后开启。Web 端继续使用浏览器能力,不申请系统通知权限,不启动桌面通知轮询,也不触发 updater。
|
||||
|
||||
## 安全会话存储
|
||||
|
||||
桌面端 token 存入系统凭据库:
|
||||
|
||||
- macOS:Keychain。
|
||||
- Windows:Credential Manager。
|
||||
|
||||
Rust 仅暴露固定 service 下的读取、写入、删除命令。凭据 account 使用规范化服务端 origin 的 SHA-256,避免明文服务端地址散落在系统凭据项名称中。
|
||||
|
||||
应用挂载前异步初始化 token:
|
||||
|
||||
1. Web 端继续读取 `localStorage.ctms_token`。
|
||||
2. 桌面端先删除 legacy `localStorage.ctms_token`。
|
||||
3. 若 legacy token 仍有效,则迁移到系统凭据库。
|
||||
4. 若迁移或读取凭据失败,则内存 token 置空并要求重新登录,不回退明文存储。
|
||||
|
||||
登出、服务器切换、认证失效时必须同步清除内存 token 和当前服务端 origin 对应的系统凭据。
|
||||
|
||||
## 原生文件能力
|
||||
|
||||
公共接口固定为:
|
||||
|
||||
```ts
|
||||
pickFiles(options): Promise<File[]>
|
||||
saveFile({ suggestedName, mimeType, data }): Promise<"saved" | "cancelled">
|
||||
openFile({ suggestedName, mimeType, data }): Promise<void>
|
||||
```
|
||||
|
||||
桌面端使用 Tauri dialog、fs、opener 插件。权限范围只覆盖用户当次选择的文件路径和 `$TEMP/ctms-desktop/**`,不启用 persisted-scope,不开放目录遍历或 shell。外部打开文件时写入随机临时目录,并使用净化后的文件名;启动和退出时清理临时目录,用户主动保存的文件不自动删除。
|
||||
|
||||
下载与预览统一先通过 Axios Bearer 请求取得 Blob,再交给文件适配器。附件全局下载接口只接受 `Authorization` header,不再接受 `?token=`。
|
||||
|
||||
## 系统通知
|
||||
|
||||
通知订阅由用户在个人设置中主动开启。开启时才请求 OS 权限并创建服务端订阅;`enabled_at` 设为当前时间,不补发历史分发记录。关闭后客户端停止领取通知,重新开启时重新设定起点。
|
||||
|
||||
后端新增:
|
||||
|
||||
- `desktop_notification_subscriptions`
|
||||
- `desktop_notification_deliveries`
|
||||
|
||||
API:
|
||||
|
||||
- `GET/PUT /api/v1/desktop-notifications/subscription`
|
||||
- `POST /api/v1/desktop-notifications/claim`
|
||||
- `POST /api/v1/desktop-notifications/ack`
|
||||
- `POST /api/v1/desktop-notifications/{distribution_id}/read`
|
||||
|
||||
claim 跨有效项目查询匹配当前用户或角色的活动文件分发,使用五分钟租约、唯一约束和事务防重。客户端显示系统通知后 ack;显示失败则等待租约到期后重试。
|
||||
|
||||
系统通知正文只显示通用内容:
|
||||
|
||||
- 标题:`CTMS 文件更新`
|
||||
- 正文:`有新的文件版本待查看`
|
||||
|
||||
项目、文件、版本等详细信息仅在应用内列表显示,避免锁屏泄露。
|
||||
|
||||
## 诊断元数据与 CORS
|
||||
|
||||
Axios 请求附加:
|
||||
|
||||
- `X-CTMS-Client-Type`
|
||||
- `X-CTMS-Client-Version`
|
||||
- `X-CTMS-Client-Platform`
|
||||
- `X-CTMS-Build-Channel`
|
||||
- `X-CTMS-Build-Commit`
|
||||
|
||||
后端安全访问日志保存这些 nullable 字段,并支持按客户端类型/版本筛选。这些字段只用于诊断与排障,不参与授权。
|
||||
|
||||
CORS origin 由环境变量白名单控制,并显式允许桌面 origin、开发 origin 和上述请求头。
|
||||
|
||||
Tauri CSP 禁止远程脚本和 shell 入口,仅允许 HTTPS API、本地开发地址、blob 预览与必要资源。
|
||||
|
||||
## 单实例
|
||||
|
||||
单实例插件必须最先注册。重复启动时只恢复、显示并聚焦主窗口,不处理命令行参数、深链或业务动作。
|
||||
|
||||
## 自动更新与发布
|
||||
|
||||
正式 release 构建启用 updater。客户端从当前 CTMS origin 派生固定清单路径:
|
||||
|
||||
```text
|
||||
/desktop-updates/stable/latest.json
|
||||
```
|
||||
|
||||
生产只允许 HTTPS;本地测试只允许 localhost HTTP。
|
||||
|
||||
更新检查策略:
|
||||
|
||||
- 启动延迟 30 秒检查。
|
||||
- 之后每 6 小时检查。
|
||||
- 发现更新时展示版本和发布说明。
|
||||
- 用户确认后下载、验签、安装并重启。
|
||||
- 用户选择“稍后”后,同版本 24 小时内不再提示。
|
||||
- 不做静默安装,不中断正在录入的业务流程。
|
||||
|
||||
Tauri 配置必须嵌入 updater 公钥并生成 updater artifacts。生产私钥只能存放在组织密钥库或 CI secret,不进仓库。macOS 更新制品为 `.app.tar.gz` 与 `.sig`。
|
||||
|
||||
release tag 流水线应从同一提交构建 Web 与桌面端:
|
||||
|
||||
1. 校验 `frontend/package.json`、Tauri 配置、Cargo manifest/lock 版本一致。
|
||||
2. 构建 macOS Universal。
|
||||
3. 完成 Apple 签名和公证。
|
||||
4. 使用 updater 私钥签名更新包。
|
||||
5. 生成 DMG、更新包、签名、`latest.json` 与校验清单。
|
||||
6. 先上传不可变制品,最后原子替换 `latest.json`。
|
||||
|
||||
`latest.json` 同时提供 `darwin-aarch64` 和 `darwin-x86_64`,指向同一个 Universal 更新制品。
|
||||
|
||||
## Windows 准备
|
||||
|
||||
第二阶段只验证 Windows x64 NSIS 构建兼容,不发布正式 Windows 安装包。
|
||||
|
||||
验证范围:
|
||||
|
||||
- Windows Credential Manager。
|
||||
- 路径净化与临时目录限制。
|
||||
- 系统通知编译兼容。
|
||||
- updater 编译兼容。
|
||||
- WebView2 前置条件。
|
||||
- 用户级安装假设。
|
||||
- 后续代码签名要求。
|
||||
|
||||
## 验收重点
|
||||
|
||||
- `localStorage`、URL、日志和系统通知正文不包含 token。
|
||||
- 附件下载不再接受 query-token。
|
||||
- 业务模块不直接导入 Tauri API。
|
||||
- Web 构建、类型检查和单测不回归。
|
||||
- macOS `.app` 构建可重复。
|
||||
- Keychain 会话、文件入口、通知权限、重复启动聚焦和签名更新链路完成端到端验证。
|
||||
@@ -67,6 +67,8 @@
|
||||
|
||||
第二阶段在不改变在线优先产品边界的前提下,增加原生桌面能力。
|
||||
|
||||
详细方案见 [`desktop-phase-2-design.md`](desktop-phase-2-design.md)。
|
||||
|
||||
范围:
|
||||
|
||||
- 在附件等场景中引入原生文件选择、下载、打开能力。
|
||||
@@ -124,9 +126,10 @@
|
||||
桌面端工作在以下位置开发:
|
||||
|
||||
- Worktree:`/Users/zcc/MyCTMS/ctms-dev/worktrees/ctms-desktop`
|
||||
- 分支:`codex/ctms-desktop`
|
||||
- 短期分支:`codex/<任务名称>`,必须从最新 `dev` 创建
|
||||
|
||||
除非发布计划另有说明,桌面端分支应持续与 `dev` 对齐。
|
||||
`codex/ctms-desktop` 仅是第一阶段 Tauri 基线临时集成分支。基线合入
|
||||
`dev` 后,第二阶段工作不得继续把它作为长期桌面主线。
|
||||
|
||||
## 每次开发前必须执行的检查
|
||||
|
||||
|
||||
@@ -26,11 +26,15 @@ The runtime contract currently provides:
|
||||
- app version, source commit, build channel, and client type metadata
|
||||
- explicit capability flags
|
||||
- Desktop server address configuration
|
||||
- secure session storage
|
||||
- file picker/save/open adapters
|
||||
- desktop system notification adapters
|
||||
- desktop updater adapters
|
||||
|
||||
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 remain disabled until their
|
||||
second-phase adapters are implemented and reviewed.
|
||||
secure session storage, and automatic updates must remain behind
|
||||
`frontend/src/runtime/` and explicit capability flags.
|
||||
|
||||
## Version Change
|
||||
|
||||
@@ -71,6 +75,51 @@ VITE_BUILD_COMMIT="$(git rev-parse HEAD)"
|
||||
|
||||
These values support diagnostics but do not replace the semantic version.
|
||||
|
||||
## Desktop Updater
|
||||
|
||||
Formal Desktop release builds must use Tauri updater signatures. The application
|
||||
embeds the updater public key in `frontend/src-tauri/tauri.conf.json`; the
|
||||
private key must live only in the organization key vault or CI secret store.
|
||||
|
||||
Runtime update checks derive the feed from the currently configured CTMS origin:
|
||||
|
||||
```text
|
||||
/desktop-updates/stable/latest.json
|
||||
```
|
||||
|
||||
Production update feeds must use HTTPS. Local update testing may use HTTP only
|
||||
for `localhost`, `127.0.0.1`, or `::1`.
|
||||
|
||||
The release pipeline must:
|
||||
|
||||
1. build from the accepted release tag and commit;
|
||||
2. build macOS Universal desktop artifacts;
|
||||
3. sign and notarize the macOS app;
|
||||
4. produce updater artifacts and `.sig` files with the updater private key;
|
||||
5. generate `latest.json` and a checksum manifest;
|
||||
6. upload immutable artifacts first;
|
||||
7. atomically replace `latest.json` last.
|
||||
|
||||
For Universal macOS artifacts, `latest.json` must provide both
|
||||
`darwin-aarch64` and `darwin-x86_64` entries pointing at the same Universal
|
||||
update package.
|
||||
|
||||
## Windows Build Readiness
|
||||
|
||||
Second-phase Windows work is limited to CI compatibility validation. A
|
||||
`windows-latest` x64 NSIS build may be produced for verification, but it is not
|
||||
a formal deliverable until Windows code signing and release support are
|
||||
approved.
|
||||
|
||||
Windows validation must cover:
|
||||
|
||||
- WebView2 runtime prerequisite behavior;
|
||||
- user-level installer assumptions;
|
||||
- Windows Credential Manager session storage;
|
||||
- path handling and temporary file cleanup;
|
||||
- notification and updater compilation;
|
||||
- future code-signing requirements.
|
||||
|
||||
## Required Checks
|
||||
|
||||
```bash
|
||||
@@ -81,9 +130,12 @@ npm run runtime:check
|
||||
npm run type-check
|
||||
npm run test:unit
|
||||
npm run build
|
||||
export TAURI_SIGNING_PRIVATE_KEY="$UPDATER_PRIVATE_KEY"
|
||||
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$UPDATER_PRIVATE_KEY_PASSWORD"
|
||||
npm run desktop:build -- --bundles app
|
||||
```
|
||||
|
||||
The Desktop build must run on macOS for the current first-phase target. A signed
|
||||
or notarized public release additionally requires the Apple credentials defined
|
||||
by the release owner; an unsigned internal build is not a formal distribution.
|
||||
by the release owner. A formal second-phase desktop release also requires the
|
||||
updater signing key; unsigned internal builds are not formal distributions.
|
||||
|
||||
Generated
+61
@@ -8,6 +8,12 @@
|
||||
"name": "ctms-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.0",
|
||||
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||
"axios": "^1.6.8",
|
||||
"date-fns": "^3.6.0",
|
||||
"echarts": "^6.0.0",
|
||||
@@ -1355,6 +1361,16 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz",
|
||||
"integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz",
|
||||
@@ -1587,6 +1603,51 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
|
||||
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-fs": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.1.tgz",
|
||||
"integrity": "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-notification": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-opener": {
|
||||
"version": "2.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
|
||||
"integrity": "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-updater": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz",
|
||||
"integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
"ui:contract": "node scripts/verify-ui-contract.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.0",
|
||||
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||
"axios": "^1.6.8",
|
||||
"date-fns": "^3.6.0",
|
||||
"echarts": "^6.0.0",
|
||||
|
||||
Generated
+1377
-7
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,23 @@ tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
time = { version = "=0.3.36", default-features = false, features = ["std", "parsing", "formatting", "macros"] }
|
||||
url = "2"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
keyring = { version = "4.1.2", default-features = false, features = ["v1", "apple-native-keyring-store"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
keyring = { version = "4.1.2", default-features = false, features = ["v1", "windows-native-keyring-store"] }
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
|
||||
@@ -3,5 +3,25 @@
|
||||
"identifier": "default",
|
||||
"description": "Default capability for the CTMS desktop window.",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default"]
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"fs:allow-read-file",
|
||||
"fs:allow-write-file",
|
||||
"fs:allow-remove",
|
||||
{
|
||||
"identifier": "fs:scope",
|
||||
"allow": [
|
||||
{ "path": "$TEMP/ctms-desktop/**" }
|
||||
]
|
||||
},
|
||||
"notification:default",
|
||||
{
|
||||
"identifier": "opener:allow-open-path",
|
||||
"allow": [
|
||||
{ "path": "$TEMP/ctms-desktop/**" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
use sha2::{Digest, Sha256};
|
||||
use url::Url;
|
||||
|
||||
const CREDENTIAL_SERVICE: &str = "cn.huapont.ctms.desktop.session";
|
||||
|
||||
fn credential_account(server_origin: &str) -> Result<String, String> {
|
||||
let parsed = Url::parse(server_origin).map_err(|_| "服务器地址格式不正确".to_string())?;
|
||||
let is_https = parsed.scheme() == "https";
|
||||
let is_local_http = parsed.scheme() == "http"
|
||||
&& matches!(
|
||||
parsed.host_str(),
|
||||
Some("localhost") | Some("127.0.0.1") | Some("::1")
|
||||
);
|
||||
if !is_https && !is_local_http {
|
||||
return Err("非本地服务必须使用 HTTPS".to_string());
|
||||
}
|
||||
if parsed.username() != "" || parsed.password().is_some() {
|
||||
return Err("服务器地址不能包含凭据".to_string());
|
||||
}
|
||||
let origin = parsed.origin().ascii_serialization();
|
||||
let digest = Sha256::digest(origin.as_bytes());
|
||||
Ok(format!("{digest:x}"))
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
fn get_entry(server_origin: &str) -> Result<keyring::Entry, String> {
|
||||
let account = credential_account(server_origin)?;
|
||||
keyring::Entry::new(CREDENTIAL_SERVICE, &account)
|
||||
.map_err(|error| format!("无法访问系统凭据库:{error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn credential_get(server_origin: String) -> Result<Option<String>, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
{
|
||||
let entry = get_entry(&server_origin)?;
|
||||
return match entry.get_password() {
|
||||
Ok(token) => Ok(Some(token)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(error) => Err(format!("读取系统凭据失败:{error}")),
|
||||
};
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
let _ = credential_account(&server_origin)?;
|
||||
Err("当前平台不支持系统凭据存储".to_string())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("读取系统凭据任务失败:{error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn credential_set(server_origin: String, token: String) -> Result<(), String> {
|
||||
if token.trim().is_empty() {
|
||||
return Err("拒绝保存空凭据".to_string());
|
||||
}
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
{
|
||||
return get_entry(&server_origin)?
|
||||
.set_password(&token)
|
||||
.map_err(|error| format!("保存系统凭据失败:{error}"));
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
let _ = credential_account(&server_origin)?;
|
||||
Err("当前平台不支持系统凭据存储".to_string())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("保存系统凭据任务失败:{error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn credential_delete(server_origin: String) -> Result<(), String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
{
|
||||
let entry = get_entry(&server_origin)?;
|
||||
return match entry.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(error) => Err(format!("删除系统凭据失败:{error}")),
|
||||
};
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
let _ = credential_account(&server_origin)?;
|
||||
Err("当前平台不支持系统凭据存储".to_string())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("删除系统凭据任务失败:{error}"))?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::credential_account;
|
||||
|
||||
#[test]
|
||||
fn account_is_stable_for_same_origin() {
|
||||
assert_eq!(
|
||||
credential_account("https://ctms.example.com/path").unwrap(),
|
||||
credential_account("https://ctms.example.com/other").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_insecure_remote_origin() {
|
||||
assert!(credential_account("http://ctms.example.com").is_err());
|
||||
assert!(credential_account("http://localhost:8000").is_ok());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,30 @@
|
||||
mod credentials;
|
||||
mod updates;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.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();
|
||||
}
|
||||
}))
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.manage(updates::PendingUpdate::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
credentials::credential_get,
|
||||
credentials::credential_set,
|
||||
credentials::credential_delete,
|
||||
updates::desktop_update_check,
|
||||
updates::desktop_update_install,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running CTMS desktop application");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, State};
|
||||
use tauri_plugin_updater::{Update, UpdaterExt};
|
||||
use url::Url;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PendingUpdate(pub Mutex<Option<Update>>);
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DesktopUpdateMetadata {
|
||||
version: String,
|
||||
current_version: String,
|
||||
notes: Option<String>,
|
||||
date: Option<String>,
|
||||
}
|
||||
|
||||
fn normalized_origin(raw: &str) -> Result<Url, String> {
|
||||
let parsed = Url::parse(raw).map_err(|_| "invalid server origin".to_string())?;
|
||||
let scheme = parsed.scheme();
|
||||
if parsed.username() != "" || parsed.password().is_some() {
|
||||
return Err("server origin must not include credentials".to_string());
|
||||
}
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| "server origin must include a host".to_string())?;
|
||||
let localhost = matches!(host, "localhost" | "127.0.0.1" | "::1");
|
||||
if scheme != "https" && !(scheme == "http" && localhost) {
|
||||
return Err("desktop updates require HTTPS outside localhost".to_string());
|
||||
}
|
||||
let port = parsed.port().map(|value| format!(":{value}")).unwrap_or_default();
|
||||
Url::parse(&format!("{scheme}://{host}{port}/")).map_err(|_| "invalid normalized origin".to_string())
|
||||
}
|
||||
|
||||
fn update_endpoint(server_origin: &str) -> Result<Url, String> {
|
||||
let origin = normalized_origin(server_origin)?;
|
||||
origin
|
||||
.join("desktop-updates/stable/latest.json")
|
||||
.map_err(|_| "invalid update endpoint".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn desktop_update_check(
|
||||
app: AppHandle,
|
||||
pending_update: State<'_, PendingUpdate>,
|
||||
server_origin: String,
|
||||
) -> Result<Option<DesktopUpdateMetadata>, String> {
|
||||
let endpoint = update_endpoint(&server_origin)?;
|
||||
let update = app
|
||||
.updater_builder()
|
||||
.endpoints(vec![endpoint])
|
||||
.map_err(|err| err.to_string())?
|
||||
.build()
|
||||
.map_err(|err| err.to_string())?
|
||||
.check()
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let metadata = update.as_ref().map(|update| DesktopUpdateMetadata {
|
||||
version: update.version.clone(),
|
||||
current_version: update.current_version.clone(),
|
||||
notes: update.body.clone(),
|
||||
date: update.date.map(|value| value.to_string()),
|
||||
});
|
||||
|
||||
let mut guard = pending_update
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| "pending update lock poisoned".to_string())?;
|
||||
*guard = update;
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn desktop_update_install(
|
||||
app: AppHandle,
|
||||
pending_update: State<'_, PendingUpdate>,
|
||||
) -> Result<(), String> {
|
||||
let update = {
|
||||
let mut guard = pending_update
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| "pending update lock poisoned".to_string())?;
|
||||
guard
|
||||
.take()
|
||||
.ok_or_else(|| "there is no pending update".to_string())?
|
||||
};
|
||||
|
||||
update
|
||||
.download_and_install(|_, _| {}, || {})
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
app.restart();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::update_endpoint;
|
||||
|
||||
#[test]
|
||||
fn update_endpoint_uses_fixed_https_path() {
|
||||
let endpoint = update_endpoint("https://ctms.example.com/app/").unwrap();
|
||||
assert_eq!(
|
||||
endpoint.as_str(),
|
||||
"https://ctms.example.com/desktop-updates/stable/latest.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_endpoint_allows_localhost_http_only() {
|
||||
assert!(update_endpoint("http://localhost:8888").is_ok());
|
||||
assert!(update_endpoint("http://ctms.example.com").is_err());
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,17 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
"csp": "default-src 'self' customprotocol: asset:; connect-src 'self' ipc: http://ipc.localhost https: http://localhost:* http://127.0.0.1:*; img-src 'self' asset: blob: data: https: http://localhost:* http://127.0.0.1:*; style-src 'self' 'unsafe-inline'; font-src 'self' data:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["app", "dmg"]
|
||||
"targets": ["app", "dmg"],
|
||||
"createUpdaterArtifacts": true
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDIwQjI5MUZFMjQ2NUM5QwpSV1NjWEViaUh5a0xBdE52U2Rhb29mZlVYZ3lnWGlDVGs1WE1RUGoyeWtSWW9pNzBnNW9qUGNaaAo="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { initSessionManager } from "./session/sessionManager";
|
||||
import { initDesktopNotificationManager } from "./session/desktopNotificationManager";
|
||||
import { initDesktopUpdateManager } from "./session/desktopUpdateManager";
|
||||
import SessionTimeoutPrompt from "./components/SessionTimeoutPrompt.vue";
|
||||
|
||||
initSessionManager();
|
||||
initDesktopNotificationManager();
|
||||
initDesktopUpdateManager();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -19,3 +19,8 @@ export const uploadAttachment = (
|
||||
};
|
||||
|
||||
export const deleteAttachment = (attachmentId: string) => apiDelete(`/api/v1/attachments/${attachmentId}`);
|
||||
|
||||
export const downloadAttachment = (attachmentId: string) =>
|
||||
apiGet<Blob>(`/api/v1/attachments/${attachmentId}/download`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
|
||||
@@ -68,4 +68,12 @@ export const updateProfile = (payload: {
|
||||
password?: string;
|
||||
}) => apiPatch<UserMeResponse>("/api/v1/auth/me", payload);
|
||||
|
||||
export const uploadAvatar = (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return apiPost<UserMeResponse>("/api/v1/auth/me/avatar", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios from "axios";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { clientRuntime, DESKTOP_SERVER_URL_CHANGED_EVENT } from "../runtime";
|
||||
import { clientRuntime, DESKTOP_SERVER_URL_CHANGED_EVENT, getAppMetadataHeaders } from "../runtime";
|
||||
|
||||
const authClient = axios.create({
|
||||
baseURL: clientRuntime.apiBaseUrl(),
|
||||
@@ -15,6 +15,12 @@ if (typeof window !== "undefined") {
|
||||
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshAuthClientBaseUrl);
|
||||
}
|
||||
|
||||
authClient.interceptors.request.use((config) => {
|
||||
config.headers = config.headers || {};
|
||||
Object.assign(config.headers, getAppMetadataHeaders());
|
||||
return config;
|
||||
});
|
||||
|
||||
export type ExtendResponse = {
|
||||
accessToken: string;
|
||||
expiresAt: string;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ElMessage } from "element-plus";
|
||||
import { getToken } from "../utils/auth";
|
||||
import type { ApiError } from "../types/api";
|
||||
import { TEXT } from "../locales";
|
||||
import { clientRuntime, DESKTOP_SERVER_URL_CHANGED_EVENT } from "../runtime";
|
||||
import { clientRuntime, DESKTOP_SERVER_URL_CHANGED_EVENT, getAppMetadataHeaders } from "../runtime";
|
||||
|
||||
const instance: AxiosInstance = axios.create({
|
||||
baseURL: clientRuntime.apiBaseUrl(),
|
||||
@@ -25,6 +25,7 @@ export type ApiRequestConfig = AxiosRequestConfig & {
|
||||
suppressErrorMessage?: boolean;
|
||||
_retry?: boolean;
|
||||
_networkRetryCount?: number;
|
||||
disableNetworkRetry?: boolean;
|
||||
};
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
@@ -44,13 +45,14 @@ const clearInvalidStudyAndNavigate = async () => {
|
||||
|
||||
const forceAuthExpiredLogout = async () => {
|
||||
const { forceLogout, LOGOUT_REASON_AUTH_EXPIRED } = await import("../session/sessionManager");
|
||||
forceLogout(LOGOUT_REASON_AUTH_EXPIRED);
|
||||
await forceLogout(LOGOUT_REASON_AUTH_EXPIRED);
|
||||
};
|
||||
|
||||
instance.interceptors.request.use((config: InternalAxiosRequestConfig & ApiRequestConfig) => {
|
||||
config.headers = config.headers || {};
|
||||
Object.assign(config.headers, getAppMetadataHeaders());
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
(config.headers as Record<string, string>).Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
@@ -71,7 +73,7 @@ instance.interceptors.response.use(
|
||||
// 认证相关的错误由具体页面自行处理,避免重复提示
|
||||
return Promise.reject(error);
|
||||
}
|
||||
if (!status && error.config) {
|
||||
if (!status && error.config && !(error.config as ApiRequestConfig).disableNetworkRetry) {
|
||||
const config = error.config as ApiRequestConfig;
|
||||
const retryCount = config._networkRetryCount || 0;
|
||||
if (retryCount < NETWORK_RETRY_LIMIT) {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { apiGet, apiPost, apiPut } from "./axios";
|
||||
import type { NotificationItem } from "../types/notifications";
|
||||
|
||||
export interface DesktopNotificationSubscription {
|
||||
enabled: boolean;
|
||||
enabled_at?: string | null;
|
||||
}
|
||||
|
||||
export interface DesktopNotificationClaim {
|
||||
claim_token?: string | null;
|
||||
lease_expires_at?: string | null;
|
||||
items: NotificationItem[];
|
||||
}
|
||||
|
||||
export const getDesktopNotificationSubscription = () =>
|
||||
apiGet<DesktopNotificationSubscription>("/api/v1/desktop-notifications/subscription", {
|
||||
suppressErrorMessage: true,
|
||||
disableNetworkRetry: true,
|
||||
});
|
||||
|
||||
export const setDesktopNotificationSubscription = (enabled: boolean) =>
|
||||
apiPut<DesktopNotificationSubscription>("/api/v1/desktop-notifications/subscription", { enabled }, {
|
||||
disableNetworkRetry: true,
|
||||
});
|
||||
|
||||
export const claimDesktopNotifications = (limit = 20) =>
|
||||
apiPost<DesktopNotificationClaim>("/api/v1/desktop-notifications/claim", { limit }, {
|
||||
suppressErrorMessage: true,
|
||||
disableNetworkRetry: true,
|
||||
});
|
||||
|
||||
export const acknowledgeDesktopNotifications = (claimToken: string, deliveredIds: string[]) =>
|
||||
apiPost<void>("/api/v1/desktop-notifications/ack", {
|
||||
claim_token: claimToken,
|
||||
delivered_ids: deliveredIds,
|
||||
}, {
|
||||
suppressErrorMessage: true,
|
||||
disableNetworkRetry: true,
|
||||
});
|
||||
|
||||
export const markDesktopNotificationRead = (distributionId: string) =>
|
||||
apiPost<void>(`/api/v1/desktop-notifications/${distributionId}/read`, undefined, {
|
||||
suppressErrorMessage: true,
|
||||
disableNetworkRetry: true,
|
||||
});
|
||||
@@ -71,6 +71,8 @@ export const fetchAccessLogs = (params: {
|
||||
export const fetchSecurityAccessLogs = (params?: {
|
||||
status_min?: number;
|
||||
auth_status?: string;
|
||||
client_type?: string;
|
||||
client_version?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}) => apiGet<SecurityAccessLogsResponse>(`/api/v1/permission-monitoring/security-logs`, { params });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { auditExportColumns } from "./auditExportColumns";
|
||||
import { formatAuditRows } from "./auditExportFormatter";
|
||||
import type { AuditEvent } from "..";
|
||||
import { saveFile } from "../../runtime";
|
||||
|
||||
const BOM = "\ufeff";
|
||||
|
||||
@@ -19,13 +20,13 @@ export interface AuditExportOptions {
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export const exportAuditCsv = (events: AuditEvent[], options: AuditExportOptions) => {
|
||||
export const exportAuditCsv = async (events: AuditEvent[], options: AuditExportOptions) => {
|
||||
const rows = formatAuditRows(events);
|
||||
const csv = buildCsv(rows);
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = options.fileName.endsWith(".csv") ? options.fileName : `${options.fileName}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
await saveFile({
|
||||
suggestedName: options.fileName.endsWith(".csv") ? options.fileName : `${options.fileName}.csv`,
|
||||
mimeType: "text/csv;charset=utf-8",
|
||||
data: blob,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -817,8 +817,8 @@ const toggleCollapse = () => {
|
||||
localStorage.setItem("ctms_sidebar_collapsed", isCollapsed.value ? "1" : "0");
|
||||
};
|
||||
|
||||
const logoutImmediately = () => {
|
||||
auth.logout();
|
||||
const logoutImmediately = async () => {
|
||||
await auth.logout();
|
||||
study.clearCurrentStudy();
|
||||
router.replace("/login");
|
||||
};
|
||||
@@ -838,7 +838,7 @@ const onLogout = async () => {
|
||||
if (!confirmed) return;
|
||||
loggingOut.value = true;
|
||||
try {
|
||||
forceLogout(LOGOUT_REASON_MANUAL);
|
||||
await forceLogout(LOGOUT_REASON_MANUAL);
|
||||
} finally {
|
||||
window.setTimeout(() => {
|
||||
loggingOut.value = false;
|
||||
@@ -856,7 +856,7 @@ onMounted(async () => {
|
||||
try {
|
||||
await auth.fetchMe();
|
||||
} catch {
|
||||
logoutImmediately();
|
||||
await logoutImmediately();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,7 @@ import type {
|
||||
} from "@/types/api";
|
||||
import { Search as SearchIcon } from "@element-plus/icons-vue";
|
||||
import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta";
|
||||
import { saveFile } from "@/runtime";
|
||||
|
||||
const props = withDefaults(defineProps<{ showSecurityLog?: boolean }>(), {
|
||||
showSecurityLog: false,
|
||||
@@ -311,7 +312,8 @@ const buildSecurityTerminalLine = (row: SecurityAccessLogItem) => {
|
||||
const account = row.account_label || "未知账号";
|
||||
const auth = SECURITY_AUTH_LABELS[row.auth_status] || row.auth_status;
|
||||
const userAgent = row.user_agent || "-";
|
||||
return `${formatTerminalTime(row.created_at)} [SECURITY] status=${row.status_code} auth_status=${row.auth_status}/${auth} account_label=${account} client_ip=${ip} method=${row.method} path=${row.path} ua=${userAgent} cost=${row.elapsed_ms.toFixed(1)}ms`;
|
||||
const client = [row.client_type, row.client_version, row.client_platform].filter(Boolean).join("/") || "unknown";
|
||||
return `${formatTerminalTime(row.created_at)} [SECURITY] status=${row.status_code} auth_status=${row.auth_status}/${auth} account_label=${account} client=${client} client_ip=${ip} method=${row.method} path=${row.path} ua=${userAgent} cost=${row.elapsed_ms.toFixed(1)}ms`;
|
||||
};
|
||||
|
||||
const terminalLogRows = computed(() =>
|
||||
@@ -515,14 +517,9 @@ const openSecurityLogDialog = () => {
|
||||
scrollSecurityTerminalToBottom();
|
||||
};
|
||||
|
||||
const downloadLogFile = (fileName: string, lines: string[]) => {
|
||||
const downloadLogFile = async (fileName: string, lines: string[]) => {
|
||||
const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
await saveFile({ suggestedName: fileName, mimeType: "text/plain;charset=utf-8", data: blob });
|
||||
};
|
||||
|
||||
const downloadInterfaceLog = () => {
|
||||
|
||||
@@ -70,7 +70,7 @@ const continueSession = () => {
|
||||
};
|
||||
|
||||
const logoutNow = () => {
|
||||
forceLogout(LOGOUT_REASON_MANUAL);
|
||||
void forceLogout(LOGOUT_REASON_MANUAL);
|
||||
};
|
||||
|
||||
watch(
|
||||
|
||||
@@ -11,9 +11,12 @@
|
||||
<el-input v-model="contentProxy" type="textarea" rows="3" :placeholder="TEXT.common.placeholders.input" />
|
||||
<div v-if="allowAttachments" class="upload-row">
|
||||
<div class="upload-label">{{ TEXT.common.labels.attachments }}</div>
|
||||
<el-upload v-model:file-list="fileListProxy" :auto-upload="false" multiple list-type="picture" class="thread-upload">
|
||||
<el-upload v-if="!nativeFiles" v-model:file-list="fileListProxy" :auto-upload="false" multiple list-type="picture" class="thread-upload">
|
||||
<el-button size="small" class="upload-button">{{ TEXT.common.actions.upload }}</el-button>
|
||||
</el-upload>
|
||||
<el-button v-else size="small" class="upload-button" @click="pickNativeAttachments">
|
||||
{{ TEXT.common.actions.upload }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button v-if="showCancel" size="small" @click="$emit('cancel')">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
@@ -26,6 +29,7 @@
|
||||
import { computed } from "vue";
|
||||
import type { UploadUserFile } from "element-plus";
|
||||
import { displayDateTime, displayUser } from "../utils/display";
|
||||
import { clientRuntime, pickFiles } from "../runtime";
|
||||
import { TEXT } from "../locales";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -46,6 +50,7 @@ const emit = defineEmits<{
|
||||
(e: "cancel"): void;
|
||||
(e: "clear-quote"): void;
|
||||
}>();
|
||||
const nativeFiles = clientRuntime.capabilities().nativeFiles;
|
||||
|
||||
const contentProxy = computed({
|
||||
get: () => props.modelValue,
|
||||
@@ -58,6 +63,21 @@ const fileListProxy = computed({
|
||||
});
|
||||
|
||||
const quoteContent = (item: any) => (item?.is_deleted ? TEXT.modules.knowledgeMedicalConsult.quoteDeleted : item?.content || TEXT.common.fallback);
|
||||
|
||||
const pickNativeAttachments = async () => {
|
||||
const files = await pickFiles({ multiple: true, title: TEXT.common.labels.attachments });
|
||||
const existing = new Set(props.fileList.map((item) => `${item.name}:${item.size}`));
|
||||
const additions: UploadUserFile[] = files
|
||||
.filter((file) => !existing.has(`${file.name}:${file.size}`))
|
||||
.map((file) => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
raw: file as any,
|
||||
status: "ready",
|
||||
uid: Date.now() + Math.floor(Math.random() * 100000),
|
||||
}));
|
||||
emit("update:fileList", [...props.fileList, ...additions]);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -45,6 +45,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, reactive, watch } from "vue";
|
||||
import { downloadAttachment } from "../api/attachments";
|
||||
import { saveFile } from "../runtime";
|
||||
import { displayDateTime, displayUser } from "../utils/display";
|
||||
import { TEXT } from "../locales";
|
||||
|
||||
@@ -68,13 +71,37 @@ const quoteContent = (item: any) =>
|
||||
const contentText = (item: any) =>
|
||||
item?.is_deleted ? TEXT.modules.knowledgeMedicalConsult.quoteDeleted : item?.content || TEXT.common.fallback;
|
||||
|
||||
const attachmentUrl = (id: string) => {
|
||||
const token = localStorage.getItem("ctms_token");
|
||||
return token ? `/api/v1/attachments/${id}/download?token=${token}` : `/api/v1/attachments/${id}/download`;
|
||||
const attachmentObjectUrls = reactive<Record<string, string>>({});
|
||||
const attachmentUrl = (id: string) => attachmentObjectUrls[id] || "";
|
||||
|
||||
const revokeAttachmentUrls = () => {
|
||||
Object.values(attachmentObjectUrls).forEach((url) => URL.revokeObjectURL(url));
|
||||
Object.keys(attachmentObjectUrls).forEach((key) => delete attachmentObjectUrls[key]);
|
||||
};
|
||||
|
||||
const download = (id: string) => {
|
||||
window.open(attachmentUrl(id), "_blank");
|
||||
const loadImageUrls = async () => {
|
||||
revokeAttachmentUrls();
|
||||
const files = Object.values(props.attachmentsMap).flat().filter(isImage);
|
||||
await Promise.all(files.map(async (file) => {
|
||||
try {
|
||||
const response = await downloadAttachment(file.id);
|
||||
attachmentObjectUrls[file.id] = URL.createObjectURL(
|
||||
new Blob([response.data], { type: response.headers?.["content-type"] || file.content_type }),
|
||||
);
|
||||
} catch {
|
||||
attachmentObjectUrls[file.id] = "";
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const download = async (id: string) => {
|
||||
const file = Object.values(props.attachmentsMap).flat().find((item) => item.id === id);
|
||||
const response = await downloadAttachment(id);
|
||||
await saveFile({
|
||||
suggestedName: file?.filename || "attachment",
|
||||
mimeType: response.headers?.["content-type"] || file?.content_type,
|
||||
data: response.data,
|
||||
});
|
||||
};
|
||||
|
||||
const isImage = (file: any) => {
|
||||
@@ -88,6 +115,9 @@ const isHighlighted = (item: any) => {
|
||||
if (!props.highlightIds?.length) return false;
|
||||
return props.highlightIds.includes(item?.id);
|
||||
};
|
||||
|
||||
watch(() => props.attachmentsMap, () => void loadImageUrls(), { deep: true, immediate: true });
|
||||
onBeforeUnmount(revokeAttachmentUrls);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<span>{{ headerTitle }}</span>
|
||||
<div v-if="canShowUploader && tableUploadGroup" class="immediate-upload">
|
||||
<el-upload
|
||||
v-if="!nativeFiles"
|
||||
:http-request="uploadImmediate"
|
||||
:show-file-list="false"
|
||||
:limit="1"
|
||||
@@ -13,6 +14,9 @@
|
||||
>
|
||||
<el-button type="primary" :loading="isImmediateUploading">{{ TEXT.common.actions.upload }}</el-button>
|
||||
</el-upload>
|
||||
<el-button v-else type="primary" :loading="isImmediateUploading" @click="pickImmediateNative">
|
||||
{{ TEXT.common.actions.upload }}
|
||||
</el-button>
|
||||
<el-progress v-if="tableProgress > 0 && tableProgress < 100" :percentage="tableProgress" :stroke-width="6" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,6 +45,7 @@
|
||||
{{ TEXT.common.labels.preview }}
|
||||
</el-button>
|
||||
<el-button link type="primary" size="small" @click="download(scope.row)">{{ TEXT.common.actions.download }}</el-button>
|
||||
<el-button link type="primary" size="small" @click="openExternally(scope.row)">打开</el-button>
|
||||
<el-button
|
||||
v-if="canDelete(scope.row)"
|
||||
link
|
||||
@@ -58,6 +63,7 @@
|
||||
<div v-else class="upload-grid" :class="{ 'upload-grid--three': uploadCardColumns === 3 }">
|
||||
<div v-for="group in uploadGroups" :key="group.key" class="attachment-upload-item">
|
||||
<el-upload
|
||||
v-if="!nativeFiles"
|
||||
class="attachment-card-upload"
|
||||
:show-file-list="false"
|
||||
multiple
|
||||
@@ -79,6 +85,21 @@
|
||||
</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div
|
||||
v-else
|
||||
class="attachment-upload-card"
|
||||
:class="{
|
||||
'is-disabled': !canUploadGroup(group),
|
||||
'attachment-upload-card--centered': centerUploadCardContent,
|
||||
}"
|
||||
@click="pickPendingNative(group)"
|
||||
>
|
||||
<div v-if="showUploadCardLabels" class="upload-card-label">{{ group.label }}</div>
|
||||
<div class="upload-trigger">
|
||||
<el-icon class="upload-icon"><UploadFilled /></el-icon>
|
||||
<span class="upload-text">{{ group.uploadText || "点击上传文件" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pendingMap[group.key]?.length" class="pending-list">
|
||||
<div
|
||||
v-for="item in pendingMap[group.key]"
|
||||
@@ -122,7 +143,7 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { UploadFilled } from "@element-plus/icons-vue";
|
||||
import { fetchAttachments, deleteAttachment, uploadAttachment } from "../../api/attachments";
|
||||
import { fetchAttachments, deleteAttachment, downloadAttachment, uploadAttachment } from "../../api/attachments";
|
||||
import { formatFileSize } from "./attachmentUtils";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
@@ -131,6 +152,7 @@ import { displayDateTime, getUserDisplayName } from "../../utils/display";
|
||||
import { getAttachmentPermissionKey } from "../../utils/attachmentPermissions";
|
||||
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
||||
import { TEXT } from "../../locales";
|
||||
import { clientRuntime, openFile, pickFiles, saveFile } from "../../runtime";
|
||||
|
||||
type AttachmentEntityGroup = {
|
||||
entityType: string;
|
||||
@@ -182,6 +204,7 @@ const previewType = ref<"image" | "pdf" | "other">("other");
|
||||
const previewTitle = ref(TEXT.common.labels.preview);
|
||||
const previewError = ref("");
|
||||
const previewLoading = ref(false);
|
||||
const nativeFiles = clientRuntime.capabilities().nativeFiles;
|
||||
const headerTitle = computed(() => props.title || TEXT.common.labels.attachments);
|
||||
const displayMode = computed(() => props.mode || "table");
|
||||
const maxSize = computed(() => props.maxSizeMb ?? 50);
|
||||
@@ -240,10 +263,9 @@ const validateFile = (file: File) => {
|
||||
|
||||
const pendingFileKey = (file: File) => `${file.name}-${file.size}-${file.lastModified}`;
|
||||
|
||||
const queuePendingUpload = (fileType: string, uploadFile: any) => {
|
||||
const queuePendingFile = (fileType: string, file: File) => {
|
||||
const group = uploadGroups.value.find((item) => item.key === fileType);
|
||||
if (!group || !canUploadGroup(group)) return;
|
||||
const file = uploadFile?.raw as File | undefined;
|
||||
if (!file || !validateFile(file)) return;
|
||||
const next = pendingMap[fileType] || [];
|
||||
if (!next.some((item) => pendingFileKey(item.file) === pendingFileKey(file))) {
|
||||
@@ -251,6 +273,17 @@ const queuePendingUpload = (fileType: string, uploadFile: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
const queuePendingUpload = (fileType: string, uploadFile: any) => {
|
||||
const file = uploadFile?.raw as File | undefined;
|
||||
if (file) queuePendingFile(fileType, file);
|
||||
};
|
||||
|
||||
const pickPendingNative = async (group: UploadGroup) => {
|
||||
if (!canUploadGroup(group)) return;
|
||||
const selected = await pickFiles({ multiple: true, title: group.label });
|
||||
selected.forEach((file) => queuePendingFile(group.key, file));
|
||||
};
|
||||
|
||||
const removePendingUpload = (fileType: string, item: PendingUploadItem) => {
|
||||
pendingMap[fileType] = (pendingMap[fileType] || []).filter((current) => pendingFileKey(current.file) !== pendingFileKey(item.file));
|
||||
};
|
||||
@@ -317,6 +350,11 @@ const uploadImmediate = async (options: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
const pickImmediateNative = async () => {
|
||||
const [file] = await pickFiles({ multiple: false, title: headerTitle.value });
|
||||
if (file) await uploadImmediate({ file });
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!props.studyId || !props.entityId) return;
|
||||
loading.value = true;
|
||||
@@ -352,25 +390,35 @@ const uploaderLabel = (row: any) => {
|
||||
return row.uploaded_by_id || row.uploaded_by || TEXT.common.fallback;
|
||||
};
|
||||
|
||||
const getDownloadUrl = (row: any) => {
|
||||
const token = localStorage.getItem("ctms_token");
|
||||
return row?.url || (token ? `/api/v1/attachments/${row.id}/download?token=${token}` : `/api/v1/attachments/${row.id}/download`);
|
||||
const fetchAttachmentBlob = async (row: any): Promise<Blob> => {
|
||||
const response = await downloadAttachment(row.id);
|
||||
return new Blob([response.data], {
|
||||
type: response.headers?.["content-type"] || row?.content_type || "application/octet-stream",
|
||||
});
|
||||
};
|
||||
|
||||
const getPreviewUrl = (row: any) => {
|
||||
const token = localStorage.getItem("ctms_token");
|
||||
return token ? `/api/v1/attachments/${row.id}/preview?token=${token}` : `/api/v1/attachments/${row.id}/preview`;
|
||||
const download = async (row: any) => {
|
||||
try {
|
||||
await saveFile({
|
||||
suggestedName: row?.filename || "download",
|
||||
mimeType: row?.content_type,
|
||||
data: await fetchAttachmentBlob(row),
|
||||
});
|
||||
} catch {
|
||||
ElMessage.error(TEXT.common.messages.downloadFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const download = (row: any) => {
|
||||
const url = getDownloadUrl(row);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = row?.filename || "download";
|
||||
link.rel = "noopener";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
const openExternally = async (row: any) => {
|
||||
try {
|
||||
await openFile({
|
||||
suggestedName: row?.filename || "attachment",
|
||||
mimeType: row?.content_type,
|
||||
data: await fetchAttachmentBlob(row),
|
||||
});
|
||||
} catch {
|
||||
ElMessage.error(TEXT.common.messages.previewNotSupported);
|
||||
}
|
||||
};
|
||||
|
||||
const getFileName = (row: any) => (row?.filename || "").toLowerCase();
|
||||
@@ -386,37 +434,29 @@ const detectPreviewType = (row: any) => {
|
||||
return "other";
|
||||
};
|
||||
|
||||
const previewImage = async (downloadUrl: string) => {
|
||||
const previewImage = async (row: any) => {
|
||||
previewLoading.value = true;
|
||||
try {
|
||||
const token = localStorage.getItem("ctms_token");
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const response = await fetch(downloadUrl, { headers });
|
||||
if (!response.ok) throw new Error("preview failed");
|
||||
const blob = await response.blob();
|
||||
const blob = await fetchAttachmentBlob(row);
|
||||
if (previewObjectUrl.value) URL.revokeObjectURL(previewObjectUrl.value);
|
||||
previewObjectUrl.value = URL.createObjectURL(blob);
|
||||
previewUrl.value = previewObjectUrl.value;
|
||||
} catch {
|
||||
previewUrl.value = downloadUrl;
|
||||
previewError.value = TEXT.common.messages.previewNotSupported;
|
||||
} finally {
|
||||
previewLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const preview = (row: any) => {
|
||||
const preview = async (row: any) => {
|
||||
previewError.value = "";
|
||||
previewType.value = detectPreviewType(row);
|
||||
previewTitle.value = row?.filename || TEXT.common.labels.preview;
|
||||
const downloadUrl = getDownloadUrl(row);
|
||||
if (previewType.value === "image") {
|
||||
if (previewType.value === "image" || previewType.value === "pdf") {
|
||||
previewUrl.value = "";
|
||||
previewImage(downloadUrl);
|
||||
} else if (previewType.value === "pdf") {
|
||||
previewUrl.value = getPreviewUrl(row);
|
||||
await previewImage(row);
|
||||
} else {
|
||||
previewUrl.value = downloadUrl;
|
||||
previewUrl.value = "";
|
||||
}
|
||||
if (previewType.value === "other") {
|
||||
previewError.value = TEXT.common.messages.previewNotSupported;
|
||||
|
||||
@@ -12,9 +12,15 @@ import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import { getToken } from "./utils/auth";
|
||||
import { useStudyStore } from "./store/study";
|
||||
import { shouldRequireDesktopServerUrl } from "./runtime";
|
||||
import { cleanupTemporaryFiles, initializeSecureSessionStorage, shouldRequireDesktopServerUrl } from "./runtime";
|
||||
|
||||
const bootstrap = async () => {
|
||||
await initializeSecureSessionStorage().catch((error) => {
|
||||
console.error("Secure session storage initialization failed", error);
|
||||
});
|
||||
await cleanupTemporaryFiles().catch((error) => {
|
||||
console.warn("Desktop temporary file cleanup failed", error);
|
||||
});
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
|
||||
@@ -527,7 +527,7 @@ router.beforeEach(async (to, _from, next) => {
|
||||
await auth.fetchMe();
|
||||
} catch {
|
||||
// 有 token 但无法获取用户,强制回登录,避免进入“无用户上下文”页面
|
||||
auth.logout();
|
||||
await auth.logout();
|
||||
next({ path: "/login" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -24,3 +24,14 @@ export const getAppMetadata = (): AppMetadata => ({
|
||||
clientType: isTauriRuntime() ? "desktop" : "web",
|
||||
platform: getRuntimePlatform(),
|
||||
});
|
||||
|
||||
export const getAppMetadataHeaders = (): Record<string, string> => {
|
||||
const metadata = getAppMetadata();
|
||||
return {
|
||||
"X-CTMS-Client-Type": metadata.clientType,
|
||||
"X-CTMS-Client-Version": metadata.version,
|
||||
"X-CTMS-Client-Platform": metadata.platform,
|
||||
"X-CTMS-Build-Channel": metadata.channel,
|
||||
"X-CTMS-Build-Commit": metadata.commit,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { resolveApiBaseUrl } from "./apiBaseUrl";
|
||||
import { getAppMetadata } from "./appMetadata";
|
||||
import * as files from "./files";
|
||||
import * as notifications from "./notifications";
|
||||
import * as updates from "./updates";
|
||||
import { isTauriRuntime } from "./platform";
|
||||
import {
|
||||
clearSessionToken,
|
||||
getSessionToken,
|
||||
initializeSecureSessionStorage,
|
||||
isSecureSessionStorageAvailable,
|
||||
setSessionToken,
|
||||
} from "./secureSessionStorage";
|
||||
|
||||
export interface RuntimeCapabilities {
|
||||
serverConfiguration: boolean;
|
||||
@@ -14,16 +24,34 @@ export interface ClientRuntime {
|
||||
apiBaseUrl(): string;
|
||||
metadata: typeof getAppMetadata;
|
||||
capabilities(): RuntimeCapabilities;
|
||||
secureSessionStorage: {
|
||||
initialize: typeof initializeSecureSessionStorage;
|
||||
get: typeof getSessionToken;
|
||||
set: typeof setSessionToken;
|
||||
clear: typeof clearSessionToken;
|
||||
};
|
||||
files: typeof files;
|
||||
notifications: typeof notifications;
|
||||
updates: typeof updates;
|
||||
}
|
||||
|
||||
export const clientRuntime: ClientRuntime = {
|
||||
apiBaseUrl: resolveApiBaseUrl,
|
||||
metadata: getAppMetadata,
|
||||
secureSessionStorage: {
|
||||
initialize: initializeSecureSessionStorage,
|
||||
get: getSessionToken,
|
||||
set: setSessionToken,
|
||||
clear: clearSessionToken,
|
||||
},
|
||||
files,
|
||||
notifications,
|
||||
updates,
|
||||
capabilities: () => ({
|
||||
serverConfiguration: isTauriRuntime(),
|
||||
nativeFiles: false,
|
||||
systemNotifications: false,
|
||||
secureSessionStorage: false,
|
||||
automaticUpdates: false,
|
||||
nativeFiles: isTauriRuntime(),
|
||||
systemNotifications: isTauriRuntime(),
|
||||
secureSessionStorage: isTauriRuntime() && isSecureSessionStorageAvailable(),
|
||||
automaticUpdates: updates.isDesktopUpdaterAvailable(),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { isTauriRuntime } from "./platform";
|
||||
|
||||
export interface FilePickerOptions {
|
||||
multiple?: boolean;
|
||||
accept?: string[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface FileOutput {
|
||||
suggestedName: string;
|
||||
mimeType?: string;
|
||||
data: Blob | ArrayBuffer | Uint8Array | string;
|
||||
}
|
||||
|
||||
export type SaveFileResult = "saved" | "cancelled";
|
||||
|
||||
const sanitizeFileName = (value: string): string => {
|
||||
const normalized = value.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_").replace(/^\.+/, "").trim();
|
||||
return normalized.slice(0, 180) || "download";
|
||||
};
|
||||
|
||||
const extensionFilters = (accept: string[] | undefined) => {
|
||||
if (!accept?.length) return undefined;
|
||||
const extensions = accept
|
||||
.map((value) => value.trim().replace(/^\./, ""))
|
||||
.filter((value) => value && !value.includes("/") && /^[a-zA-Z0-9]+$/.test(value));
|
||||
return extensions.length ? [{ name: "可选文件", extensions }] : undefined;
|
||||
};
|
||||
|
||||
const toBlobPart = (data: FileOutput["data"]): BlobPart => {
|
||||
if (typeof data === "string" || data instanceof Blob || data instanceof ArrayBuffer) return data;
|
||||
const copy = new Uint8Array(data.byteLength);
|
||||
copy.set(data);
|
||||
return copy;
|
||||
};
|
||||
|
||||
const toBlob = (output: FileOutput): Blob =>
|
||||
output.data instanceof Blob
|
||||
? output.data
|
||||
: new Blob([toBlobPart(output.data)], { type: output.mimeType || "application/octet-stream" });
|
||||
|
||||
const toBytes = async (output: FileOutput): Promise<Uint8Array> =>
|
||||
new Uint8Array(await toBlob(output).arrayBuffer());
|
||||
|
||||
const webPickFiles = (options: FilePickerOptions): Promise<File[]> =>
|
||||
new Promise((resolve) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = Boolean(options.multiple);
|
||||
input.accept = options.accept?.map((item) => (item.includes("/") ? item : `.${item.replace(/^\./, "")}`)).join(",") || "";
|
||||
input.style.display = "none";
|
||||
input.addEventListener(
|
||||
"change",
|
||||
() => {
|
||||
resolve(Array.from(input.files || []));
|
||||
input.remove();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
document.body.appendChild(input);
|
||||
input.click();
|
||||
});
|
||||
|
||||
export const pickFiles = async (options: FilePickerOptions = {}): Promise<File[]> => {
|
||||
if (!isTauriRuntime()) return webPickFiles(options);
|
||||
const [{ open }, { readFile }] = await Promise.all([
|
||||
import("@tauri-apps/plugin-dialog"),
|
||||
import("@tauri-apps/plugin-fs"),
|
||||
]);
|
||||
const selected = await open({
|
||||
title: options.title,
|
||||
multiple: Boolean(options.multiple),
|
||||
directory: false,
|
||||
filters: extensionFilters(options.accept),
|
||||
});
|
||||
const paths = selected ? (Array.isArray(selected) ? selected : [selected]) : [];
|
||||
return Promise.all(
|
||||
paths.map(async (path) => {
|
||||
const bytes = await readFile(path);
|
||||
const name = path.split(/[\\/]/).pop() || "upload";
|
||||
return new File([bytes], name, { lastModified: Date.now() });
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const saveFile = async (output: FileOutput): Promise<SaveFileResult> => {
|
||||
const suggestedName = sanitizeFileName(output.suggestedName);
|
||||
if (!isTauriRuntime()) {
|
||||
const url = URL.createObjectURL(toBlob(output));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = suggestedName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
return "saved";
|
||||
}
|
||||
|
||||
const [{ save }, { writeFile }] = await Promise.all([
|
||||
import("@tauri-apps/plugin-dialog"),
|
||||
import("@tauri-apps/plugin-fs"),
|
||||
]);
|
||||
const path = await save({ defaultPath: suggestedName });
|
||||
if (!path) return "cancelled";
|
||||
await writeFile(path, await toBytes(output));
|
||||
return "saved";
|
||||
};
|
||||
|
||||
export const openFile = async (output: FileOutput): Promise<void> => {
|
||||
if (!isTauriRuntime()) {
|
||||
const url = URL.createObjectURL(toBlob(output));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
return;
|
||||
}
|
||||
|
||||
const [{ tempDir, join }, { mkdir, writeFile }, { openPath }] = await Promise.all([
|
||||
import("@tauri-apps/api/path"),
|
||||
import("@tauri-apps/plugin-fs"),
|
||||
import("@tauri-apps/plugin-opener"),
|
||||
]);
|
||||
const root = await join(await tempDir(), "ctms-desktop");
|
||||
const directory = await join(root, crypto.randomUUID());
|
||||
await mkdir(directory, { recursive: true });
|
||||
const path = await join(directory, sanitizeFileName(output.suggestedName));
|
||||
await writeFile(path, await toBytes(output));
|
||||
await openPath(path);
|
||||
};
|
||||
|
||||
export const cleanupTemporaryFiles = async (): Promise<void> => {
|
||||
if (!isTauriRuntime()) return;
|
||||
const [{ tempDir, join }, { exists, remove }] = await Promise.all([
|
||||
import("@tauri-apps/api/path"),
|
||||
import("@tauri-apps/plugin-fs"),
|
||||
]);
|
||||
const root = await join(await tempDir(), "ctms-desktop");
|
||||
if (await exists(root)) await remove(root, { recursive: true });
|
||||
};
|
||||
@@ -8,5 +8,33 @@ export {
|
||||
setDesktopServerUrl,
|
||||
shouldRequireDesktopServerUrl,
|
||||
} from "./desktopServerConfig";
|
||||
export { getAppMetadata, type AppMetadata, type BuildChannel, type ClientType } from "./appMetadata";
|
||||
export { getAppMetadata, getAppMetadataHeaders, type AppMetadata, type BuildChannel, type ClientType } from "./appMetadata";
|
||||
export {
|
||||
cleanupTemporaryFiles,
|
||||
openFile,
|
||||
pickFiles,
|
||||
saveFile,
|
||||
type FileOutput,
|
||||
type FilePickerOptions,
|
||||
type SaveFileResult,
|
||||
} from "./files";
|
||||
export {
|
||||
getNotificationPermission,
|
||||
requestNotificationPermission,
|
||||
showSystemNotification,
|
||||
type NotificationPermissionState,
|
||||
} from "./notifications";
|
||||
export { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform";
|
||||
export {
|
||||
clearSessionToken,
|
||||
getSessionToken,
|
||||
initializeSecureSessionStorage,
|
||||
isSecureSessionStorageAvailable,
|
||||
setSessionToken,
|
||||
} from "./secureSessionStorage";
|
||||
export {
|
||||
checkForDesktopUpdate,
|
||||
installPendingDesktopUpdate,
|
||||
isDesktopUpdaterAvailable,
|
||||
type DesktopUpdateInfo,
|
||||
} from "./updates";
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { isTauriRuntime } from "./platform";
|
||||
|
||||
export type NotificationPermissionState = "granted" | "denied" | "prompt" | "unsupported";
|
||||
|
||||
export const getNotificationPermission = async (): Promise<NotificationPermissionState> => {
|
||||
if (!isTauriRuntime()) return "unsupported";
|
||||
const { isPermissionGranted } = await import("@tauri-apps/plugin-notification");
|
||||
return (await isPermissionGranted()) ? "granted" : "prompt";
|
||||
};
|
||||
|
||||
export const requestNotificationPermission = async (): Promise<NotificationPermissionState> => {
|
||||
if (!isTauriRuntime()) return "unsupported";
|
||||
const { isPermissionGranted, requestPermission } = await import("@tauri-apps/plugin-notification");
|
||||
if (await isPermissionGranted()) return "granted";
|
||||
return (await requestPermission()) === "granted" ? "granted" : "denied";
|
||||
};
|
||||
|
||||
export const showSystemNotification = async (): Promise<void> => {
|
||||
if (!isTauriRuntime()) return;
|
||||
const { sendNotification } = await import("@tauri-apps/plugin-notification");
|
||||
sendNotification({
|
||||
title: "CTMS 文件更新",
|
||||
body: "有新的文件版本待查看",
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import { getDesktopServerUrl } from "./desktopServerConfig";
|
||||
import { isTauriRuntime } from "./platform";
|
||||
|
||||
const LEGACY_TOKEN_KEY = "ctms_token";
|
||||
|
||||
let cachedToken: string | null = null;
|
||||
let initialized = false;
|
||||
let activeServerOrigin: string | null = null;
|
||||
let secureStorageAvailable = false;
|
||||
|
||||
const invokeCredential = async <T>(
|
||||
command: "credential_get" | "credential_set" | "credential_delete",
|
||||
args: Record<string, string>,
|
||||
): Promise<T> => {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
return invoke<T>(command, args);
|
||||
};
|
||||
|
||||
const isUsableJwt = (token: string): boolean => {
|
||||
const segments = token.split(".");
|
||||
if (segments.length !== 3) return false;
|
||||
try {
|
||||
const base64 = segments[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=");
|
||||
const payload = JSON.parse(atob(padded));
|
||||
return typeof payload.exp !== "number" || payload.exp * 1000 > Date.now();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const initializeSecureSessionStorage = async (): Promise<void> => {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
if (!isTauriRuntime()) {
|
||||
cachedToken = window.localStorage.getItem(LEGACY_TOKEN_KEY);
|
||||
secureStorageAvailable = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const legacyToken = window.localStorage.getItem(LEGACY_TOKEN_KEY);
|
||||
window.localStorage.removeItem(LEGACY_TOKEN_KEY);
|
||||
activeServerOrigin = getDesktopServerUrl();
|
||||
if (!activeServerOrigin) {
|
||||
cachedToken = null;
|
||||
secureStorageAvailable = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (legacyToken && isUsableJwt(legacyToken)) {
|
||||
await invokeCredential<void>("credential_set", {
|
||||
serverOrigin: activeServerOrigin,
|
||||
token: legacyToken,
|
||||
});
|
||||
cachedToken = legacyToken;
|
||||
} else {
|
||||
cachedToken = await invokeCredential<string | null>("credential_get", {
|
||||
serverOrigin: activeServerOrigin,
|
||||
});
|
||||
}
|
||||
secureStorageAvailable = true;
|
||||
} catch (error) {
|
||||
cachedToken = null;
|
||||
secureStorageAvailable = false;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getSessionToken = (): string | null => {
|
||||
if (!initialized && !isTauriRuntime() && typeof localStorage !== "undefined") {
|
||||
return localStorage.getItem(LEGACY_TOKEN_KEY);
|
||||
}
|
||||
return cachedToken;
|
||||
};
|
||||
|
||||
export const setSessionToken = async (token: string): Promise<void> => {
|
||||
if (!token) throw new Error("拒绝保存空 token");
|
||||
if (!isTauriRuntime()) {
|
||||
window.localStorage.setItem(LEGACY_TOKEN_KEY, token);
|
||||
cachedToken = token;
|
||||
initialized = true;
|
||||
secureStorageAvailable = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const serverOrigin = getDesktopServerUrl();
|
||||
if (!serverOrigin) throw new Error("尚未配置桌面服务器地址");
|
||||
await invokeCredential<void>("credential_set", { serverOrigin, token });
|
||||
activeServerOrigin = serverOrigin;
|
||||
cachedToken = token;
|
||||
initialized = true;
|
||||
secureStorageAvailable = true;
|
||||
};
|
||||
|
||||
export const clearSessionToken = async (): Promise<void> => {
|
||||
cachedToken = null;
|
||||
if (!isTauriRuntime()) {
|
||||
if (typeof localStorage !== "undefined") localStorage.removeItem(LEGACY_TOKEN_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
const serverOrigin = activeServerOrigin || getDesktopServerUrl();
|
||||
activeServerOrigin = getDesktopServerUrl();
|
||||
if (!serverOrigin) return;
|
||||
await invokeCredential<void>("credential_delete", { serverOrigin });
|
||||
};
|
||||
|
||||
export const isSecureSessionStorageAvailable = (): boolean => secureStorageAvailable;
|
||||
|
||||
export const resetSecureSessionStorageForTests = (): void => {
|
||||
cachedToken = null;
|
||||
initialized = false;
|
||||
activeServerOrigin = null;
|
||||
secureStorageAvailable = false;
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { getDesktopServerUrl } from "./desktopServerConfig";
|
||||
import { getAppMetadata } from "./appMetadata";
|
||||
import { isTauriRuntime } from "./platform";
|
||||
|
||||
export interface DesktopUpdateInfo {
|
||||
version: string;
|
||||
currentVersion: string;
|
||||
notes?: string | null;
|
||||
date?: string | null;
|
||||
}
|
||||
|
||||
type TauriCore = typeof import("@tauri-apps/api/core");
|
||||
|
||||
const loadTauriCore = async (): Promise<TauriCore | null> => {
|
||||
if (!isTauriRuntime()) return null;
|
||||
return import("@tauri-apps/api/core");
|
||||
};
|
||||
|
||||
export const isDesktopUpdaterAvailable = (): boolean => {
|
||||
if (!isTauriRuntime()) return false;
|
||||
const metadata = getAppMetadata();
|
||||
return metadata.channel === "release" || import.meta.env.VITE_ENABLE_DESKTOP_UPDATES === "true";
|
||||
};
|
||||
|
||||
export const checkForDesktopUpdate = async (): Promise<DesktopUpdateInfo | null> => {
|
||||
if (!isDesktopUpdaterAvailable()) return null;
|
||||
const serverOrigin = getDesktopServerUrl();
|
||||
if (!serverOrigin) return null;
|
||||
const tauri = await loadTauriCore();
|
||||
if (!tauri) return null;
|
||||
return tauri.invoke<DesktopUpdateInfo | null>("desktop_update_check", { serverOrigin });
|
||||
};
|
||||
|
||||
export const installPendingDesktopUpdate = async (): Promise<void> => {
|
||||
if (!isDesktopUpdaterAvailable()) return;
|
||||
const tauri = await loadTauriCore();
|
||||
if (!tauri) return;
|
||||
await tauri.invoke("desktop_update_install");
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
acknowledgeDesktopNotifications,
|
||||
claimDesktopNotifications,
|
||||
getDesktopNotificationSubscription,
|
||||
} from "../api/desktopNotifications";
|
||||
import { getToken } from "../utils/auth";
|
||||
import {
|
||||
getNotificationPermission,
|
||||
isTauriRuntime,
|
||||
showSystemNotification,
|
||||
} from "../runtime";
|
||||
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
const MAX_BACKOFF_MS = 15 * 60_000;
|
||||
|
||||
let initialized = false;
|
||||
let timer: number | null = null;
|
||||
let failureCount = 0;
|
||||
|
||||
const schedule = (delay: number) => {
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => void poll(), delay);
|
||||
};
|
||||
|
||||
const poll = async () => {
|
||||
if (!getToken()) {
|
||||
failureCount = 0;
|
||||
schedule(POLL_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const permission = await getNotificationPermission();
|
||||
if (permission !== "granted") {
|
||||
schedule(POLL_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
const { data: subscription } = await getDesktopNotificationSubscription();
|
||||
if (!subscription.enabled) {
|
||||
schedule(POLL_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
const { data } = await claimDesktopNotifications();
|
||||
const deliveredIds: string[] = [];
|
||||
for (const item of data.items) {
|
||||
await showSystemNotification();
|
||||
deliveredIds.push(item.id);
|
||||
}
|
||||
if (data.claim_token && deliveredIds.length) {
|
||||
await acknowledgeDesktopNotifications(data.claim_token, deliveredIds);
|
||||
}
|
||||
failureCount = 0;
|
||||
schedule(POLL_INTERVAL_MS);
|
||||
} catch {
|
||||
failureCount += 1;
|
||||
schedule(Math.min(POLL_INTERVAL_MS * 2 ** failureCount, MAX_BACKOFF_MS));
|
||||
}
|
||||
};
|
||||
|
||||
export const initDesktopNotificationManager = (): void => {
|
||||
if (initialized || !isTauriRuntime()) return;
|
||||
initialized = true;
|
||||
schedule(5_000);
|
||||
};
|
||||
|
||||
export const triggerDesktopNotificationPoll = (): void => {
|
||||
if (!initialized) return;
|
||||
failureCount = 0;
|
||||
schedule(0);
|
||||
};
|
||||
|
||||
export const stopDesktopNotificationManager = (): void => {
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
timer = null;
|
||||
initialized = false;
|
||||
failureCount = 0;
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import {
|
||||
checkForDesktopUpdate,
|
||||
installPendingDesktopUpdate,
|
||||
isDesktopUpdaterAvailable,
|
||||
type DesktopUpdateInfo,
|
||||
} from "../runtime";
|
||||
|
||||
const INITIAL_CHECK_DELAY_MS = 30_000;
|
||||
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||
const POSTPONE_MS = 24 * 60 * 60 * 1000;
|
||||
const POSTPONE_PREFIX = "ctms_desktop_update_postponed:";
|
||||
|
||||
let initialized = false;
|
||||
let checkTimer: number | null = null;
|
||||
let intervalTimer: number | null = null;
|
||||
let promptVisible = false;
|
||||
|
||||
const postponeKey = (version: string) => `${POSTPONE_PREFIX}${version}`;
|
||||
|
||||
const getPostponeUntil = (version: string): number => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(postponeKey(version));
|
||||
return raw ? Number(raw) || 0 : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const postponeVersion = (version: string) => {
|
||||
try {
|
||||
window.localStorage.setItem(postponeKey(version), String(Date.now() + POSTPONE_MS));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const isSuppressed = (version: string): boolean => getPostponeUntil(version) > Date.now();
|
||||
|
||||
const releaseNotes = (update: DesktopUpdateInfo): string => {
|
||||
const lines = [`发现 CTMS 桌面端新版本 ${update.version}(当前 ${update.currentVersion})。`];
|
||||
if (update.notes?.trim()) {
|
||||
lines.push("", "发布说明:", update.notes.trim());
|
||||
}
|
||||
lines.push("", "确认后将下载、验签、安装并重启应用。");
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
const promptForUpdate = async (update: DesktopUpdateInfo) => {
|
||||
if (promptVisible || isSuppressed(update.version)) return;
|
||||
promptVisible = true;
|
||||
try {
|
||||
await ElMessageBox.confirm(releaseNotes(update), "CTMS 桌面端更新", {
|
||||
confirmButtonText: "立即更新并重启",
|
||||
cancelButtonText: "稍后",
|
||||
distinguishCancelAndClose: true,
|
||||
type: "info",
|
||||
});
|
||||
await installPendingDesktopUpdate();
|
||||
} catch (error) {
|
||||
if (error === "cancel" || error === "close") {
|
||||
postponeVersion(update.version);
|
||||
return;
|
||||
}
|
||||
ElMessage.error("桌面端更新安装失败,请稍后重试或联系管理员。");
|
||||
} finally {
|
||||
promptVisible = false;
|
||||
}
|
||||
};
|
||||
|
||||
export const checkDesktopUpdateAndPrompt = async () => {
|
||||
if (!isDesktopUpdaterAvailable()) return;
|
||||
try {
|
||||
const update = await checkForDesktopUpdate();
|
||||
if (update) {
|
||||
await promptForUpdate(update);
|
||||
}
|
||||
} catch {
|
||||
// 启动和定时检查不打断录入;下一轮继续检查。
|
||||
}
|
||||
};
|
||||
|
||||
export const initDesktopUpdateManager = () => {
|
||||
if (initialized || !isDesktopUpdaterAvailable()) return;
|
||||
initialized = true;
|
||||
checkTimer = window.setTimeout(() => {
|
||||
void checkDesktopUpdateAndPrompt();
|
||||
}, INITIAL_CHECK_DELAY_MS);
|
||||
intervalTimer = window.setInterval(() => {
|
||||
void checkDesktopUpdateAndPrompt();
|
||||
}, CHECK_INTERVAL_MS);
|
||||
};
|
||||
|
||||
export const stopDesktopUpdateManager = () => {
|
||||
if (checkTimer) {
|
||||
window.clearTimeout(checkTimer);
|
||||
checkTimer = null;
|
||||
}
|
||||
if (intervalTimer) {
|
||||
window.clearInterval(intervalTimer);
|
||||
intervalTimer = null;
|
||||
}
|
||||
initialized = false;
|
||||
promptVisible = false;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import router from "../router";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { useSessionStore } from "../store/session";
|
||||
import { getToken, setToken, clearToken } from "../utils/auth";
|
||||
import { getToken, setToken } from "../utils/auth";
|
||||
import { extendToken } from "../api/authClient";
|
||||
import { parseJwtExp } from "./jwt";
|
||||
|
||||
@@ -30,7 +30,7 @@ const getTimeoutAt = (session: ReturnType<typeof useSessionStore>) =>
|
||||
const reconcileSessionState = (now: number = Date.now()) => {
|
||||
const session = useSessionStore();
|
||||
if (now >= getTimeoutAt(session)) {
|
||||
forceLogout(LOGOUT_REASON_TIMEOUT);
|
||||
void forceLogout(LOGOUT_REASON_TIMEOUT);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -56,11 +56,11 @@ const handleBroadcast = (message: BroadcastMessage) => {
|
||||
}
|
||||
if (message.type === "TOKEN_UPDATED") {
|
||||
auth.setToken(message.token);
|
||||
setToken(message.token);
|
||||
void setToken(message.token);
|
||||
session.resetActivity();
|
||||
}
|
||||
if (message.type === "LOGOUT") {
|
||||
performLogout(message.reason);
|
||||
void performLogout(message.reason);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -156,25 +156,25 @@ export const consumeLogoutReason = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const performLogout = (reason?: string) => {
|
||||
const performLogout = async (reason?: string) => {
|
||||
setLogoutReason(reason);
|
||||
const auth = useAuthStore();
|
||||
const session = useSessionStore();
|
||||
auth.logout();
|
||||
const logoutPromise = auth.logout();
|
||||
session.resetActivity();
|
||||
clearToken();
|
||||
router.replace("/login");
|
||||
await logoutPromise;
|
||||
};
|
||||
|
||||
export const forceLogout = (reason?: string) => {
|
||||
performLogout(reason);
|
||||
export const forceLogout = async (reason?: string) => {
|
||||
await performLogout(reason);
|
||||
broadcast({ type: "LOGOUT", reason });
|
||||
};
|
||||
|
||||
const updateToken = (token: string) => {
|
||||
const updateToken = async (token: string) => {
|
||||
const auth = useAuthStore();
|
||||
auth.setToken(token);
|
||||
setToken(token);
|
||||
await setToken(token);
|
||||
broadcast({ type: "TOKEN_UPDATED", token });
|
||||
};
|
||||
|
||||
@@ -194,15 +194,15 @@ export const extendAccessToken = async (reason: "early" | "response-401") => {
|
||||
try {
|
||||
const { data } = await extendToken(token);
|
||||
session.setLastExtendAt(Date.now());
|
||||
updateToken(data.accessToken);
|
||||
await updateToken(data.accessToken);
|
||||
return { token: data.accessToken, authFailed: false };
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status;
|
||||
if (status === 401) {
|
||||
forceLogout(LOGOUT_REASON_AUTH_EXPIRED);
|
||||
await forceLogout(LOGOUT_REASON_AUTH_EXPIRED);
|
||||
authFailed = true;
|
||||
} else if (status === 403) {
|
||||
forceLogout(LOGOUT_REASON_AUTH_EXPIRED);
|
||||
await forceLogout(LOGOUT_REASON_AUTH_EXPIRED);
|
||||
authFailed = true;
|
||||
}
|
||||
return { token: null, authFailed };
|
||||
|
||||
@@ -92,7 +92,7 @@ describe("auth store logout", () => {
|
||||
session.recordUserActivity(staleTs);
|
||||
session.setLastExtendAt(staleTs);
|
||||
session.showTimeoutWarning(staleTs + 30_000);
|
||||
auth.logout();
|
||||
await auth.logout();
|
||||
|
||||
expect(session.lastUserActiveAt).toBeGreaterThan(staleTs);
|
||||
expect(session.lastExtendAt).toBe(0);
|
||||
|
||||
@@ -25,7 +25,7 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
? await devLogin({ email, password })
|
||||
: await encryptedLogin(email, password);
|
||||
token.value = data.access_token;
|
||||
setToken(data.access_token);
|
||||
await setToken(data.access_token);
|
||||
useSessionStore().resetActivity();
|
||||
localStorage.setItem(LAST_LOGIN_EMAIL_KEY, email);
|
||||
const me = await fetchMeAction();
|
||||
@@ -62,7 +62,7 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
return data;
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
const logout = async () => {
|
||||
const studyStore = useStudyStore();
|
||||
const sessionStore = useSessionStore();
|
||||
const userKey = user.value?.email || localStorage.getItem(LAST_LOGIN_EMAIL_KEY) || "";
|
||||
@@ -71,7 +71,7 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
user.value = null;
|
||||
forceLogin.value = false;
|
||||
sessionStore.resetActivity();
|
||||
clearToken();
|
||||
await clearToken();
|
||||
studyStore.clearCurrentStudy();
|
||||
};
|
||||
|
||||
|
||||
@@ -376,6 +376,11 @@ export interface SecurityAccessLogItem {
|
||||
ip_city: string;
|
||||
ip_isp: string;
|
||||
user_agent: string | null;
|
||||
client_type: string | null;
|
||||
client_version: string | null;
|
||||
client_platform: string | null;
|
||||
build_channel: string | null;
|
||||
build_commit: string | null;
|
||||
auth_status: "ANONYMOUS" | "INVALID_TOKEN" | "AUTHENTICATED" | string;
|
||||
user_identifier: string | null;
|
||||
account_label: string;
|
||||
|
||||
@@ -8,4 +8,8 @@ export interface NotificationItem {
|
||||
change_summary?: string | null;
|
||||
effective_at?: string | null;
|
||||
created_at: string;
|
||||
study_id?: string | null;
|
||||
study_name?: string | null;
|
||||
delivered_at?: string | null;
|
||||
read_at?: string | null;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
const TOKEN_KEY = "ctms_token";
|
||||
import { clearSessionToken, getSessionToken, setSessionToken } from "../runtime";
|
||||
|
||||
export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY);
|
||||
export const getToken = (): string | null => getSessionToken();
|
||||
|
||||
export const setToken = (token: string): void => {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
};
|
||||
export const setToken = (token: string): Promise<void> => setSessionToken(token);
|
||||
|
||||
export const clearToken = (): void => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
};
|
||||
export const clearToken = (): Promise<void> => clearSessionToken();
|
||||
|
||||
@@ -60,8 +60,8 @@ const checkHealth = async (baseUrl: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const clearSessionForServerChange = () => {
|
||||
auth.logout();
|
||||
const clearSessionForServerChange = async () => {
|
||||
await auth.logout();
|
||||
studyStore.clearCurrentStudy();
|
||||
};
|
||||
|
||||
@@ -83,7 +83,7 @@ const save = async () => {
|
||||
return;
|
||||
}
|
||||
if (previous !== result.url) {
|
||||
clearSessionForServerChange();
|
||||
await clearSessionForServerChange();
|
||||
}
|
||||
ElMessage.success("服务器连接已确认");
|
||||
router.replace("/login");
|
||||
|
||||
@@ -10,19 +10,9 @@
|
||||
<div class="avatar-name">{{ form.full_name || TEXT.modules.profile.title }}</div>
|
||||
<div class="avatar-email">{{ form.email }}</div>
|
||||
</div>
|
||||
<el-upload
|
||||
class="avatar-uploader"
|
||||
:show-file-list="false"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
action="/api/v1/auth/me/avatar"
|
||||
name="file"
|
||||
:headers="uploadHeaders"
|
||||
:before-upload="beforeAvatarUpload"
|
||||
:on-success="onAvatarUploaded"
|
||||
:on-error="onAvatarError"
|
||||
>
|
||||
<el-button :icon="Upload" class="upload-button">{{ TEXT.modules.profile.uploadAvatar }}</el-button>
|
||||
</el-upload>
|
||||
<el-button :icon="Upload" class="upload-button avatar-uploader" @click="selectAndUploadAvatar">
|
||||
{{ TEXT.modules.profile.uploadAvatar }}
|
||||
</el-button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -74,6 +64,27 @@
|
||||
</el-form-item>
|
||||
</section>
|
||||
|
||||
<section class="form-section form-section--desktop">
|
||||
<div class="section-heading">
|
||||
<span class="section-kicker">Desktop</span>
|
||||
<h4>客户端与通知</h4>
|
||||
</div>
|
||||
<el-form-item v-if="isDesktop" label="系统通知">
|
||||
<el-switch
|
||||
v-model="desktopNotificationsEnabled"
|
||||
:loading="desktopNotificationLoading"
|
||||
@change="onDesktopNotificationChange"
|
||||
/>
|
||||
<span class="desktop-setting-hint">仅推送不含项目详情的文件更新提示</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="客户端信息">
|
||||
<div class="client-metadata">
|
||||
<code>{{ clientMetadataText }}</code>
|
||||
<el-button size="small" @click="copyClientMetadata">复制</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</section>
|
||||
|
||||
<div class="actions">
|
||||
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
|
||||
</div>
|
||||
@@ -85,12 +96,22 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import type { FormInstance, FormRules, UploadRawFile } from "element-plus";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Close, Upload } from "@element-plus/icons-vue";
|
||||
import { updateProfile, fetchMe } from "../api/auth";
|
||||
import { updateProfile, fetchMe, uploadAvatar } from "../api/auth";
|
||||
import {
|
||||
getDesktopNotificationSubscription,
|
||||
setDesktopNotificationSubscription,
|
||||
} from "../api/desktopNotifications";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { getToken } from "../utils/auth";
|
||||
import {
|
||||
getAppMetadata,
|
||||
isTauriRuntime,
|
||||
pickFiles,
|
||||
requestNotificationPermission,
|
||||
} from "../runtime";
|
||||
import { triggerDesktopNotificationPoll } from "../session/desktopNotificationManager";
|
||||
import { TEXT, requiredMessage } from "../locales";
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -99,6 +120,16 @@ const emit = defineEmits<{
|
||||
saved: [];
|
||||
}>();
|
||||
const auth = useAuthStore();
|
||||
const isDesktop = isTauriRuntime();
|
||||
const clientMetadata = getAppMetadata();
|
||||
const clientMetadataText = [
|
||||
`${clientMetadata.clientType} ${clientMetadata.version}`,
|
||||
clientMetadata.platform,
|
||||
clientMetadata.channel,
|
||||
clientMetadata.commit,
|
||||
].join(" · ");
|
||||
const desktopNotificationsEnabled = ref(false);
|
||||
const desktopNotificationLoading = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
const submitting = ref(false);
|
||||
const form = reactive({
|
||||
@@ -114,9 +145,6 @@ const savedProfile = ref({
|
||||
clinical_department: "",
|
||||
});
|
||||
const avatarPreview = ref<string | undefined>();
|
||||
const uploadHeaders = computed<Record<string, string>>(() => ({
|
||||
Authorization: `Bearer ${getToken() || ""}`,
|
||||
}));
|
||||
const profileInitial = computed(() => (form.full_name?.charAt(0) || form.email?.charAt(0) || "?").toUpperCase());
|
||||
const avatarAcceptedTypes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
||||
const hasUnsavedChanges = computed(
|
||||
@@ -180,6 +208,41 @@ const loadProfile = async () => {
|
||||
avatarPreview.value = data.avatar_url || undefined;
|
||||
};
|
||||
|
||||
const loadDesktopNotificationSubscription = async () => {
|
||||
if (!isDesktop) return;
|
||||
const { data } = await getDesktopNotificationSubscription();
|
||||
desktopNotificationsEnabled.value = data.enabled;
|
||||
};
|
||||
|
||||
const onDesktopNotificationChange = async (value: string | number | boolean) => {
|
||||
if (!isDesktop || desktopNotificationLoading.value) return;
|
||||
desktopNotificationLoading.value = true;
|
||||
try {
|
||||
const enable = Boolean(value);
|
||||
if (enable) {
|
||||
const permission = await requestNotificationPermission();
|
||||
if (permission !== "granted") {
|
||||
desktopNotificationsEnabled.value = false;
|
||||
ElMessage.warning("系统通知权限未开启,请在系统设置中允许 CTMS 通知");
|
||||
return;
|
||||
}
|
||||
}
|
||||
const { data } = await setDesktopNotificationSubscription(enable);
|
||||
desktopNotificationsEnabled.value = data.enabled;
|
||||
if (data.enabled) triggerDesktopNotificationPoll();
|
||||
} catch (error: any) {
|
||||
desktopNotificationsEnabled.value = !Boolean(value);
|
||||
ElMessage.error(error?.response?.data?.detail || "通知设置保存失败");
|
||||
} finally {
|
||||
desktopNotificationLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const copyClientMetadata = async () => {
|
||||
await navigator.clipboard.writeText(clientMetadataText);
|
||||
ElMessage.success("客户端信息已复制");
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!formRef.value) return;
|
||||
await formRef.value.validate(async (valid) => {
|
||||
@@ -211,24 +274,31 @@ const onSubmit = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
const onAvatarUploaded = async () => {
|
||||
const selectAndUploadAvatar = async () => {
|
||||
const [file] = await pickFiles({
|
||||
multiple: false,
|
||||
accept: ["png", "jpg", "jpeg", "gif", "webp"],
|
||||
title: TEXT.modules.profile.uploadAvatar,
|
||||
});
|
||||
if (!file) return;
|
||||
const extensionAllowed = /\.(png|jpe?g|gif|webp)$/i.test(file.name);
|
||||
if (!avatarAcceptedTypes.has(file.type) && !extensionAllowed) {
|
||||
ElMessage.error(TEXT.modules.profile.avatarTypeInvalid);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await uploadAvatar(file);
|
||||
await auth.fetchMe();
|
||||
avatarPreview.value = auth.user?.avatar_url || undefined;
|
||||
ElMessage.success(TEXT.modules.profile.avatarUpdated);
|
||||
};
|
||||
|
||||
const beforeAvatarUpload = (file: UploadRawFile) => {
|
||||
if (avatarAcceptedTypes.has(file.type)) return true;
|
||||
ElMessage.error(TEXT.modules.profile.avatarTypeInvalid);
|
||||
return false;
|
||||
};
|
||||
|
||||
const onAvatarError = (err: any) => {
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err?.response?.data?.message || TEXT.modules.profile.avatarUploadFailed);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadProfile();
|
||||
loadDesktopNotificationSubscription().catch(() => {});
|
||||
});
|
||||
|
||||
watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: true });
|
||||
@@ -343,6 +413,31 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
|
||||
border-top: 1px solid #e5ebf2;
|
||||
}
|
||||
|
||||
.form-section--desktop {
|
||||
margin-top: 26px;
|
||||
padding-top: 28px;
|
||||
border-top: 1px solid #e5ebf2;
|
||||
}
|
||||
|
||||
.desktop-setting-hint {
|
||||
margin-left: 12px;
|
||||
color: #7f92ad;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.client-metadata {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.client-metadata code {
|
||||
overflow-wrap: anywhere;
|
||||
color: #40566f;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
margin-bottom: 18px;
|
||||
padding-left: 112px;
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
<el-skeleton v-if="loading.notifications" :rows="3" animated />
|
||||
<StateEmpty v-else-if="!notifications.length" :description="TEXT.modules.projectOverview.notificationsEmpty" />
|
||||
<div v-else class="notification-list">
|
||||
<div class="notification-item" v-for="item in notifications" :key="item.id">
|
||||
<div class="notification-item" :class="{ 'is-unread': !item.read_at }" v-for="item in notifications" :key="item.id">
|
||||
<div class="notification-main">
|
||||
<div class="notification-title">
|
||||
<span class="notification-doc">{{ item.document_title }}</span>
|
||||
@@ -87,7 +87,7 @@
|
||||
<span v-if="item.change_summary" class="notification-summary">{{ item.change_summary }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-button link type="primary" @click="openDocument(item.document_id)">
|
||||
<el-button link type="primary" @click="openDocument(item)">
|
||||
{{ TEXT.common.actions.view }}
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -111,6 +111,7 @@ import { useAuthStore } from "../store/auth";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { fetchFinanceSummary, fetchOverdueAesCount, fetchProgress } from "../api/dashboard";
|
||||
import { listNotifications } from "../api/notifications";
|
||||
import { markDesktopNotificationRead } from "../api/desktopNotifications";
|
||||
import KpiCard from "../components/KpiCard.vue";
|
||||
import QuickActions from "../components/QuickActions.vue";
|
||||
import { List, Warning, Money } from "@element-plus/icons-vue";
|
||||
@@ -202,8 +203,10 @@ const formatDate = (value?: string | null) => {
|
||||
return value.replace("T", " ").replace("Z", "").split(".")[0];
|
||||
};
|
||||
|
||||
const openDocument = (documentId: string) => {
|
||||
router.push(`/documents/${documentId}`);
|
||||
const openDocument = async (item: NotificationItem) => {
|
||||
await markDesktopNotificationRead(item.id).catch(() => {});
|
||||
item.read_at = new Date().toISOString();
|
||||
router.push(`/documents/${item.document_id}`);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -1954,6 +1954,7 @@ import {
|
||||
type SetupWorkflowTagMeta,
|
||||
} from "../../utils/setupPublishWorkflow";
|
||||
import { useSetupConfig } from "../../composables/useSetupConfig";
|
||||
import { saveFile } from "../../runtime";
|
||||
|
||||
type SetupStepKey =
|
||||
| "project-info"
|
||||
@@ -5174,7 +5175,7 @@ const buildExcelWorksheetXml = (
|
||||
return `<Worksheet ss:Name="${excelEscapeXml(sheetName)}"><Table>${metaXml}<Row/>${headerXml}${dataXml}</Table></Worksheet>`;
|
||||
};
|
||||
|
||||
const downloadVersionRaw = (versionItem: StudySetupConfigVersionItem) => {
|
||||
const downloadVersionRaw = async (versionItem: StudySetupConfigVersionItem) => {
|
||||
if (!project.value) return;
|
||||
const displayVersion = getDisplayVersionLabel(versionItem.version);
|
||||
const branchName = versionItem.branch_name || "main";
|
||||
@@ -5342,16 +5343,13 @@ ${buildExcelWorksheetXml("第6步-中心确认", metaRows, step6Headers, step6Ro
|
||||
</Workbook>`;
|
||||
|
||||
const blob = new Blob([`\uFEFF${workbookXml}`], { type: "application/vnd.ms-excel;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const safeVersionLabel = displayVersion.replace(/[^\w.-]/g, "_");
|
||||
const filename = `setup-config-${safeVersionLabel}-${project.value.code || project.value.id}.xls`;
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
await saveFile({
|
||||
suggestedName: filename,
|
||||
mimeType: "application/vnd.ms-excel;charset=utf-8",
|
||||
data: blob,
|
||||
});
|
||||
};
|
||||
|
||||
const removeVersion = async (targetVersion: number) => {
|
||||
|
||||
@@ -123,6 +123,9 @@
|
||||
<el-button link type="primary" size="small" @click="downloadVersion(row)" v-if="canReadDocument">
|
||||
{{ TEXT.modules.fileVersionManagement.actions.download }}
|
||||
</el-button>
|
||||
<el-button link type="primary" size="small" @click="openVersion(row)" v-if="canReadDocument">
|
||||
打开
|
||||
</el-button>
|
||||
<el-button v-if="canDeleteDocument" link type="danger" size="small" @click="confirmDeleteVersion(row)">
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
@@ -262,7 +265,6 @@
|
||||
上传文件
|
||||
</div>
|
||||
<div class="upload-zone" :class="{ 'has-file': uploadFile }" @click="triggerFileInput">
|
||||
<input type="file" ref="fileInputRef" @change="onFileChange" class="file-input-hidden" accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.png,.jpg" />
|
||||
<template v-if="!uploadFile">
|
||||
<div class="upload-zone-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
@@ -370,6 +372,7 @@ import { useRoleTemplateMeta } from "../../composables/useRoleTemplateMeta";
|
||||
import { usePermission } from "../../utils/permission";
|
||||
import StateError from "../../components/StateError.vue";
|
||||
import StateLoading from "../../components/StateLoading.vue";
|
||||
import { openFile, pickFiles, saveFile } from "../../runtime";
|
||||
|
||||
const route = useRoute();
|
||||
const auth = useAuthStore();
|
||||
@@ -494,7 +497,6 @@ const editorDirtyGuard = useDrawerDirtyGuard(() => editorForm);
|
||||
const uploadVisible = ref(false);
|
||||
const uploading = ref(false);
|
||||
const uploadFormRef = ref<FormInstance>();
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
const uploadForm = reactive({
|
||||
version_no: "",
|
||||
version_date: "",
|
||||
@@ -513,8 +515,15 @@ const uploadDirtyGuard = useDrawerDirtyGuard(() => ({
|
||||
: null,
|
||||
}));
|
||||
|
||||
const triggerFileInput = () => { fileInputRef.value?.click(); };
|
||||
const removeFile = () => { uploadFile.value = null; if (fileInputRef.value) fileInputRef.value.value = ""; };
|
||||
const triggerFileInput = async () => {
|
||||
const [file] = await pickFiles({
|
||||
multiple: false,
|
||||
accept: ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "png", "jpg", "jpeg"],
|
||||
title: "选择文档版本",
|
||||
});
|
||||
if (file) uploadFile.value = file;
|
||||
};
|
||||
const removeFile = () => { uploadFile.value = null; };
|
||||
|
||||
const distributeVisible = ref(false);
|
||||
const distributing = ref(false);
|
||||
@@ -706,11 +715,6 @@ const openUpload = () => {
|
||||
uploadVisible.value = true;
|
||||
};
|
||||
|
||||
const onFileChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target.files && target.files[0]) uploadFile.value = target.files[0];
|
||||
};
|
||||
|
||||
const submitUpload = async () => {
|
||||
if (!canUpdateDocument.value) { ElMessage.warning("权限不足"); return; }
|
||||
if (!uploadFormRef.value) return;
|
||||
@@ -797,13 +801,25 @@ const downloadVersion = async (version: DocumentVersion) => {
|
||||
const contentType = response.headers?.["content-type"] || "application/octet-stream";
|
||||
const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`;
|
||||
const blob = new Blob([response.data], { type: contentType });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a"); link.href = url; link.download = filename;
|
||||
document.body.appendChild(link); link.click(); link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
await saveFile({ suggestedName: filename, mimeType: contentType, data: blob });
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.downloadFailed); }
|
||||
};
|
||||
|
||||
const openVersion = async (version: DocumentVersion) => {
|
||||
try {
|
||||
const response = await downloadDocumentVersion(version.id);
|
||||
const contentType = response.headers?.["content-type"] || "application/octet-stream";
|
||||
const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`;
|
||||
await openFile({
|
||||
suggestedName: filename,
|
||||
mimeType: contentType,
|
||||
data: new Blob([response.data], { type: contentType }),
|
||||
});
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.downloadFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeleteVersion = async (version: DocumentVersion) => {
|
||||
if (!version?.id) return;
|
||||
if (!canDeleteDocument.value) { ElMessage.warning("权限不足"); return; }
|
||||
|
||||
@@ -132,20 +132,17 @@
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>问题导入模板</span>
|
||||
</el-button>
|
||||
<el-upload
|
||||
<el-button
|
||||
v-if="canCreateIssue"
|
||||
ref="importUploadRef"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
accept=".xlsx,.csv"
|
||||
:on-change="onImportChange"
|
||||
:disabled="importing"
|
||||
class="toolbar-button toolbar-button-warning"
|
||||
type="warning"
|
||||
plain
|
||||
:loading="importing"
|
||||
@click="selectImportFile"
|
||||
>
|
||||
<el-button class="toolbar-button toolbar-button-warning" type="warning" plain :loading="importing">
|
||||
<el-icon><Upload /></el-icon>
|
||||
<span>导入问题</span>
|
||||
</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
<div class="toolbar-icons">
|
||||
<el-button circle title="刷新" @click="loadIssues">
|
||||
@@ -455,7 +452,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules, type UploadFile, type UploadInstance } from "element-plus";
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
|
||||
import { CircleCheck, Delete, Document, Download, Location, Plus, Refresh, Search, Upload } from "@element-plus/icons-vue";
|
||||
import {
|
||||
createMonitoringVisitIssue,
|
||||
@@ -469,6 +466,7 @@ import {
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import StateEmpty from "../../components/StateEmpty.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
import { pickFiles, saveFile } from "../../runtime";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import type { Site } from "../../types/api";
|
||||
@@ -523,7 +521,6 @@ const viewDialogVisible = ref(false);
|
||||
const formMode = ref<"create" | "edit">("create");
|
||||
const editingIssueId = ref("");
|
||||
const createFormRef = ref<FormInstance>();
|
||||
const importUploadRef = ref<UploadInstance>();
|
||||
const allItems = ref<MonitoringIssueRow[]>([]);
|
||||
const viewIssue = ref<MonitoringIssueRow | null>(null);
|
||||
const sitesLoading = ref(false);
|
||||
@@ -981,14 +978,7 @@ const handleExportExcel = async () => {
|
||||
const contentType = response.headers?.["content-type"] || "application/octet-stream";
|
||||
const filename = getFilename(response.headers?.["content-disposition"]) || "监查访视问题.xlsx";
|
||||
const blob = new Blob([response.data], { type: contentType });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
await saveFile({ suggestedName: filename, mimeType: contentType, data: blob });
|
||||
ElMessage.success("导出成功");
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.downloadFailed);
|
||||
@@ -997,11 +987,9 @@ const handleExportExcel = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const onImportChange = async (uploadFile: UploadFile) => {
|
||||
const importIssueFile = async (file: File) => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId || !canCreateIssue.value) return;
|
||||
const file = uploadFile.raw;
|
||||
if (!file) return;
|
||||
|
||||
importing.value = true;
|
||||
try {
|
||||
@@ -1018,10 +1006,18 @@ const onImportChange = async (uploadFile: UploadFile) => {
|
||||
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.uploadFailed);
|
||||
} finally {
|
||||
importing.value = false;
|
||||
importUploadRef.value?.clearFiles();
|
||||
}
|
||||
};
|
||||
|
||||
const selectImportFile = async () => {
|
||||
const [file] = await pickFiles({
|
||||
multiple: false,
|
||||
accept: ["xlsx", "csv"],
|
||||
title: "导入监查访视问题",
|
||||
});
|
||||
if (file) await importIssueFile(file);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => study.currentStudy?.id,
|
||||
(studyId) => {
|
||||
|
||||
Reference in New Issue
Block a user