3 Commits

Author SHA1 Message Date
Cheng Zhou f33cc0948e feat(desktop): 支持桌面端三十天免登录 2026-07-02 09:06:24 +08:00
Cheng Zhou 46f488c8ae feat(desktop): 稳定桌面端界面与文件操作反馈
- 重构 DesktopPreferences 为分栏式设置面板,整合连接、外观、通知、更新与诊断信息分区,并补充过渡动效与暗色主题样式
- DesktopLayout 侧边栏导航分组支持展开折叠,调整管理/项目区块顺序并统一图标与标题
- 新增 fileTaskFeedback 工具,统一 pickFiles/saveFile/openFile 的成功/取消提示,替换审计导出、权限日志、附件、文档、线程、项目配置等处的直接调用
- desktopUpdateManager 暴露更新状态快照与状态变更监听,区分检查中、安装中、已推迟、失败等状态
- DesktopServerSettings 增加连接诊断信息(检查时间、健康地址、耗时、HTTP 状态)
- unified-page.css 与 ProjectMilestones 引入 CSS 变量以适配暗色主题
- WebLayout 将服务器设置入口改为打开系统偏好面板,管理菜单中邮件服务归入系统设置分组
- ProfileSettings 移除已迁入偏好面板的桌面端专属区块
- 补充 Layout.desktop 布局与偏好面板契约测试
2026-07-01 17:04:23 +08:00
Cheng Zhou d60a2fa5b2 fix(auth): 支持无邮箱后缀时手动输入 2026-07-01 14:37:57 +08:00
30 changed files with 2493 additions and 642 deletions
+54 -11
View File
@@ -1,5 +1,6 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from dataclasses import dataclass
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from fastapi import File, UploadFile from fastapi import File, UploadFile
from pydantic import BaseModel, EmailStr, Field from pydantic import BaseModel, EmailStr, Field
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -70,12 +71,50 @@ AVATAR_ALLOWED_CONTENT_TYPES = {
} }
def issue_user_token(db_user) -> Token: @dataclass(frozen=True)
class SessionPolicy:
access_minutes: int
absolute_max_seconds: int
def normalize_session_client_type(value: str | None) -> str:
return "desktop" if (value or "").strip().lower() == "desktop" else "web"
def get_session_policy_for_client_type(client_type: str) -> SessionPolicy:
if client_type == "desktop":
max_seconds = settings.DESKTOP_SESSION_MAX_DAYS * 24 * 3600
return SessionPolicy(
access_minutes=settings.DESKTOP_SESSION_MAX_DAYS * 24 * 60,
absolute_max_seconds=max_seconds,
)
return SessionPolicy(
access_minutes=settings.JWT_EXPIRE_MINUTES,
absolute_max_seconds=settings.ABSOLUTE_SESSION_MAX_HOURS * 3600,
)
def get_request_session_client_type(request: Request) -> str:
return normalize_session_client_type(request.headers.get("x-ctms-client-type"))
def policy_expires_at(issued_at: datetime, session_start: datetime, policy: SessionPolicy) -> datetime:
access_expires_at = issued_at + timedelta(minutes=policy.access_minutes)
session_expires_at = session_start + timedelta(seconds=policy.absolute_max_seconds)
return min(access_expires_at, session_expires_at)
def issue_user_token(db_user, request: Request) -> Token:
session_start = datetime.now(timezone.utc) session_start = datetime.now(timezone.utc)
client_type = get_request_session_client_type(request)
policy = get_session_policy_for_client_type(client_type)
access_token = create_access_token( access_token = create_access_token(
user_id=str(db_user.id), user_id=str(db_user.id),
expires_minutes=None, expires_minutes=policy.access_minutes,
session_start=session_start, session_start=session_start,
max_age_seconds=policy.absolute_max_seconds,
issued_at=session_start,
client_type=client_type,
) )
return Token(access_token=access_token, token_type="bearer") return Token(access_token=access_token, token_type="bearer")
@@ -240,23 +279,23 @@ async def get_login_key() -> LoginKeyResponse:
@router.post("/login", response_model=Token) @router.post("/login", response_model=Token)
async def login_for_access_token( async def login_for_access_token(
payload: LoginRequest, db: AsyncSession = Depends(get_db_session) payload: LoginRequest, request: Request, db: AsyncSession = Depends(get_db_session)
) -> Token: ) -> Token:
db_user = await authenticate_encrypted_password(payload, db) db_user = await authenticate_encrypted_password(payload, db)
ensure_user_active(db_user) ensure_user_active(db_user)
return issue_user_token(db_user) return issue_user_token(db_user, request)
@router.post("/dev-login", response_model=Token) @router.post("/dev-login", response_model=Token)
async def dev_login_for_access_token( async def dev_login_for_access_token(
payload: DevLoginRequest, db: AsyncSession = Depends(get_db_session) payload: DevLoginRequest, request: Request, db: AsyncSession = Depends(get_db_session)
) -> Token: ) -> Token:
if settings.ENV != "development": if settings.ENV != "development":
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
db_user = await authenticate_plain_password(payload, db) db_user = await authenticate_plain_password(payload, db)
ensure_user_active(db_user) ensure_user_active(db_user)
return issue_user_token(db_user) return issue_user_token(db_user, request)
@router.get("/me", response_model=UserRead) @router.get("/me", response_model=UserRead)
@@ -285,19 +324,23 @@ async def extend_access_token(
if db_user.status != UserStatus.ACTIVE: if db_user.status != UserStatus.ACTIVE:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已停用") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已停用")
session_start_ts = payload.get("orig_iat") or payload.get("iat") session_start_ts = payload.get("orig_iat") or payload.get("iat")
policy = get_session_policy_for_client_type(normalize_session_client_type(payload.get("client_type")))
if session_start_ts: if session_start_ts:
max_seconds = settings.ABSOLUTE_SESSION_MAX_HOURS * 3600 if now_ts - int(session_start_ts) > policy.absolute_max_seconds:
if now_ts - int(session_start_ts) > max_seconds:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="会话已到期,请重新登录") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="会话已到期,请重新登录")
session_start = datetime.fromtimestamp(int(session_start_ts), tz=timezone.utc) session_start = datetime.fromtimestamp(int(session_start_ts), tz=timezone.utc)
else: else:
session_start = datetime.now(timezone.utc) session_start = datetime.now(timezone.utc)
issued_at = datetime.now(timezone.utc)
new_token = create_access_token( new_token = create_access_token(
user_id=str(db_user.id), user_id=str(db_user.id),
expires_minutes=None, expires_minutes=policy.access_minutes,
session_start=session_start, session_start=session_start,
max_age_seconds=policy.absolute_max_seconds,
issued_at=issued_at,
client_type=normalize_session_client_type(payload.get("client_type")),
) )
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.JWT_EXPIRE_MINUTES) expires_at = policy_expires_at(issued_at, session_start, policy)
return ExtendResponse(accessToken=new_token, expiresAt=expires_at) return ExtendResponse(accessToken=new_token, expiresAt=expires_at)
+1
View File
@@ -19,6 +19,7 @@ class Settings(BaseSettings):
JWT_EXPIRE_MINUTES: int = 60 JWT_EXPIRE_MINUTES: int = 60
JWT_EXTEND_GRACE_SECONDS: int = 120 JWT_EXTEND_GRACE_SECONDS: int = 120
ABSOLUTE_SESSION_MAX_HOURS: int = 8 ABSOLUTE_SESSION_MAX_HOURS: int = 8
DESKTOP_SESSION_MAX_DAYS: int = 30
LOGIN_RSA_PRIVATE_KEY: Optional[str] = None LOGIN_RSA_PRIVATE_KEY: Optional[str] = None
LOGIN_RSA_PUBLIC_KEY: Optional[str] = None LOGIN_RSA_PUBLIC_KEY: Optional[str] = None
LOGIN_RSA_KEY_ID: str = "default" LOGIN_RSA_KEY_ID: str = "default"
+14 -1
View File
@@ -19,16 +19,29 @@ def create_access_token(
user_id: str, user_id: str,
expires_minutes: Optional[int] = None, expires_minutes: Optional[int] = None,
session_start: Optional[datetime] = None, session_start: Optional[datetime] = None,
max_age_seconds: Optional[int] = None,
issued_at: Optional[datetime] = None,
client_type: Optional[str] = None,
) -> str: ) -> str:
now = datetime.now(timezone.utc) now = issued_at or datetime.now(timezone.utc)
if now.tzinfo is None:
now = now.replace(tzinfo=timezone.utc)
expire = now + timedelta(minutes=expires_minutes or settings.JWT_EXPIRE_MINUTES) expire = now + timedelta(minutes=expires_minutes or settings.JWT_EXPIRE_MINUTES)
session_start_time = session_start or now session_start_time = session_start or now
if session_start_time.tzinfo is None:
session_start_time = session_start_time.replace(tzinfo=timezone.utc)
if max_age_seconds is not None:
session_expire = session_start_time + timedelta(seconds=max_age_seconds)
if expire > session_expire:
expire = session_expire
to_encode: Dict[str, Any] = { to_encode: Dict[str, Any] = {
"sub": user_id, "sub": user_id,
"exp": expire, "exp": expire,
"iat": int(now.timestamp()), "iat": int(now.timestamp()),
"orig_iat": int(session_start_time.timestamp()), "orig_iat": int(session_start_time.timestamp()),
} }
if client_type:
to_encode["client_type"] = client_type
return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=ALGORITHM) return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=ALGORITHM)
+69 -1
View File
@@ -16,7 +16,7 @@ import os
from app.main import create_app from app.main import create_app
from app.core.config import settings from app.core.config import settings
from app.core.deps import get_db_session from app.core.deps import get_db_session
from app.core.security import hash_password, verify_password from app.core.security import create_access_token, decode_token_allow_expired, hash_password, verify_password
from app.crud import user as user_crud from app.crud import user as user_crud
from app.db.base_class import Base from app.db.base_class import Base
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
@@ -422,6 +422,74 @@ async def test_registered_user_can_login_after_email_verification(client_and_db)
assert resp.json()["access_token"] assert resp.json()["access_token"]
@pytest.mark.asyncio
async def test_desktop_login_uses_30_day_token_without_changing_web_login(client_and_db):
client, _ = client_and_db
web_resp = await encrypted_login(client, "admin@test.com", "admin123")
desktop_resp = await client.post(
"/api/v1/auth/login",
json=await encrypted_auth_payload(client, "admin@test.com", "admin123"),
headers={"X-CTMS-Client-Type": "desktop"},
)
assert web_resp.status_code == 200
assert desktop_resp.status_code == 200
web_payload = decode_token_allow_expired(web_resp.json()["access_token"])
desktop_payload = decode_token_allow_expired(desktop_resp.json()["access_token"])
assert web_payload["client_type"] == "web"
assert desktop_payload["client_type"] == "desktop"
assert web_payload["exp"] - web_payload["iat"] == settings.JWT_EXPIRE_MINUTES * 60
assert desktop_payload["exp"] - desktop_payload["iat"] == settings.DESKTOP_SESSION_MAX_DAYS * 24 * 3600
@pytest.mark.asyncio
async def test_web_token_extension_cannot_be_upgraded_with_desktop_header(client_and_db):
client, _ = client_and_db
web_resp = await encrypted_login(client, "admin@test.com", "admin123")
token = web_resp.json()["access_token"]
resp = await client.post(
"/api/v1/auth/extend",
headers={
"Authorization": f"Bearer {token}",
"X-CTMS-Client-Type": "desktop",
},
)
assert resp.status_code == 200
payload = decode_token_allow_expired(resp.json()["accessToken"])
assert payload["client_type"] == "web"
assert payload["exp"] - payload["iat"] == settings.JWT_EXPIRE_MINUTES * 60
@pytest.mark.asyncio
async def test_desktop_token_extension_rejects_sessions_after_30_days(client_and_db):
client, SessionLocal = client_and_db
async with SessionLocal() as session:
admin = await user_crud.get_by_email(session, "admin@test.com")
session_start = datetime.now(timezone.utc) - timedelta(days=settings.DESKTOP_SESSION_MAX_DAYS, seconds=1)
token = create_access_token(
user_id=str(admin.id),
expires_minutes=settings.DESKTOP_SESSION_MAX_DAYS * 24 * 60,
session_start=session_start,
max_age_seconds=settings.DESKTOP_SESSION_MAX_DAYS * 24 * 3600,
client_type="desktop",
)
resp = await client.post(
"/api/v1/auth/extend",
headers={
"Authorization": f"Bearer {token}",
"X-CTMS-Client-Type": "desktop",
},
)
assert resp.status_code == 401
assert "会话已到期" in resp.json().get("detail", "")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_admin_created_user_is_active_by_default(client_and_db): async def test_admin_created_user_is_active_by_default(client_and_db):
client, SessionLocal = client_and_db client, SessionLocal = client_and_db
@@ -63,6 +63,7 @@ npm run desktop:build:app
| 场景 | Web | macOS Desktop | 预期 | | 场景 | Web | macOS Desktop | 预期 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 登录与项目恢复 | 必测 | 必测 | 登录成功后恢复可访问项目;401 后重新登录 | | 登录与项目恢复 | 必测 | 必测 | 登录成功后恢复可访问项目;401 后重新登录 |
| 30 天免登录 | 不适用 | 必测 | 关闭并重启 App 后复用系统凭据库中的后端在线会话;超过 30 天或 `/me` 校验失败后重新登录 |
| 服务器地址未配置 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 | | 服务器地址未配置 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 |
| 服务器地址切换 | 不适用 | 必测 | 清除当前会话和项目上下文,要求重新登录 | | 服务器地址切换 | 不适用 | 必测 | 清除当前会话和项目上下文,要求重新登录 |
| 服务端不可达 | 必测 | 必测 | 显示可恢复错误,不进入离线模式 | | 服务端不可达 | 必测 | 必测 | 显示可恢复错误,不进入离线模式 |
@@ -80,6 +81,7 @@ npm run desktop:build:app
## 4. 桌面体验验收 ## 4. 桌面体验验收
- [ ] 登录页显示当前桌面服务器地址,长 URL 不撑破登录面板。 - [ ] 登录页显示当前桌面服务器地址,长 URL 不撑破登录面板。
- [ ] 30 天免登录仍只保存系统凭据库会话记录,不保存密码,不把 token 写入 URL、日志、通知正文或业务缓存。
- [ ] 服务器设置页显示当前服务器、连接检查状态、HTTP 错误、超时和网络失败原因。 - [ ] 服务器设置页显示当前服务器、连接检查状态、HTTP 错误、超时和网络失败原因。
- [ ] 个人中心显示客户端类型、版本、平台、构建通道、提交、服务器和能力状态。 - [ ] 个人中心显示客户端类型、版本、平台、构建通道、提交、服务器和能力状态。
- [ ] 个人中心可复制诊断信息,内容不包含 token 或业务敏感数据。 - [ ] 个人中心可复制诊断信息,内容不包含 token 或业务敏感数据。
+3 -1
View File
@@ -34,13 +34,15 @@
- macOSKeychain。 - macOSKeychain。
- WindowsCredential Manager。 - WindowsCredential Manager。
桌面端登录使用后端签发的在线会话 token,可在系统凭据库中保存最长 30 天,以支持重启 App 后免输入密码。启动恢复后仍必须使用后端 token 校验和 `/auth/me` 用户状态校验;服务端不可达或会话被后端拒绝时不得进入离线模式。
Rust 仅暴露固定 service 下的读取、写入、删除命令。凭据 account 使用规范化服务端 origin 的 SHA-256,避免明文服务端地址散落在系统凭据项名称中。 Rust 仅暴露固定 service 下的读取、写入、删除命令。凭据 account 使用规范化服务端 origin 的 SHA-256,避免明文服务端地址散落在系统凭据项名称中。
应用挂载前异步初始化 token 应用挂载前异步初始化 token
1. Web 端继续读取 `localStorage.ctms_token` 1. Web 端继续读取 `localStorage.ctms_token`
2. 桌面端先删除 legacy `localStorage.ctms_token` 2. 桌面端先删除 legacy `localStorage.ctms_token`
3. 若 legacy token 仍有效,则迁移系统凭据库。 3. 若 legacy token 仍有效,则迁移为带 30 天本机到期时间的系统凭据库会话记录
4. 若迁移或读取凭据失败,则内存 token 置空并要求重新登录,不回退明文存储。 4. 若迁移或读取凭据失败,则内存 token 置空并要求重新登录,不回退明文存储。
登出、服务器切换、认证失效时必须同步清除内存 token 和当前服务端 origin 对应的系统凭据。 登出、服务器切换、认证失效时必须同步清除内存 token 和当前服务端 origin 对应的系统凭据。
+1
View File
@@ -59,6 +59,7 @@
- `apiBaseUrl`:分别解析 Web 和桌面端的服务端 API 地址。 - `apiBaseUrl`:分别解析 Web 和桌面端的服务端 API 地址。
- `desktopServerConfig`:管理桌面服务端地址配置和切换事件。 - `desktopServerConfig`:管理桌面服务端地址配置和切换事件。
- `secureSessionStorage`:隔离浏览器 token 存储与桌面系统凭据库。 - `secureSessionStorage`:隔离浏览器 token 存储与桌面系统凭据库。
- 桌面端允许保存后端签发的最长 30 天在线会话,用于重启 App 后免输入密码;该会话必须存放在系统凭据库中,启动后仍需由后端 token 和 `/me` 校验确认身份,不等同于离线登录。
- `files`:隔离浏览器上传下载与原生文件能力。 - `files`:隔离浏览器上传下载与原生文件能力。
- `notifications`:隔离 Web 通知与桌面系统通知。 - `notifications`:隔离 Web 通知与桌面系统通知。
- `updates`:隔离桌面自动更新检查与安装入口。 - `updates`:隔离桌面自动更新检查与安装入口。
@@ -1,7 +1,7 @@
import { auditExportColumns } from "./auditExportColumns"; import { auditExportColumns } from "./auditExportColumns";
import { formatAuditRows } from "./auditExportFormatter"; import { formatAuditRows } from "./auditExportFormatter";
import type { AuditEvent } from ".."; import type { AuditEvent } from "..";
import { saveFile } from "../../runtime"; import { saveFileWithFeedback } from "../../utils/fileTaskFeedback";
const BOM = "\ufeff"; const BOM = "\ufeff";
@@ -24,7 +24,7 @@ export const exportAuditCsv = async (events: AuditEvent[], options: AuditExportO
const rows = formatAuditRows(events); const rows = formatAuditRows(events);
const csv = buildCsv(rows); const csv = buildCsv(rows);
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
await saveFile({ await saveFileWithFeedback({
suggestedName: options.fileName.endsWith(".csv") ? options.fileName : `${options.fileName}.csv`, suggestedName: options.fileName.endsWith(".csv") ? options.fileName : `${options.fileName}.csv`,
mimeType: "text/csv;charset=utf-8", mimeType: "text/csv;charset=utf-8",
data: blob, data: blob,
+441 -165
View File
@@ -1,15 +1,12 @@
<template> <template>
<div class="desktop-workbench"> <div class="desktop-workbench" @click="closeWorkspaceTabMenu">
<aside class="desktop-sidebar"> <aside class="desktop-sidebar">
<header class="sidebar-head"> <header class="sidebar-head">
<div class="sidebar-title-row"> <div class="sidebar-title-row">
<div> <div>
<p class="sidebar-kicker">Workspace</p> <p class="sidebar-kicker">Workspace</p>
<h1>{{ sidebarTitle }}</h1> <h1>{{ TEXT.common.appName }} Desktop</h1>
</div> </div>
<button class="icon-button" type="button" title="搜索命令" @click="openCommandPalette">
<el-icon><Search /></el-icon>
</button>
</div> </div>
<el-dropdown v-if="study.currentStudy" trigger="click" class="study-switcher" @command="handleStudySwitch"> <el-dropdown v-if="study.currentStudy" trigger="click" class="study-switcher" @command="handleStudySwitch">
@@ -66,6 +63,47 @@
</button> </button>
</section> </section>
<section v-if="adminNavigationItems.length" class="nav-section">
<div class="section-label">{{ TEXT.menu.admin }}</div>
<template v-for="item in adminNavigationItems" :key="item.path">
<button
v-if="!item.children?.length"
type="button"
class="sidebar-link"
:class="{ active: activeMenu === item.path }"
@click="router.push(item.path)"
>
<el-icon><component :is="iconFor(item.icon)" /></el-icon>
<span>{{ item.label }}</span>
</button>
<div v-else class="sidebar-group" :class="{ expanded: isNavigationGroupExpanded(item) }">
<button
type="button"
class="sidebar-link group-title"
:class="{ active: isNavigationGroupActive(item) }"
:aria-expanded="isNavigationGroupExpanded(item)"
@click="toggleNavigationGroup(item)"
>
<el-icon><component :is="iconFor(item.icon)" /></el-icon>
<span>{{ item.label }}</span>
<el-icon class="group-expander"><ArrowDown /></el-icon>
</button>
<div v-show="isNavigationGroupExpanded(item)" class="sidebar-children">
<button
v-for="child in item.children"
:key="child.path"
type="button"
class="sidebar-link child"
:class="{ active: activeMenu === child.path }"
@click="router.push(child.path)"
>
<span>{{ child.label }}</span>
</button>
</div>
</div>
</template>
</section>
<section v-if="projectNavigationItems.length" class="nav-section"> <section v-if="projectNavigationItems.length" class="nav-section">
<div class="section-label">{{ TEXT.menu.currentProject }}</div> <div class="section-label">{{ TEXT.menu.currentProject }}</div>
<template v-for="item in projectNavigationItems" :key="item.path"> <template v-for="item in projectNavigationItems" :key="item.path">
@@ -79,54 +117,33 @@
<el-icon><component :is="iconFor(item.icon)" /></el-icon> <el-icon><component :is="iconFor(item.icon)" /></el-icon>
<span>{{ item.label }}</span> <span>{{ item.label }}</span>
</button> </button>
<div v-else class="sidebar-group"> <div v-else class="sidebar-group" :class="{ expanded: isNavigationGroupExpanded(item) }">
<button <button
type="button" type="button"
class="sidebar-link group-title" class="sidebar-link group-title"
:class="{ active: item.children.some((child) => activeMenu === child.path) }" :class="{ active: isNavigationGroupActive(item) }"
@click="router.push(item.path)" :aria-expanded="isNavigationGroupExpanded(item)"
@click="toggleNavigationGroup(item)"
> >
<el-icon><component :is="iconFor(item.icon)" /></el-icon> <el-icon><component :is="iconFor(item.icon)" /></el-icon>
<span>{{ item.label }}</span> <span>{{ item.label }}</span>
<el-icon class="group-expander"><ArrowDown /></el-icon>
</button> </button>
<button <div v-show="isNavigationGroupExpanded(item)" class="sidebar-children">
v-for="child in item.children" <button
:key="child.path" v-for="child in item.children"
type="button" :key="child.path"
class="sidebar-link child" type="button"
:class="{ active: activeMenu === child.path }" class="sidebar-link child"
@click="router.push(child.path)" :class="{ active: activeMenu === child.path }"
> @click="router.push(child.path)"
<span>{{ child.label }}</span> >
</button> <span>{{ child.label }}</span>
</button>
</div>
</div> </div>
</template> </template>
</section> </section>
<section v-if="adminNavigationItems.length" class="nav-section">
<div class="section-label">{{ TEXT.menu.admin }}</div>
<template v-for="item in adminNavigationItems" :key="item.path">
<button
type="button"
class="sidebar-link"
:class="{ active: activeMenu === item.path || item.children?.some((child) => activeMenu === child.path) }"
@click="router.push(item.path)"
>
<el-icon><component :is="iconFor(item.icon)" /></el-icon>
<span>{{ item.label }}</span>
</button>
<button
v-for="child in item.children || []"
:key="child.path"
type="button"
class="sidebar-link child"
:class="{ active: activeMenu === child.path }"
@click="router.push(child.path)"
>
<span>{{ child.label }}</span>
</button>
</template>
</section>
</div> </div>
</aside> </aside>
@@ -150,6 +167,11 @@
</div> </div>
<div class="toolbar-right"> <div class="toolbar-right">
<button v-if="desktopUpdateNoticeVisible" class="update-notice" type="button" title="打开系统偏好安装更新" @click="openDesktopPreferences">
<el-icon><Download /></el-icon>
<span>{{ desktopUpdateNoticeLabel }}</span>
</button>
<button class="command-trigger" type="button" @click="openCommandPalette"> <button class="command-trigger" type="button" @click="openCommandPalette">
<el-icon><Search /></el-icon> <el-icon><Search /></el-icon>
<span>命令</span> <span>命令</span>
@@ -177,7 +199,7 @@
<span>客户端</span> <span>客户端</span>
<code>{{ desktopMetadata.version }} / {{ desktopMetadata.platform }}</code> <code>{{ desktopMetadata.version }} / {{ desktopMetadata.platform }}</code>
</div> </div>
<el-button size="small" @click="router.push('/desktop/server-settings')">服务器设置</el-button> <el-button size="small" @click="openDesktopPreferences">系统偏好</el-button>
</div> </div>
</el-popover> </el-popover>
@@ -252,9 +274,15 @@
v-for="item in workspaceTabs" v-for="item in workspaceTabs"
:key="item.path" :key="item.path"
class="workspace-tab" class="workspace-tab"
:class="{ active: activeMenu === item.path }" :class="{ active: activeMenu === item.path, dragging: draggingWorkspaceTabPath === item.path }"
draggable="true"
@dragstart="startWorkspaceTabDrag(item.path, $event)"
@dragover="allowWorkspaceTabDrop"
@drop.prevent="dropWorkspaceTab(item.path, $event)"
@dragend="draggingWorkspaceTabPath = ''"
@contextmenu.prevent.stop="openWorkspaceTabMenu(item, $event)"
> >
<button class="workspace-tab-action" type="button" @click="router.push(item.path)"> <button class="workspace-tab-action" type="button" @click="navigateWorkspaceTab(item.path)">
<span>{{ item.title }}</span> <span>{{ item.title }}</span>
<span class="tab-group">{{ item.group }}</span> <span class="tab-group">{{ item.group }}</span>
</button> </button>
@@ -267,24 +295,17 @@
<main class="desktop-content"> <main class="desktop-content">
<router-view v-slot="{ Component, route: currentRoute }"> <router-view v-slot="{ Component, route: currentRoute }">
<transition name="desktop-route" mode="out-in"> <transition name="desktop-route" mode="out-in">
<div :key="currentRoute.fullPath" class="desktop-route-shell"> <KeepAlive :max="DESKTOP_WORKSPACE_TAB_CACHE_MAX">
<component v-if="Component" :is="Component" /> <component
</div> :is="Component"
v-if="Component"
:key="desktopRouteCacheKey(currentRoute)"
class="desktop-route-shell"
/>
</KeepAlive>
</transition> </transition>
</router-view> </router-view>
</main> </main>
<footer class="desktop-statusbar">
<span class="status-item">
<span class="status-dot" :class="desktopConnectionClass"></span>
{{ desktopServerUrl || "未配置服务器" }}
</span>
<span v-if="study.currentStudy" class="status-item">{{ study.currentStudy.name }}</span>
<span v-if="accountProjectRoleLabel" class="status-item">{{ accountProjectRoleLabel }}</span>
<span v-if="projectStatusLabel" class="status-item">{{ projectStatusLabel }}</span>
<span class="status-spacer"></span>
<span class="status-item">CTMS Desktop {{ desktopMetadata.version }}</span>
</footer>
</section> </section>
</div> </div>
@@ -314,12 +335,31 @@
class="desktop-preferences-dialog" class="desktop-preferences-dialog"
:show-close="false" :show-close="false"
:close-on-click-modal="true" :close-on-click-modal="true"
width="720px" width="940px"
align-center align-center
destroy-on-close destroy-on-close
> >
<DesktopPreferences @close-request="desktopPreferencesVisible = false" /> <DesktopPreferences @close-request="desktopPreferencesVisible = false" />
</el-dialog> </el-dialog>
<div
v-if="workspaceTabMenu.visible && workspaceTabMenu.tab"
class="workspace-tab-context-menu"
:style="{ left: `${workspaceTabMenu.x}px`, top: `${workspaceTabMenu.y}px` }"
@click.stop
>
<button type="button" @click="navigateWorkspaceTabFromMenu(workspaceTabMenu.tab.path)">切换到标签</button>
<button type="button" @click="toggleWorkspaceTabFavorite(workspaceTabMenu.tab)">
{{ isWorkspaceTabFavorited(workspaceTabMenu.tab) ? "取消收藏模块" : "收藏模块" }}
</button>
<button type="button" @click="closeWorkspaceTabFromMenu(workspaceTabMenu.tab.path)">关闭标签</button>
<button type="button" :disabled="workspaceTabs.length <= 1" @click="closeOtherWorkspaceTabs(workspaceTabMenu.tab.path)">
关闭其他标签
</button>
<button type="button" :disabled="!lastClosedWorkspaceTab" @click="restoreLastClosedWorkspaceTab">
重新打开最近关闭
</button>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -337,6 +377,7 @@ import {
Coin, Coin,
DataAnalysis, DataAnalysis,
Document, Document,
Download,
Files, Files,
Flag, Flag,
Folder, Folder,
@@ -362,7 +403,12 @@ import { fetchOverdueAesCount } from "../api/dashboard";
import { listMonitoringVisitIssues } from "../api/monitoringVisitIssues"; import { listMonitoringVisitIssues } from "../api/monitoringVisitIssues";
import { TEXT } from "../locales"; import { TEXT } from "../locales";
import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager"; import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager";
import { checkDesktopUpdateAndPrompt } from "../session/desktopUpdateManager"; import {
checkDesktopUpdateAndPrompt,
getDesktopUpdateStatus,
listenDesktopUpdateStatus,
type DesktopUpdateStatusSnapshot,
} from "../session/desktopUpdateManager";
import { import {
DESKTOP_SERVER_URL_CHANGED_EVENT, DESKTOP_SERVER_URL_CHANGED_EVENT,
getAppMetadata, getAppMetadata,
@@ -387,6 +433,7 @@ import {
flattenLayoutNavigationItems, flattenLayoutNavigationItems,
getActiveLayoutPath, getActiveLayoutPath,
type LayoutNavigationIcon, type LayoutNavigationIcon,
type LayoutNavigationItem,
} from "./layout/navigation"; } from "./layout/navigation";
const auth = useAuthStore(); const auth = useAuthStore();
@@ -394,6 +441,7 @@ const study = useStudyStore();
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const desktopMetadata = getAppMetadata(); const desktopMetadata = getAppMetadata();
const DESKTOP_WORKSPACE_TAB_CACHE_MAX = 12;
const isAdmin = computed(() => !!auth.user?.is_admin); const isAdmin = computed(() => !!auth.user?.is_admin);
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || ""); const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const canAccessAdminPermissions = computed(() => isAdmin.value || projectRole.value === "PM"); const canAccessAdminPermissions = computed(() => isAdmin.value || projectRole.value === "PM");
@@ -407,13 +455,29 @@ const desktopPreferencesVisible = ref(false);
const profileDialogVisible = ref(false); const profileDialogVisible = ref(false);
const profileDialogDirty = ref(false); const profileDialogDirty = ref(false);
const desktopServerUrl = ref(getDesktopServerUrl()); const desktopServerUrl = ref(getDesktopServerUrl());
const desktopUpdateStatus = ref<DesktopUpdateStatusSnapshot>(getDesktopUpdateStatus());
const studies = ref<any[]>([]); const studies = ref<any[]>([]);
const desktopRecentRoutes = ref<DesktopRoutePreference[]>(readDesktopRecentRoutes()); const desktopRecentRoutes = ref<DesktopRoutePreference[]>(readDesktopRecentRoutes());
const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes()); const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes());
const workspaceTabs = ref<DesktopRoutePreference[]>([]); const workspaceTabs = ref<DesktopRoutePreference[]>([]);
const draggingWorkspaceTabPath = ref("");
const lastClosedWorkspaceTab = ref<DesktopRoutePreference | null>(null);
const workspaceTabMenu = ref<{
visible: boolean;
x: number;
y: number;
tab: DesktopRoutePreference | null;
}>({
visible: false,
x: 0,
y: 0,
tab: null,
});
const expandedNavigationGroups = ref<Set<string>>(new Set());
const headerRemindersLoading = ref(false); const headerRemindersLoading = ref(false);
const headerReminderStats = ref({ overdueAes: 0, overdueMonitoringIssues: 0 }); const headerReminderStats = ref({ overdueAes: 0, overdueMonitoringIssues: 0 });
let desktopMenuUnlisten: (() => void) | undefined; let desktopMenuUnlisten: (() => void) | undefined;
let desktopUpdateStatusUnlisten: (() => void) | undefined;
const iconComponentMap = { const iconComponentMap = {
audit: Document, audit: Document,
@@ -436,6 +500,21 @@ const iconComponentMap = {
const iconFor = (icon: LayoutNavigationIcon) => iconComponentMap[icon] || Folder; const iconFor = (icon: LayoutNavigationIcon) => iconComponentMap[icon] || Folder;
const activeMenu = computed(() => getActiveLayoutPath(route.path)); const activeMenu = computed(() => getActiveLayoutPath(route.path));
const navigationGroupKey = (item: LayoutNavigationItem) => `${item.group}:${item.label}:${item.path}`;
const isNavigationGroupActive = (item: LayoutNavigationItem) =>
Boolean(item.children?.some((child) => activeMenu.value === child.path));
const isNavigationGroupExpanded = (item: LayoutNavigationItem) =>
isNavigationGroupActive(item) || expandedNavigationGroups.value.has(navigationGroupKey(item));
const toggleNavigationGroup = (item: LayoutNavigationItem) => {
const key = navigationGroupKey(item);
const next = new Set(expandedNavigationGroups.value);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
expandedNavigationGroups.value = next;
};
const adminNavigationItems = computed(() => const adminNavigationItems = computed(() =>
buildAdminNavigationItems({ buildAdminNavigationItems({
hasUser: Boolean(auth.user), hasUser: Boolean(auth.user),
@@ -450,8 +529,10 @@ const projectNavigationItems = computed(() =>
canAccessProjectPath, canAccessProjectPath,
}), }),
); );
const adminDesktopNavigationItems = computed(() => flattenLayoutNavigationItems(adminNavigationItems.value));
const projectDesktopNavigationItems = computed(() => flattenLayoutNavigationItems(projectNavigationItems.value));
const desktopNavigationItems = computed(() => const desktopNavigationItems = computed(() =>
flattenLayoutNavigationItems([...adminNavigationItems.value, ...projectNavigationItems.value]), [...adminDesktopNavigationItems.value, ...projectDesktopNavigationItems.value],
); );
const userDisplayName = computed( const userDisplayName = computed(
@@ -461,17 +542,18 @@ const userDisplayInitial = computed(() => {
const source = auth.user?.full_name || auth.user?.username || auth.user?.email || TEXT.common.labels.userInitialFallback; const source = auth.user?.full_name || auth.user?.username || auth.user?.email || TEXT.common.labels.userInitialFallback;
return source.charAt(0).toUpperCase(); return source.charAt(0).toUpperCase();
}); });
const sidebarTitle = computed(() => study.currentStudy?.name || TEXT.menu.admin);
const desktopConnectionLabel = computed(() => (desktopServerUrl.value ? "已连接" : "未配置")); const desktopConnectionLabel = computed(() => (desktopServerUrl.value ? "已连接" : "未配置"));
const desktopConnectionClass = computed(() => (desktopServerUrl.value ? "is-connected" : "is-warning")); const desktopConnectionClass = computed(() => (desktopServerUrl.value ? "is-connected" : "is-warning"));
const accountProjectRoleLabel = computed(() => { const desktopUpdateNoticeVisible = computed(
if (isAdminContext.value || !study.currentStudy) return ""; () =>
const roleCode = (projectRole.value || "").toUpperCase(); Boolean(desktopUpdateStatus.value.pendingUpdate) &&
return roleCode ? ((TEXT.enums.userRole as Record<string, string>)[roleCode] || projectRole.value) : ""; !desktopUpdateStatus.value.checking &&
}); !desktopUpdateStatus.value.installing &&
const projectStatusLabel = computed(() => { !["postponed", "suppressed"].includes(desktopUpdateStatus.value.lastStatus),
const status = (study.currentStudy?.status || "").toUpperCase(); );
return status ? ((TEXT.enums.projectStatus as Record<string, string>)[status] || study.currentStudy?.status || "") : ""; const desktopUpdateNoticeLabel = computed(() => {
const pending = desktopUpdateStatus.value.pendingUpdate;
return pending ? `新版本 ${pending.version}` : "";
}); });
const currentDesktopRoutePreference = computed(() => { const currentDesktopRoutePreference = computed(() => {
@@ -486,9 +568,17 @@ const currentRouteFavorited = computed(() => {
const desktopBreadcrumbs = computed(() => { const desktopBreadcrumbs = computed(() => {
const current = currentDesktopRoutePreference.value; const current = currentDesktopRoutePreference.value;
if (!current) return [String(route.meta?.title || "")].filter(Boolean); if (!current) return [String(route.meta?.title || "")].filter(Boolean);
if (current.group === TEXT.menu.admin) return [TEXT.menu.admin, current.title]; const isAdminRoute = adminDesktopNavigationItems.value.some((item) => item.path === current.path);
const root = study.currentStudy?.name || TEXT.menu.currentProject; if (isAdminRoute) {
return [root, current.group || TEXT.menu.currentProject, current.title].filter(Boolean); const section = current.group && current.group !== TEXT.menu.admin ? current.group : "";
return [TEXT.menu.admin, section, current.title].filter(Boolean);
}
const isProjectRoute = projectDesktopNavigationItems.value.some((item) => item.path === current.path);
if (isProjectRoute) {
const section = current.group && current.group !== TEXT.menu.currentProject ? current.group : "";
return [TEXT.menu.currentProject, section, current.title].filter(Boolean);
}
return [current.group, current.title].filter(Boolean);
}); });
const canReadRiskIssueAes = computed(() => !isAdminContext.value && canAccessProjectPath("/risk-issues/sae")); const canReadRiskIssueAes = computed(() => !isAdminContext.value && canAccessProjectPath("/risk-issues/sae"));
@@ -534,14 +624,78 @@ const refreshDesktopRoutePreferences = () => {
const addWorkspaceTab = (routePreference: Pick<DesktopRoutePreference, "path" | "title" | "group">) => { const addWorkspaceTab = (routePreference: Pick<DesktopRoutePreference, "path" | "title" | "group">) => {
const item = { ...routePreference, updatedAt: new Date().toISOString() }; const item = { ...routePreference, updatedAt: new Date().toISOString() };
workspaceTabs.value = [ const existingIndex = workspaceTabs.value.findIndex((tab) => tab.path === item.path);
...workspaceTabs.value.filter((tab) => tab.path !== item.path), if (existingIndex >= 0) {
item, const next = [...workspaceTabs.value];
].slice(-7); next[existingIndex] = { ...next[existingIndex], ...item };
workspaceTabs.value = next;
return;
}
workspaceTabs.value = [...workspaceTabs.value, item];
};
const desktopRouteCacheKey = (currentRoute: { fullPath: string }) => currentRoute.fullPath;
const navigateWorkspaceTab = (path: string) => {
if (activeMenu.value === path) return;
void router.push(path);
};
const closeWorkspaceTabMenu = () => {
if (!workspaceTabMenu.value.visible) return;
workspaceTabMenu.value = { visible: false, x: 0, y: 0, tab: null };
};
const openWorkspaceTabMenu = (tab: DesktopRoutePreference, event: MouseEvent) => {
workspaceTabMenu.value = {
visible: true,
x: Math.min(event.clientX, window.innerWidth - 196),
y: Math.min(event.clientY, window.innerHeight - 184),
tab,
};
};
const navigateWorkspaceTabFromMenu = (path: string) => {
navigateWorkspaceTab(path);
closeWorkspaceTabMenu();
};
const isWorkspaceTabFavorited = (tab: DesktopRoutePreference) =>
desktopFavoriteRoutes.value.some((item) => item.path === tab.path);
const toggleWorkspaceTabFavorite = (tab: DesktopRoutePreference) => {
desktopFavoriteRoutes.value = toggleDesktopFavoriteRoute({ path: tab.path, title: tab.title, group: tab.group });
refreshDesktopRoutePreferences();
closeWorkspaceTabMenu();
};
const startWorkspaceTabDrag = (path: string, event: DragEvent) => {
draggingWorkspaceTabPath.value = path;
event.dataTransfer?.setData("text/plain", path);
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
};
const allowWorkspaceTabDrop = (event: DragEvent) => {
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
};
const dropWorkspaceTab = (targetPath: string, event: DragEvent) => {
const sourcePath = draggingWorkspaceTabPath.value || event.dataTransfer?.getData("text/plain") || "";
draggingWorkspaceTabPath.value = "";
if (!sourcePath || sourcePath === targetPath) return;
const sourceIndex = workspaceTabs.value.findIndex((tab) => tab.path === sourcePath);
const targetIndex = workspaceTabs.value.findIndex((tab) => tab.path === targetPath);
if (sourceIndex < 0 || targetIndex < 0) return;
const next = [...workspaceTabs.value];
const [moved] = next.splice(sourceIndex, 1);
next.splice(targetIndex, 0, moved);
workspaceTabs.value = next;
}; };
const closeWorkspaceTab = (path: string) => { const closeWorkspaceTab = (path: string) => {
const index = workspaceTabs.value.findIndex((tab) => tab.path === path); const index = workspaceTabs.value.findIndex((tab) => tab.path === path);
if (index >= 0) lastClosedWorkspaceTab.value = workspaceTabs.value[index];
workspaceTabs.value = workspaceTabs.value.filter((tab) => tab.path !== path); workspaceTabs.value = workspaceTabs.value.filter((tab) => tab.path !== path);
if (activeMenu.value !== path) return; if (activeMenu.value !== path) return;
const next = workspaceTabs.value[index] || workspaceTabs.value[index - 1] || desktopRecentRoutes.value[0]; const next = workspaceTabs.value[index] || workspaceTabs.value[index - 1] || desktopRecentRoutes.value[0];
@@ -552,6 +706,28 @@ const closeWorkspaceTab = (path: string) => {
} }
}; };
const closeWorkspaceTabFromMenu = (path: string) => {
closeWorkspaceTab(path);
closeWorkspaceTabMenu();
};
const closeOtherWorkspaceTabs = (path: string) => {
const target = workspaceTabs.value.find((tab) => tab.path === path);
if (!target) return;
workspaceTabs.value = [target];
if (activeMenu.value !== path) void router.push(path);
closeWorkspaceTabMenu();
};
const restoreLastClosedWorkspaceTab = () => {
const tab = lastClosedWorkspaceTab.value;
if (!tab) return;
addWorkspaceTab(tab);
void router.push(tab.path);
lastClosedWorkspaceTab.value = null;
closeWorkspaceTabMenu();
};
const updateDesktopServerUrl = () => { const updateDesktopServerUrl = () => {
desktopServerUrl.value = getDesktopServerUrl(); desktopServerUrl.value = getDesktopServerUrl();
}; };
@@ -586,7 +762,7 @@ const handleDesktopMenuCommand = (command: string) => {
return; return;
} }
if (command === "ctms.desktop.serverSettings") { if (command === "ctms.desktop.serverSettings") {
router.push("/desktop/server-settings"); openDesktopPreferences();
return; return;
} }
if (command === "ctms.desktop.refresh") { if (command === "ctms.desktop.refresh") {
@@ -625,6 +801,14 @@ const desktopCommands = computed<DesktopCommand[]>(() => {
await router.push("/project/overview"); await router.push("/project/overview");
}, },
})); }));
const workspaceTabCommands: DesktopCommand[] = workspaceTabs.value.map((item) => ({
id: `workspace-tab:${item.path}`,
title: `切换标签:${item.title}`,
group: "标签页",
keywords: [item.title, item.group, item.path].filter((value): value is string => Boolean(value)),
visible: true,
run: () => navigateWorkspaceTab(item.path),
}));
return [ return [
{ {
id: "desktop:refresh", id: "desktop:refresh",
@@ -644,12 +828,10 @@ const desktopCommands = computed<DesktopCommand[]>(() => {
}, },
{ {
id: "desktop:server-settings", id: "desktop:server-settings",
title: "服务器设置", title: "连接设置",
group: "桌面", group: "桌面",
visible: true, visible: true,
run: () => { run: openDesktopPreferences,
void router.push("/desktop/server-settings");
},
}, },
{ {
id: "desktop:update", id: "desktop:update",
@@ -667,6 +849,14 @@ const desktopCommands = computed<DesktopCommand[]>(() => {
visible: Boolean(currentDesktopRoutePreference.value), visible: Boolean(currentDesktopRoutePreference.value),
run: toggleCurrentFavorite, run: toggleCurrentFavorite,
}, },
{
id: "desktop:restore-closed-tab",
title: "重新打开最近关闭标签",
group: "标签页",
visible: Boolean(lastClosedWorkspaceTab.value),
run: restoreLastClosedWorkspaceTab,
},
...workspaceTabCommands,
...navigationCommands, ...navigationCommands,
...projectCommands, ...projectCommands,
]; ];
@@ -822,6 +1012,9 @@ onMounted(async () => {
} }
refreshDesktopRoutePreferences(); refreshDesktopRoutePreferences();
desktopMenuUnlisten = await listenDesktopMenuCommand(handleDesktopMenuCommand).catch(() => undefined); desktopMenuUnlisten = await listenDesktopMenuCommand(handleDesktopMenuCommand).catch(() => undefined);
desktopUpdateStatusUnlisten = listenDesktopUpdateStatus((status) => {
desktopUpdateStatus.value = status;
});
if (auth.token && !auth.user) { if (auth.token && !auth.user) {
try { try {
await auth.fetchMe(); await auth.fetchMe();
@@ -837,6 +1030,7 @@ onMounted(async () => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl); window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
desktopMenuUnlisten?.(); desktopMenuUnlisten?.();
desktopUpdateStatusUnlisten?.();
}); });
useDesktopShortcuts( useDesktopShortcuts(
@@ -844,6 +1038,10 @@ useDesktopShortcuts(
{ {
openCommandPalette, openCommandPalette,
closeActiveLayer: () => { closeActiveLayer: () => {
if (workspaceTabMenu.value.visible) {
closeWorkspaceTabMenu();
return true;
}
if (commandPaletteVisible.value) { if (commandPaletteVisible.value) {
commandPaletteVisible.value = false; commandPaletteVisible.value = false;
return true; return true;
@@ -1076,12 +1274,28 @@ useDesktopShortcuts(
} }
.group-title { .group-title {
grid-template-columns: 18px minmax(0, 1fr) 14px;
font-weight: 700; font-weight: 700;
} }
.group-expander {
justify-self: end;
color: #7a8ca2;
font-size: 12px;
transition: transform 0.16s ease;
}
.sidebar-group.expanded .group-expander {
transform: rotate(180deg);
}
.sidebar-children {
padding: 2px 0 4px;
}
.desktop-main { .desktop-main {
display: grid; display: grid;
grid-template-rows: 48px auto minmax(0, 1fr) 26px; grid-template-rows: 48px auto minmax(0, 1fr);
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
} }
@@ -1144,6 +1358,7 @@ useDesktopShortcuts(
} }
.command-trigger, .command-trigger,
.update-notice,
.connection-button, .connection-button,
.account-button { .account-button {
appearance: none; appearance: none;
@@ -1160,6 +1375,21 @@ useDesktopShortcuts(
font-weight: 700; font-weight: 700;
} }
.update-notice {
gap: 6px;
max-width: 138px;
padding: 0 9px;
border-color: #d8bd6c;
background: #fff8e5;
color: #6f5317;
}
.update-notice span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-trigger { .command-trigger {
gap: 7px; gap: 7px;
padding: 0 8px; padding: 0 8px;
@@ -1180,16 +1410,14 @@ useDesktopShortcuts(
padding: 0 9px; padding: 0 9px;
} }
.connection-dot, .connection-dot {
.status-dot {
width: 7px; width: 7px;
height: 7px; height: 7px;
border-radius: 999px; border-radius: 999px;
background: #c58b2a; background: #c58b2a;
} }
.connection-dot.is-connected, .connection-dot.is-connected {
.status-dot.is-connected {
background: #3f8f6b; background: #3f8f6b;
} }
@@ -1376,16 +1604,25 @@ useDesktopShortcuts(
border-radius: 8px; border-radius: 8px;
background: #ffffff; background: #ffffff;
color: #3d5068; color: #3d5068;
cursor: grab;
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
} }
.workspace-tab:active {
cursor: grabbing;
}
.workspace-tab.active { .workspace-tab.active {
border-color: #aebfd1; border-color: #aebfd1;
background: #eaf1f8; background: #eaf1f8;
color: #183756; color: #183756;
} }
.workspace-tab.dragging {
opacity: 0.58;
}
.workspace-tab-action { .workspace-tab-action {
appearance: none; appearance: none;
display: grid; display: grid;
@@ -1430,6 +1667,48 @@ useDesktopShortcuts(
color: #142033; color: #142033;
} }
.workspace-tab-context-menu {
position: fixed;
z-index: 2300;
display: flex;
width: 184px;
flex-direction: column;
gap: 2px;
padding: 6px;
border: 1px solid #cbd7e5;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 14px 38px rgba(15, 23, 42, 0.16);
}
.workspace-tab-context-menu button {
appearance: none;
display: flex;
align-items: center;
width: 100%;
min-height: 30px;
padding: 0 9px;
border: 0;
border-radius: 6px;
background: transparent;
color: #223349;
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 700;
text-align: left;
}
.workspace-tab-context-menu button:hover:not(:disabled) {
background: #eef4fb;
color: #183756;
}
.workspace-tab-context-menu button:disabled {
cursor: not-allowed;
color: #9aaabd;
}
.desktop-content { .desktop-content {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
@@ -1442,32 +1721,6 @@ useDesktopShortcuts(
min-height: 100%; min-height: 100%;
} }
.desktop-statusbar {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
padding: 0 12px;
border-top: 1px solid #d7e0ea;
background: #f8fafc;
color: #66778d;
font-size: 11px;
}
.status-item {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-spacer {
flex: 1;
}
.desktop-route-enter-active, .desktop-route-enter-active,
.desktop-route-leave-active { .desktop-route-leave-active {
transition: opacity 140ms ease, transform 160ms ease; transition: opacity 140ms ease, transform 160ms ease;
@@ -1487,6 +1740,8 @@ useDesktopShortcuts(
:global(.desktop-preferences-dialog) { :global(.desktop-preferences-dialog) {
overflow: hidden; overflow: hidden;
border-radius: 14px; border-radius: 14px;
/* dialog 容器本身设置侧边栏同色背景,彻底兜底 */
background: #f2f4f7 !important;
} }
:global(.profile-settings-dialog .el-dialog__header), :global(.profile-settings-dialog .el-dialog__header),
@@ -1499,147 +1754,167 @@ useDesktopShortcuts(
} }
:global(.desktop-preferences-dialog .el-dialog__body) { :global(.desktop-preferences-dialog .el-dialog__body) {
padding: 20px; /* 背景与侧边栏一致,避免内容高度不足时白色透出 */
background: #f8fafc; padding: 0;
background: #f2f4f7;
display: flex;
flex-direction: column;
} }
:global([data-ctms-theme="dark"]) .desktop-workbench { :global(.desktop-preferences-dialog .el-dialog__body > *) {
flex: 1;
min-height: 0;
}
:global([data-ctms-theme="dark"] .desktop-workbench) {
background: #0f172a; background: #0f172a;
color: #e5edf7; color: #e5edf7;
} }
:global([data-ctms-theme="dark"]) .desktop-sidebar { :global([data-ctms-theme="dark"] .desktop-sidebar) {
border-right-color: #26364a; border-right-color: #26364a;
background: #111827; background: #111827;
} }
:global([data-ctms-theme="dark"]) .sidebar-head { :global([data-ctms-theme="dark"] .sidebar-head) {
border-bottom-color: #26364a; border-bottom-color: #26364a;
} }
:global([data-ctms-theme="dark"]) .sidebar-kicker, :global([data-ctms-theme="dark"] .sidebar-kicker),
:global([data-ctms-theme="dark"]) .section-label, :global([data-ctms-theme="dark"] .section-label),
:global([data-ctms-theme="dark"]) .panel-subtitle, :global([data-ctms-theme="dark"] .panel-subtitle),
:global([data-ctms-theme="dark"]) .tab-group, :global([data-ctms-theme="dark"] .tab-group) {
:global([data-ctms-theme="dark"]) .desktop-statusbar,
:global([data-ctms-theme="dark"]) .status-item {
color: #94a3b8; color: #94a3b8;
} }
:global([data-ctms-theme="dark"]) .sidebar-head h1, :global([data-ctms-theme="dark"] .sidebar-head h1),
:global([data-ctms-theme="dark"]) .panel-title, :global([data-ctms-theme="dark"] .panel-title),
:global([data-ctms-theme="dark"]) .panel-row code, :global([data-ctms-theme="dark"] .panel-row code),
:global([data-ctms-theme="dark"]) .reminder-body span { :global([data-ctms-theme="dark"] .reminder-body span) {
color: #f8fafc; color: #f8fafc;
} }
:global([data-ctms-theme="dark"]) .icon-button { :global([data-ctms-theme="dark"] .icon-button) {
color: #9aaabd; color: #9aaabd;
} }
:global([data-ctms-theme="dark"]) .icon-button:hover { :global([data-ctms-theme="dark"] .icon-button:hover) {
border-color: #334155; border-color: #334155;
background: #1f2d3d; background: #1f2d3d;
color: #f8fafc; color: #f8fafc;
} }
:global([data-ctms-theme="dark"]) .study-switcher-trigger, :global([data-ctms-theme="dark"] .study-switcher-trigger),
:global([data-ctms-theme="dark"]) .command-trigger, :global([data-ctms-theme="dark"] .command-trigger),
:global([data-ctms-theme="dark"]) .connection-button, :global([data-ctms-theme="dark"] .update-notice),
:global([data-ctms-theme="dark"]) .account-button, :global([data-ctms-theme="dark"] .connection-button),
:global([data-ctms-theme="dark"]) .breadcrumb-chip, :global([data-ctms-theme="dark"] .account-button),
:global([data-ctms-theme="dark"]) .workspace-tab { :global([data-ctms-theme="dark"] .breadcrumb-chip),
:global([data-ctms-theme="dark"] .workspace-tab) {
border-color: #334155; border-color: #334155;
background: #172033; background: #172033;
color: #dbe5f1; color: #dbe5f1;
} }
:global([data-ctms-theme="dark"]) .study-switcher-trigger.empty { :global([data-ctms-theme="dark"] .study-switcher-trigger.empty) {
color: #bfdbfe; color: #bfdbfe;
} }
:global([data-ctms-theme="dark"]) .sidebar-link { :global([data-ctms-theme="dark"] .sidebar-link) {
color: #cbd5e1; color: #cbd5e1;
} }
:global([data-ctms-theme="dark"]) .sidebar-link.child { :global([data-ctms-theme="dark"] .sidebar-link.child) {
color: #9aaabd; color: #9aaabd;
} }
:global([data-ctms-theme="dark"]) .sidebar-link:hover { :global([data-ctms-theme="dark"] .sidebar-link:hover) {
background: #1f2d3d; background: #1f2d3d;
color: #f8fafc; color: #f8fafc;
} }
:global([data-ctms-theme="dark"]) .sidebar-link.active, :global([data-ctms-theme="dark"] .sidebar-link.active),
:global([data-ctms-theme="dark"]) .workspace-tab.active { :global([data-ctms-theme="dark"] .workspace-tab.active) {
border-color: #3e5c77; border-color: #3e5c77;
background: #243247; background: #243247;
color: #bfdbfe; color: #bfdbfe;
} }
:global([data-ctms-theme="dark"]) .desktop-toolbar { :global([data-ctms-theme="dark"] .desktop-toolbar) {
border-bottom-color: #26364a; border-bottom-color: #26364a;
background: rgba(17, 24, 39, 0.92); background: rgba(17, 24, 39, 0.92);
} }
:global([data-ctms-theme="dark"]) .workspace-tabs { :global([data-ctms-theme="dark"] .workspace-tabs) {
border-bottom-color: #26364a; border-bottom-color: #26364a;
background: #111827; background: #111827;
} }
:global([data-ctms-theme="dark"]) .desktop-content { :global([data-ctms-theme="dark"] .desktop-content) {
background: #0f172a; background: #0f172a;
} }
:global([data-ctms-theme="dark"]) .desktop-statusbar { :global([data-ctms-theme="dark"] .command-trigger kbd) {
border-top-color: #26364a;
background: #111827;
}
:global([data-ctms-theme="dark"]) .command-trigger kbd {
border-color: #334155; border-color: #334155;
background: #0f172a; background: #0f172a;
color: #94a3b8; color: #94a3b8;
} }
:global([data-ctms-theme="dark"]) .account-avatar { :global([data-ctms-theme="dark"] .account-avatar) {
background: #3e5c77; background: #3e5c77;
} }
:global([data-ctms-theme="dark"]) .tab-close { :global([data-ctms-theme="dark"] .workspace-tab-context-menu) {
border-color: #334155;
background: #172033;
}
:global([data-ctms-theme="dark"] .workspace-tab-context-menu button) {
color: #dbe5f1;
}
:global([data-ctms-theme="dark"] .workspace-tab-context-menu button:hover:not(:disabled)) {
background: #243247;
color: #bfdbfe;
}
:global([data-ctms-theme="dark"] .workspace-tab-context-menu button:disabled) {
color: #64748b;
}
:global([data-ctms-theme="dark"] .tab-close) {
color: #94a3b8; color: #94a3b8;
} }
:global([data-ctms-theme="dark"]) .tab-close:hover { :global([data-ctms-theme="dark"] .tab-close:hover) {
background: rgba(226, 232, 240, 0.1); background: rgba(226, 232, 240, 0.1);
color: #f8fafc; color: #f8fafc;
} }
:global([data-ctms-theme="dark"]) .connection-panel { :global([data-ctms-theme="dark"] .connection-panel) {
color: #dbe5f1; color: #dbe5f1;
} }
:global([data-ctms-theme="dark"]) .panel-row { :global([data-ctms-theme="dark"] .panel-row) {
color: #94a3b8; color: #94a3b8;
} }
:global([data-ctms-theme="dark"]) .reminder-total { :global([data-ctms-theme="dark"] .reminder-total) {
background: rgba(242, 139, 139, 0.14); background: rgba(242, 139, 139, 0.14);
color: #fecaca; color: #fecaca;
} }
:global([data-ctms-theme="dark"]) .reminder-body small, :global([data-ctms-theme="dark"] .reminder-body small),
:global([data-ctms-theme="dark"]) .reminder-empty { :global([data-ctms-theme="dark"] .reminder-empty) {
color: #94a3b8; color: #94a3b8;
} }
:global([data-ctms-theme="dark"]) .sidebar-scroll::-webkit-scrollbar-thumb { :global([data-ctms-theme="dark"] .sidebar-scroll::-webkit-scrollbar-thumb) {
border-color: #111827; border-color: #111827;
background: #334155; background: #334155;
} }
:global([data-ctms-theme="dark"]) .sidebar-scroll::-webkit-scrollbar-thumb:hover { :global([data-ctms-theme="dark"] .sidebar-scroll::-webkit-scrollbar-thumb:hover) {
background: #475569; background: #475569;
} }
@@ -1649,7 +1924,8 @@ useDesktopShortcuts(
} }
:global([data-ctms-theme="dark"] .desktop-preferences-dialog .el-dialog__body) { :global([data-ctms-theme="dark"] .desktop-preferences-dialog .el-dialog__body) {
background: #111827; /* 与深色侧边栏 --pref-bg-sidebar 保持一致 */
background: #131b28;
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
@@ -6,6 +6,13 @@ const readLayoutSource = () => readFileSync(resolve(__dirname, "./Layout.vue"),
const readDesktopLayoutSource = () => readFileSync(resolve(__dirname, "./DesktopLayout.vue"), "utf8"); const readDesktopLayoutSource = () => readFileSync(resolve(__dirname, "./DesktopLayout.vue"), "utf8");
const readWebLayoutSource = () => readFileSync(resolve(__dirname, "./WebLayout.vue"), "utf8"); const readWebLayoutSource = () => readFileSync(resolve(__dirname, "./WebLayout.vue"), "utf8");
const readNavigationSource = () => readFileSync(resolve(__dirname, "./layout/navigation.ts"), "utf8"); const readNavigationSource = () => readFileSync(resolve(__dirname, "./layout/navigation.ts"), "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 readProfileSettingsSource = () => readFileSync(resolve(__dirname, "../views/ProfileSettings.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");
describe("desktop layout shell", () => { describe("desktop layout shell", () => {
it("routes desktop and web shells through the runtime flag", () => { it("routes desktop and web shells through the runtime flag", () => {
@@ -34,4 +41,192 @@ describe("desktop layout shell", () => {
expect(desktopLayout).toContain("getActiveLayoutPath(route.path)"); expect(desktopLayout).toContain("getActiveLayoutPath(route.path)");
expect(navigation).toContain("export const getActiveLayoutPath"); expect(navigation).toContain("export const getActiveLayoutPath");
}); });
it("keeps desktop context labels and command entry points de-duplicated", () => {
const source = readDesktopLayoutSource();
expect(source).toContain("<h1>{{ TEXT.common.appName }} Desktop</h1>");
expect(source).not.toContain("const sidebarTitle");
expect(source).not.toContain('title="搜索命令"');
expect(source).not.toContain("desktop-statusbar");
expect(source).not.toContain("status-item");
expect(source).not.toContain("accountProjectRoleLabel");
expect(source).not.toContain("projectStatusLabel");
expect(source).toContain("grid-template-rows: 48px auto minmax(0, 1fr);");
expect(source).toContain("const adminDesktopNavigationItems");
expect(source).toContain("const projectDesktopNavigationItems");
expect(source).not.toContain("const root = study.currentStudy?.name || TEXT.menu.currentProject");
});
it("renders desktop preferences as a split settings panel", () => {
const preferences = readDesktopPreferencesSource();
const desktopLayout = readDesktopLayoutSource();
const webLayout = readWebLayoutSource();
expect(preferences).toContain('class="preferences-sidebar"');
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("scrollbar-gutter: stable;");
expect(preferences).toContain('{ id: "connection", label: "连接"');
expect(preferences).toContain('{ id: "appearance", label: "外观"');
expect(preferences).toContain('{ id: "notifications", label: "通知"');
expect(preferences).toContain('{ id: "updates", label: "更新"');
expect(preferences).toContain('{ id: "diagnostics", label: "诊断信息"');
expect(preferences).toContain("testDesktopServerConnection");
expect(preferences).toContain("saveDesktopServerConfig");
expect(preferences).toContain("测试连通性");
expect(preferences).not.toContain("保存服务器");
expect(preferences).toContain('ElMessage.success("服务器设置已保存")');
expect(preferences).toContain("ElMessage.error(`保存失败:${message}`)");
expect(preferences).toContain("normalizeDesktopServerUrl");
expect(preferences).toContain("确认切换服务器");
expect(preferences).toContain('<Transition name="preferences-header" mode="out-in">');
expect(preferences).toContain('<Transition name="preferences-panel" mode="out-in">');
expect(preferences).toContain('<Transition name="connection-feedback">');
expect(preferences).toContain("const selectSection = (sectionId: PreferenceSectionId)");
expect(preferences).toContain("const setConnectionPending = (message: string)");
expect(preferences).toContain("const scheduleConnectionPending = (message: string)");
expect(preferences).toContain("}, 180);");
expect(preferences).toContain('scrollTo({ top: 0, behavior: "smooth" })');
expect(preferences).not.toContain("connectionStatus.value = null");
expect(preferences).toContain("min-width: 100px;");
expect(preferences).toContain("connection-feedback-enter-active");
expect(preferences).toContain("@media (prefers-reduced-motion: reduce)");
expect(preferences).toContain(':global([data-ctms-theme="dark"] .desktop-preferences)');
expect(preferences).not.toContain(':global([data-ctms-theme="dark"]) .desktop-preferences');
expect(desktopLayout).toContain(':global([data-ctms-theme="dark"] .desktop-workbench)');
expect(desktopLayout).not.toContain(':global([data-ctms-theme="dark"]) .desktop-workbench');
expect(desktopLayout).toContain('width="940px"');
expect(webLayout).toContain('width="940px"');
expect(desktopLayout).toContain('title: "连接设置"');
expect(webLayout).toContain('title: "连接设置"');
expect(desktopLayout).not.toContain('router.push("/desktop/server-settings")');
expect(webLayout).not.toContain('router.push("/desktop/server-settings")');
expect(desktopLayout).not.toContain('width="720px"');
expect(webLayout).not.toContain('width="720px"');
});
it("keeps desktop notification and diagnostics settings out of profile settings", () => {
const profile = readProfileSettingsSource();
const preferences = readDesktopPreferencesSource();
expect(profile).not.toContain("form-section--desktop");
expect(profile).not.toContain("客户端与通知");
expect(profile).not.toContain("getDesktopNotificationSubscription");
expect(profile).not.toContain("setDesktopNotificationSubscription");
expect(profile).not.toContain("checkDesktopUpdateAndPrompt");
expect(profile).not.toContain("clientMetadataRows");
expect(profile).not.toContain("desktopNotificationsEnabled");
expect(preferences).toContain("getDesktopNotificationSubscription");
expect(preferences).toContain("checkDesktopUpdateAndPrompt");
expect(preferences).toContain("clientMetadataRows");
expect(preferences).not.toContain('{ label: "主题"');
expect(preferences).not.toContain('{ label: "服务器"');
});
it("keeps workspace tabs stable and draggable like browser tabs", () => {
const source = readDesktopLayoutSource();
const tabsStart = source.indexOf('<div class="workspace-tabs"');
const tabsEnd = source.indexOf('<main class="desktop-content"', tabsStart);
const tabsTemplate = source.slice(tabsStart, tabsEnd);
expect(tabsTemplate).toContain('@click="navigateWorkspaceTab(item.path)"');
expect(tabsTemplate).toContain('draggable="true"');
expect(tabsTemplate).toContain('@dragstart="startWorkspaceTabDrag(item.path, $event)"');
expect(tabsTemplate).toContain('@drop.prevent="dropWorkspaceTab(item.path, $event)"');
expect(source).toContain("const draggingWorkspaceTabPath = ref(\"\")");
expect(source).toContain("const navigateWorkspaceTab = (path: string) => {");
expect(source).toContain("if (activeMenu.value === path) return;");
expect(source).toContain("next[existingIndex] = { ...next[existingIndex], ...item };");
expect(source).toContain("const [moved] = next.splice(sourceIndex, 1);");
expect(source).toContain("<KeepAlive :max=\"DESKTOP_WORKSPACE_TAB_CACHE_MAX\">");
expect(source).toContain(":key=\"desktopRouteCacheKey(currentRoute)\"");
expect(source).toContain("const desktopRouteCacheKey = (currentRoute: { fullPath: string }) => currentRoute.fullPath;");
expect(source).not.toContain('<div :key="currentRoute.fullPath" class="desktop-route-shell">');
expect(tabsTemplate).not.toContain('@click="router.push(item.path)"');
expect(source).not.toContain(".slice(-7)");
expect(tabsTemplate).toContain('@contextmenu.prevent.stop="openWorkspaceTabMenu(item, $event)"');
expect(source).toContain("const lastClosedWorkspaceTab = ref<DesktopRoutePreference | null>(null);");
expect(source).toContain("const workspaceTabMenu = ref<");
expect(source).toContain("const closeOtherWorkspaceTabs = (path: string) => {");
expect(source).toContain("const restoreLastClosedWorkspaceTab = () => {");
expect(source).toContain('id: `workspace-tab:${item.path}`');
expect(source).toContain('title: `切换标签:${item.title}`');
expect(source).toContain('title: "重新打开最近关闭标签"');
});
it("keeps desktop navigation hierarchy aligned with the web sidebar", () => {
const desktopLayout = readDesktopLayoutSource();
const navigation = readNavigationSource();
const sidebarStart = desktopLayout.indexOf('<div class="sidebar-scroll">');
const sidebarEnd = desktopLayout.indexOf("</aside>", sidebarStart);
const sidebarTemplate = desktopLayout.slice(sidebarStart, sidebarEnd);
expect(sidebarTemplate.indexOf('v-if="adminNavigationItems.length"')).toBeLessThan(
sidebarTemplate.indexOf('v-if="projectNavigationItems.length"'),
);
expect(sidebarTemplate).toContain('@click="toggleNavigationGroup(item)"');
expect(sidebarTemplate).toContain(':aria-expanded="isNavigationGroupExpanded(item)"');
expect(sidebarTemplate).toContain('v-show="isNavigationGroupExpanded(item)"');
expect(desktopLayout).toContain("const expandedNavigationGroups = ref<Set<string>>(new Set());");
expect(navigation).toContain('label: "系统设置"');
expect(navigation).toContain('label: "邮件服务"');
expect(navigation).toContain('group: "系统设置"');
expect(navigation).toContain("item.children?.length ? item.children : [item]");
});
it("keeps notification and update preferences lightweight", () => {
const serverSettings = readDesktopServerSettingsSource();
const preferences = readDesktopPreferencesSource();
const desktopUpdateManager = readDesktopUpdateManagerSource();
const desktopLayout = readDesktopLayoutSource();
expect(serverSettings).toContain("connectionDiagnostic");
expect(serverSettings).not.toContain("复制连接诊断");
expect(serverSettings).not.toContain("copyConnectionDiagnostic");
expect(serverSettings).toContain("确认切换服务器");
expect(serverSettings).toContain("切换服务器会退出当前会话并清除当前项目上下文");
expect(serverSettings).not.toContain("服务器连接已确认");
expect(preferences).toContain("connectionDiagnostic");
expect(preferences).not.toContain("复制连接诊断");
expect(preferences).not.toContain("copyConnectionDiagnostic");
expect(preferences).not.toContain("服务器连通性正常");
expect(preferences).not.toContain("服务器连接已确认");
expect(preferences).not.toContain("系统通知已开启");
expect(preferences).not.toContain("系统通知已关闭");
expect(preferences).not.toContain("通知诊断");
expect(preferences).not.toContain("更新诊断");
expect(preferences).not.toContain("listenDesktopNotificationDiagnostics");
expect(preferences).not.toContain("发送测试通知");
expect(preferences).toContain("listenDesktopUpdateStatus");
expect(preferences).toContain("promptForPendingDesktopUpdate");
expect(preferences).toContain("当前已是最新版本");
expect(desktopUpdateManager).not.toContain("当前构建未启用桌面端自动更新");
expect(desktopUpdateManager).not.toContain("该版本已选择稍后提醒");
expect(desktopUpdateManager).not.toContain("当前已是最新版本");
expect(desktopUpdateManager).toContain("桌面端更新检查失败,请稍后重试或联系管理员。");
expect(desktopLayout).toContain("desktopUpdateNoticeVisible");
expect(desktopLayout).toContain("listenDesktopUpdateStatus");
expect(desktopLayout).toContain("新版本 ${pending.version}");
});
it("routes user-facing file actions through feedback helpers", () => {
const helper = readFileTaskFeedbackSource();
const attachments = readAttachmentListSource();
const documentDetail = readDocumentDetailSource();
expect(helper).toContain("export const pickFilesWithFeedback");
expect(helper).toContain("export const saveFileWithFeedback");
expect(helper).toContain("export const openFileWithFeedback");
expect(attachments).toContain("pickFilesWithFeedback");
expect(attachments).toContain("saveFileWithFeedback");
expect(attachments).toContain("openFileWithFeedback");
expect(documentDetail).toContain("pickFilesWithFeedback");
expect(documentDetail).toContain("saveFileWithFeedback");
expect(documentDetail).toContain("openFileWithFeedback");
expect(attachments).not.toContain("openFile, pickFiles, saveFile");
expect(documentDetail).not.toContain("openFile, pickFiles, saveFile");
});
}); });
@@ -154,7 +154,7 @@ import type {
} from "@/types/api"; } from "@/types/api";
import { Search as SearchIcon } from "@element-plus/icons-vue"; import { Search as SearchIcon } from "@element-plus/icons-vue";
import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta"; import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta";
import { saveFile } from "@/runtime"; import { saveFileWithFeedback } from "@/utils/fileTaskFeedback";
const props = withDefaults(defineProps<{ showSecurityLog?: boolean }>(), { const props = withDefaults(defineProps<{ showSecurityLog?: boolean }>(), {
showSecurityLog: false, showSecurityLog: false,
@@ -519,7 +519,7 @@ const openSecurityLogDialog = () => {
const downloadLogFile = async (fileName: string, lines: string[]) => { const downloadLogFile = async (fileName: string, lines: string[]) => {
const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain;charset=utf-8" }); const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain;charset=utf-8" });
await saveFile({ suggestedName: fileName, mimeType: "text/plain;charset=utf-8", data: blob }); await saveFileWithFeedback({ suggestedName: fileName, mimeType: "text/plain;charset=utf-8", data: blob });
}; };
const downloadInterfaceLog = () => { const downloadInterfaceLog = () => {
+3 -2
View File
@@ -29,7 +29,8 @@
import { computed } from "vue"; import { computed } from "vue";
import type { UploadUserFile } from "element-plus"; import type { UploadUserFile } from "element-plus";
import { displayDateTime, displayUser } from "../utils/display"; import { displayDateTime, displayUser } from "../utils/display";
import { clientRuntime, pickFiles } from "../runtime"; import { clientRuntime } from "../runtime";
import { pickFilesWithFeedback } from "../utils/fileTaskFeedback";
import { TEXT } from "../locales"; import { TEXT } from "../locales";
const props = defineProps<{ const props = defineProps<{
@@ -65,7 +66,7 @@ const fileListProxy = computed({
const quoteContent = (item: any) => (item?.is_deleted ? TEXT.modules.knowledgeMedicalConsult.quoteDeleted : item?.content || TEXT.common.fallback); const quoteContent = (item: any) => (item?.is_deleted ? TEXT.modules.knowledgeMedicalConsult.quoteDeleted : item?.content || TEXT.common.fallback);
const pickNativeAttachments = async () => { const pickNativeAttachments = async () => {
const files = await pickFiles({ multiple: true, title: TEXT.common.labels.attachments }); const files = await pickFilesWithFeedback({ multiple: true, title: TEXT.common.labels.attachments });
const existing = new Set(props.fileList.map((item) => `${item.name}:${item.size}`)); const existing = new Set(props.fileList.map((item) => `${item.name}:${item.size}`));
const additions: UploadUserFile[] = files const additions: UploadUserFile[] = files
.filter((file) => !existing.has(`${file.name}:${file.size}`)) .filter((file) => !existing.has(`${file.name}:${file.size}`))
+2 -2
View File
@@ -47,8 +47,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, reactive, watch } from "vue"; import { onBeforeUnmount, reactive, watch } from "vue";
import { downloadAttachment } from "../api/attachments"; import { downloadAttachment } from "../api/attachments";
import { saveFile } from "../runtime";
import { displayDateTime, displayUser } from "../utils/display"; import { displayDateTime, displayUser } from "../utils/display";
import { saveFileWithFeedback } from "../utils/fileTaskFeedback";
import { TEXT } from "../locales"; import { TEXT } from "../locales";
const props = defineProps<{ const props = defineProps<{
@@ -97,7 +97,7 @@ const loadImageUrls = async () => {
const download = async (id: string) => { const download = async (id: string) => {
const file = Object.values(props.attachmentsMap).flat().find((item) => item.id === id); const file = Object.values(props.attachmentsMap).flat().find((item) => item.id === id);
const response = await downloadAttachment(id); const response = await downloadAttachment(id);
await saveFile({ await saveFileWithFeedback({
suggestedName: file?.filename || "attachment", suggestedName: file?.filename || "attachment",
mimeType: response.headers?.["content-type"] || file?.content_type, mimeType: response.headers?.["content-type"] || file?.content_type,
data: response.data, data: response.data,
+6 -8
View File
@@ -405,7 +405,7 @@
class="desktop-preferences-dialog" class="desktop-preferences-dialog"
:show-close="false" :show-close="false"
:close-on-click-modal="true" :close-on-click-modal="true"
width="720px" width="940px"
align-center align-center
destroy-on-close destroy-on-close
> >
@@ -779,7 +779,7 @@ const handleDesktopMenuCommand = (command: string) => {
return; return;
} }
if (command === "ctms.desktop.serverSettings") { if (command === "ctms.desktop.serverSettings") {
router.push("/desktop/server-settings"); openDesktopPreferences();
return; return;
} }
if (command === "ctms.desktop.refresh") { if (command === "ctms.desktop.refresh") {
@@ -839,12 +839,10 @@ const desktopCommands = computed<DesktopCommand[]>(() => {
}, },
{ {
id: "desktop:server-settings", id: "desktop:server-settings",
title: "服务器设置", title: "连接设置",
group: "桌面", group: "桌面",
visible: true, visible: true,
run: () => { run: openDesktopPreferences,
void router.push("/desktop/server-settings");
},
}, },
{ {
id: "desktop:update", id: "desktop:update",
@@ -2529,7 +2527,7 @@ useDesktopShortcuts(
} }
:global(.desktop-preferences-dialog .el-dialog__body) { :global(.desktop-preferences-dialog .el-dialog__body) {
padding: 20px; padding: 0;
background: #f8fafc; background: #ffffff;
} }
</style> </style>
@@ -152,7 +152,8 @@ import { displayDateTime, getUserDisplayName } from "../../utils/display";
import { getAttachmentPermissionKey } from "../../utils/attachmentPermissions"; import { getAttachmentPermissionKey } from "../../utils/attachmentPermissions";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue"; import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { clientRuntime, openFile, pickFiles, saveFile } from "../../runtime"; import { clientRuntime } from "../../runtime";
import { openFileWithFeedback, pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
type AttachmentEntityGroup = { type AttachmentEntityGroup = {
entityType: string; entityType: string;
@@ -280,7 +281,7 @@ const queuePendingUpload = (fileType: string, uploadFile: any) => {
const pickPendingNative = async (group: UploadGroup) => { const pickPendingNative = async (group: UploadGroup) => {
if (!canUploadGroup(group)) return; if (!canUploadGroup(group)) return;
const selected = await pickFiles({ multiple: true, title: group.label }); const selected = await pickFilesWithFeedback({ multiple: true, title: group.label });
selected.forEach((file) => queuePendingFile(group.key, file)); selected.forEach((file) => queuePendingFile(group.key, file));
}; };
@@ -351,7 +352,7 @@ const uploadImmediate = async (options: any) => {
}; };
const pickImmediateNative = async () => { const pickImmediateNative = async () => {
const [file] = await pickFiles({ multiple: false, title: headerTitle.value }); const [file] = await pickFilesWithFeedback({ multiple: false, title: headerTitle.value });
if (file) await uploadImmediate({ file }); if (file) await uploadImmediate({ file });
}; };
@@ -399,7 +400,7 @@ const fetchAttachmentBlob = async (row: any): Promise<Blob> => {
const download = async (row: any) => { const download = async (row: any) => {
try { try {
await saveFile({ await saveFileWithFeedback({
suggestedName: row?.filename || "download", suggestedName: row?.filename || "download",
mimeType: row?.content_type, mimeType: row?.content_type,
data: await fetchAttachmentBlob(row), data: await fetchAttachmentBlob(row),
@@ -411,7 +412,7 @@ const download = async (row: any) => {
const openExternally = async (row: any) => { const openExternally = async (row: any) => {
try { try {
await openFile({ await openFileWithFeedback({
suggestedName: row?.filename || "attachment", suggestedName: row?.filename || "attachment",
mimeType: row?.content_type, mimeType: row?.content_type,
data: await fetchAttachmentBlob(row), data: await fetchAttachmentBlob(row),
+12 -3
View File
@@ -95,11 +95,20 @@ export const buildAdminNavigationItems = (options: {
keywords: ["monitoring"], keywords: ["monitoring"],
}, },
{ {
label: "邮件服务", label: "系统设置",
path: "/admin/email-settings", path: "/admin/email-settings",
group: TEXT.menu.admin, group: TEXT.menu.admin,
icon: "settings", icon: "settings",
keywords: ["email"], keywords: ["settings"],
children: [
{
label: "邮件服务",
path: "/admin/email-settings",
group: "系统设置",
icon: "settings",
keywords: ["email", "settings"],
},
],
}, },
); );
} }
@@ -295,4 +304,4 @@ export const buildProjectNavigationItems = (options: {
}; };
export const flattenLayoutNavigationItems = (items: LayoutNavigationItem[]) => export const flattenLayoutNavigationItems = (items: LayoutNavigationItem[]) =>
items.flatMap((item) => [item, ...(item.children || [])]); items.flatMap((item) => item.children?.length ? item.children : [item]);
@@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DESKTOP_SERVER_URL_KEY } from "./desktopServerConfig";
import {
getSessionToken,
initializeSecureSessionStorage,
resetSecureSessionStorageForTests,
setSessionToken,
} from "./secureSessionStorage";
const invokeMock = vi.hoisted(() => vi.fn());
vi.mock("@tauri-apps/api/core", () => ({
invoke: invokeMock,
}));
const SERVER_ORIGIN = "https://ctms.example.com/";
const DESKTOP_SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
const encodeJson = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString("base64url");
const createJwt = (expiresAtMs: number): string =>
`${encodeJson({ alg: "none", typ: "JWT" })}.${encodeJson({ exp: Math.floor(expiresAtMs / 1000) })}.signature`;
const createStorage = (): Storage => {
const data = new Map<string, string>();
return {
get length() {
return data.size;
},
clear: () => data.clear(),
getItem: (key) => data.get(key) ?? null,
key: (index) => Array.from(data.keys())[index] ?? null,
removeItem: (key) => data.delete(key),
setItem: (key, value) => {
data.set(key, String(value));
},
};
};
describe("secure session storage", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-02T00:00:00.000Z"));
resetSecureSessionStorageForTests();
Object.defineProperty(window, "localStorage", { value: createStorage(), configurable: true });
localStorage.clear();
localStorage.setItem(DESKTOP_SERVER_URL_KEY, SERVER_ORIGIN);
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
invokeMock.mockReset();
invokeMock.mockResolvedValue(undefined);
});
afterEach(() => {
vi.useRealTimers();
resetSecureSessionStorageForTests();
localStorage.clear();
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
});
it("stores desktop tokens as a 30 day secure session record", async () => {
const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS);
await setSessionToken(token);
expect(invokeMock).toHaveBeenCalledWith("credential_set", {
serverOrigin: SERVER_ORIGIN,
token: expect.any(String),
});
const stored = JSON.parse(invokeMock.mock.calls[0][1].token);
expect(stored).toMatchObject({ version: 1, token });
expect(stored.expiresAt - stored.storedAt).toBe(DESKTOP_SESSION_MAX_AGE_MS);
});
it("restores a valid desktop secure session record on startup", async () => {
const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS);
invokeMock.mockImplementation(async (command: string) => {
if (command === "credential_get") {
return JSON.stringify({
version: 1,
token,
storedAt: Date.now(),
expiresAt: Date.now() + DESKTOP_SESSION_MAX_AGE_MS,
});
}
return undefined;
});
await initializeSecureSessionStorage();
expect(getSessionToken()).toBe(token);
expect(invokeMock).toHaveBeenCalledWith("credential_get", { serverOrigin: SERVER_ORIGIN });
});
it("deletes an expired desktop secure session record on startup", async () => {
const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS);
invokeMock.mockImplementation(async (command: string) => {
if (command === "credential_get") {
return JSON.stringify({
version: 1,
token,
storedAt: Date.now() - DESKTOP_SESSION_MAX_AGE_MS - 1_000,
expiresAt: Date.now() - 1_000,
});
}
return undefined;
});
await initializeSecureSessionStorage();
expect(getSessionToken()).toBeNull();
expect(invokeMock).toHaveBeenCalledWith("credential_delete", { serverOrigin: SERVER_ORIGIN });
});
it("rewrites a legacy raw desktop token into a secure session record", async () => {
const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS);
invokeMock.mockImplementation(async (command: string) => {
if (command === "credential_get") return token;
return undefined;
});
await initializeSecureSessionStorage();
expect(getSessionToken()).toBe(token);
expect(invokeMock).toHaveBeenCalledWith("credential_set", {
serverOrigin: SERVER_ORIGIN,
token: expect.stringContaining(token),
});
});
});
+61 -5
View File
@@ -2,6 +2,13 @@ import { getDesktopServerUrl } from "./desktopServerConfig";
import { isTauriRuntime } from "./platform"; import { isTauriRuntime } from "./platform";
const LEGACY_TOKEN_KEY = "ctms_token"; const LEGACY_TOKEN_KEY = "ctms_token";
const DESKTOP_SESSION_RECORD_VERSION = 1;
const DESKTOP_SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
type StoredSessionToken = {
token: string;
needsRewrite: boolean;
};
let cachedToken: string | null = null; let cachedToken: string | null = null;
let initialized = false; let initialized = false;
@@ -29,6 +36,57 @@ const isUsableJwt = (token: string): boolean => {
} }
}; };
const serializeSessionToken = (token: string, now: number = Date.now()): string =>
JSON.stringify({
version: DESKTOP_SESSION_RECORD_VERSION,
token,
storedAt: now,
expiresAt: now + DESKTOP_SESSION_MAX_AGE_MS,
});
const parseStoredSessionToken = (stored: string | null, now: number = Date.now()): StoredSessionToken | null => {
if (!stored) return null;
try {
const payload = JSON.parse(stored) as {
version?: unknown;
token?: unknown;
storedAt?: unknown;
expiresAt?: unknown;
};
if (payload.version !== DESKTOP_SESSION_RECORD_VERSION || typeof payload.token !== "string") {
return null;
}
if (
typeof payload.storedAt !== "number" ||
typeof payload.expiresAt !== "number" ||
!Number.isFinite(payload.storedAt) ||
!Number.isFinite(payload.expiresAt)
) {
return null;
}
const localExpiresAt = Math.min(payload.expiresAt, payload.storedAt + DESKTOP_SESSION_MAX_AGE_MS);
if (localExpiresAt <= now || !isUsableJwt(payload.token)) return null;
return { token: payload.token, needsRewrite: false };
} catch {
return isUsableJwt(stored) ? { token: stored, needsRewrite: true } : null;
}
};
const readCredentialToken = async (serverOrigin: string): Promise<string | null> => {
const stored = await invokeCredential<string | null>("credential_get", { serverOrigin });
const parsed = parseStoredSessionToken(stored);
if (parsed?.needsRewrite) {
await invokeCredential<void>("credential_set", {
serverOrigin,
token: serializeSessionToken(parsed.token),
});
}
if (!parsed && stored) {
await invokeCredential<void>("credential_delete", { serverOrigin });
}
return parsed?.token ?? null;
};
export const initializeSecureSessionStorage = async (): Promise<void> => { export const initializeSecureSessionStorage = async (): Promise<void> => {
if (initialized) return; if (initialized) return;
initialized = true; initialized = true;
@@ -52,13 +110,11 @@ export const initializeSecureSessionStorage = async (): Promise<void> => {
if (legacyToken && isUsableJwt(legacyToken)) { if (legacyToken && isUsableJwt(legacyToken)) {
await invokeCredential<void>("credential_set", { await invokeCredential<void>("credential_set", {
serverOrigin: activeServerOrigin, serverOrigin: activeServerOrigin,
token: legacyToken, token: serializeSessionToken(legacyToken),
}); });
cachedToken = legacyToken; cachedToken = legacyToken;
} else { } else {
cachedToken = await invokeCredential<string | null>("credential_get", { cachedToken = await readCredentialToken(activeServerOrigin);
serverOrigin: activeServerOrigin,
});
} }
secureStorageAvailable = true; secureStorageAvailable = true;
} catch (error) { } catch (error) {
@@ -87,7 +143,7 @@ export const setSessionToken = async (token: string): Promise<void> => {
const serverOrigin = getDesktopServerUrl(); const serverOrigin = getDesktopServerUrl();
if (!serverOrigin) throw new Error("尚未配置桌面服务器地址"); if (!serverOrigin) throw new Error("尚未配置桌面服务器地址");
await invokeCredential<void>("credential_set", { serverOrigin, token }); await invokeCredential<void>("credential_set", { serverOrigin, token: serializeSessionToken(token) });
activeServerOrigin = serverOrigin; activeServerOrigin = serverOrigin;
cachedToken = token; cachedToken = token;
initialized = true; initialized = true;
+107 -5
View File
@@ -10,6 +10,7 @@ const INITIAL_CHECK_DELAY_MS = 30_000;
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
const POSTPONE_MS = 24 * 60 * 60 * 1000; const POSTPONE_MS = 24 * 60 * 60 * 1000;
const POSTPONE_PREFIX = "ctms_desktop_update_postponed:"; const POSTPONE_PREFIX = "ctms_desktop_update_postponed:";
export const DESKTOP_UPDATE_STATUS_CHANGED_EVENT = "ctms:desktop-update-status-changed";
let initialized = false; let initialized = false;
let checkTimer: number | null = null; let checkTimer: number | null = null;
@@ -18,10 +19,60 @@ let promptVisible = false;
export type DesktopUpdateCheckStatus = "disabled" | "up-to-date" | "available" | "postponed" | "suppressed" | "failed"; export type DesktopUpdateCheckStatus = "disabled" | "up-to-date" | "available" | "postponed" | "suppressed" | "failed";
export interface DesktopUpdateStatusSnapshot {
available: boolean;
checking: boolean;
installing: boolean;
lastStatus: DesktopUpdateCheckStatus | "idle";
lastCheckedAt: string | null;
lastError: string;
pendingUpdate: DesktopUpdateInfo | null;
postponedUntil: string | null;
}
export interface DesktopUpdateCheckOptions { export interface DesktopUpdateCheckOptions {
notifyWhenCurrent?: boolean; notifyWhenCurrent?: boolean;
promptWhenAvailable?: boolean;
} }
const updateStatus: DesktopUpdateStatusSnapshot = {
available: isDesktopUpdaterAvailable(),
checking: false,
installing: false,
lastStatus: "idle",
lastCheckedAt: null,
lastError: "",
pendingUpdate: null,
postponedUntil: null,
};
const snapshotUpdateStatus = (): DesktopUpdateStatusSnapshot => ({ ...updateStatus });
const emitUpdateStatus = () => {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent(DESKTOP_UPDATE_STATUS_CHANGED_EVENT, {
detail: snapshotUpdateStatus(),
}),
);
};
const setUpdateStatus = (patch: Partial<DesktopUpdateStatusSnapshot>) => {
Object.assign(updateStatus, patch);
emitUpdateStatus();
};
export const getDesktopUpdateStatus = (): DesktopUpdateStatusSnapshot => snapshotUpdateStatus();
export const listenDesktopUpdateStatus = (listener: (status: DesktopUpdateStatusSnapshot) => void) => {
if (typeof window === "undefined") return () => {};
const onStatusChange = (event: Event) => {
listener((event as CustomEvent<DesktopUpdateStatusSnapshot>).detail);
};
window.addEventListener(DESKTOP_UPDATE_STATUS_CHANGED_EVENT, onStatusChange);
return () => window.removeEventListener(DESKTOP_UPDATE_STATUS_CHANGED_EVENT, onStatusChange);
};
const postponeKey = (version: string) => `${POSTPONE_PREFIX}${version}`; const postponeKey = (version: string) => `${POSTPONE_PREFIX}${version}`;
const getPostponeUntil = (version: string): number => { const getPostponeUntil = (version: string): number => {
@@ -34,11 +85,13 @@ const getPostponeUntil = (version: string): number => {
}; };
const postponeVersion = (version: string) => { const postponeVersion = (version: string) => {
const until = Date.now() + POSTPONE_MS;
try { try {
window.localStorage.setItem(postponeKey(version), String(Date.now() + POSTPONE_MS)); window.localStorage.setItem(postponeKey(version), String(until));
} catch { } catch {
/* ignore */ /* ignore */
} }
setUpdateStatus({ lastStatus: "postponed", postponedUntil: new Date(until).toISOString() });
}; };
const isSuppressed = (version: string): boolean => getPostponeUntil(version) > Date.now(); const isSuppressed = (version: string): boolean => getPostponeUntil(version) > Date.now();
@@ -62,13 +115,16 @@ const promptForUpdate = async (update: DesktopUpdateInfo): Promise<DesktopUpdate
distinguishCancelAndClose: true, distinguishCancelAndClose: true,
type: "info", type: "info",
}); });
setUpdateStatus({ installing: true, lastError: "" });
await installPendingDesktopUpdate(); await installPendingDesktopUpdate();
setUpdateStatus({ installing: false, lastStatus: "available", pendingUpdate: update });
return "available"; return "available";
} catch (error) { } catch (error) {
if (error === "cancel" || error === "close") { if (error === "cancel" || error === "close") {
postponeVersion(update.version); postponeVersion(update.version);
return "postponed"; return "postponed";
} }
setUpdateStatus({ installing: false, lastStatus: "failed", lastError: "桌面端更新安装失败" });
ElMessage.error("桌面端更新安装失败,请稍后重试或联系管理员。"); ElMessage.error("桌面端更新安装失败,请稍后重试或联系管理员。");
return "failed"; return "failed";
} finally { } finally {
@@ -76,21 +132,66 @@ const promptForUpdate = async (update: DesktopUpdateInfo): Promise<DesktopUpdate
} }
}; };
export const promptForPendingDesktopUpdate = async (): Promise<DesktopUpdateCheckStatus> => {
if (updateStatus.pendingUpdate) {
return promptForUpdate(updateStatus.pendingUpdate);
}
return checkDesktopUpdateAndPrompt({ notifyWhenCurrent: true, promptWhenAvailable: true });
};
export const checkDesktopUpdateAndPrompt = async ( export const checkDesktopUpdateAndPrompt = async (
options: DesktopUpdateCheckOptions = {}, options: DesktopUpdateCheckOptions = {},
): Promise<DesktopUpdateCheckStatus> => { ): Promise<DesktopUpdateCheckStatus> => {
if (!isDesktopUpdaterAvailable()) { if (!isDesktopUpdaterAvailable()) {
if (options.notifyWhenCurrent) ElMessage.info("当前构建未启用桌面端自动更新"); setUpdateStatus({
available: false,
checking: false,
installing: false,
lastStatus: "disabled",
lastCheckedAt: new Date().toISOString(),
lastError: "",
pendingUpdate: null,
postponedUntil: null,
});
return "disabled"; return "disabled";
} }
setUpdateStatus({ available: true, checking: true, lastError: "" });
try { try {
const update = await checkForDesktopUpdate(); const update = await checkForDesktopUpdate();
const checkedAt = new Date().toISOString();
if (update) { if (update) {
return promptForUpdate(update); const postponedUntilMs = getPostponeUntil(update.version);
const suppressed = postponedUntilMs > Date.now();
setUpdateStatus({
checking: false,
lastStatus: suppressed ? "suppressed" : "available",
lastCheckedAt: checkedAt,
pendingUpdate: update,
postponedUntil: postponedUntilMs ? new Date(postponedUntilMs).toISOString() : null,
});
const shouldPrompt = options.promptWhenAvailable ?? options.notifyWhenCurrent ?? false;
if (shouldPrompt && !suppressed) {
return promptForUpdate(update);
}
return suppressed ? "suppressed" : "available";
} }
if (options.notifyWhenCurrent) ElMessage.success("当前已是最新版本"); setUpdateStatus({
checking: false,
lastStatus: "up-to-date",
lastCheckedAt: checkedAt,
lastError: "",
pendingUpdate: null,
postponedUntil: null,
});
return "up-to-date"; return "up-to-date";
} catch { } catch (error) {
const message = error instanceof Error ? error.message : "桌面端更新检查失败";
setUpdateStatus({
checking: false,
lastStatus: "failed",
lastCheckedAt: new Date().toISOString(),
lastError: message,
});
// 启动和定时检查不打断录入;下一轮继续检查。 // 启动和定时检查不打断录入;下一轮继续检查。
if (options.notifyWhenCurrent) ElMessage.error("桌面端更新检查失败,请稍后重试或联系管理员。"); if (options.notifyWhenCurrent) ElMessage.error("桌面端更新检查失败,请稍后重试或联系管理员。");
return "failed"; return "failed";
@@ -119,4 +220,5 @@ export const stopDesktopUpdateManager = () => {
} }
initialized = false; initialized = false;
promptVisible = false; promptVisible = false;
setUpdateStatus({ checking: false, installing: false });
}; };
+19 -5
View File
@@ -7,6 +7,20 @@
--unified-shell-padding-y: 10px; --unified-shell-padding-y: 10px;
--unified-title-color: #0f2345; --unified-title-color: #0f2345;
--unified-muted-color: #6f84a8; --unified-muted-color: #6f84a8;
--unified-table-header-bg: #f7f9fc;
--unified-table-header-color: #34506f;
--unified-row-divider: #edf2f8;
}
:root[data-ctms-theme="dark"] {
--unified-shell-bg: var(--ctms-bg-card);
--unified-shell-border: var(--ctms-border-color);
--unified-shell-divider: var(--ctms-border-color);
--unified-title-color: var(--ctms-text-main);
--unified-muted-color: var(--ctms-text-secondary);
--unified-table-header-bg: var(--ctms-bg-muted);
--unified-table-header-color: var(--ctms-text-main);
--unified-row-divider: var(--ctms-border-color);
} }
.ctms-page-shell { .ctms-page-shell {
@@ -85,7 +99,7 @@
.page>.page-header.unified-action-bar { .page>.page-header.unified-action-bar {
border: 0; border: 0;
border-radius: var(--unified-shell-radius); border-radius: var(--unified-shell-radius);
background: #ffffff; background: var(--unified-shell-bg);
box-shadow: none; box-shadow: none;
} }
@@ -156,15 +170,15 @@
} }
.unified-shell .el-table th.el-table__cell { .unified-shell .el-table th.el-table__cell {
background-color: #f7f9fc; background-color: var(--unified-table-header-bg);
color: #34506f; color: var(--unified-table-header-color);
font-weight: 600; font-weight: 600;
height: 40px; height: 40px;
font-size: 13px; font-size: 13px;
} }
.unified-shell .el-table td.el-table__cell { .unified-shell .el-table td.el-table__cell {
border-bottom-color: #edf2f8; border-bottom-color: var(--unified-row-divider);
padding: 7px 0; padding: 7px 0;
} }
@@ -178,7 +192,7 @@
} }
.unified-shell .el-tabs__nav-wrap::after { .unified-shell .el-tabs__nav-wrap::after {
background-color: #edf2f8; background-color: var(--unified-row-divider);
} }
.unified-shell .el-tabs__item { .unified-shell .el-tabs__item {
+35
View File
@@ -0,0 +1,35 @@
import { ElMessage } from "element-plus";
import {
clientRuntime,
openFile,
pickFiles,
saveFile,
type FileOutput,
type FilePickerOptions,
type SaveFileResult,
} from "../runtime";
const selectedFilesMessage = (count: number) => (count > 1 ? `已选择 ${count} 个文件` : "已选择 1 个文件");
export const pickFilesWithFeedback = async (options: FilePickerOptions = {}): Promise<File[]> => {
const files = await pickFiles(options);
if (files.length) {
ElMessage.success(selectedFilesMessage(files.length));
}
return files;
};
export const saveFileWithFeedback = async (output: FileOutput): Promise<SaveFileResult> => {
const result = await saveFile(output);
if (result === "cancelled") {
ElMessage.info("已取消保存文件");
return result;
}
ElMessage.success(clientRuntime.capabilities().nativeFiles ? "文件已保存" : "文件下载已开始");
return result;
};
export const openFileWithFeedback = async (output: FileOutput): Promise<void> => {
await openFile(output);
ElMessage.success("文件已打开");
};
File diff suppressed because it is too large Load Diff
+116 -14
View File
@@ -22,6 +22,19 @@
:closable="false" :closable="false"
/> />
<div v-if="connectionDiagnostic" class="connection-diagnostic">
<div class="diagnostic-grid">
<span>检查时间</span>
<strong>{{ connectionDiagnostic.checkedAt }}</strong>
<span>健康检查</span>
<code>{{ connectionDiagnostic.healthUrl }}</code>
<span>耗时</span>
<strong>{{ connectionDiagnostic.durationMs }}ms</strong>
<span>HTTP</span>
<strong>{{ connectionDiagnostic.httpStatus || "-" }}</strong>
</div>
</div>
<el-form label-position="top" @submit.prevent> <el-form label-position="top" @submit.prevent>
<el-form-item label="服务器地址" :error="urlError"> <el-form-item label="服务器地址" :error="urlError">
<el-input <el-input
@@ -33,10 +46,6 @@
/> />
</el-form-item> </el-form-item>
<div class="hint">
允许 HTTPS 服务地址本地开发可使用 http://localhost http://127.0.0.1
</div>
<div class="actions"> <div class="actions">
<el-button v-if="canCancel" size="large" @click="goBack">取消</el-button> <el-button v-if="canCancel" size="large" @click="goBack">取消</el-button>
<el-button type="primary" size="large" :loading="saving" :disabled="!serverUrl.trim()" @click="save"> <el-button type="primary" size="large" :loading="saving" :disabled="!serverUrl.trim()" @click="save">
@@ -51,7 +60,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessageBox } from "element-plus";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study"; import { useStudyStore } from "../store/study";
import { getDesktopServerUrl, normalizeDesktopServerUrl, setDesktopServerUrl } from "../runtime"; import { getDesktopServerUrl, normalizeDesktopServerUrl, setDesktopServerUrl } from "../runtime";
@@ -65,13 +74,46 @@ const serverUrl = ref(currentServerUrl || "");
const urlError = ref(""); const urlError = ref("");
const saving = ref(false); const saving = ref(false);
const connectionStatus = ref<{ type: "success" | "warning" | "error"; title: string; message: string } | null>(null); const connectionStatus = ref<{ type: "success" | "warning" | "error"; title: string; message: string } | null>(null);
const connectionDiagnostic = ref<{
healthUrl: string;
checkedAt: string;
durationMs: number;
httpStatus?: number;
} | null>(null);
const canCancel = computed(() => Boolean(currentServerUrl)); const canCancel = computed(() => Boolean(currentServerUrl));
const HEALTH_TIMEOUT_MS = 10_000; const HEALTH_TIMEOUT_MS = 10_000;
const formatDiagnosticTime = (date = new Date()) =>
date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
const toConnectionError = (
message: string,
details: {
serverUrl: string;
healthUrl: string;
durationMs: number;
httpStatus?: number;
},
) => Object.assign(new Error(message), details);
const checkHealth = async (baseUrl: string) => { const checkHealth = async (baseUrl: string) => {
const healthUrl = new URL("health", baseUrl).toString(); const healthUrl = new URL("health", baseUrl).toString();
const controller = new AbortController(); const controller = new AbortController();
const startedAt = performance.now();
const timeout = window.setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS); const timeout = window.setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
const details = () => ({
serverUrl: baseUrl,
healthUrl,
durationMs: Math.max(0, Math.round(performance.now() - startedAt)),
});
try { try {
const response = await fetch(healthUrl, { const response = await fetch(healthUrl, {
method: "GET", method: "GET",
@@ -79,14 +121,21 @@ const checkHealth = async (baseUrl: string) => {
signal: controller.signal, signal: controller.signal,
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`服务器健康检查返回 HTTP ${response.status}`); throw toConnectionError(`服务器健康检查返回 HTTP ${response.status}`, {
...details(),
httpStatus: response.status,
});
} }
return {
...details(),
httpStatus: response.status,
};
} catch (error) { } catch (error) {
if (error instanceof DOMException && error.name === "AbortError") { if (error instanceof DOMException && error.name === "AbortError") {
throw new Error("连接超时,请确认服务端地址和网络状态"); throw toConnectionError("连接超时,请确认服务端地址和网络状态", details());
} }
if (error instanceof TypeError) { if (error instanceof TypeError) {
throw new Error("网络请求失败,请确认地址、证书或 CORS 配置"); throw toConnectionError("网络请求失败,请确认地址、证书或 CORS 配置", details());
} }
throw error; throw error;
} finally { } finally {
@@ -99,6 +148,21 @@ const clearSessionForServerChange = async () => {
studyStore.clearCurrentStudy(); studyStore.clearCurrentStudy();
}; };
const confirmServerChange = async (previous: string | null, next: string) => {
if (!previous || previous === next) return true;
const confirmed = await ElMessageBox.confirm(
"切换服务器会退出当前会话并清除当前项目上下文,确认后需要重新登录。",
"确认切换服务器",
{
type: "warning",
confirmButtonText: "切换并退出登录",
cancelButtonText: "继续编辑",
distinguishCancelAndClose: true,
},
).catch(() => null);
return Boolean(confirmed);
};
const save = async () => { const save = async () => {
urlError.value = ""; urlError.value = "";
connectionStatus.value = null; connectionStatus.value = null;
@@ -110,8 +174,9 @@ const save = async () => {
saving.value = true; saving.value = true;
try { try {
await checkHealth(normalized.url); const health = await checkHealth(normalized.url);
const previous = getDesktopServerUrl(); const previous = getDesktopServerUrl();
if (!(await confirmServerChange(previous, normalized.url))) return;
const result = setDesktopServerUrl(normalized.url); const result = setDesktopServerUrl(normalized.url);
if (!result.ok) { if (!result.ok) {
urlError.value = result.reason; urlError.value = result.reason;
@@ -125,15 +190,32 @@ const save = async () => {
title: "连接已确认", title: "连接已确认",
message: result.url, message: result.url,
}; };
ElMessage.success("服务器连接已确认"); connectionDiagnostic.value = {
healthUrl: health.healthUrl,
checkedAt: formatDiagnosticTime(),
durationMs: health.durationMs,
httpStatus: health.httpStatus,
};
router.replace("/login"); router.replace("/login");
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "无法连接服务器的 /health"; const message = error instanceof Error ? error.message : "无法连接服务器的 /health";
const details = error as Error & {
serverUrl?: string;
healthUrl?: string;
durationMs?: number;
httpStatus?: number;
};
connectionStatus.value = { connectionStatus.value = {
type: "error", type: "error",
title: "连接检查失败", title: "连接检查失败",
message, message,
}; };
connectionDiagnostic.value = {
healthUrl: details.healthUrl || new URL("health", normalized.url).toString(),
checkedAt: formatDiagnosticTime(),
durationMs: details.durationMs ?? 0,
httpStatus: details.httpStatus,
};
urlError.value = message; urlError.value = message;
} finally { } finally {
saving.value = false; saving.value = false;
@@ -215,11 +297,31 @@ h1 {
margin-bottom: 18px; margin-bottom: 18px;
} }
.hint { .connection-diagnostic {
margin-top: -8px; display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
margin-bottom: 18px;
padding: 12px;
border: 1px solid #dbe6f2;
border-radius: 8px;
background: #fbfdff;
}
.diagnostic-grid {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 6px 10px;
min-width: 0;
color: #64748b; color: #64748b;
font-size: 13px; font-size: 12px;
line-height: 1.6; }
.diagnostic-grid strong {
min-width: 0;
color: #1e293b;
font-weight: 700;
} }
.actions { .actions {
+7 -2
View File
@@ -96,12 +96,17 @@ describe("Login protocol agreement", () => {
expect(source).toContain("max-width: calc(100vw - 40px);"); expect(source).toContain("max-width: calc(100vw - 40px);");
}); });
it("uses only server-configured email domains on web and desktop", () => { it("falls back to full email input when no server-configured domains exist", () => {
const source = readLoginView(); const source = readLoginView();
expect(source).toContain('fetchEmailDomains()'); expect(source).toContain('fetchEmailDomains()');
expect(source).toContain(':disabled="availableEmailDomains.length === 0"');
expect(source).toContain("const availableEmailDomains = computed(() => configuredEmailDomains.value)"); expect(source).toContain("const availableEmailDomains = computed(() => configuredEmailDomains.value)");
expect(source).toContain("availableEmailDomains.value.length > 0 && availableEmailDomains.value.includes(form.emailDomain)");
expect(source).toContain("const manualEmailInput = computed(() => !hasConfiguredEmailDomains.value)");
expect(source).toContain('v-if="hasConfiguredEmailDomains"');
expect(source).toContain("未检测到邮箱后缀配置,请输入完整邮箱地址。");
expect(source).toContain("form.email = local;");
expect(source).toContain("if (domains.length === 0)");
expect(source).toContain("configuredEmailDomains.value.includes(domain)"); expect(source).toContain("configuredEmailDomains.value.includes(domain)");
expect(source).not.toContain("preservedEmailDomain"); expect(source).not.toContain("preservedEmailDomain");
expect(source).not.toContain("showDomainSelect"); expect(source).not.toContain("showDomainSelect");
+54 -9
View File
@@ -145,19 +145,19 @@
<!-- 登录表单 --> <!-- 登录表单 -->
<el-form ref="formRef" :model="form" :rules="rules" @keyup.enter="onSubmit" label-position="top" class="login-form"> <el-form ref="formRef" :model="form" :rules="rules" @keyup.enter="onSubmit" label-position="top" class="login-form">
<el-form-item label="账号" prop="email"> <el-form-item label="账号" prop="email">
<div class="email-unified-box"> <div class="email-unified-box" :class="{ 'email-unified-box--manual': manualEmailInput }">
<input <input
id="email" id="email"
v-model="form.emailLocal" v-model="form.emailLocal"
type="text" :type="manualEmailInput ? 'email' : 'text'"
placeholder="请输入账号" :placeholder="emailInputPlaceholder"
name="username" name="username"
autocomplete="username" autocomplete="username"
class="email-local-field" class="email-local-field"
@paste="handleAccountPaste" @paste="handleAccountPaste"
/> />
<span class="email-at-sign">@</span> <span v-if="hasConfiguredEmailDomains" class="email-at-sign">@</span>
<div class="email-domain-wrapper"> <div v-if="hasConfiguredEmailDomains" class="email-domain-wrapper">
<select <select
v-model="form.emailDomain" v-model="form.emailDomain"
class="email-domain-field" class="email-domain-field"
@@ -169,6 +169,7 @@
<svg class="email-domain-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m6 9 6 6 6-6"/></svg> <svg class="email-domain-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m6 9 6 6 6-6"/></svg>
</div> </div>
</div> </div>
<p v-if="manualEmailInput" class="email-manual-hint">未检测到邮箱后缀配置请输入完整邮箱地址</p>
</el-form-item> </el-form-item>
<el-form-item label="密码" prop="password"> <el-form-item label="密码" prop="password">
@@ -294,9 +295,19 @@ const refreshDesktopServerUrl = () => {
const normalizeDomain = (value: string) => value.trim().toLowerCase().replace(/^@/, ""); const normalizeDomain = (value: string) => value.trim().toLowerCase().replace(/^@/, "");
const availableEmailDomains = computed(() => configuredEmailDomains.value); const availableEmailDomains = computed(() => configuredEmailDomains.value);
const hasConfiguredEmailDomains = computed(() =>
availableEmailDomains.value.length > 0 && availableEmailDomains.value.includes(form.emailDomain)
);
const manualEmailInput = computed(() => !hasConfiguredEmailDomains.value);
const emailInputPlaceholder = computed(() => manualEmailInput.value ? "请输入完整邮箱" : "请输入账号");
const syncEmailFromParts = () => { const syncEmailFromParts = () => {
const local = form.emailLocal.trim().toLowerCase(); const local = form.emailLocal.trim().toLowerCase();
if (manualEmailInput.value) {
form.emailDomain = "";
form.email = local;
return;
}
const domain = normalizeDomain(form.emailDomain); const domain = normalizeDomain(form.emailDomain);
form.emailDomain = domain; form.emailDomain = domain;
form.email = local && domain ? `${local}@${domain}` : ""; form.email = local && domain ? `${local}@${domain}` : "";
@@ -304,6 +315,12 @@ const syncEmailFromParts = () => {
const applyEmailValue = (email: string) => { const applyEmailValue = (email: string) => {
const normalized = email.trim().toLowerCase(); const normalized = email.trim().toLowerCase();
if (availableEmailDomains.value.length === 0) {
form.emailLocal = normalized;
form.emailDomain = "";
syncEmailFromParts();
return;
}
const separator = normalized.lastIndexOf("@"); const separator = normalized.lastIndexOf("@");
if (separator > 0) { if (separator > 0) {
form.emailLocal = normalized.slice(0, separator); form.emailLocal = normalized.slice(0, separator);
@@ -321,16 +338,32 @@ const applyEmailValue = (email: string) => {
const loadEmailDomains = async () => { const loadEmailDomains = async () => {
try { try {
const { data } = await fetchEmailDomains(); const { data } = await fetchEmailDomains();
configuredEmailDomains.value = Array.from(new Set( const domains = Array.from(new Set(
data.items.map(normalizeDomain).filter(Boolean) (Array.isArray(data.items) ? data.items : [])
.map(item => typeof item === "string" ? normalizeDomain(item) : "")
.filter(Boolean)
)); ));
if (!configuredEmailDomains.value.includes(form.emailDomain)) { configuredEmailDomains.value = domains;
form.emailDomain = configuredEmailDomains.value[0] || "";
if (domains.length === 0) {
form.emailDomain = "";
syncEmailFromParts();
return;
}
if (form.emailLocal.includes("@")) {
applyEmailValue(form.emailLocal);
return;
}
if (!domains.includes(form.emailDomain)) {
form.emailDomain = domains[0];
} }
} catch { } catch {
configuredEmailDomains.value = []; configuredEmailDomains.value = [];
form.emailDomain = ""; form.emailDomain = "";
} }
syncEmailFromParts();
}; };
const handleAccountPaste = (event: ClipboardEvent) => { const handleAccountPaste = (event: ClipboardEvent) => {
@@ -367,6 +400,7 @@ const confirmProtocol = () => { form.agreeProtocol = true; protocolDialogVisible
const onSubmit = async () => { const onSubmit = async () => {
if (!formRef.value) return; if (!formRef.value) return;
syncEmailFromParts();
const valid = await formRef.value.validate(); const valid = await formRef.value.validate();
if (!valid) return; if (!valid) return;
if (!form.agreeProtocol) { ElMessage.warning("请先阅读并同意用户协议"); return; } if (!form.agreeProtocol) { ElMessage.warning("请先阅读并同意用户协议"); return; }
@@ -948,6 +982,10 @@ const onSubmit = async () => {
border-bottom-color: #2563eb; border-bottom-color: #2563eb;
} }
.email-unified-box--manual .email-local-field {
width: 100%;
}
.email-local-field, .email-local-field,
.email-domain-field { .email-domain-field {
min-width: 0; min-width: 0;
@@ -969,6 +1007,13 @@ const onSubmit = async () => {
color: #94a3b8; color: #94a3b8;
} }
.email-manual-hint {
margin: 8px 0 0;
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.email-at-sign { .email-at-sign {
flex-shrink: 0; flex-shrink: 0;
padding: 0 4px; padding: 0 4px;
+2 -202
View File
@@ -64,45 +64,6 @@
</el-form-item> </el-form-item>
</section> </section>
<section class="form-section form-section--desktop">
<div class="section-heading">
<span class="section-kicker">Desktop</span>
<h4>客户端与通知</h4>
</div>
<el-form-item v-if="isDesktop" label="系统通知">
<div class="desktop-setting-stack">
<div class="desktop-setting-row">
<el-switch
v-model="desktopNotificationsEnabled"
:loading="desktopNotificationLoading"
@change="onDesktopNotificationChange"
/>
<el-tag size="small" :type="notificationPermissionTagType">{{ notificationPermissionText }}</el-tag>
</div>
<span class="desktop-setting-hint">仅推送不含项目详情的文件更新提示</span>
</div>
</el-form-item>
<el-form-item v-if="isDesktop && desktopUpdaterAvailable" label="桌面更新">
<div class="desktop-setting-row">
<el-button size="small" :loading="desktopUpdateChecking" @click="checkDesktopUpdateNow">
检查更新
</el-button>
<span class="desktop-setting-hint">正式版本会按发布源检查签名更新</span>
</div>
</el-form-item>
<el-form-item label="客户端信息">
<div class="client-metadata-panel">
<dl class="client-metadata-list">
<template v-for="row in clientMetadataRows" :key="row.label">
<dt>{{ row.label }}</dt>
<dd>{{ row.value }}</dd>
</template>
</dl>
<el-button size="small" @click="copyClientMetadata">复制</el-button>
</div>
</el-form-item>
</section>
<div class="actions"> <div class="actions">
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button> <el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
</div> </div>
@@ -118,25 +79,9 @@ import type { FormInstance, FormRules } from "element-plus";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { Close, Upload } from "@element-plus/icons-vue"; import { Close, Upload } from "@element-plus/icons-vue";
import { updateProfile, fetchMe, uploadAvatar } from "../api/auth"; import { updateProfile, fetchMe, uploadAvatar } from "../api/auth";
import {
getDesktopNotificationSubscription,
setDesktopNotificationSubscription,
} from "../api/desktopNotifications";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
import {
clientRuntime,
getAppMetadata,
getDesktopServerUrl,
getNotificationPermission,
isDesktopUpdaterAvailable,
isTauriRuntime,
pickFiles,
requestNotificationPermission,
type NotificationPermissionState,
} from "../runtime";
import { triggerDesktopNotificationPoll } from "../session/desktopNotificationManager";
import { checkDesktopUpdateAndPrompt } from "../session/desktopUpdateManager";
import { TEXT, requiredMessage } from "../locales"; import { TEXT, requiredMessage } from "../locales";
import { pickFilesWithFeedback } from "../utils/fileTaskFeedback";
const emit = defineEmits<{ const emit = defineEmits<{
"close-request": []; "close-request": [];
@@ -144,31 +89,6 @@ const emit = defineEmits<{
saved: []; saved: [];
}>(); }>();
const auth = useAuthStore(); const auth = useAuthStore();
const isDesktop = isTauriRuntime();
const clientMetadata = getAppMetadata();
const desktopCapabilities = clientRuntime.capabilities();
const desktopUpdaterAvailable = isDesktopUpdaterAvailable();
const clientMetadataRows = [
{ label: "客户端", value: `${clientMetadata.clientType} ${clientMetadata.version}` },
{ label: "平台", value: clientMetadata.platform },
{ label: "构建通道", value: clientMetadata.channel },
{ label: "提交", value: clientMetadata.commit },
{ label: "服务器", value: getDesktopServerUrl() || "未配置" },
{
label: "能力",
value: [
desktopCapabilities.secureSessionStorage ? "安全会话" : "浏览器会话",
desktopCapabilities.nativeFiles ? "原生文件" : "浏览器文件",
desktopCapabilities.systemNotifications ? "系统通知" : "无系统通知",
desktopCapabilities.automaticUpdates ? "自动更新" : "无自动更新",
].join(" / "),
},
];
const clientMetadataText = clientMetadataRows.map((row) => `${row.label}: ${row.value}`).join("\n");
const desktopNotificationsEnabled = ref(false);
const desktopNotificationLoading = ref(false);
const desktopUpdateChecking = ref(false);
const notificationPermission = ref<NotificationPermissionState>("unsupported");
const formRef = ref<FormInstance>(); const formRef = ref<FormInstance>();
const submitting = ref(false); const submitting = ref(false);
const form = reactive({ const form = reactive({
@@ -247,72 +167,6 @@ const loadProfile = async () => {
avatarPreview.value = data.avatar_url || undefined; avatarPreview.value = data.avatar_url || undefined;
}; };
const loadDesktopNotificationSubscription = async () => {
if (!isDesktop) return;
notificationPermission.value = await getNotificationPermission().catch(() => "unsupported");
const { data } = await getDesktopNotificationSubscription();
desktopNotificationsEnabled.value = data.enabled;
};
const notificationPermissionText = computed(() => {
if (!isDesktop) return "不可用";
if (notificationPermission.value === "granted") return "系统已允许";
if (notificationPermission.value === "denied") return "系统已拒绝";
if (notificationPermission.value === "prompt") return "等待授权";
return "不可用";
});
const notificationPermissionTagType = computed<"success" | "warning" | "danger" | "info">(() => {
if (notificationPermission.value === "granted") return "success";
if (notificationPermission.value === "denied") return "danger";
if (notificationPermission.value === "prompt") return "warning";
return "info";
});
const onDesktopNotificationChange = async (value: string | number | boolean) => {
if (!isDesktop || desktopNotificationLoading.value) return;
desktopNotificationLoading.value = true;
try {
const enable = Boolean(value);
if (enable) {
const permission = await requestNotificationPermission();
notificationPermission.value = permission;
if (permission !== "granted") {
desktopNotificationsEnabled.value = false;
ElMessage.warning("系统通知权限未开启,请在系统设置中允许 CTMS 通知");
return;
}
}
const { data } = await setDesktopNotificationSubscription(enable);
desktopNotificationsEnabled.value = data.enabled;
if (data.enabled) triggerDesktopNotificationPoll();
} catch (error: any) {
desktopNotificationsEnabled.value = !Boolean(value);
ElMessage.error(error?.response?.data?.detail || "通知设置保存失败");
} finally {
desktopNotificationLoading.value = false;
}
};
const copyClientMetadata = async () => {
if (!navigator.clipboard?.writeText) {
ElMessage.warning("当前环境无法访问剪贴板");
return;
}
await navigator.clipboard.writeText(clientMetadataText);
ElMessage.success("客户端信息已复制");
};
const checkDesktopUpdateNow = async () => {
if (desktopUpdateChecking.value) return;
desktopUpdateChecking.value = true;
try {
await checkDesktopUpdateAndPrompt({ notifyWhenCurrent: true });
} finally {
desktopUpdateChecking.value = false;
}
};
const onSubmit = async () => { const onSubmit = async () => {
if (!formRef.value) return; if (!formRef.value) return;
await formRef.value.validate(async (valid) => { await formRef.value.validate(async (valid) => {
@@ -345,7 +199,7 @@ const onSubmit = async () => {
}; };
const selectAndUploadAvatar = async () => { const selectAndUploadAvatar = async () => {
const [file] = await pickFiles({ const [file] = await pickFilesWithFeedback({
multiple: false, multiple: false,
accept: ["png", "jpg", "jpeg", "gif", "webp"], accept: ["png", "jpg", "jpeg", "gif", "webp"],
title: TEXT.modules.profile.uploadAvatar, title: TEXT.modules.profile.uploadAvatar,
@@ -368,7 +222,6 @@ const selectAndUploadAvatar = async () => {
onMounted(() => { onMounted(() => {
loadProfile(); loadProfile();
loadDesktopNotificationSubscription().catch(() => {});
}); });
watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: true }); watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: true });
@@ -483,59 +336,6 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
border-top: 1px solid #e5ebf2; border-top: 1px solid #e5ebf2;
} }
.form-section--desktop {
margin-top: 26px;
padding-top: 28px;
border-top: 1px solid #e5ebf2;
}
.desktop-setting-stack {
display: flex;
flex-direction: column;
gap: 8px;
}
.desktop-setting-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.desktop-setting-hint {
color: #7f92ad;
font-size: 12px;
}
.client-metadata-panel {
display: flex;
align-items: flex-start;
gap: 12px;
min-width: 0;
}
.client-metadata-list {
display: grid;
grid-template-columns: 76px minmax(0, 1fr);
gap: 6px 10px;
flex: 1;
min-width: 0;
margin: 0;
color: #40566f;
font-size: 12px;
}
.client-metadata-list dt {
color: #7f92ad;
font-weight: 700;
}
.client-metadata-list dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
.section-heading { .section-heading {
margin-bottom: 18px; margin-bottom: 18px;
padding-left: 112px; padding-left: 112px;
+2 -2
View File
@@ -1954,7 +1954,7 @@ import {
type SetupWorkflowTagMeta, type SetupWorkflowTagMeta,
} from "../../utils/setupPublishWorkflow"; } from "../../utils/setupPublishWorkflow";
import { useSetupConfig } from "../../composables/useSetupConfig"; import { useSetupConfig } from "../../composables/useSetupConfig";
import { saveFile } from "../../runtime"; import { saveFileWithFeedback } from "../../utils/fileTaskFeedback";
type SetupStepKey = type SetupStepKey =
| "project-info" | "project-info"
@@ -5345,7 +5345,7 @@ ${buildExcelWorksheetXml("第6步-中心确认", metaRows, step6Headers, step6Ro
const blob = new Blob([`\uFEFF${workbookXml}`], { type: "application/vnd.ms-excel;charset=utf-8;" }); const blob = new Blob([`\uFEFF${workbookXml}`], { type: "application/vnd.ms-excel;charset=utf-8;" });
const safeVersionLabel = displayVersion.replace(/[^\w.-]/g, "_"); const safeVersionLabel = displayVersion.replace(/[^\w.-]/g, "_");
const filename = `setup-config-${safeVersionLabel}-${project.value.code || project.value.id}.xls`; const filename = `setup-config-${safeVersionLabel}-${project.value.code || project.value.id}.xls`;
await saveFile({ await saveFileWithFeedback({
suggestedName: filename, suggestedName: filename,
mimeType: "application/vnd.ms-excel;charset=utf-8", mimeType: "application/vnd.ms-excel;charset=utf-8",
data: blob, data: blob,
@@ -378,8 +378,8 @@ import { useRoleTemplateMeta } from "../../composables/useRoleTemplateMeta";
import { usePermission } from "../../utils/permission"; import { usePermission } from "../../utils/permission";
import StateError from "../../components/StateError.vue"; import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue"; import StateLoading from "../../components/StateLoading.vue";
import { openFile, pickFiles, saveFile } from "../../runtime";
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh"; import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
import { openFileWithFeedback, pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
const route = useRoute(); const route = useRoute();
const auth = useAuthStore(); const auth = useAuthStore();
@@ -524,7 +524,7 @@ const uploadDirtyGuard = useDrawerDirtyGuard(() => ({
})); }));
const triggerFileInput = async () => { const triggerFileInput = async () => {
const [file] = await pickFiles({ const [file] = await pickFilesWithFeedback({
multiple: false, multiple: false,
accept: ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "png", "jpg", "jpeg"], accept: ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "png", "jpg", "jpeg"],
title: "选择文档版本", title: "选择文档版本",
@@ -813,7 +813,7 @@ const downloadVersion = async (version: DocumentVersion) => {
const contentType = response.headers?.["content-type"] || "application/octet-stream"; const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`; const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`;
const blob = new Blob([response.data], { type: contentType }); const blob = new Blob([response.data], { type: contentType });
await saveFile({ suggestedName: filename, mimeType: contentType, data: blob }); await saveFileWithFeedback({ suggestedName: filename, mimeType: contentType, data: blob });
} catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.downloadFailed); } } catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.downloadFailed); }
}; };
@@ -822,7 +822,7 @@ const openVersion = async (version: DocumentVersion) => {
const response = await downloadDocumentVersion(version.id); const response = await downloadDocumentVersion(version.id);
const contentType = response.headers?.["content-type"] || "application/octet-stream"; const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`; const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`;
await openFile({ await openFileWithFeedback({
suggestedName: filename, suggestedName: filename,
mimeType: contentType, mimeType: contentType,
data: new Blob([response.data], { type: contentType }), data: new Blob([response.data], { type: contentType }),
+11 -11
View File
@@ -480,7 +480,7 @@ watch(
.card-title { .card-title {
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: 700;
color: #0f2345; color: var(--unified-title-color);
} }
.card-subtitle { .card-subtitle {
@@ -520,11 +520,11 @@ watch(
border-radius: 50%; border-radius: 50%;
border: 3px solid #2f84c6; border: 3px solid #2f84c6;
box-sizing: border-box; box-sizing: border-box;
background: #ffffff; background: var(--ctms-bg-card);
} }
.plan-time-label { .plan-time-label {
color: #4b5563; color: var(--ctms-text-secondary);
font-size: 11px; font-size: 11px;
} }
@@ -546,10 +546,10 @@ watch(
} }
.time-editor-group { .time-editor-group {
border: 1px solid #e8eef6; border: 1px solid var(--ctms-border-color);
border-radius: 10px; border-radius: 10px;
padding: 14px 16px 10px; padding: 14px 16px 10px;
background: #fbfcfe; background: var(--ctms-bg-muted);
} }
.time-editor-group + .time-editor-group { .time-editor-group + .time-editor-group {
@@ -566,7 +566,7 @@ watch(
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
margin-bottom: 12px; margin-bottom: 12px;
color: #1a3560; color: var(--ctms-text-main);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
@@ -600,14 +600,14 @@ watch(
.duration-label { .duration-label {
font-size: 13px; font-size: 13px;
color: #6b7280; color: var(--ctms-text-secondary);
font-weight: 500; font-weight: 500;
} }
.duration-value { .duration-value {
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
color: #1a3560; color: var(--ctms-text-main);
} }
.time-editor-footer { .time-editor-footer {
@@ -628,7 +628,7 @@ watch(
.time-editor-form :deep(.el-form-item__label) { .time-editor-form :deep(.el-form-item__label) {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #4a6283; color: var(--ctms-text-regular);
padding-bottom: 4px; padding-bottom: 4px;
} }
@@ -655,7 +655,7 @@ watch(
.status-detail { .status-detail {
padding-left: 14px; padding-left: 14px;
color: #6b7280; color: var(--ctms-text-secondary);
font-size: 11px; font-size: 11px;
} }
@@ -684,7 +684,7 @@ watch(
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #8a97ab; color: var(--ctms-text-secondary);
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
letter-spacing: 0.02em; letter-spacing: 0.02em;
@@ -466,7 +466,7 @@ import {
import { fetchSites } from "../../api/sites"; import { fetchSites } from "../../api/sites";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { pickFiles, saveFile } from "../../runtime"; import { pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import type { Site } from "../../types/api"; import type { Site } from "../../types/api";
@@ -978,7 +978,7 @@ const handleExportExcel = async () => {
const contentType = response.headers?.["content-type"] || "application/octet-stream"; const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getFilename(response.headers?.["content-disposition"]) || "监查访视问题.xlsx"; const filename = getFilename(response.headers?.["content-disposition"]) || "监查访视问题.xlsx";
const blob = new Blob([response.data], { type: contentType }); const blob = new Blob([response.data], { type: contentType });
await saveFile({ suggestedName: filename, mimeType: contentType, data: blob }); await saveFileWithFeedback({ suggestedName: filename, mimeType: contentType, data: blob });
ElMessage.success("导出成功"); ElMessage.success("导出成功");
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.downloadFailed); ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.downloadFailed);
@@ -1010,7 +1010,7 @@ const importIssueFile = async (file: File) => {
}; };
const selectImportFile = async () => { const selectImportFile = async () => {
const [file] = await pickFiles({ const [file] = await pickFilesWithFeedback({
multiple: false, multiple: false,
accept: ["xlsx", "csv"], accept: ["xlsx", "csv"],
title: "导入监查访视问题", title: "导入监查访视问题",