feat(desktop): implement phase 2 native capabilities
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled

This commit is contained in:
Cheng Zhou
2026-06-30 21:21:55 +08:00
parent 7c721d4e5c
commit 628ff8828b
64 changed files with 3516 additions and 222 deletions
@@ -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)
+1 -4
View File
@@ -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
)
+19 -1
View File
@@ -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),
+2 -1
View File
@@ -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"])
+12
View File
@@ -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()
]
+4
View File
@@ -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
View File
@@ -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(
+28
View File
@@ -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()
+18 -1
View File
@@ -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)),
+6 -13
View File
@@ -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` | 认证/会话数据(非业务主数据) |
+160
View File
@@ -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 存入系统凭据库:
- macOSKeychain。
- WindowsCredential 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 会话、文件入口、通知权限、重复启动聚焦和签名更新链路完成端到端验证。
+5 -2
View File
@@ -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` 后,第二阶段工作不得继续把它作为长期桌面主线。
## 每次开发前必须执行的检查
+55 -3
View File
@@ -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.
+61
View File
@@ -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",
+6
View File
@@ -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",
+1377 -7
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -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"]
+21 -1
View File
@@ -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/**" }
]
}
]
}
+114
View File
@@ -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());
}
}
+25
View File
@@ -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");
}
+116
View File
@@ -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());
}
}
+8 -2
View File
@@ -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="
}
}
}
+4
View File
@@ -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>
+5
View File
@@ -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",
});
+8
View File
@@ -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;
+7 -1
View File
@@ -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;
+6 -4
View File
@@ -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) {
+45
View File
@@ -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,
});
+2
View File
@@ -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,
});
};
+4 -4
View File
@@ -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(
+21 -1
View File
@@ -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>
+35 -5
View File
@@ -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;
+7 -1
View File
@@ -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);
+1 -1
View File
@@ -527,7 +527,7 @@ router.beforeEach(async (to, _from, next) => {
await auth.fetchMe();
} catch {
// 有 token 但无法获取用户,强制回登录,避免进入“无用户上下文”页面
auth.logout();
await auth.logout();
next({ path: "/login" });
return;
}
+11
View File
@@ -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,
};
};
+32 -4
View File
@@ -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(),
}),
};
+145
View File
@@ -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 });
};
+29 -1
View File
@@ -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";
+25
View File
@@ -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;
};
+39
View File
@@ -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;
};
+14 -14
View File
@@ -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 };
+1 -1
View File
@@ -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);
+3 -3
View File
@@ -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();
};
+5
View File
@@ -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;
+4
View File
@@ -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;
}
+4 -8
View File
@@ -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();
+3 -3
View File
@@ -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");
+128 -33
View File
@@ -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 () => {
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) => {
ElMessage.error(err?.response?.data?.message || TEXT.modules.profile.avatarUploadFailed);
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);
} 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;
+7 -4
View File
@@ -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(() => {
+7 -9
View File
@@ -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) => {
+29 -13
View File
@@ -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>
<el-icon><Upload /></el-icon>
<span>导入问题</span>
</el-button>
</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) => {