Compare commits
13 Commits
1dc10f569d
...
400c9be3a7
| Author | SHA1 | Date | |
|---|---|---|---|
| 400c9be3a7 | |||
| 5ea9b24912 | |||
| aed628a49b | |||
| 0bbc125806 | |||
| 878d1dc701 | |||
| 0c187a35fa | |||
| fd4208d06f | |||
| 0aceb1fd74 | |||
| 141fea674f | |||
| 6b980f884a | |||
| 0841eb0f79 | |||
| f4844aa8f1 | |||
| b03e30d0dd |
@@ -6,6 +6,8 @@ on:
|
||||
- "AGENTS.md"
|
||||
- "frontend/**"
|
||||
- ".github/workflows/client-quality-gates.yml"
|
||||
- ".github/workflows/desktop-release-candidate.yml"
|
||||
- ".github/workflows/desktop-windows-internal.yml"
|
||||
- "docs/branch-governance.md"
|
||||
- "docs/desktop-project-plan.md"
|
||||
- "docs/desktop-phase-1-design.md"
|
||||
@@ -24,6 +26,8 @@ on:
|
||||
- "AGENTS.md"
|
||||
- "frontend/**"
|
||||
- ".github/workflows/client-quality-gates.yml"
|
||||
- ".github/workflows/desktop-release-candidate.yml"
|
||||
- ".github/workflows/desktop-windows-internal.yml"
|
||||
- "docs/branch-governance.md"
|
||||
- "docs/desktop-project-plan.md"
|
||||
- "docs/desktop-phase-1-design.md"
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
name: Desktop Windows Internal Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: desktop-windows-internal-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
windows-internal:
|
||||
name: Windows NSIS internal validation
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Resolve internal build metadata
|
||||
id: build-metadata
|
||||
shell: pwsh
|
||||
run: |
|
||||
if ($env:GITHUB_REF_TYPE -eq "tag") {
|
||||
throw "Windows internal validation builds must run from a branch, not a release tag."
|
||||
}
|
||||
|
||||
$channel = "dev"
|
||||
if ($env:GITHUB_REF_NAME -in @("dev", "main", "release")) {
|
||||
$channel = $env:GITHUB_REF_NAME
|
||||
}
|
||||
|
||||
"channel=$channel" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
|
||||
"commit=$env:GITHUB_SHA" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Check release build metadata
|
||||
run: npm run release:env:check
|
||||
env:
|
||||
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
|
||||
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
|
||||
|
||||
- name: Check synchronized client version
|
||||
run: npm run version:check
|
||||
|
||||
- name: Check runtime boundary
|
||||
run: npm run runtime:check
|
||||
|
||||
- name: Check desktop release and security gate
|
||||
run: npm run desktop:release:check
|
||||
|
||||
- name: Check UI contract
|
||||
run: npm run ui:contract
|
||||
|
||||
- name: Type check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Unit tests
|
||||
run: npm run test:unit
|
||||
|
||||
- name: Build Web artifact
|
||||
run: npm run build
|
||||
env:
|
||||
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
|
||||
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
|
||||
|
||||
- name: Write Windows internal Tauri config
|
||||
shell: pwsh
|
||||
run: |
|
||||
$config = @{
|
||||
bundle = @{
|
||||
createUpdaterArtifacts = $false
|
||||
}
|
||||
} | ConvertTo-Json -Depth 8
|
||||
Set-Content -Path "tauri.windows.internal.conf.json" -Value $config -Encoding utf8
|
||||
Get-Content "tauri.windows.internal.conf.json"
|
||||
|
||||
- name: Build unsigned Windows NSIS internal installer
|
||||
timeout-minutes: 30
|
||||
run: npm run desktop:build -- --config tauri.windows.internal.conf.json --bundles nsis --ci
|
||||
env:
|
||||
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
|
||||
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
|
||||
|
||||
- name: Create Windows installer checksum manifest
|
||||
shell: pwsh
|
||||
run: |
|
||||
$bundleDir = "src-tauri/target/release/bundle/nsis"
|
||||
$installers = Get-ChildItem -Path $bundleDir -Filter "*.exe" -File
|
||||
if ($installers.Count -eq 0) {
|
||||
throw "No Windows NSIS EXE installer was produced."
|
||||
}
|
||||
|
||||
$checksums = foreach ($installer in $installers) {
|
||||
$hash = Get-FileHash -Path $installer.FullName -Algorithm SHA256
|
||||
"$($hash.Hash.ToLowerInvariant()) $($installer.Name)"
|
||||
}
|
||||
|
||||
$manifest = Join-Path $bundleDir "SHA256SUMS.txt"
|
||||
$checksums | Set-Content -Path $manifest -Encoding utf8
|
||||
Get-Content $manifest
|
||||
|
||||
- name: Upload Windows internal installer
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ctms-windows-internal-${{ steps.build-metadata.outputs.channel }}-${{ steps.build-metadata.outputs.commit }}
|
||||
path: |
|
||||
frontend/src-tauri/target/release/bundle/nsis/*.exe
|
||||
frontend/src-tauri/target/release/bundle/nsis/SHA256SUMS.txt
|
||||
if-no-files-found: error
|
||||
@@ -55,8 +55,6 @@ frontend/dist/
|
||||
frontend/.vite/
|
||||
frontend/src-tauri/target/
|
||||
frontend/src-tauri/gen/schemas/
|
||||
frontend/src-tauri/icons/android/
|
||||
frontend/src-tauri/icons/ios/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
# Agent Instructions
|
||||
|
||||
处理 CTMS 桌面端任务前,必须先阅读 `docs/desktop-project-plan.md`。桌面端任务包括但不限于 Tauri、macOS、Windows、桌面打包、桌面存储、文件集成、系统通知和桌面端安全边界。
|
||||
桌面端项目计划书已完成并移除;当前桌面端执行约束以本文件为准。桌面端任务包括但不限于 Tauri、macOS、Windows、桌面打包、桌面存储、文件集成、系统通知、自动更新和桌面端安全边界。历史设计只作追溯参考,见 `docs/desktop-phase-1-design.md`、`docs/desktop-phase-2-design.md`、`docs/audits/desktop-release-stabilization-checklist.md`。
|
||||
|
||||
桌面端当前阶段边界以 `docs/desktop-project-plan.md` 为准。除非先明确修改计划书,否则不要实现离线功能、本地业务数据存储、内嵌后端服务、离线同步或新的阶段性桌面产品线。
|
||||
桌面端当前不是空白初始化项目。Tauri 基线、macOS 在线桌面壳和第二阶段原生能力主体已经形成;后续工作限于修复、稳定化、体验收口、发布准备、在线辅助缓存和 Windows 兼容验证。不得重新按第一阶段空白项目初始化 Tauri,不得绕过现有 `frontend/src/runtime/` 适配层直接在业务模块中使用 Tauri API。
|
||||
|
||||
`docs/desktop-project-plan.md` 是当前桌面端进展和工作边界的事实来源。本文件只保留执行约束,不重复维护审查结论或优化方向;不得重新按第一阶段空白项目初始化 Tauri,也不得绕过现有 `frontend/src/runtime/` 适配层直接在业务模块中使用 Tauri API。如实际代码状态与计划书不一致,先更新计划书再实现。
|
||||
除非用户明确要求先调整本文件中的约束,否则不要实现离线功能、本地业务权威数据存储、内嵌后端服务、离线同步、本地优先工作流或新的阶段性桌面产品线。在线辅助本地缓存只允许作为已认证在线客户端的体验加速能力;处理桌面本地缓存、请求去重、条件请求、缓存诊断或缓存清理相关任务时,必须阅读并遵守 `docs/desktop-local-cache-plan.md`。
|
||||
|
||||
处理前端或桌面端实现时,优先保持以下边界:
|
||||
|
||||
- 共享业务代码通过 `frontend/src/runtime/index.ts` 获取平台能力。
|
||||
- Tauri API 仅允许出现在 `frontend/src/runtime/`、`frontend/src-tauri/` 或有明确记录的窄入口中。
|
||||
- 新增或调整 Tauri command、capability、CSP、updater、凭据、文件、通知能力时,必须同步评估 `frontend/scripts/verify-desktop-release.mjs`、`npm run runtime:check` 和桌面发布检查清单是否需要更新。
|
||||
- 本地缓存能力只能通过 `frontend/src/runtime/desktopDataCache.ts`、`frontend/src/runtime/index.ts`、`clientRuntime.dataCache` 和 API 客户端的受控入口暴露;业务模块不得直接使用 Tauri 存储 API、文件系统、SQLite、IndexedDB 或 Cache Storage 实现缓存。
|
||||
- 未完成后端 token 校验和 `/me` 身份确认前,不得展示业务缓存;登出、切换服务器、切换用户、401/403、权限上下文变化或缓存 schema 变化时必须清理或失效相关缓存。
|
||||
- 新增或调整 Tauri command、capability、CSP、updater、凭据、文件、通知、本地缓存持久化或底层存储能力时,必须同步评估 `frontend/scripts/verify-desktop-release.mjs`、`npm run runtime:check` 和桌面发布检查清单是否需要更新。
|
||||
- token、附件下载凭据和敏感业务信息不得写入 URL、日志、系统通知正文或明文浏览器存储。
|
||||
- Windows 仍只作为第二阶段兼容性验证目标;未获明确批准前不发布正式 Windows 安装包。
|
||||
- Windows 仍只作为第二阶段兼容性验证目标;未获明确批准前不发布正式 Windows 安装包。Windows 内测构建只能使用 `.github/workflows/desktop-windows-internal.yml` 的手动验证入口,不得生成生产 `latest.json`、updater feed 或正式发布制品。
|
||||
|
||||
## 分支与发布治理
|
||||
|
||||
@@ -53,4 +55,6 @@ npm run build
|
||||
npm run desktop:build:app
|
||||
```
|
||||
|
||||
本地缓存相关变更至少执行 `runtime:check`、`desktop:release:check`、`ui:contract`、`type-check`、`test:unit` 和 `build`;若新增 Tauri command、capability、CSP 或底层持久化存储,还需执行 `desktop:build:app` 并在真实桌面 App 中验证缓存清理和诊断入口。
|
||||
|
||||
正式桌面发布构建仍必须使用组织批准的 updater 签名私钥和 Apple 签名/公证流程;未签名或 ad-hoc 构建只能作为内部验证构建描述。
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
- `nginx` 负责托管前端静态资源,并将 `/api` 与 `/health` 转发到 `backend`
|
||||
|
||||
## macOS 桌面端开发
|
||||
- 桌面端遵循 `docs/desktop-project-plan.md` 第一阶段边界:Tauri 在线客户端,不内嵌后端、不保存本地业务数据、不做离线同步。
|
||||
- 桌面端约束集中在 `AGENTS.md`:Tauri 在线客户端,不内嵌后端、不保存本地业务权威数据、不做离线同步;在线辅助缓存必须遵守 `docs/desktop-local-cache-plan.md`。
|
||||
- 开发启动:进入 `frontend/` 后执行 `npm run desktop:dev`。
|
||||
- 生产构建:进入 `frontend/` 后执行 `npm run desktop:build`;DMG 构建执行 `npm run desktop:bundle:dmg`。
|
||||
- 首次启动桌面端会要求配置 CTMS 服务端地址,并在保存前检查 `${serverUrl}/health`。
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""add access context to audit logs
|
||||
|
||||
Revision ID: 20260709_01
|
||||
Revises: 20260630_02
|
||||
Create Date: 2026-07-09 10:00:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "20260709_01"
|
||||
down_revision: Union[str, None] = "20260630_02"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("audit_logs", sa.Column("client_ip", sa.String(45), nullable=True))
|
||||
op.add_column("audit_logs", sa.Column("user_agent", sa.String(500), nullable=True))
|
||||
op.add_column("audit_logs", sa.Column("client_type", sa.String(16), nullable=True))
|
||||
op.add_column("audit_logs", sa.Column("client_version", sa.String(32), nullable=True))
|
||||
op.add_column("audit_logs", sa.Column("client_platform", sa.String(16), nullable=True))
|
||||
op.add_column("audit_logs", sa.Column("build_channel", sa.String(16), nullable=True))
|
||||
op.add_column("audit_logs", sa.Column("build_commit", sa.String(64), nullable=True))
|
||||
op.create_index("ix_audit_logs_operator_created", "audit_logs", ["operator_id", "created_at"])
|
||||
op.create_index("ix_audit_logs_client_ip_created", "audit_logs", ["client_ip", "created_at"])
|
||||
op.create_index("ix_audit_logs_client_source_created", "audit_logs", ["client_type", "created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_audit_logs_client_source_created", table_name="audit_logs")
|
||||
op.drop_index("ix_audit_logs_client_ip_created", table_name="audit_logs")
|
||||
op.drop_index("ix_audit_logs_operator_created", table_name="audit_logs")
|
||||
for column in (
|
||||
"build_commit",
|
||||
"build_channel",
|
||||
"client_platform",
|
||||
"client_version",
|
||||
"client_type",
|
||||
"user_agent",
|
||||
"client_ip",
|
||||
):
|
||||
op.drop_column("audit_logs", column)
|
||||
@@ -1,13 +1,18 @@
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_api_permission
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.models.user import User
|
||||
from app.schemas.audit import AuditEventCreate, AuditLogRead
|
||||
from app.services.ip_location import resolve_ip_location
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -40,6 +45,10 @@ async def list_audit_logs(
|
||||
entity_id: uuid.UUID | None = None,
|
||||
action: str | None = None,
|
||||
operator_id: uuid.UUID | None = None,
|
||||
client_ip: str | None = None,
|
||||
client_type: str | None = None,
|
||||
start_time: datetime | None = None,
|
||||
end_time: datetime | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
@@ -51,10 +60,51 @@ async def list_audit_logs(
|
||||
entity_id=entity_id,
|
||||
action=action,
|
||||
operator_id=operator_id,
|
||||
client_ip=client_ip,
|
||||
client_type=client_type,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
return list(logs)
|
||||
operator_ids = {log.operator_id for log in logs}
|
||||
operator_map: dict[uuid.UUID, User] = {}
|
||||
if operator_ids:
|
||||
result = await db.execute(select(User).where(User.id.in_(operator_ids)))
|
||||
operator_map = {user.id: user for user in result.scalars().all()}
|
||||
|
||||
items: list[AuditLogRead] = []
|
||||
for log in logs:
|
||||
ip_location = resolve_ip_location(log.client_ip)
|
||||
operator = operator_map.get(log.operator_id)
|
||||
items.append(
|
||||
AuditLogRead(
|
||||
id=log.id,
|
||||
study_id=log.study_id,
|
||||
entity_type=log.entity_type,
|
||||
entity_id=log.entity_id,
|
||||
action=log.action,
|
||||
detail=log.detail,
|
||||
operator_id=log.operator_id,
|
||||
operator_name=operator.full_name if operator else None,
|
||||
operator_email=operator.email if operator else None,
|
||||
operator_role=log.operator_role,
|
||||
client_ip=log.client_ip,
|
||||
ip_location=ip_location.location,
|
||||
ip_country=ip_location.country,
|
||||
ip_province=ip_location.province,
|
||||
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,
|
||||
created_at=log.created_at,
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
@router.post(
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Request-scoped metadata used by server-side audit writers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from contextvars import ContextVar, Token
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequestAuditContext:
|
||||
client_ip: str | None = None
|
||||
user_agent: str | None = None
|
||||
client_type: str | None = None
|
||||
client_version: str | None = None
|
||||
client_platform: str | None = None
|
||||
build_channel: str | None = None
|
||||
build_commit: str | None = None
|
||||
|
||||
|
||||
_request_audit_context: ContextVar[RequestAuditContext | None] = ContextVar(
|
||||
"request_audit_context",
|
||||
default=None,
|
||||
)
|
||||
|
||||
_SENSITIVE_TEXT_PATTERN = re.compile(
|
||||
r"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]+|((?:access_)?token|authorization)=([^&\s]+)"
|
||||
)
|
||||
|
||||
|
||||
def _clean_header_value(value: str | None, max_length: int) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
cleaned = _SENSITIVE_TEXT_PATTERN.sub(lambda m: f"{m.group(1) or m.group(2) + '='}[redacted]", value.strip())
|
||||
if not cleaned:
|
||||
return None
|
||||
return cleaned[:max_length]
|
||||
|
||||
|
||||
def resolve_client_ip(request: Any) -> str | None:
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return _clean_header_value(forwarded.split(",")[0].strip(), 45)
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
return _clean_header_value(real_ip.strip(), 45)
|
||||
return _clean_header_value(request.client.host if request.client else None, 45)
|
||||
|
||||
|
||||
def build_request_audit_context(request: Any) -> RequestAuditContext:
|
||||
headers = request.headers
|
||||
return RequestAuditContext(
|
||||
client_ip=resolve_client_ip(request),
|
||||
user_agent=_clean_header_value(headers.get("user-agent"), 500),
|
||||
client_type=_clean_header_value(headers.get("x-ctms-client-type"), 16),
|
||||
client_version=_clean_header_value(headers.get("x-ctms-client-version"), 32),
|
||||
client_platform=_clean_header_value(headers.get("x-ctms-client-platform"), 16),
|
||||
build_channel=_clean_header_value(headers.get("x-ctms-build-channel"), 16),
|
||||
build_commit=_clean_header_value(headers.get("x-ctms-build-commit"), 64),
|
||||
)
|
||||
|
||||
|
||||
def set_request_audit_context(context: RequestAuditContext) -> Token[RequestAuditContext | None]:
|
||||
return _request_audit_context.set(context)
|
||||
|
||||
|
||||
def reset_request_audit_context(token: Token[RequestAuditContext | None]) -> None:
|
||||
_request_audit_context.reset(token)
|
||||
|
||||
|
||||
def get_request_audit_context() -> RequestAuditContext | None:
|
||||
return _request_audit_context.get()
|
||||
@@ -4,8 +4,10 @@ import uuid
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.request_context import get_request_audit_context
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
|
||||
@@ -19,8 +21,16 @@ async def log_action(
|
||||
detail: str | None,
|
||||
operator_id: uuid.UUID,
|
||||
operator_role: str,
|
||||
client_ip: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
client_type: str | None = None,
|
||||
client_version: str | None = None,
|
||||
client_platform: str | None = None,
|
||||
build_channel: str | None = None,
|
||||
build_commit: str | None = None,
|
||||
auto_commit: bool = True,
|
||||
) -> AuditLog:
|
||||
request_context = get_request_audit_context()
|
||||
log = AuditLog(
|
||||
study_id=study_id,
|
||||
entity_type=entity_type,
|
||||
@@ -29,6 +39,13 @@ async def log_action(
|
||||
detail=detail,
|
||||
operator_id=operator_id,
|
||||
operator_role=operator_role,
|
||||
client_ip=client_ip if client_ip is not None else (request_context.client_ip if request_context else None),
|
||||
user_agent=user_agent if user_agent is not None else (request_context.user_agent if request_context else None),
|
||||
client_type=client_type if client_type is not None else (request_context.client_type if request_context else None),
|
||||
client_version=client_version if client_version is not None else (request_context.client_version if request_context else None),
|
||||
client_platform=client_platform if client_platform is not None else (request_context.client_platform if request_context else None),
|
||||
build_channel=build_channel if build_channel is not None else (request_context.build_channel if request_context else None),
|
||||
build_commit=build_commit if build_commit is not None else (request_context.build_commit if request_context else None),
|
||||
)
|
||||
db.add(log)
|
||||
if auto_commit:
|
||||
@@ -47,6 +64,10 @@ async def list_logs(
|
||||
entity_id: uuid.UUID | None = None,
|
||||
action: str | None = None,
|
||||
operator_id: uuid.UUID | None = None,
|
||||
client_ip: str | None = None,
|
||||
client_type: str | None = None,
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> Sequence[AuditLog]:
|
||||
@@ -59,6 +80,24 @@ async def list_logs(
|
||||
stmt = stmt.where(AuditLog.action == action)
|
||||
if operator_id:
|
||||
stmt = stmt.where(AuditLog.operator_id == operator_id)
|
||||
if client_ip:
|
||||
stmt = stmt.where(AuditLog.client_ip.ilike(f"%{client_ip.strip()}%"))
|
||||
if client_type:
|
||||
normalized_client_type = client_type.strip().lower()
|
||||
if normalized_client_type == "unknown":
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
AuditLog.client_type.is_(None),
|
||||
AuditLog.client_type == "",
|
||||
~AuditLog.client_type.in_(("web", "desktop")),
|
||||
)
|
||||
)
|
||||
else:
|
||||
stmt = stmt.where(AuditLog.client_type == normalized_client_type)
|
||||
if start_time:
|
||||
stmt = stmt.where(AuditLog.created_at >= start_time)
|
||||
if end_time:
|
||||
stmt = stmt.where(AuditLog.created_at <= end_time)
|
||||
stmt = stmt.order_by(AuditLog.created_at.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -25,6 +25,12 @@ from app.services.security_access_log_writer import (
|
||||
stop_security_log_writer,
|
||||
)
|
||||
from app.core.security import decode_token
|
||||
from app.core.request_context import (
|
||||
build_request_audit_context,
|
||||
reset_request_audit_context,
|
||||
resolve_client_ip,
|
||||
set_request_audit_context,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("ctms.setup_config")
|
||||
UUID_RE = re.compile(
|
||||
@@ -137,60 +143,65 @@ def create_app() -> FastAPI:
|
||||
is_setup_config_path = "/api/v1/studies/" in path and "/setup-config" in path
|
||||
should_security_log = path.startswith("/api/")
|
||||
started_at = time.perf_counter()
|
||||
audit_context = build_request_audit_context(request)
|
||||
audit_context_token = set_request_audit_context(audit_context)
|
||||
status_code = 500
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
if is_setup_config_path:
|
||||
normalized_path = UUID_RE.sub("{study_id}", path)
|
||||
duration_ms = int((time.perf_counter() - started_at) * 1000)
|
||||
key = f"{request.method} {normalized_path} 5xx"
|
||||
setup_config_stats[key] += 1
|
||||
logger.exception(
|
||||
"setup_config_request_error method=%s path=%s status=%s duration_ms=%s",
|
||||
request.method,
|
||||
normalized_path,
|
||||
500,
|
||||
duration_ms,
|
||||
)
|
||||
if should_security_log:
|
||||
_enqueue_security_access_log(request, path, status_code, started_at)
|
||||
raise
|
||||
|
||||
status_code = int(response.status_code)
|
||||
if is_setup_config_path:
|
||||
normalized_path = UUID_RE.sub("{study_id}", path)
|
||||
duration_ms = int((time.perf_counter() - started_at) * 1000)
|
||||
key = f"{request.method} {normalized_path} 5xx"
|
||||
status_bucket = f"{status_code // 100}xx"
|
||||
key = f"{request.method} {normalized_path} {status_bucket}"
|
||||
setup_config_stats[key] += 1
|
||||
logger.exception(
|
||||
"setup_config_request_error method=%s path=%s status=%s duration_ms=%s",
|
||||
request.method,
|
||||
normalized_path,
|
||||
500,
|
||||
duration_ms,
|
||||
)
|
||||
if status_code >= 500:
|
||||
logger.error(
|
||||
"setup_config_request method=%s path=%s status=%s duration_ms=%s",
|
||||
request.method,
|
||||
normalized_path,
|
||||
status_code,
|
||||
duration_ms,
|
||||
)
|
||||
elif status_code >= 400:
|
||||
logger.warning(
|
||||
"setup_config_request method=%s path=%s status=%s duration_ms=%s",
|
||||
request.method,
|
||||
normalized_path,
|
||||
status_code,
|
||||
duration_ms,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"setup_config_request method=%s path=%s status=%s duration_ms=%s",
|
||||
request.method,
|
||||
normalized_path,
|
||||
status_code,
|
||||
duration_ms,
|
||||
)
|
||||
if should_security_log:
|
||||
_enqueue_security_access_log(request, path, status_code, started_at)
|
||||
raise
|
||||
|
||||
status_code = int(response.status_code)
|
||||
if is_setup_config_path:
|
||||
normalized_path = UUID_RE.sub("{study_id}", path)
|
||||
duration_ms = int((time.perf_counter() - started_at) * 1000)
|
||||
status_bucket = f"{status_code // 100}xx"
|
||||
key = f"{request.method} {normalized_path} {status_bucket}"
|
||||
setup_config_stats[key] += 1
|
||||
if status_code >= 500:
|
||||
logger.error(
|
||||
"setup_config_request method=%s path=%s status=%s duration_ms=%s",
|
||||
request.method,
|
||||
normalized_path,
|
||||
status_code,
|
||||
duration_ms,
|
||||
)
|
||||
elif status_code >= 400:
|
||||
logger.warning(
|
||||
"setup_config_request method=%s path=%s status=%s duration_ms=%s",
|
||||
request.method,
|
||||
normalized_path,
|
||||
status_code,
|
||||
duration_ms,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"setup_config_request method=%s path=%s status=%s duration_ms=%s",
|
||||
request.method,
|
||||
normalized_path,
|
||||
status_code,
|
||||
duration_ms,
|
||||
)
|
||||
if should_security_log:
|
||||
_enqueue_security_access_log(request, path, status_code, started_at)
|
||||
return response
|
||||
return response
|
||||
finally:
|
||||
reset_request_audit_context(audit_context_token)
|
||||
|
||||
register_exception_handlers(app)
|
||||
|
||||
@@ -240,16 +251,6 @@ def create_app() -> FastAPI:
|
||||
app = create_app()
|
||||
|
||||
|
||||
def _resolve_client_ip(request) -> str | None:
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
return request.client.host if request.client else None
|
||||
|
||||
|
||||
def _resolve_auth_context(request) -> tuple[str, str | None]:
|
||||
authorization = request.headers.get("authorization") or ""
|
||||
if not authorization.lower().startswith("bearer "):
|
||||
@@ -276,7 +277,7 @@ def _enqueue_security_access_log(request, path: str, status_code: int, started_a
|
||||
"path": path,
|
||||
"status_code": status_code,
|
||||
"elapsed_ms": round((time.perf_counter() - started_at) * 1000, 2),
|
||||
"client_ip": _resolve_client_ip(request),
|
||||
"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"),
|
||||
|
||||
@@ -13,7 +13,12 @@ from app.db.base_class import Base
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
__table_args__ = (Index("ix_audit_logs_entity_at", "entity_type", "entity_id", "created_at"),)
|
||||
__table_args__ = (
|
||||
Index("ix_audit_logs_entity_at", "entity_type", "entity_id", "created_at"),
|
||||
Index("ix_audit_logs_operator_created", "operator_id", "created_at"),
|
||||
Index("ix_audit_logs_client_ip_created", "client_ip", "created_at"),
|
||||
Index("ix_audit_logs_client_source_created", "client_type", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
study_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=True)
|
||||
@@ -23,4 +28,11 @@ class AuditLog(Base):
|
||||
detail: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
operator_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
operator_role: Mapped[str] = mapped_column(String(50), 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)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
|
||||
@@ -7,10 +7,27 @@ from pydantic import BaseModel, ConfigDict
|
||||
|
||||
class AuditLogRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
study_id: Optional[uuid.UUID] = None
|
||||
entity_type: str
|
||||
entity_id: Optional[uuid.UUID] = None
|
||||
action: str
|
||||
detail: Optional[str]
|
||||
operator_id: uuid.UUID
|
||||
operator_name: Optional[str] = None
|
||||
operator_email: Optional[str] = None
|
||||
operator_role: str
|
||||
client_ip: Optional[str] = None
|
||||
ip_location: str = ""
|
||||
ip_country: str = ""
|
||||
ip_province: str = ""
|
||||
ip_city: str = ""
|
||||
ip_isp: str = ""
|
||||
user_agent: Optional[str] = None
|
||||
client_type: Optional[str] = None
|
||||
client_version: Optional[str] = None
|
||||
client_platform: Optional[str] = None
|
||||
build_channel: Optional[str] = None
|
||||
build_commit: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.request_context import (
|
||||
RequestAuditContext,
|
||||
reset_request_audit_context,
|
||||
set_request_audit_context,
|
||||
)
|
||||
from app.crud.audit import log_action
|
||||
|
||||
|
||||
class FakeAsyncSession:
|
||||
def __init__(self) -> None:
|
||||
self.added = None
|
||||
self.flushed = False
|
||||
|
||||
def add(self, value) -> None:
|
||||
self.added = value
|
||||
|
||||
async def flush(self) -> None:
|
||||
self.flushed = True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audit_log_action_captures_request_access_context():
|
||||
context_token = set_request_audit_context(
|
||||
RequestAuditContext(
|
||||
client_ip="203.0.113.10",
|
||||
user_agent="curl/8.1.2",
|
||||
client_type="desktop",
|
||||
client_version="1.2.3",
|
||||
client_platform="macos",
|
||||
build_channel="release",
|
||||
build_commit="abcdef1",
|
||||
)
|
||||
)
|
||||
db = FakeAsyncSession()
|
||||
try:
|
||||
log = await log_action(
|
||||
db,
|
||||
study_id=uuid.uuid4(),
|
||||
entity_type="subject",
|
||||
entity_id=uuid.uuid4(),
|
||||
action="UPDATE_SUBJECT",
|
||||
detail=None,
|
||||
operator_id=uuid.uuid4(),
|
||||
operator_role="PM",
|
||||
auto_commit=False,
|
||||
)
|
||||
finally:
|
||||
reset_request_audit_context(context_token)
|
||||
|
||||
assert db.added is log
|
||||
assert db.flushed is True
|
||||
assert log.client_ip == "203.0.113.10"
|
||||
assert log.user_agent == "curl/8.1.2"
|
||||
assert log.client_type == "desktop"
|
||||
assert log.client_version == "1.2.3"
|
||||
assert log.client_platform == "macos"
|
||||
assert log.build_channel == "release"
|
||||
assert log.build_commit == "abcdef1"
|
||||
@@ -455,6 +455,13 @@ CREATE TABLE IF NOT EXISTS public.audit_logs (
|
||||
detail text,
|
||||
operator_id uuid NOT NULL,
|
||||
operator_role character varying(50) NOT NULL,
|
||||
client_ip character varying(45),
|
||||
user_agent character varying(500),
|
||||
client_type character varying(16),
|
||||
client_version character varying(32),
|
||||
client_platform character varying(16),
|
||||
build_channel character varying(16),
|
||||
build_commit character varying(64),
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
CONSTRAINT fk_audit_logs_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_audit_logs_operator_id FOREIGN KEY (operator_id) REFERENCES public.users(id) ON DELETE RESTRICT
|
||||
@@ -950,6 +957,9 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_document_versions_pending ON public.documen
|
||||
CREATE INDEX IF NOT EXISTS ix_distributions_version_target ON public.distributions (version_id, target_type, target_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_acknowledgements_distribution_type ON public.acknowledgements (distribution_id, ack_type);
|
||||
CREATE INDEX IF NOT EXISTS ix_audit_logs_entity_at ON public.audit_logs (entity_type, entity_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS ix_audit_logs_operator_created ON public.audit_logs (operator_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS ix_audit_logs_client_ip_created ON public.audit_logs (client_ip, created_at);
|
||||
CREATE INDEX IF NOT EXISTS ix_audit_logs_client_source_created ON public.audit_logs (client_type, created_at);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
|
||||
@@ -4,8 +4,9 @@ CTMS 文档入口只展示当前仍会影响开发、发布和运维决策的内
|
||||
|
||||
## 当前约束
|
||||
|
||||
- [`desktop-project-plan.md`](desktop-project-plan.md): 桌面端 Tauri 项目边界、阶段计划与必读约束
|
||||
- [`../AGENTS.md`](../AGENTS.md): Agent 执行约束、桌面端当前边界、分支治理和常用质量门禁
|
||||
- [`desktop-phase-1-design.md`](desktop-phase-1-design.md): 桌面端第一阶段 macOS 在线客户端详细方案
|
||||
- [`desktop-local-cache-plan.md`](desktop-local-cache-plan.md): 桌面端在线辅助缓存、请求去重、失效和后续持久化缓存边界
|
||||
- [`branch-governance.md`](branch-governance.md): 长期分支治理规则
|
||||
- [`guides/release-checklist.md`](guides/release-checklist.md): 发布前检查项与回归门禁
|
||||
- [`guides/client-release.md`](guides/client-release.md): Web/桌面端统一版本、构建与发布流程
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
# CTMS 桌面端项目计划书
|
||||
|
||||
## 目的
|
||||
|
||||
本文档是 CTMS 桌面端工作的长期方向约束。每次开始任何桌面端相关任务前,都必须先阅读本文档,包括 Tauri 初始化、macOS 打包、Windows 适配、桌面端存储、文件集成、系统通知、安全边界和发布流程。
|
||||
|
||||
桌面端必须服务于现有 CTMS Web 应用和后端架构。目标是为当前 CTMS 服务提供原生桌面入口,而不是创建一个独立的离线产品。
|
||||
|
||||
## 不可突破的边界
|
||||
|
||||
- 技术路线固定为 Tauri。
|
||||
- 第一开发目标是 macOS 桌面端。
|
||||
- Windows 仍只作为第二阶段兼容性验证目标,未获明确批准前不发布正式安装包。
|
||||
- 当前桌面端工作只允许在第一、二阶段边界内做修复、稳定化、体验收口、发布准备,以及本文档明确批准的在线辅助本地缓存;不新增独立第三阶段桌面产品线。
|
||||
- 不做离线登录、离线写入、离线同步或本地优先工作流;本地缓存只作为已认证在线客户端的体验加速能力。
|
||||
- 不在桌面 App 内嵌本地后端服务。
|
||||
- 允许桌面端保存可清除的服务端响应缓存;缓存不是业务权威数据,不得替代后端持久化、权限裁决或审计判断。
|
||||
- 不实现离线同步、冲突解决、本地业务数据队列、本地优先工作流。
|
||||
- 不把 CTMS 业务 UI 拆成一套独立的桌面端产品;除非桌面能力确实需要小范围适配层。
|
||||
- FastAPI 后端仍是业务权威来源。权限裁决、审计判断、认证、业务数据持久化都保持在服务端。
|
||||
|
||||
## 当前技术基线
|
||||
|
||||
桌面端工作基于当前 CTMS Web 技术栈:
|
||||
|
||||
- 前端:Vue 3、Vite、TypeScript、Element Plus、Pinia、Vue Router、Axios、ECharts。
|
||||
- 后端:FastAPI、Uvicorn、SQLAlchemy、Alembic。
|
||||
- 数据库:PostgreSQL。
|
||||
- 当前部署:Docker Compose、nginx 托管前端静态资源、nginx 反代 `/api`。
|
||||
|
||||
桌面端应复用现有前端代码和 API 契约。任何共享适配层都应保持小而明确,并可测试。
|
||||
|
||||
## 已完成阶段边界
|
||||
|
||||
第一阶段 macOS 在线桌面壳和第二阶段原生能力主体改造已经形成。详细历史方案见 [`desktop-phase-1-design.md`](desktop-phase-1-design.md) 和 [`desktop-phase-2-design.md`](desktop-phase-2-design.md)。
|
||||
|
||||
后续不再按第一阶段空白项目初始化 Tauri,也不再扩展第二阶段以外的新桌面产品能力。当前允许推进的工作仅包括:
|
||||
|
||||
- 修复既有 Tauri、运行时适配层、文件、通知、凭据、更新、菜单/快捷键和打包问题。
|
||||
- 稳定 Web 与 Desktop 共用业务代码,确保平台差异继续收敛在 `frontend/src/runtime/` 后面。
|
||||
- 按 [`desktop-local-cache-plan.md`](desktop-local-cache-plan.md) 推进在线辅助本地缓存、请求去重、条件请求和缓存失效能力。
|
||||
- 完成 macOS 正式发布前的签名、公证、updater 签名、制品发布、CI 门禁和人工回归。
|
||||
- 做 Windows 第二阶段兼容性验证,但不发布正式 Windows 安装包。
|
||||
|
||||
仍然不允许:
|
||||
|
||||
- 离线登录、离线写入、离线队列、离线同步或本地优先工作流。
|
||||
- 未完成后端会话恢复、token 校验和 `/me` 身份确认前展示业务缓存。
|
||||
- 本地 PostgreSQL、本地 API 镜像、内嵌业务后端,或把 SQLite/IndexedDB/Cache Storage/Tauri app data 目录作为业务数据库使用。
|
||||
- 内嵌 Python/FastAPI 后端服务。
|
||||
- 绕过后端做本地权限裁决、本地审计判定、审计回放或写入补偿。
|
||||
- 为桌面端复制或重写一套独立业务 UI。
|
||||
|
||||
## 架构方向
|
||||
|
||||
使用运行时适配层,不在业务页面里散落平台判断。
|
||||
|
||||
当前已采用并必须继续保持的适配层边界:
|
||||
|
||||
- `platform`:识别 Web、macOS 桌面端、Windows 桌面端。
|
||||
- `apiBaseUrl`:分别解析 Web 和桌面端的服务端 API 地址。
|
||||
- `desktopServerConfig`:管理桌面服务端地址配置和切换事件。
|
||||
- `secureSessionStorage`:隔离浏览器 token 存储与桌面系统凭据库。
|
||||
- 桌面端允许保存后端签发的最长 30 天在线会话,用于重启 App 后免输入密码;该会话必须存放在系统凭据库中,启动后仍需由后端 token 和 `/me` 校验确认身份,不等同于离线登录。
|
||||
- `savedLoginCredentials`:隔离网页端浏览器凭据管理与桌面端系统凭据库中的登录表单密码保存;不得把密码写入 `localStorage`、`sessionStorage`、URL、日志或通知正文,且不等同于离线登录。
|
||||
- `files`:隔离浏览器上传下载与原生文件能力。
|
||||
- `notifications`:隔离 Web 通知与桌面系统通知。
|
||||
- `updates`:隔离桌面自动更新检查与安装入口。
|
||||
- `appMetadata`:在可用时提供桌面 App 版本、平台、构建通道。
|
||||
- `desktopMenu` 和 `desktopUiPreferences`:承接桌面菜单命令、收藏模块等桌面体验状态。
|
||||
- `desktopDataCache`:承接桌面端在线辅助本地缓存、缓存命名空间、失效、清理和诊断;业务模块不得直接使用 Tauri 存储 API、IndexedDB、SQLite 或文件系统实现桌面缓存。
|
||||
- `clientRuntime`:作为业务侧获取平台能力的聚合入口。
|
||||
|
||||
业务模块应调用这些适配层,而不是直接调用 Tauri API。Tauri command 应保持窄职责,不包含 CTMS 业务规则。
|
||||
|
||||
## 安全与合规方向
|
||||
|
||||
- 将桌面端视为受监管业务系统的在线客户端。
|
||||
- 非本地服务连接优先使用 HTTPS。
|
||||
- 认证与授权决策保留在后端。
|
||||
- 审计敏感决策保留在后端。
|
||||
- 敏感凭据必须继续使用明确批准的安全存储方案;网页端密码只能交给浏览器凭据管理能力,桌面端密码只能交给系统凭据库。
|
||||
- token、登录密码、附件下载凭据和临时授权信息不得进入本地业务缓存、URL、日志或系统通知正文。
|
||||
- 本地业务缓存必须按 `server origin + user id + cache schema version` 隔离;登出、切换服务器、切换用户、后端返回 401/403、权限版本变化或缓存结构升级时必须清理或失效。
|
||||
- 本地缓存命中只能用于展示后端曾返回的数据副本;新增、编辑、删除、审批、权限判断和审计判断仍必须请求后端并以后端结果为准。
|
||||
- 不向前端暴露宽泛文件系统访问权限。
|
||||
- Tauri 权限保持最小化,并按功能精确授权。
|
||||
- 每个新增 Tauri command 都需要被视为桌面端安全边界的一部分进行审查。
|
||||
|
||||
## 分支与工作区
|
||||
|
||||
桌面端工作在以下位置开发:
|
||||
|
||||
- Worktree:`/Users/zcc/MyCTMS/ctms-dev/worktrees/ctms-desktop`
|
||||
- 短期分支:Agent 默认使用 `codex/<任务名称>`,也可按任务类型使用 `feature/*`、`fix/*`、`docs/*` 或 `release-prep/*`;所有普通桌面端工作都必须从最新 `dev` 创建
|
||||
|
||||
当前代码状态:Tauri 基线和第二阶段原生能力已经形成,后续桌面端工作默认是第二阶段范围内的修复、稳定化、体验收口和发布准备。`codex/ctms-desktop` 是历史临时集成分支,不再作为当前工作线;不得继续向该分支提交、变基或推送新的桌面端工作,除非用户明确要求做收尾或删除分支。
|
||||
|
||||
## 2026-07-01 审查结论与后续方向
|
||||
|
||||
本轮审查结论:桌面端已经完成从 Tauri 接入基线到第二阶段原生能力的主体改造,不再按空白桌面项目推进。当前重点不是扩展离线或本地业务能力,而是围绕既有在线桌面客户端做准发布稳定化。
|
||||
|
||||
已形成的能力边界:
|
||||
|
||||
- Tauri 工程、macOS App/DMG/updater artifacts 配置已经存在。
|
||||
- `frontend/src/runtime/` 已作为平台能力统一入口,业务代码不得绕过该适配层直接使用 Tauri API。
|
||||
- 桌面服务器地址、API baseURL、客户端元数据请求头、安全 session 存储、原生文件能力、系统通知、单实例、菜单/快捷键和自动更新入口已经形成。
|
||||
- 后端已经包含桌面通知订阅/投递状态、相关 API 和客户端诊断请求头支持。
|
||||
- 已有 `npm run version:check`、`npm run runtime:check`、`npm run desktop:release:check` 等门禁用于约束版本、运行时边界和桌面发布安全边界。
|
||||
|
||||
后续优化优先级:
|
||||
|
||||
1. 发布稳定化:补齐 macOS 签名、公证、组织 updater 私钥签名、release tag 构建变量注入、不可变制品上传和 `latest.json` 原子替换。
|
||||
2. 端到端回归:按 `docs/audits/desktop-release-stabilization-checklist.md` 覆盖服务器配置、服务器切换清会话、Keychain/凭据库、附件上传下载、系统通知、单实例和自动更新失败恢复。
|
||||
3. 安全复审:持续确认 token 不进入 URL、日志、系统通知正文、下载链接或明文持久化;Tauri command、capability、CSP 和 updater 改动必须同步评估发布门禁。
|
||||
4. 桌面体验收口:重点检查登录、服务器设置、个人中心诊断信息、通知开关、更新弹窗和最小窗口 `1180x760` 下的布局稳定性。
|
||||
5. CI 与发布流程:Web 与 Desktop 必须从同一提交、同一语义化版本号和同一正式标签构建;发布候选应执行本文档列出的相关质量门禁。
|
||||
6. Windows 兼容验证:仅作为第二阶段兼容性目标,验证 Credential Manager、路径处理、通知/updater 编译、WebView2 和安装器假设;未获明确批准前不发布正式 Windows 安装包。
|
||||
|
||||
## 2026-07-02 发布稳定化推进记录
|
||||
|
||||
本次推进仍保持第一、二阶段边界,不引入离线、本地业务存储、内嵌后端或独立桌面业务 UI。
|
||||
|
||||
已补齐的发布稳定化自动化:
|
||||
|
||||
- 新增 `npm run desktop:build:macos-release`,用于 macOS Universal `app`/`dmg` 正式候选构建。
|
||||
- 新增 `npm run desktop:update-feed:create`,从签名 updater artifact 和 `.sig` 生成 `latest.json` 与 `SHA256SUMS.txt`,并要求 artifact URL 使用包含当前版本号的 HTTPS 不可变路径。
|
||||
- `npm run desktop:update-feed:check` 在传入 `--artifacts-dir` 时同步校验 `SHA256SUMS.txt`、updater artifact、`.sig` 和 `latest.json`。
|
||||
- 新增 `npm run desktop:release-readiness:check`,用于在进入正式签名候选构建前确认当前提交精确匹配 `vX.Y.Z` tag、构建元数据、签名/公证变量、updater 私钥和生产 artifact HTTPS 基址已经齐备。
|
||||
- 新增 `.github/workflows/desktop-release-candidate.yml`,在 release tag 上执行签名 macOS 候选构建、feed 生成、feed 校验和 GitHub artifact 上传;它不替代人工发布审批和生产下载源原子替换。
|
||||
- `npm run desktop:release:check` 已纳入上述发布候选工作流和脚本存在性检查,防止发布链路门禁被误删。
|
||||
|
||||
仍需正式发布负责人在真实发布环境完成:
|
||||
|
||||
- Apple Developer 签名、公证凭据和组织 updater 私钥配置。
|
||||
- 从正式 `vX.Y.Z` tag 运行 signed macOS release candidate workflow。
|
||||
- 将已校验的不可变制品上传到生产下载源,最后原子替换线上 `latest.json`。
|
||||
- 执行桌面端人工端到端回归、最小窗口体验验收和真实系统通知/自动更新验证。
|
||||
|
||||
进入端到端人工回归前,应先确认 `npm run desktop:release-readiness:check` 在正式 release tag 和签名环境中通过;否则只能进行普通 smoke 验证,不能判定 macOS 发布稳定化已经完成。
|
||||
|
||||
## 2026-07-02 安全边界复审推进记录
|
||||
|
||||
本次安全边界复审在端到端自动化收口之后继续推进,仍不引入离线能力、本地业务数据存储、内嵌后端或独立桌面业务 UI。
|
||||
|
||||
已补齐的安全边界自动化:
|
||||
|
||||
- Tauri 通知 capability 已从 `notification:default` 收敛为权限查询、权限请求和发送通知三项显式权限。
|
||||
- `npm run desktop:release:check` 已拒绝 `notification:default`、opener URL/reveal 权限和 WebView 直连 updater 权限,继续要求文件与 opener scope 仅限 `$TEMP/ctms-desktop/**`。
|
||||
- `npm run desktop:release:check` 已扩展 token URL 和日志静态检查,覆盖 `token`、`access_token`、`Authorization` 和 `Bearer` 形态。
|
||||
- 自动更新弹窗 release notes 已过滤 URL、token 查询参数和 Authorization/Bearer 形态文本,避免从 feed 将下载链接或凭据样式文本带入用户界面。
|
||||
- Rust 凭据命令新增单测,确认带凭据 server origin 被拒绝,系统凭据库 account 使用 origin 哈希且不暴露原始服务器地址。
|
||||
|
||||
仍需正式发布负责人在真实发布环境确认:
|
||||
|
||||
- 生产 release notes 内容保持通用,不包含项目、文件、下载链接或敏感业务详情。
|
||||
- 签名、公证、updater feed 和 artifact 上传日志不泄露 Apple 凭据、updater 私钥或下载源内部凭据。
|
||||
|
||||
## 2026-07-02 桌面体验收口推进记录
|
||||
|
||||
本次体验收口继续保持在线桌面客户端边界,不新增离线、本地业务存储或独立桌面业务 UI。
|
||||
|
||||
已补齐的桌面体验收口:
|
||||
|
||||
- 个人中心新增客户端诊断信息展示与复制能力,内容仅包含客户端类型、版本、平台、构建通道、提交、服务器和能力状态,不包含 token 或业务敏感数据。
|
||||
- 桌面侧栏已移除被动“最近访问”入口和对应本地记录,只保留用户主动维护的收藏入口;收藏仅保存路由元数据,不保存业务数据或敏感凭据。
|
||||
- 登录页、服务器设置页、个人中心和系统偏好增加最小窗口布局约束,长服务器地址、健康检查 URL、诊断值和更新/通知状态说明均可在容器内换行。
|
||||
- 服务器设置页和个人中心在内容高度超过窗口时使用内部滚动,避免 `1180x760` 下对话框或面板溢出。
|
||||
- 通知权限运行时已区分 WebView `Notification.permission` 的授权、拒绝和待授权状态;取消 macOS 权限请求时继续保持“待授权”,避免错误显示为“已拒绝”。
|
||||
- 系统偏好通知页已移除授权成功态的固定标签文案,开启状态只由开关表达;待授权、已拒绝和不可用状态继续显示提示标签。
|
||||
- 系统偏好通知页新增“测试通知”命令,完整走 `frontend/src/runtime/notifications.ts` -> `@tauri-apps/plugin-notification` 的系统通知发送链路;测试通知文案为固定通用内容,不包含项目、文件、token 或其他敏感业务信息。
|
||||
- `showSystemNotification` 和测试通知调用会在发送前确认系统通知权限,只有真正发起系统通知时才返回成功;桌面通知轮询据此只 ack 已实际发起系统通知的后端通知。
|
||||
- 真实 macOS `.app` 已验证系统偏好通知开关可调用系统通知权限并生效。验证截图位于 `/Users/zcc/Library/Application Support/CleanShot/media/media_boKznpjOwt/CleanShot 2026-07-02 at 11.07.52@2x.png`。
|
||||
- `Layout.desktop.test.ts` 已覆盖个人中心诊断、最小窗口断点、服务器设置滚动和偏好页控制区换行契约。
|
||||
- `notifications.test.ts` 已覆盖通知权限状态映射和测试通知发送链路。
|
||||
|
||||
仍需真实桌面环境人工确认:
|
||||
|
||||
- macOS `.app` 在 `1180x760` 下的登录页、服务器设置、个人中心、系统偏好和更新弹窗实际布局。
|
||||
- 系统通知权限拒绝后的开关状态、权限提示和错误提示是否与 OS 状态一致。
|
||||
|
||||
## 2026-07-08 本地缓存边界调整与执行方案
|
||||
|
||||
经当前项目边界复核,桌面端允许新增在线辅助本地缓存,用于减少重复请求、缩短页面回访和 App 重启后的数据展示等待时间。该能力不改变 CTMS 的业务权威边界:FastAPI 后端仍是认证、授权、审计和业务持久化的唯一权威来源。
|
||||
|
||||
执行方案以 [`desktop-local-cache-plan.md`](desktop-local-cache-plan.md) 为准,实施顺序为:
|
||||
|
||||
1. 建立请求观测和缓存清单,确认高频 GET 接口、页面冷启动接口、基础数据接口和 mutation 后需要失效的资源。
|
||||
2. 先实现请求去重、短时内存缓存和页面级 stale-while-revalidate,减少同一会话内重复拉取。
|
||||
3. 在 `frontend/src/runtime/` 增加 `desktopDataCache` 适配层,统一提供缓存读写、命名空间、TTL、容量上限、失效、清理和诊断能力。
|
||||
4. 增加持久化缓存,默认只缓存后端 GET 响应和明确标记可缓存的数据;缓存 key 必须包含 server origin、user id、请求签名和 cache schema version。
|
||||
5. 推进后端 HTTP 条件请求支持,优先为字典、菜单、权限摘要、组织/中心、项目摘要等稳定数据提供 `ETag` 或 `Last-Modified`,客户端使用 `If-None-Match` 或 `If-Modified-Since` 降低 304 场景的数据传输。
|
||||
6. 建立统一失效机制:登出、切换服务器、切换用户、401/403、权限版本变化、mutation 成功、应用版本或 cache schema 变化时清理或精准失效。
|
||||
7. 在系统偏好或诊断信息中提供缓存状态和手动清理入口,清理文案不得包含业务详情。
|
||||
8. 补充单元测试、运行时边界检查和桌面发布检查,确保 token、密码、下载凭据不会写入缓存,业务模块不会绕过 `frontend/src/runtime/` 使用 Tauri 或底层存储 API。
|
||||
|
||||
仍禁止把缓存扩展为离线产品能力:不得实现离线登录、离线写入队列、冲突解决、本地审批、本地权限裁决、本地审计回放或本地 API 镜像。服务端不可达时,桌面端不得把缓存解释为已完成身份认证;是否展示已过期缓存必须以后续明确的产品验收口径为准,默认只在在线身份校验通过后使用缓存。
|
||||
|
||||
如果后续任务试图新增离线登录、离线写入、本地业务队列、离线同步、内嵌后端、本地 API 镜像或绕过后端权限审计,应先修改并评审本计划书,不能直接实现。
|
||||
|
||||
## 当前质量门禁
|
||||
|
||||
前端或桌面端代码变更应按影响范围执行相关检查。发布、桌面端适配层、Tauri 配置或安全边界相关变更至少考虑:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run version:check
|
||||
npm run runtime:check
|
||||
npm run desktop:release:check
|
||||
npm run ui:contract
|
||||
npm run type-check
|
||||
npm run test:unit
|
||||
npm run build
|
||||
npm run desktop:build:app
|
||||
```
|
||||
|
||||
正式桌面发布构建仍必须使用组织批准的 updater 签名私钥和 Apple 签名/公证流程;未签名或 ad-hoc 构建只能作为内部验证构建描述。文档-only 变更可以不执行完整代码门禁,但必须在结果说明中明确未运行。
|
||||
|
||||
## 每次开发前必须执行的检查
|
||||
|
||||
开始任何桌面端相关任务前:
|
||||
|
||||
- 先阅读本文档。
|
||||
- 确认任务属于第一阶段或第二阶段。
|
||||
- 确认任务不会引入未批准的离线能力;本地缓存任务必须符合 2026-07-08 缓存边界和 `desktop-local-cache-plan.md`。
|
||||
- 确认实现不会破坏 Web 运行时。
|
||||
- 确认 Tauri API 使用被隔离在适配层之后,除非有明确记录的理由。
|
||||
- 确认不会重复初始化 Tauri 或绕过既有 `frontend/src/runtime/` 运行时边界。
|
||||
- 涉及本地缓存、Tauri 权限、CSP、updater、凭据、文件或通知能力时,确认桌面发布检查脚本和发布清单是否需要同步更新。
|
||||
|
||||
如果用户请求与本文档冲突,先停止实现并确认范围,不要直接推进。
|
||||
@@ -154,6 +154,16 @@ Windows validation must cover:
|
||||
- notification and updater compilation;
|
||||
- future code-signing requirements.
|
||||
|
||||
The manual internal Windows validation workflow lives at
|
||||
`.github/workflows/desktop-windows-internal.yml`. It is `workflow_dispatch`
|
||||
only, runs on `windows-latest`, injects `VITE_BUILD_CHANNEL` and
|
||||
`VITE_BUILD_COMMIT` from the selected branch context, executes the shared client
|
||||
and Desktop safety gates, builds an unsigned NSIS installer with updater
|
||||
artifacts disabled, and uploads the `.exe` plus `SHA256SUMS.txt` as a GitHub
|
||||
Actions artifact. This workflow is for internal compatibility verification
|
||||
only; it must not generate `latest.json`, update feeds, signed release
|
||||
directories, or formal Windows release artifacts.
|
||||
|
||||
## Required Checks
|
||||
|
||||
```bash
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="data:," />
|
||||
<meta name="theme-color" content="#13243a" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="icon" type="image/png" href="/icons/ctms-icon-192.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<title>CTMS</title>
|
||||
</head>
|
||||
|
||||
@@ -41,11 +45,16 @@
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
background: #183b63;
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 10px 22px rgba(15, 35, 58, 0.16);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ctms-boot-mark img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.ctms-boot-title {
|
||||
@@ -62,7 +71,9 @@
|
||||
</style>
|
||||
<div class="ctms-boot-splash">
|
||||
<div class="ctms-boot-card">
|
||||
<div class="ctms-boot-mark">CTMS</div>
|
||||
<div class="ctms-boot-mark" aria-hidden="true">
|
||||
<img src="/icons/ctms-icon-192.png" alt="" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="ctms-boot-title">正在启动 CTMS</p>
|
||||
<p class="ctms-boot-subtitle">正在加载系统...</p>
|
||||
|
||||
|
After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 74 B After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 387 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "CTMS",
|
||||
"short_name": "CTMS",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/ctms-icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/ctms-icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#13243a",
|
||||
"background_color": "#f4f7fb",
|
||||
"display": "standalone"
|
||||
}
|
||||
@@ -29,6 +29,7 @@ const walk = async (directory) => {
|
||||
|
||||
const permissionIdentifier = (permission) =>
|
||||
typeof permission === "string" ? permission : typeof permission?.identifier === "string" ? permission.identifier : "";
|
||||
const toPosixPath = (path) => path.split("\\").join("/");
|
||||
|
||||
const assertPathScope = (permission, expectedPrefix, description) => {
|
||||
const allow = Array.isArray(permission.allow) ? permission.allow : [];
|
||||
@@ -74,6 +75,11 @@ const verifyTauriConfig = async () => {
|
||||
assert(mainWindow?.decorations === true, "Main desktop window must keep native window decorations enabled.");
|
||||
assert(mainWindow?.titleBarStyle === "Overlay", "Main desktop window must use the macOS overlay title bar.");
|
||||
assert(mainWindow?.hiddenTitle === true, "Main desktop window must hide the native title text.");
|
||||
assert(mainWindow?.backgroundColor === "#f9fafb", "Main desktop window must use a non-black WebView background.");
|
||||
assert(
|
||||
mainWindow?.backgroundThrottling === "disabled",
|
||||
"Main desktop window must disable background suspend to avoid macOS resume black flashes.",
|
||||
);
|
||||
assert(
|
||||
mainWindow?.trafficLightPosition?.x === 16 && mainWindow?.trafficLightPosition?.y === 18,
|
||||
"Main desktop window must keep traffic light controls at x=16 y=18.",
|
||||
@@ -205,7 +211,7 @@ const verifySourceSafety = async () => {
|
||||
|
||||
for (const path of files) {
|
||||
const source = await readFile(path, "utf8");
|
||||
const file = relative(rootDir, path);
|
||||
const file = toPosixPath(relative(rootDir, path));
|
||||
const isRuntimeFile = file.startsWith("frontend/src/runtime/");
|
||||
assert(!/[?&](?:token|access_token)=/i.test(source), `${file}: token must not be passed through query parameters.`);
|
||||
assert(
|
||||
@@ -308,6 +314,45 @@ const verifyWorkflowGates = async () => {
|
||||
for (const token of requiredReleaseWorkflowTokens) {
|
||||
assert(releaseWorkflow.includes(token), `Desktop release candidate workflow must include ${token}.`);
|
||||
}
|
||||
|
||||
const windowsInternalWorkflow = await readFile(resolve(rootDir, ".github/workflows/desktop-windows-internal.yml"), "utf8");
|
||||
const requiredWindowsInternalWorkflowTokens = [
|
||||
"workflow_dispatch",
|
||||
"runs-on: windows-latest",
|
||||
"VITE_BUILD_CHANNEL",
|
||||
"VITE_BUILD_COMMIT",
|
||||
"npm run release:env:check",
|
||||
"npm run version:check",
|
||||
"npm run runtime:check",
|
||||
"npm run desktop:release:check",
|
||||
"npm run ui:contract",
|
||||
"npm run type-check",
|
||||
"npm run test:unit",
|
||||
"npm run build",
|
||||
"npm run desktop:build",
|
||||
"createUpdaterArtifacts",
|
||||
"false",
|
||||
"--bundles nsis",
|
||||
"SHA256SUMS.txt",
|
||||
"actions/upload-artifact",
|
||||
];
|
||||
|
||||
for (const token of requiredWindowsInternalWorkflowTokens) {
|
||||
assert(windowsInternalWorkflow.includes(token), `Desktop Windows internal workflow must include ${token}.`);
|
||||
}
|
||||
|
||||
const forbiddenWindowsInternalWorkflowTokens = [
|
||||
"desktop:update-feed:create",
|
||||
"desktop:update-feed:check",
|
||||
"desktop:release-readiness:check",
|
||||
"TAURI_SIGNING_PRIVATE_KEY",
|
||||
"REQUIRE_DESKTOP_SIGNING",
|
||||
"latest.json",
|
||||
];
|
||||
|
||||
for (const token of forbiddenWindowsInternalWorkflowTokens) {
|
||||
assert(!windowsInternalWorkflow.includes(token), `Desktop Windows internal workflow must not include ${token}.`);
|
||||
}
|
||||
};
|
||||
|
||||
await verifyTauriConfig();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { extname, relative, resolve } from "node:path";
|
||||
import { extname, isAbsolute, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
|
||||
@@ -7,6 +7,7 @@ const sourceDir = resolve(frontendDir, "src");
|
||||
const runtimeDir = resolve(sourceDir, "runtime");
|
||||
const sourceExtensions = new Set([".ts", ".tsx", ".vue", ".js", ".jsx"]);
|
||||
const violations = [];
|
||||
const toPosixPath = (path) => path.split("\\").join("/");
|
||||
|
||||
const walk = async (directory) => {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
@@ -21,10 +22,12 @@ const walk = async (directory) => {
|
||||
};
|
||||
|
||||
for (const path of await walk(sourceDir)) {
|
||||
if (!sourceExtensions.has(extname(path)) || path.startsWith(`${runtimeDir}/`)) continue;
|
||||
const runtimeRelativePath = relative(runtimeDir, path);
|
||||
const isRuntimeFile = Boolean(runtimeRelativePath) && !runtimeRelativePath.startsWith("..") && !isAbsolute(runtimeRelativePath);
|
||||
if (!sourceExtensions.has(extname(path)) || isRuntimeFile) continue;
|
||||
|
||||
const source = await readFile(path, "utf8");
|
||||
const file = relative(frontendDir, path);
|
||||
const file = toPosixPath(relative(frontendDir, path));
|
||||
if (source.includes("@tauri-apps/") || source.includes("__TAURI")) {
|
||||
violations.push(`${file}: direct Tauri access is only allowed inside src/runtime`);
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 293 B After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 659 B After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 99 B After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 154 B After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 233 B After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 322 B After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 341 B After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 756 B After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 96 B After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 846 B After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 114 B After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 166 B After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 194 B After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 123 B After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 374 KiB |
@@ -21,6 +21,8 @@
|
||||
"decorations": true,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"backgroundColor": "#f9fafb",
|
||||
"backgroundThrottling": "disabled",
|
||||
"trafficLightPosition": { "x": 16, "y": 18 }
|
||||
}
|
||||
],
|
||||
@@ -31,7 +33,14 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["app", "dmg"],
|
||||
"createUpdaterArtifacts": true
|
||||
"createUpdaterArtifacts": true,
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
|
||||
@@ -30,5 +30,6 @@ initDesktopUpdateManager();
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
background: var(--ctms-bg-base);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { apiGet, apiPost } from "./axios";
|
||||
import type { ApiListResponse } from "../types/api";
|
||||
import type { ApiListResponse, AuditLogItem } from "../types/api";
|
||||
|
||||
export const fetchAuditLogs = (studyId: string, params?: Record<string, any>) =>
|
||||
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/audit-logs/`, { params });
|
||||
apiGet<ApiListResponse<AuditLogItem>>(`/api/v1/studies/${studyId}/audit-logs/`, { params });
|
||||
|
||||
export const createAuditEvent = (studyId: string, payload: Record<string, any>) =>
|
||||
apiPost(`/api/v1/studies/${studyId}/audit-logs/events`, payload);
|
||||
|
||||
@@ -1,7 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeAuditEvent } from ".";
|
||||
import { normalizeAuditEvent, resolveAuditClientSourceLabel } from ".";
|
||||
|
||||
describe("normalizeAuditEvent", () => {
|
||||
it("normalizes request source and IP context for access audit troubleshooting", () => {
|
||||
const event = normalizeAuditEvent(
|
||||
{
|
||||
action: "UPDATE_SUBJECT",
|
||||
detail: JSON.stringify({ targetName: "S001" }),
|
||||
operator_id: "operator-1",
|
||||
operator_name: "张三",
|
||||
operator_email: "pm@example.com",
|
||||
operator_role: "PM",
|
||||
entity_type: "subject",
|
||||
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
|
||||
client_ip: "203.0.113.10",
|
||||
ip_country: "中国",
|
||||
ip_province: "重庆",
|
||||
ip_city: "渝中",
|
||||
ip_isp: "电信",
|
||||
client_type: "desktop",
|
||||
client_version: "1.2.3",
|
||||
client_platform: "macos",
|
||||
build_channel: "release",
|
||||
build_commit: "abcdef1",
|
||||
user_agent: "CTMS Desktop",
|
||||
created_at: "2026-07-09T10:00:00Z",
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(event.actorName).toBe("张三");
|
||||
expect(event.actorAccount).toBe("pm@example.com");
|
||||
expect(event.clientIp).toBe("203.0.113.10");
|
||||
expect(event.ipLocation).toBe("重庆 / 渝中 / 电信");
|
||||
expect(event.clientSourceLabel).toBe("桌面端 / macos");
|
||||
expect(event.clientType).toBe("desktop");
|
||||
expect(event.clientVersion).toBe("1.2.3");
|
||||
expect(event.buildCommit).toBe("abcdef1");
|
||||
});
|
||||
|
||||
it("labels unmarked automation clients as script or command-line sources", () => {
|
||||
expect(resolveAuditClientSourceLabel({ user_agent: "curl/8.1.2" })).toBe("脚本/命令行");
|
||||
expect(resolveAuditClientSourceLabel({ user_agent: "" })).toBe("未知来源");
|
||||
});
|
||||
|
||||
it("removes UUID tokens from legacy audit detail text", () => {
|
||||
const event = normalizeAuditEvent(
|
||||
{
|
||||
|
||||
@@ -19,5 +19,9 @@ export const auditExportColumns: AuditExportColumn[] = [
|
||||
{ key: "resultLabel", label: TEXT.audit.exportColumns.resultLabel },
|
||||
{ key: "reason", label: TEXT.audit.exportColumns.reason },
|
||||
{ key: "ip", label: TEXT.audit.exportColumns.ip },
|
||||
{ key: "ipLocation", label: TEXT.audit.exportColumns.ipLocation },
|
||||
{ key: "clientSource", label: TEXT.audit.exportColumns.clientSource },
|
||||
{ key: "clientType", label: TEXT.audit.exportColumns.clientType },
|
||||
{ key: "userAgent", label: TEXT.audit.exportColumns.userAgent },
|
||||
{ key: "remark", label: TEXT.audit.exportColumns.remark },
|
||||
];
|
||||
|
||||
@@ -43,7 +43,11 @@ export const formatAuditRow = (event: AuditEvent): AuditExportRow => {
|
||||
afterStatus,
|
||||
resultLabel: event.resultLabel || "",
|
||||
reason: event.reason || "",
|
||||
ip: "",
|
||||
ip: event.clientIp || "",
|
||||
ipLocation: event.ipLocation || "",
|
||||
clientSource: event.clientSourceLabel || "",
|
||||
clientType: [event.clientType, event.clientVersion, event.clientPlatform].filter(Boolean).join("/") || "",
|
||||
userAgent: event.userAgent || "",
|
||||
remark: event.diffText?.join(";") || "",
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface AuditEvent {
|
||||
eventLabel: string;
|
||||
actorId: string;
|
||||
actorName: string;
|
||||
actorAccount?: string;
|
||||
actorRole: string;
|
||||
actorRoleLabel: string;
|
||||
targetType: string;
|
||||
@@ -21,6 +22,19 @@ export interface AuditEvent {
|
||||
result: "SUCCESS" | "FAIL";
|
||||
resultLabel: string;
|
||||
timestamp: string;
|
||||
clientIp?: string;
|
||||
ipLocation?: string;
|
||||
ipCountry?: string;
|
||||
ipProvince?: string;
|
||||
ipCity?: string;
|
||||
ipIsp?: string;
|
||||
clientType?: string;
|
||||
clientVersion?: string;
|
||||
clientPlatform?: string;
|
||||
buildChannel?: string;
|
||||
buildCommit?: string;
|
||||
userAgent?: string;
|
||||
clientSourceLabel?: string;
|
||||
}
|
||||
|
||||
const entityTypeLabelMap: Record<string, string> = {
|
||||
@@ -619,6 +633,57 @@ const buildDiffText = (
|
||||
return lines;
|
||||
};
|
||||
|
||||
const fallbackIpLocation = (ip: string | null | undefined): string => {
|
||||
if (!ip) return "";
|
||||
if (ip === "127.0.0.1" || ip === "::1") return "本机访问";
|
||||
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[0-1])\.)/.test(ip)) return "内网地址";
|
||||
return "";
|
||||
};
|
||||
|
||||
export const formatAuditIpLocation = (raw: {
|
||||
ip_country?: string | null;
|
||||
ip_province?: string | null;
|
||||
ip_city?: string | null;
|
||||
ip_isp?: string | null;
|
||||
ip_location?: string | null;
|
||||
client_ip?: string | null;
|
||||
clientIp?: string | null;
|
||||
}): string => {
|
||||
const parts = [
|
||||
raw.ip_country && raw.ip_country !== "中国" ? raw.ip_country : "",
|
||||
raw.ip_province,
|
||||
raw.ip_city,
|
||||
raw.ip_isp,
|
||||
].filter(Boolean);
|
||||
if (parts.length) return parts.join(" / ");
|
||||
return raw.ip_location || fallbackIpLocation(raw.client_ip || raw.clientIp) || "";
|
||||
};
|
||||
|
||||
const isAutomationUserAgent = (userAgent = ""): boolean =>
|
||||
/curl|wget|python-requests|httpie|go-http-client|node-fetch|axios|postman|insomnia|okhttp|java|powershell|libwww/i.test(userAgent);
|
||||
|
||||
const isBrowserUserAgent = (userAgent = ""): boolean =>
|
||||
/mozilla|chrome|safari|firefox|edg\//i.test(userAgent);
|
||||
|
||||
export const resolveAuditClientSourceLabel = (raw: {
|
||||
client_type?: string | null;
|
||||
client_platform?: string | null;
|
||||
user_agent?: string | null;
|
||||
clientType?: string | null;
|
||||
clientPlatform?: string | null;
|
||||
userAgent?: string | null;
|
||||
}): string => {
|
||||
const clientType = String(raw.client_type || raw.clientType || "").trim().toLowerCase();
|
||||
const platform = String(raw.client_platform || raw.clientPlatform || "").trim();
|
||||
const userAgent = String(raw.user_agent || raw.userAgent || "").trim();
|
||||
if (clientType === "web") return "网页端";
|
||||
if (clientType === "desktop") return platform ? `桌面端 / ${platform}` : "桌面端";
|
||||
if (clientType) return `未知客户端 / ${clientType}`;
|
||||
if (isAutomationUserAgent(userAgent)) return "脚本/命令行";
|
||||
if (isBrowserUserAgent(userAgent)) return "浏览器(未标识)";
|
||||
return "未知来源";
|
||||
};
|
||||
|
||||
export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>): AuditEvent => {
|
||||
const dict = auditDict[raw.action] || buildReadableAuditDict(raw.action, raw.entity_type);
|
||||
let detailObj: any = {};
|
||||
@@ -668,7 +733,8 @@ export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>):
|
||||
eventType: raw.action,
|
||||
eventLabel: dict.label,
|
||||
actorId: raw.operator_id,
|
||||
actorName: userMap[raw.operator_id] || raw.operator_id,
|
||||
actorName: raw.operator_name || userMap[raw.operator_id] || raw.operator_id,
|
||||
actorAccount: raw.operator_email || raw.operator_id,
|
||||
actorRole: raw.operator_role,
|
||||
actorRoleLabel: getDictLabel(roleDict, raw.operator_role),
|
||||
targetType: raw.entity_type || "",
|
||||
@@ -683,6 +749,19 @@ export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>):
|
||||
result: detailObj.result || "SUCCESS",
|
||||
resultLabel: detailObj.result === "FAIL" ? TEXT.audit.resultFail : TEXT.audit.resultSuccess,
|
||||
timestamp: raw.created_at,
|
||||
clientIp: raw.client_ip || "",
|
||||
ipLocation: formatAuditIpLocation(raw),
|
||||
ipCountry: raw.ip_country || "",
|
||||
ipProvince: raw.ip_province || "",
|
||||
ipCity: raw.ip_city || "",
|
||||
ipIsp: raw.ip_isp || "",
|
||||
clientType: raw.client_type || "",
|
||||
clientVersion: raw.client_version || "",
|
||||
clientPlatform: raw.client_platform || "",
|
||||
buildChannel: raw.build_channel || "",
|
||||
buildCommit: raw.build_commit || "",
|
||||
userAgent: raw.user_agent || "",
|
||||
clientSourceLabel: resolveAuditClientSourceLabel(raw),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
<aside class="desktop-sidebar">
|
||||
<header class="sidebar-head">
|
||||
<div class="sidebar-title-row">
|
||||
<h1>{{ TEXT.common.appName }}</h1>
|
||||
<div class="sidebar-app-brand">
|
||||
<img class="sidebar-app-icon" src="/icons/ctms-icon-192.png" alt="" aria-hidden="true" />
|
||||
<h1>{{ TEXT.common.appName }}</h1>
|
||||
</div>
|
||||
|
||||
<button v-if="showSidebarProjectChooser" class="study-switcher-trigger empty" type="button" @click="openDesktopProjectEntry">
|
||||
选择项目
|
||||
@@ -1383,6 +1386,23 @@ useDesktopShortcuts(
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-app-brand {
|
||||
display: inline-grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: 30px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.sidebar-app-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.14);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -2357,13 +2377,6 @@ useDesktopShortcuts(
|
||||
:global(.desktop-preferences-dialog .el-dialog__body) {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:global(.desktop-preferences-dialog .el-dialog__body > *) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .desktop-workbench) {
|
||||
|
||||
@@ -2,28 +2,31 @@ import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readLayoutSource = () => readFileSync(resolve(__dirname, "./Layout.vue"), "utf8");
|
||||
const readAppSource = () => readFileSync(resolve(__dirname, "../App.vue"), "utf8");
|
||||
const readDesktopLayoutSource = () => readFileSync(resolve(__dirname, "./DesktopLayout.vue"), "utf8");
|
||||
const readWebLayoutSource = () => readFileSync(resolve(__dirname, "./WebLayout.vue"), "utf8");
|
||||
const readNavigationSource = () => readFileSync(resolve(__dirname, "./layout/navigation.ts"), "utf8");
|
||||
const readTauriConfigSource = () => readFileSync(resolve(__dirname, "../../src-tauri/tauri.conf.json"), "utf8");
|
||||
const readTauriLibSource = () => readFileSync(resolve(__dirname, "../../src-tauri/src/lib.rs"), "utf8");
|
||||
const readDesktopPreferencesSource = () => readFileSync(resolve(__dirname, "../views/DesktopPreferences.vue"), "utf8");
|
||||
const readDesktopServerSettingsSource = () => readFileSync(resolve(__dirname, "../views/DesktopServerSettings.vue"), "utf8");
|
||||
const readDesktopUpdateManagerSource = () => readFileSync(resolve(__dirname, "../session/desktopUpdateManager.ts"), "utf8");
|
||||
const readDesktopActivityCenterSource = () => readFileSync(resolve(__dirname, "../session/desktopActivityCenter.ts"), "utf8");
|
||||
const readProfileSettingsSource = () => readFileSync(resolve(__dirname, "../views/ProfileSettings.vue"), "utf8");
|
||||
const readLoginSource = () => readFileSync(resolve(__dirname, "../views/Login.vue"), "utf8");
|
||||
const readRegisterSource = () => readFileSync(resolve(__dirname, "../views/Register.vue"), "utf8");
|
||||
const readForgotPasswordSource = () => readFileSync(resolve(__dirname, "../views/ForgotPassword.vue"), "utf8");
|
||||
const readFileTaskFeedbackSource = () => readFileSync(resolve(__dirname, "../utils/fileTaskFeedback.ts"), "utf8");
|
||||
const readAttachmentListSource = () => readFileSync(resolve(__dirname, "./attachments/AttachmentList.vue"), "utf8");
|
||||
const readDocumentDetailSource = () => readFileSync(resolve(__dirname, "../views/documents/DocumentDetail.vue"), "utf8");
|
||||
const readMainStyleSource = () => readFileSync(resolve(__dirname, "../styles/main.css"), "utf8");
|
||||
const readFaqSource = () => readFileSync(resolve(__dirname, "../views/Faq.vue"), "utf8");
|
||||
const readFaqListSource = () => readFileSync(resolve(__dirname, "./FaqList.vue"), "utf8");
|
||||
const readProjectOverviewSource = () => readFileSync(resolve(__dirname, "../views/ia/ProjectOverview.vue"), "utf8");
|
||||
const normalizeSource = (source: string) => source.replace(/\r\n/g, "\n");
|
||||
const readSource = (path: string) => normalizeSource(readFileSync(path, "utf8"));
|
||||
|
||||
const readLayoutSource = () => readSource(resolve(__dirname, "./Layout.vue"));
|
||||
const readAppSource = () => readSource(resolve(__dirname, "../App.vue"));
|
||||
const readDesktopLayoutSource = () => readSource(resolve(__dirname, "./DesktopLayout.vue"));
|
||||
const readWebLayoutSource = () => readSource(resolve(__dirname, "./WebLayout.vue"));
|
||||
const readNavigationSource = () => readSource(resolve(__dirname, "./layout/navigation.ts"));
|
||||
const readTauriConfigSource = () => readSource(resolve(__dirname, "../../src-tauri/tauri.conf.json"));
|
||||
const readTauriLibSource = () => readSource(resolve(__dirname, "../../src-tauri/src/lib.rs"));
|
||||
const readDesktopPreferencesSource = () => readSource(resolve(__dirname, "../views/DesktopPreferences.vue"));
|
||||
const readDesktopServerSettingsSource = () => readSource(resolve(__dirname, "../views/DesktopServerSettings.vue"));
|
||||
const readDesktopUpdateManagerSource = () => readSource(resolve(__dirname, "../session/desktopUpdateManager.ts"));
|
||||
const readDesktopActivityCenterSource = () => readSource(resolve(__dirname, "../session/desktopActivityCenter.ts"));
|
||||
const readProfileSettingsSource = () => readSource(resolve(__dirname, "../views/ProfileSettings.vue"));
|
||||
const readLoginSource = () => readSource(resolve(__dirname, "../views/Login.vue"));
|
||||
const readRegisterSource = () => readSource(resolve(__dirname, "../views/Register.vue"));
|
||||
const readForgotPasswordSource = () => readSource(resolve(__dirname, "../views/ForgotPassword.vue"));
|
||||
const readFileTaskFeedbackSource = () => readSource(resolve(__dirname, "../utils/fileTaskFeedback.ts"));
|
||||
const readAttachmentListSource = () => readSource(resolve(__dirname, "./attachments/AttachmentList.vue"));
|
||||
const readDocumentDetailSource = () => readSource(resolve(__dirname, "../views/documents/DocumentDetail.vue"));
|
||||
const readMainStyleSource = () => readSource(resolve(__dirname, "../styles/main.css"));
|
||||
const readFaqSource = () => readSource(resolve(__dirname, "../views/Faq.vue"));
|
||||
const readFaqListSource = () => readSource(resolve(__dirname, "./FaqList.vue"));
|
||||
const readProjectOverviewSource = () => readSource(resolve(__dirname, "../views/ia/ProjectOverview.vue"));
|
||||
|
||||
describe("desktop layout shell", () => {
|
||||
it("routes desktop and web shells through the runtime flag", () => {
|
||||
@@ -70,9 +73,11 @@ describe("desktop layout shell", () => {
|
||||
it("keeps web project switching routed through the workbench entry", () => {
|
||||
const webLayout = readWebLayoutSource();
|
||||
|
||||
expect(webLayout).toContain('class="workbench-return-button"');
|
||||
expect(webLayout).toContain('v-if="hasProjectContext" class="workbench-return-button"');
|
||||
expect(webLayout).toContain('await router.push("/workbench")');
|
||||
expect(webLayout).toContain("const showAdminNavigation = computed(() => Boolean(auth.user) && (!study.currentStudy || isAdminContext.value))");
|
||||
expect(webLayout).toContain("const hasProjectContext = computed(() => Boolean(study.currentStudy) && !isAdminContext.value);");
|
||||
expect(webLayout).toContain('v-if="hasProjectContext && hasAnyProjectModuleAccess"');
|
||||
expect(webLayout).toContain("const showAdminNavigation = computed(() => Boolean(auth.user) && !hasProjectContext.value)");
|
||||
expect(webLayout).not.toContain("hasDropdown: true");
|
||||
expect(webLayout).not.toContain("type: 'study'");
|
||||
expect(webLayout).not.toContain('type: "study"');
|
||||
@@ -120,6 +125,8 @@ describe("desktop layout shell", () => {
|
||||
expect(tauriConfig).toContain('"decorations": true');
|
||||
expect(tauriConfig).toContain('"titleBarStyle": "Overlay"');
|
||||
expect(tauriConfig).toContain('"hiddenTitle": true');
|
||||
expect(tauriConfig).toContain('"backgroundColor": "#f9fafb"');
|
||||
expect(tauriConfig).toContain('"backgroundThrottling": "disabled"');
|
||||
expect(tauriConfig).toContain('"trafficLightPosition": { "x": 16, "y": 18 }');
|
||||
expect(layout).toContain("padding: 44px 10px 10px;");
|
||||
expect(layout).toContain("-webkit-app-region: drag;");
|
||||
@@ -170,7 +177,8 @@ describe("desktop layout shell", () => {
|
||||
expect(preferences).toContain('class="preferences-pane"');
|
||||
expect(preferences).toContain("const activeSectionId = ref<PreferenceSectionId>");
|
||||
expect(preferences).toContain('grid-template-columns: 232px minmax(0, 1fr);');
|
||||
expect(preferences).toContain("height: min(700px, calc(100vh - 96px));");
|
||||
expect(preferences).toContain("height: 700px;");
|
||||
expect(preferences).toContain("max-height: calc(100vh - 96px);");
|
||||
expect(preferences).toContain("scrollbar-gutter: stable;");
|
||||
expect(preferences).toContain('{ id: "connection", label: "连接"');
|
||||
expect(preferences).toContain('{ id: "appearance", label: "外观"');
|
||||
@@ -204,6 +212,7 @@ describe("desktop layout shell", () => {
|
||||
expect(desktopLayout).not.toContain(':global([data-ctms-theme="dark"]) .desktop-workbench');
|
||||
expect(desktopLayout).toContain('width="940px"');
|
||||
expect(webLayout).toContain('width="940px"');
|
||||
expect(desktopLayout).not.toContain(":global(.desktop-preferences-dialog .el-dialog__body > *)");
|
||||
expect(desktopLayout).toContain('title: "连接设置"');
|
||||
expect(webLayout).toContain('title: "连接设置"');
|
||||
expect(desktopLayout).not.toContain('router.push("/desktop/server-settings")');
|
||||
@@ -495,7 +504,9 @@ describe("desktop layout shell", () => {
|
||||
expect(styles).toContain("body.is-desktop-runtime .el-message-box__header");
|
||||
expect(styles).toContain("body.is-desktop-runtime .desktop-preferences-dialog");
|
||||
expect(styles).toContain("body.is-desktop-runtime .desktop-preferences-dialog .el-dialog__body");
|
||||
expect(styles).toContain("body.is-desktop-runtime .desktop-preferences-dialog .el-dialog__body {\n padding: 0;");
|
||||
expect(styles).toContain("body.is-desktop-runtime .desktop-preferences-dialog .el-dialog__body {\n height: 700px;");
|
||||
expect(styles).toContain("max-height: calc(100vh - 96px);");
|
||||
expect(styles).toContain("padding: 0;");
|
||||
expect(styles).toContain("body.is-desktop-runtime .el-message-box__btns");
|
||||
expect(styles).toContain("body.is-desktop-runtime .dialog-fade-enter-from .el-dialog");
|
||||
expect(styles).toContain("body.is-desktop-runtime .msgbox-fade-enter-from .el-message-box");
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<el-container class="layout-container web-layout-container">
|
||||
<el-aside :width="isCollapsed ? '68px' : '200px'" class="layout-aside" :class="{ collapsed: isCollapsed }">
|
||||
<div class="aside-logo">
|
||||
<div class="logo-icon"></div>
|
||||
<img class="logo-icon" src="/icons/ctms-icon-192.png" alt="" aria-hidden="true" />
|
||||
<span class="logo-text">{{ TEXT.common.appName }}</span>
|
||||
</div>
|
||||
<el-menu
|
||||
@@ -59,7 +59,7 @@
|
||||
</el-sub-menu>
|
||||
</el-menu-item-group>
|
||||
|
||||
<el-menu-item-group v-if="study.currentStudy && hasAnyProjectModuleAccess" class="menu-group">
|
||||
<el-menu-item-group v-if="hasProjectContext && hasAnyProjectModuleAccess" class="menu-group">
|
||||
<template #title>
|
||||
<span class="menu-divider">{{ TEXT.menu.currentProject }}</span>
|
||||
</template>
|
||||
@@ -204,7 +204,7 @@
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<button v-if="study.currentStudy" class="workbench-return-button" type="button" @click="openWorkbenchEntry">
|
||||
<button v-if="hasProjectContext" class="workbench-return-button" type="button" @click="openWorkbenchEntry">
|
||||
<el-icon><Suitcase /></el-icon>
|
||||
<span>工作台</span>
|
||||
</button>
|
||||
@@ -468,6 +468,8 @@ const headerReminderStats = ref({
|
||||
const headerClockNow = ref(new Date());
|
||||
let headerClockTimer: number | undefined;
|
||||
let desktopMenuUnlisten: (() => void) | undefined;
|
||||
const isAdminContext = computed(() => route.path.startsWith("/admin"));
|
||||
const hasProjectContext = computed(() => Boolean(study.currentStudy) && !isAdminContext.value);
|
||||
|
||||
/* 动态设置 body 上的侧边栏宽度 CSS 变量,用于全局弹窗定位偏移 */
|
||||
const hasSidebar = computed(() => isAdmin.value || !!study.currentStudy);
|
||||
@@ -489,10 +491,9 @@ const userDisplayInitial = computed(() => {
|
||||
return source.charAt(0).toUpperCase();
|
||||
});
|
||||
|
||||
const isAdminContext = computed(() => route.path.startsWith("/admin"));
|
||||
const showAdminNavigation = computed(() => Boolean(auth.user) && (!study.currentStudy || isAdminContext.value));
|
||||
const canReadRiskIssueAes = computed(() => !isAdminContext.value && canAccessProjectPath("/risk-issues/sae"));
|
||||
const canReadMonitoringIssues = computed(() => !isAdminContext.value && canAccessProjectPath("/risk-issues/monitoring-visits"));
|
||||
const showAdminNavigation = computed(() => Boolean(auth.user) && !hasProjectContext.value);
|
||||
const canReadRiskIssueAes = computed(() => hasProjectContext.value && canAccessProjectPath("/risk-issues/sae"));
|
||||
const canReadMonitoringIssues = computed(() => hasProjectContext.value && canAccessProjectPath("/risk-issues/monitoring-visits"));
|
||||
const hasAnyRiskReminderAccess = computed(() => canReadRiskIssueAes.value || canReadMonitoringIssues.value);
|
||||
|
||||
const toHeaderNumber = (value: unknown) => {
|
||||
@@ -518,7 +519,7 @@ const headerClockText = computed(() => formatHeaderDateTime(headerClockNow.value
|
||||
|
||||
const loadHeaderOverviewStats = async () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId || isAdminContext.value) {
|
||||
if (!studyId || !hasProjectContext.value) {
|
||||
headerOverviewStats.value = null;
|
||||
return;
|
||||
}
|
||||
@@ -541,7 +542,7 @@ const loadHeaderOverviewStats = async () => {
|
||||
|
||||
const loadHeaderReminders = async () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId || isAdminContext.value || !hasAnyRiskReminderAccess.value) {
|
||||
if (!studyId || !hasProjectContext.value || !hasAnyRiskReminderAccess.value) {
|
||||
headerReminderStats.value = { overdueAes: 0, overdueMonitoringIssues: 0 };
|
||||
return;
|
||||
}
|
||||
@@ -602,7 +603,7 @@ const headerReminderBadgeValue = computed(() =>
|
||||
|
||||
// 顶栏中间区:项目上下文(状态 + 关键进度 + 数据时间)
|
||||
const headerProjectInfo = computed(() => {
|
||||
if (isAdminContext.value || !study.currentStudy) return null;
|
||||
if (!hasProjectContext.value || !study.currentStudy) return null;
|
||||
const s = study.currentStudy;
|
||||
const status = (s.status || "").toUpperCase();
|
||||
const statusLabel = (TEXT.enums.projectStatus as Record<string, string>)[status] || s.status || "";
|
||||
@@ -640,7 +641,7 @@ const headerProjectInfo = computed(() => {
|
||||
});
|
||||
|
||||
const accountProjectRoleLabel = computed(() => {
|
||||
if (isAdminContext.value || !study.currentStudy) return "";
|
||||
if (!hasProjectContext.value || !study.currentStudy) return "";
|
||||
const roleCode = (projectRole.value || "").toUpperCase();
|
||||
return roleCode ? ((TEXT.enums.userRole as Record<string, string>)[roleCode] || projectRole.value) : "";
|
||||
});
|
||||
@@ -673,7 +674,7 @@ const desktopNavigationItems = computed<DesktopNavigationItem[]>(() => {
|
||||
}
|
||||
}
|
||||
|
||||
if (study.currentStudy && hasAnyProjectModuleAccess.value) {
|
||||
if (hasProjectContext.value && hasAnyProjectModuleAccess.value) {
|
||||
const projectItems: DesktopNavigationItem[] = [
|
||||
{ label: TEXT.menu.projectOverview, path: "/project/overview", group: TEXT.menu.currentProject, keywords: ["overview"] },
|
||||
{ label: TEXT.menu.projectMilestones, path: "/project/milestones", group: TEXT.menu.currentProject, keywords: ["milestone"] },
|
||||
@@ -896,7 +897,7 @@ const breadcrumbs = computed(() => {
|
||||
});
|
||||
return items;
|
||||
}
|
||||
} else if (study.currentStudy) {
|
||||
} else if (hasProjectContext.value && study.currentStudy) {
|
||||
// 项目内不提供横向切换项目;需要切换时先回工作台重新选择。
|
||||
items.push({
|
||||
label: (study.currentStudy.name || "").replace(/^示例项目[::]/, ''),
|
||||
@@ -1287,6 +1288,7 @@ useDesktopShortcuts(
|
||||
border-radius: 8px;
|
||||
margin-right: 12px;
|
||||
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.26);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
|
||||
@@ -242,6 +242,10 @@ export const TEXT = {
|
||||
resultLabel: "操作结果",
|
||||
reason: "拒绝原因",
|
||||
ip: "IP 地址",
|
||||
ipLocation: "IP 位置",
|
||||
clientSource: "请求来源",
|
||||
clientType: "客户端标识",
|
||||
userAgent: "User-Agent",
|
||||
remark: "备注",
|
||||
},
|
||||
eventDict: {
|
||||
@@ -926,6 +930,9 @@ export const TEXT = {
|
||||
filterEntityType: "对象类型",
|
||||
filterEvent: "操作类型",
|
||||
filterOperator: "操作人",
|
||||
filterSource: "来源",
|
||||
filterIpLocation: "IP / 位置",
|
||||
filterKeyword: "账号、IP、位置或来源",
|
||||
filterResult: "结果",
|
||||
rangeStart: "开始",
|
||||
rangeEnd: "结束",
|
||||
@@ -941,6 +948,7 @@ export const TEXT = {
|
||||
columns: {
|
||||
time: "时间",
|
||||
actor: "操作人",
|
||||
source: "请求来源",
|
||||
event: "操作类型",
|
||||
action: "内容",
|
||||
target: "对象",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readRouter = () => readFileSync(resolve(__dirname, "./router/index.ts"), "utf8");
|
||||
const readRouter = () => readFileSync(resolve(__dirname, "./router/index.ts"), "utf8").replace(/\r\n/g, "\n");
|
||||
|
||||
describe("admin project route permissions", () => {
|
||||
it("lets any authorized project role open the project management detail page", () => {
|
||||
|
||||
@@ -121,6 +121,7 @@ html,
|
||||
body,
|
||||
#app {
|
||||
min-height: 100%;
|
||||
background: var(--ctms-bg-base);
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -1114,6 +1115,8 @@ body.is-desktop-runtime .desktop-preferences-dialog {
|
||||
}
|
||||
|
||||
body.is-desktop-runtime .desktop-preferences-dialog .el-dialog__body {
|
||||
height: 700px;
|
||||
max-height: calc(100vh - 96px);
|
||||
padding: 0;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
@@ -402,6 +402,32 @@ export interface SecurityAccessLogsResponse {
|
||||
items: SecurityAccessLogItem[];
|
||||
}
|
||||
|
||||
export interface AuditLogItem {
|
||||
id: string;
|
||||
study_id: string | null;
|
||||
entity_type: string;
|
||||
entity_id: string | null;
|
||||
action: string;
|
||||
detail: string | null;
|
||||
operator_id: string;
|
||||
operator_name: string | null;
|
||||
operator_email: string | null;
|
||||
operator_role: string;
|
||||
client_ip: string | null;
|
||||
ip_location: string;
|
||||
ip_country: string;
|
||||
ip_province: string;
|
||||
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;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// 系统监测 - 趋势数据
|
||||
export interface TrendDataPoint {
|
||||
bucket_time: string;
|
||||
|
||||
@@ -807,9 +807,8 @@ onBeforeUnmount(() => {
|
||||
display: grid;
|
||||
grid-template-columns: 232px minmax(0, 1fr);
|
||||
width: 100%;
|
||||
/* height 让组件有确定高度,配合 dialog body 的 flex:1 撑满容器
|
||||
* 若 dialog 比 700px 高,组件会通过 flex:1 继续撑大 */
|
||||
height: min(700px, calc(100vh - 96px));
|
||||
height: 700px;
|
||||
max-height: calc(100vh - 96px);
|
||||
overflow: hidden;
|
||||
background: var(--pref-bg-pane);
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<aside class="entry-sidebar" data-tauri-drag-region>
|
||||
<div class="sidebar-top">
|
||||
<div class="sidebar-brand">
|
||||
<div class="brand-mark"><span>CTMS</span></div>
|
||||
<img class="brand-mark" src="/icons/ctms-icon-192.png" alt="" aria-hidden="true" />
|
||||
<div class="brand-text">
|
||||
<span class="brand-kicker">Desktop Workbench</span>
|
||||
<h1>工作台总控</h1>
|
||||
@@ -131,7 +131,7 @@
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else class="empty-project-workspace">
|
||||
<div class="empty-icon-capsule"><span>CTMS</span></div>
|
||||
<img class="empty-icon-capsule" src="/icons/ctms-icon-192.png" alt="" aria-hidden="true" />
|
||||
<h3>未检测到可用项目</h3>
|
||||
<p>您的账号尚未关联至任何研究项目,请联系系统管理员进行项目授权与分配。</p>
|
||||
</div>
|
||||
@@ -141,7 +141,7 @@
|
||||
<div class="transition-overlay" :class="{ active: isTransitioning }" aria-hidden="true">
|
||||
<div class="overlay-glow"></div>
|
||||
<div class="overlay-spinner">
|
||||
<span class="spinner-brand">CTMS</span>
|
||||
<img class="spinner-brand" src="/icons/ctms-icon-192.png" alt="" />
|
||||
<div class="spinner-ring"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -293,19 +293,13 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 1px solid rgba(59, 130, 246, 0.1);
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
color: #ffffff;
|
||||
font-size: 13.5px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.05em;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 8px 20px rgba(37, 99, 235, 0.18);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brand-text h1 {
|
||||
@@ -809,18 +803,13 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.empty-icon-capsule {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
background: #ffffff;
|
||||
margin-bottom: 12px;
|
||||
object-fit: contain;
|
||||
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
.empty-project-workspace h3 {
|
||||
@@ -1064,11 +1053,11 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.spinner-brand {
|
||||
color: #ffffff;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.1em;
|
||||
text-shadow: 0 0 12px rgba(59, 130, 246, 0.4);
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
object-fit: contain;
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.24);
|
||||
animation: pulseBrand 1.4s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="desktop-session-restore" data-tauri-drag-region>
|
||||
<section class="restore-panel">
|
||||
<div class="restore-mark">CTMS</div>
|
||||
<img class="restore-mark" src="/icons/ctms-icon-192.png" alt="" aria-hidden="true" />
|
||||
<div class="restore-copy">
|
||||
<span class="restore-eyebrow">Desktop Session</span>
|
||||
<h1>正在恢复登录状态</h1>
|
||||
@@ -134,14 +134,10 @@ onBeforeUnmount(() => {
|
||||
.restore-mark {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 16px;
|
||||
background: #15344f;
|
||||
color: #ffffff;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 14px 30px rgba(21, 52, 79, 0.18);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.restore-copy {
|
||||
|
||||
@@ -11,13 +11,7 @@
|
||||
<div class="brand-content-wrapper">
|
||||
<!-- 顶部系统微标 -->
|
||||
<div class="brand-mini-logo">
|
||||
<div class="mini-logo-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<img class="mini-logo-icon" src="/icons/ctms-icon-192.png" alt="" aria-hidden="true" />
|
||||
<span class="mini-logo-text">华邦制药 · CTMS</span>
|
||||
</div>
|
||||
|
||||
@@ -84,13 +78,7 @@
|
||||
<div class="login-card-container">
|
||||
<!-- 顶部 Logo 组合 -->
|
||||
<div class="login-brand-header">
|
||||
<div class="brand-logo-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<img class="brand-logo-icon" src="/icons/ctms-icon-192.png" alt="" aria-hidden="true" />
|
||||
<div class="brand-divider"></div>
|
||||
<span class="brand-system-text">CTMS</span>
|
||||
</div>
|
||||
@@ -598,14 +586,12 @@ const onSubmit = async () => {
|
||||
}
|
||||
|
||||
.mini-logo-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 55px;
|
||||
height: 55px;
|
||||
background: #2563eb;
|
||||
background: #ffffff;
|
||||
border-radius: 14px;
|
||||
color: white;
|
||||
box-shadow: 0 12px 26px rgba(37, 99, 235, 0.18);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.mini-logo-text {
|
||||
@@ -811,14 +797,12 @@ const onSubmit = async () => {
|
||||
}
|
||||
|
||||
.brand-logo-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
background: #2563eb;
|
||||
background: #ffffff;
|
||||
border-radius: 9px;
|
||||
color: white;
|
||||
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.16);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brand-divider {
|
||||
|
||||
@@ -8,13 +8,7 @@
|
||||
<div class="brand-content-wrapper">
|
||||
<!-- 顶部系统微标 -->
|
||||
<div class="brand-mini-logo">
|
||||
<div class="mini-logo-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<img class="mini-logo-icon" src="/icons/ctms-icon-192.png" alt="" aria-hidden="true" />
|
||||
<span class="mini-logo-text">华邦制药 · CTMS</span>
|
||||
</div>
|
||||
|
||||
@@ -77,13 +71,7 @@
|
||||
<div class="reg-card-container">
|
||||
<!-- 顶部 Logo -->
|
||||
<div class="reg-brand-header">
|
||||
<div class="brand-logo-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<img class="brand-logo-icon" src="/icons/ctms-icon-192.png" alt="" aria-hidden="true" />
|
||||
<div class="brand-divider"></div>
|
||||
<span class="brand-system-text">新用户注册</span>
|
||||
</div>
|
||||
@@ -821,14 +809,12 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.mini-logo-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 55px;
|
||||
height: 55px;
|
||||
background: #2563eb;
|
||||
background: #ffffff;
|
||||
border-radius: 14px;
|
||||
color: white;
|
||||
box-shadow: 0 12px 26px rgba(37, 99, 235, 0.18);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.mini-logo-text {
|
||||
@@ -989,14 +975,12 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.brand-logo-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
background: #2563eb;
|
||||
background: #ffffff;
|
||||
border-radius: 9px;
|
||||
color: white;
|
||||
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.16);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brand-divider {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="sidebar-brand">
|
||||
<div class="brand-mark"><span>CTMS</span></div>
|
||||
<div class="brand-text">
|
||||
<span class="brand-kicker">Desktop Workbench</span>
|
||||
<span class="brand-kicker">Web Workspace</span>
|
||||
<h1>工作台总控</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,6 +67,30 @@ describe("audit logs access", () => {
|
||||
expect(source).not.toContain("limit: 2000");
|
||||
});
|
||||
|
||||
it("surfaces request source, IP and location filters for audit troubleshooting", () => {
|
||||
const source = readAuditLogsView();
|
||||
|
||||
expect(source).toContain("filters.clientType");
|
||||
expect(source).toContain("filters.ipKeyword");
|
||||
expect(source).toContain("TEXT.modules.adminAuditLogs.filterSource");
|
||||
expect(source).toContain("TEXT.modules.adminAuditLogs.filterIpLocation");
|
||||
expect(source).toContain("TEXT.modules.adminAuditLogs.columns.source");
|
||||
expect(source).toContain("formatClientIpLine(scope.row)");
|
||||
expect(source).toContain("client_ip: ipKeyword && isIpSearchToken(ipKeyword) ? ipKeyword : undefined");
|
||||
});
|
||||
|
||||
it("includes request source context in audit exports", () => {
|
||||
const columns = readFileSync(resolve(__dirname, "../../audit/export/auditExportColumns.ts"), "utf8");
|
||||
const formatter = readFileSync(resolve(__dirname, "../../audit/export/auditExportFormatter.ts"), "utf8");
|
||||
|
||||
expect(columns).toContain('key: "ipLocation"');
|
||||
expect(columns).toContain('key: "clientSource"');
|
||||
expect(columns).toContain('key: "userAgent"');
|
||||
expect(formatter).toContain("ip: event.clientIp || \"\"");
|
||||
expect(formatter).toContain("clientSource: event.clientSourceLabel || \"\"");
|
||||
expect(formatter).toContain("userAgent: event.userAgent || \"\"");
|
||||
});
|
||||
|
||||
it("keeps the audit table compact by removing the duplicate content column", () => {
|
||||
const source = readAuditLogsView();
|
||||
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-value">{{ operatorCount }}</span>
|
||||
<span class="stat-label">操作人数</span>
|
||||
<span class="stat-value">{{ uniqueIpCount }}</span>
|
||||
<span class="stat-label">来源 IP</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,6 +59,22 @@
|
||||
<el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-item-form">
|
||||
<el-select v-model="filters.clientType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterSource" @change="onServerFilterChange" class="filter-select-source">
|
||||
<el-option v-for="opt in sourceTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-item-form">
|
||||
<el-input
|
||||
v-model="filters.ipKeyword"
|
||||
clearable
|
||||
:placeholder="TEXT.modules.adminAuditLogs.filterIpLocation"
|
||||
class="filter-input-ip"
|
||||
@input="onLocalFilterChange"
|
||||
@clear="onServerFilterChange"
|
||||
@change="onServerFilterChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-item-form">
|
||||
<el-select v-model="filters.result" clearable :placeholder="TEXT.modules.adminAuditLogs.filterResult" @change="onLocalFilterChange" class="filter-select-result">
|
||||
<el-option :label="TEXT.audit.resultSuccess" value="SUCCESS" />
|
||||
@@ -105,6 +121,16 @@
|
||||
<template #default="scope">
|
||||
<div class="actor-cell">
|
||||
<span class="actor-name">{{ scope.row.actorName }}</span>
|
||||
<span v-if="scope.row.actorAccount" class="actor-account">{{ scope.row.actorAccount }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.source" width="230">
|
||||
<template #default="scope">
|
||||
<div class="source-cell">
|
||||
<span class="source-main">{{ scope.row.clientSourceLabel }}</span>
|
||||
<span class="source-meta">{{ formatClientMeta(scope.row) }}</span>
|
||||
<span class="source-ip">{{ formatClientIpLine(scope.row) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -211,6 +237,34 @@
|
||||
<span class="meta-value">{{ selectedLog.actionText || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-meta-grid">
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">请求来源</span>
|
||||
<span class="meta-value">{{ selectedLog.clientSourceLabel || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">来源 IP</span>
|
||||
<span class="meta-value">{{ selectedLog.clientIp || "未知 IP" }}</span>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">IP 位置</span>
|
||||
<span class="meta-value">{{ selectedLog.ipLocation || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">客户端</span>
|
||||
<span class="meta-value">{{ formatClientMeta(selectedLog) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 0 20"/><path d="M12 2a15.3 15.3 0 0 0 0 20"/></svg>
|
||||
访问上下文
|
||||
</div>
|
||||
<div class="context-lines">
|
||||
<div><span>User-Agent</span><code>{{ selectedLog.userAgent || TEXT.common.fallback }}</code></div>
|
||||
<div><span>构建信息</span><code>{{ formatBuildMeta(selectedLog) }}</code></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 变更明细 -->
|
||||
<div class="detail-section">
|
||||
@@ -265,7 +319,6 @@ import { listMembers } from "../../api/members";
|
||||
import { auditDict, normalizeAuditEvent } from "../../audit";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { roleDict, getDictLabel } from "../../dictionaries";
|
||||
import { exportAuditCsv } from "../../audit/export/auditExportService";
|
||||
import { displayDateTime } from "../../utils/display";
|
||||
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
|
||||
@@ -296,13 +349,15 @@ const DIFF_PREVIEW_LIMIT = 2;
|
||||
const filters = ref({
|
||||
eventType: "",
|
||||
operatorId: "",
|
||||
clientType: "",
|
||||
ipKeyword: "",
|
||||
result: "",
|
||||
range: [],
|
||||
});
|
||||
|
||||
const successCount = computed(() => allLogs.value.filter(l => (l.result || 'SUCCESS') === 'SUCCESS').length);
|
||||
const failCount = computed(() => allLogs.value.filter(l => l.result === 'FAIL').length);
|
||||
const operatorCount = computed(() => new Set(allLogs.value.map(l => l.actorId || l.actorName)).size);
|
||||
const uniqueIpCount = computed(() => new Set(allLogs.value.map(l => l.clientIp || "未知 IP")).size);
|
||||
|
||||
const formatDiffLine = (line: any): string => {
|
||||
if (typeof line === 'string') return line;
|
||||
@@ -375,6 +430,11 @@ const eventTypeOptions = Object.entries(auditDict).map(([value, cfg]) => ({
|
||||
value,
|
||||
label: cfg.label,
|
||||
}));
|
||||
const sourceTypeOptions = [
|
||||
{ value: "web", label: "网页端" },
|
||||
{ value: "desktop", label: "桌面端" },
|
||||
{ value: "unknown", label: "未知/异常" },
|
||||
];
|
||||
const resolveUserDisplayName = (u: any): string => {
|
||||
return u?.full_name || u?.display_name || u?.username || u?.email || u?.id || TEXT.common.fallback;
|
||||
};
|
||||
@@ -470,6 +530,34 @@ const fetchAuditLogPages = async (baseParams: Record<string, any> = {}) => {
|
||||
return allItems;
|
||||
};
|
||||
|
||||
const isIpSearchToken = (value: string) => /^[0-9a-f:.]+$/i.test(value.trim());
|
||||
|
||||
const buildAuditServerParams = () => {
|
||||
const ipKeyword = String(filters.value.ipKeyword || "").trim();
|
||||
return {
|
||||
action: filters.value.eventType || undefined,
|
||||
operator_id: filters.value.operatorId || undefined,
|
||||
client_type: filters.value.clientType || undefined,
|
||||
client_ip: ipKeyword && isIpSearchToken(ipKeyword) ? ipKeyword : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const formatClientMeta = (log: any) => {
|
||||
const meta = [log.clientType, log.clientVersion, log.clientPlatform].filter(Boolean).join("/");
|
||||
if (meta) return meta;
|
||||
return log.userAgent ? "未标识客户端" : TEXT.common.fallback;
|
||||
};
|
||||
|
||||
const formatBuildMeta = (log: any) => {
|
||||
const meta = [log.buildChannel, log.buildCommit].filter(Boolean).join("/");
|
||||
return meta || TEXT.common.fallback;
|
||||
};
|
||||
|
||||
const formatClientIpLine = (log: any) => {
|
||||
const parts = [log.clientIp || "未知 IP", log.ipLocation].filter(Boolean);
|
||||
return parts.join(" / ");
|
||||
};
|
||||
|
||||
const loadLogs = async () => {
|
||||
if (!selectedStudyId.value) return;
|
||||
loading.value = true;
|
||||
@@ -477,10 +565,7 @@ const loadLogs = async () => {
|
||||
if (!permissionMatrix.value) {
|
||||
await loadPermissionMatrix();
|
||||
}
|
||||
const allItems = await fetchAuditLogPages({
|
||||
action: filters.value.eventType || undefined,
|
||||
operator_id: filters.value.operatorId || undefined,
|
||||
});
|
||||
const allItems = await fetchAuditLogPages(buildAuditServerParams());
|
||||
enrichLogs(allItems);
|
||||
refreshPagedLogs();
|
||||
} catch (e: any) {
|
||||
@@ -498,20 +583,36 @@ const enrichLogs = (items: any[]) => {
|
||||
allLogs.value = items
|
||||
.map((log) => normalizeAuditEvent(log, userMap))
|
||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
if (users.value.length === 0) {
|
||||
const byActor = new Map<string, string>();
|
||||
allLogs.value.forEach((log) => {
|
||||
if (!byActor.has(log.actorId)) {
|
||||
byActor.set(log.actorId, log.actorName || log.actorId);
|
||||
}
|
||||
});
|
||||
users.value = Array.from(byActor.entries()).map(([id, full_name]) => ({ id, full_name }));
|
||||
}
|
||||
const byActor = new Map(users.value.map((user) => [user.id, resolveUserDisplayName(user)]));
|
||||
allLogs.value.forEach((log) => {
|
||||
if (!byActor.has(log.actorId)) {
|
||||
byActor.set(log.actorId, log.actorName || log.actorAccount || log.actorId);
|
||||
}
|
||||
});
|
||||
users.value = Array.from(byActor.entries()).map(([id, full_name]) => ({ id, full_name }));
|
||||
};
|
||||
|
||||
const filterLogs = (items: any[]) =>
|
||||
items.filter((log) => {
|
||||
if (filters.value.result && (log.result || "SUCCESS") !== filters.value.result) return false;
|
||||
const ipKeyword = String(filters.value.ipKeyword || "").trim().toLowerCase();
|
||||
if (ipKeyword) {
|
||||
const haystack = [
|
||||
log.clientIp,
|
||||
log.ipLocation,
|
||||
log.ipCountry,
|
||||
log.ipProvince,
|
||||
log.ipCity,
|
||||
log.ipIsp,
|
||||
log.clientSourceLabel,
|
||||
log.clientType,
|
||||
log.clientPlatform,
|
||||
log.userAgent,
|
||||
log.actorName,
|
||||
log.actorAccount,
|
||||
];
|
||||
if (!haystack.some((value) => String(value || "").toLowerCase().includes(ipKeyword))) return false;
|
||||
}
|
||||
if (filters.value.range?.length === 2) {
|
||||
const ts = new Date(log.timestamp);
|
||||
const start = new Date(filters.value.range[0]);
|
||||
@@ -582,24 +683,12 @@ const fetchAllForExport = async () => {
|
||||
if (!selectedStudyId.value) return [];
|
||||
exportLoading.value = true;
|
||||
try {
|
||||
const items = await fetchAuditLogPages({
|
||||
action: filters.value.eventType || undefined,
|
||||
operator_id: filters.value.operatorId || undefined,
|
||||
});
|
||||
const items = await fetchAuditLogPages(buildAuditServerParams());
|
||||
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
|
||||
acc[cur.id] = resolveUserDisplayName(cur);
|
||||
return acc;
|
||||
}, {});
|
||||
const filtered = items.filter((log: any) => {
|
||||
if (filters.value.range?.length === 2) {
|
||||
const ts = new Date(log.created_at);
|
||||
const start = new Date(filters.value.range[0]);
|
||||
const end = new Date(filters.value.range[1]);
|
||||
if (ts < start || ts > end) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return filtered.map((log: any) => normalizeAuditEvent(log, userMap));
|
||||
return filterLogs(items.map((log: any) => normalizeAuditEvent(log, userMap)));
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminAuditLogs.exportLoadFailed);
|
||||
return [];
|
||||
@@ -701,7 +790,7 @@ onMounted(async () => {
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-item-form {
|
||||
@@ -712,9 +801,11 @@ onMounted(async () => {
|
||||
|
||||
.filter-select-comp { width: 132px; }
|
||||
.filter-select-project { width: 180px; }
|
||||
.filter-select-source { width: 112px; }
|
||||
.filter-input-ip { width: 170px; }
|
||||
.filter-select-result { width: 96px; }
|
||||
.date-range-picker-comp { width: 248px !important; }
|
||||
.filter-spacer { flex: 1; }
|
||||
.filter-spacer { flex: 1; min-width: 16px; }
|
||||
|
||||
.audit-export-dropdown { flex-shrink: 0; }
|
||||
.audit-export-btn {
|
||||
@@ -748,12 +839,56 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.actor-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.actor-name {
|
||||
.actor-name,
|
||||
.actor-account {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.actor-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.actor-account {
|
||||
font-size: 11px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.source-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.source-main {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
max-width: 100%;
|
||||
padding: 1px 7px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #155e75;
|
||||
background: #ecfeff;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-meta,
|
||||
.source-ip {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--ctms-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -923,6 +1058,35 @@ onMounted(async () => {
|
||||
color: var(--ctms-primary);
|
||||
}
|
||||
|
||||
.context-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.context-lines div {
|
||||
display: grid;
|
||||
grid-template-columns: 92px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.context-lines span {
|
||||
color: var(--ctms-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.context-lines code {
|
||||
display: block;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
background: var(--ctms-neutral-100);
|
||||
color: var(--ctms-text-main);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.diff-count {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -16,7 +16,8 @@ describe("project management access", () => {
|
||||
const layout = readLayout();
|
||||
const router = readRouter();
|
||||
|
||||
expect(layout).toContain('v-if="auth.user"');
|
||||
expect(layout).toContain('v-if="showAdminNavigation"');
|
||||
expect(layout).toContain("const showAdminNavigation = computed(() => Boolean(auth.user)");
|
||||
expect(layout).toContain('index="/admin/projects"');
|
||||
expect(router).toContain('name: "AdminProjects"');
|
||||
expect(router).toContain("meta: { title: TEXT.menu.projectManagement }");
|
||||
@@ -75,7 +76,14 @@ describe("project management access", () => {
|
||||
expect(source).toContain(':label="TEXT.common.fields.status" align="center" width="150"');
|
||||
expect(source).toContain('label="锁定状态" align="center" width="150"');
|
||||
expect(source).toContain(':label="TEXT.common.labels.actions" align="center" width="300" fixed="right"');
|
||||
expect(source).not.toContain('projectColumnWidth');
|
||||
expect(source).toContain("min-width: 252px;");
|
||||
expect(source).toContain("min-width: 1060px;");
|
||||
});
|
||||
|
||||
it("reserves enough width for the densest project action set", () => {
|
||||
const source = readProjects();
|
||||
|
||||
expect(source).toContain('width="300" fixed="right"');
|
||||
expect(source).toContain("min-width: 252px;");
|
||||
expect(source).toContain("min-width: 1060px;");
|
||||
});
|
||||
|
||||
@@ -350,6 +350,18 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page,
|
||||
.main-content-flat,
|
||||
.user-table-section {
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.main-content-flat {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
@@ -427,19 +439,24 @@ onBeforeUnmount(() => {
|
||||
width: 100%;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-item-form {
|
||||
margin-bottom: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filter-input-comp {
|
||||
width: 220px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 用户单元格 */
|
||||
@@ -546,12 +563,18 @@ onBeforeUnmount(() => {
|
||||
/* 表格区域 */
|
||||
.user-table-section {
|
||||
padding: 0;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.user-table :deep(.el-table__inner-wrapper::before) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-table :deep(.el-scrollbar__bar.is-horizontal) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-table :deep(th.el-table__cell) {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
@@ -567,11 +590,24 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 8px 16px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination) {
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
row-gap: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||