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

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

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

- 更新桌面发布检查、运维文档和前后端测试覆盖
This commit is contained in:
Cheng Zhou
2026-07-13 16:03:20 +08:00
parent b26ebdda02
commit ab59476d10
50 changed files with 2086 additions and 191 deletions
+6 -2
View File
@@ -313,6 +313,7 @@ const desktopUpdateStatus = ref<DesktopUpdateStatusSnapshot>(getDesktopUpdateSta
let updateStatusUnlisten: (() => void) | undefined;
let connectionPendingTimer: number | undefined;
const themeOptions = [
{ value: "system" as const, label: "跟随系统", icon: Monitor },
{ value: "light" as const, label: "明亮", icon: Sunny },
{ value: "dark" as const, label: "暗黑", icon: Moon },
];
@@ -1358,7 +1359,9 @@ h3 {
* ===================================================== */
.theme-segmented {
display: inline-grid;
grid-template-columns: repeat(2, minmax(72px, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
flex: 0 1 336px;
width: min(100%, 336px);
gap: 3px;
padding: 3px;
border: 1px solid var(--pref-border-card);
@@ -1372,7 +1375,7 @@ h3 {
align-items: center;
justify-content: center;
gap: 6px;
min-width: 72px;
min-width: 0;
height: 28px;
padding: 0 10px;
border: 0;
@@ -1383,6 +1386,7 @@ h3 {
font: inherit;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
transition:
background-color 140ms ease,
color 140ms ease,
@@ -24,6 +24,10 @@ describe("DesktopProjectEntry", () => {
expect(source).toContain("project-cards-grid");
expect(source).toContain("card-action-bar");
expect(source).toContain("进入项目工作空间");
expect(source).toContain('v-if="isAdmin || project.role_in_study"');
expect(source).toContain('<span class="meta-label">我的角色</span>');
expect(source).toContain('isAdmin.value ? "系统管理员" : project.role_in_study || "未分配"');
expect(source).not.toContain('project.role_in_study && !isAdmin');
expect(source).toContain('router.push(isAdmin.value ? "/admin/users" : "/admin/projects")');
expect(source).toContain("studyStore.clearCurrentStudy()");
expect(source).toContain("studyStore.setCurrentStudy(pmProject)");
+4 -3
View File
@@ -109,9 +109,9 @@
<span class="meta-label">申办方</span>
<span class="meta-val">{{ project.sponsor }}</span>
</div>
<div v-if="project.role_in_study && !isAdmin" class="meta-item">
<span class="meta-label">分配角色</span>
<span class="meta-val">{{ project.role_in_study }}</span>
<div v-if="isAdmin || project.role_in_study" class="meta-item">
<span class="meta-label">我的角色</span>
<span class="meta-val" :title="projectRoleLabel(project)">{{ projectRoleLabel(project) }}</span>
</div>
</div>
@@ -180,6 +180,7 @@ const entrySubtitle = computed(() =>
const statusLabel = (status: string | undefined) =>
status ? TEXT.enums.projectStatus[status as keyof typeof TEXT.enums.projectStatus] || status : "未知";
const projectRoleLabel = (project: Study) => isAdmin.value ? "系统管理员" : project.role_in_study || "未分配";
const loadProjects = async () => {
loading.value = true;
+20 -1
View File
@@ -316,10 +316,29 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
}
.dialog-close {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin-top: -4px;
color: #718096;
padding: 0;
border: 1px solid #d7dee8;
border-radius: 8px;
background: #f8fafc;
color: #475569;
}
.dialog-close:hover,
.dialog-close:focus-visible {
border-color: #b8c4d3;
background: #eef2f7;
color: #172033;
}
.dialog-close :deep(.el-icon) {
font-size: 18px;
}
.title {
@@ -15,6 +15,10 @@ describe("WebWorkbenchEntry", () => {
expect(source).toContain("换项目先回工作台");
expect(source).toContain("项目内不直接切换项目");
expect(source).toContain("项目工作区");
expect(source).toContain('v-if="isAdmin || project.role_in_study"');
expect(source).toContain('<span class="meta-label">我的角色</span>');
expect(source).toContain('isAdmin.value ? "系统管理员" : project.role_in_study || "未分配"');
expect(source).not.toContain('project.role_in_study && !isAdmin');
expect(source).toContain("fetchStudies()");
expect(source).toContain('projects.value.some((project) => project.role_in_study === "PM")');
expect(source).toContain('v-if="canEnterManagement"');
+3 -2
View File
@@ -134,9 +134,9 @@
<span class="meta-label">申办方</span>
<span class="meta-val" :title="project.sponsor">{{ project.sponsor }}</span>
</div>
<div v-if="project.role_in_study && !isAdmin" class="meta-item">
<div v-if="isAdmin || project.role_in_study" class="meta-item">
<span class="meta-label">我的角色</span>
<span class="meta-val" :title="project.role_in_study">{{ project.role_in_study }}</span>
<span class="meta-val" :title="projectRoleLabel(project)">{{ projectRoleLabel(project) }}</span>
</div>
</div>
@@ -219,6 +219,7 @@ const managementActionLabel = computed(() => canEnterManagement.value ? "进入
const statusLabel = (status: string | undefined) =>
status ? TEXT.enums.projectStatus[status as keyof typeof TEXT.enums.projectStatus] || status : "未知";
const projectRoleLabel = (project: Study) => isAdmin.value ? "系统管理员" : project.role_in_study || "未分配";
const loadProjects = async () => {
loading.value = true;
@@ -3,7 +3,7 @@
v-model="visibleProxy"
title="登录记录"
direction="rtl"
size="min(520px, 92vw)"
size="min(760px, 96vw)"
destroy-on-close
>
<div class="login-activity-drawer">
@@ -18,28 +18,50 @@
</span>
</div>
<el-table v-loading="loading" :data="activities" class="login-activity-table" empty-text="暂无登录记录">
<div class="activity-view-toolbar">
<div>
<strong>{{ activityViewMode === 'source' ? '最近登录来源' : '全部登录会话' }}</strong>
<span v-if="activityViewMode === 'all'">展示最近 100 条原始登录会话。</span>
</div>
<el-segmented v-model="activityViewMode" :options="activityViewOptions" size="small" />
</div>
<el-table v-loading="loading" :data="displayActivities" class="login-activity-table" empty-text="暂无登录记录">
<el-table-column label="状态" width="102">
<template #default="scope">
<span class="status-badge compact" :class="activityStatusClass(scope.row)">
<span class="status-badge-dot"></span>
<span>{{ activityStatusLabel(scope.row) }}</span>
</span>
<el-tooltip :content="activityStatusDescription(scope.row)" placement="top">
<span class="status-badge compact" :class="activityStatusClass(scope.row)">
<span class="status-badge-dot"></span>
<span>{{ activityStatusLabel(scope.row) }}</span>
</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="客户端" min-width="130">
<el-table-column label="客户端" min-width="150">
<template #default="scope">
<div class="client-cell">
<strong>{{ scope.row.client_type === 'desktop' ? '桌面端' : '网页端' }}</strong>
<span>{{ clientDescription(scope.row) }}</span>
<el-tag v-if="scope.row.grouped_count > 1" type="info" effect="plain" size="small">
合并 {{ scope.row.grouped_count }} 条
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="登录 / 最近活动" min-width="160">
<el-table-column label="登录 IP(位置)" min-width="190">
<template #default="scope">
<div class="source-cell">
<strong>{{ scope.row.login_ip || 'IP 未记录' }}</strong>
<span>{{ scope.row.ip_location || '--' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="登录 / 最近活动" min-width="190">
<template #default="scope">
<div class="time-cell">
<span>登录 {{ displayDateTime(scope.row.login_at) }}</span>
<span>活动 {{ displayDateTime(scope.row.last_seen_at) }}</span>
<span v-if="scope.row.ended_at">退出 {{ displayDateTime(scope.row.ended_at) }}</span>
</div>
</template>
</el-table-column>
@@ -63,22 +85,70 @@ const emit = defineEmits<{ "update:modelValue": [value: boolean] }>();
const activities = ref<UserLoginActivity[]>([]);
const loading = ref(false);
const activityViewMode = ref<"source" | "all">("source");
const activityViewOptions = [
{ label: "最近来源", value: "source" },
{ label: "全部会话", value: "all" },
];
const visibleProxy = computed({
get: () => props.modelValue,
set: (value: boolean) => emit("update:modelValue", value),
});
const isRecent = (value: string) => Date.now() - new Date(value).getTime() <= 5 * 60 * 1000;
type DisplayLoginActivity = UserLoginActivity & { grouped_count: number };
const resolvedActivityStatus = (item: UserLoginActivity) => {
if (item.activity_status) return item.activity_status;
if (item.ended_at) return "ENDED";
return Date.now() - new Date(item.last_seen_at).getTime() <= 5 * 60 * 1000 ? "ONLINE" : "OFFLINE";
};
const activityStatusLabel = (item: UserLoginActivity) => {
if (item.ended_at) return "已退出";
return isRecent(item.last_seen_at) ? "在线" : "已离线";
const status = resolvedActivityStatus(item);
if (status === "ENDED") return "已退出";
return status === "ONLINE" ? "在线" : "已离线";
};
const activityStatusClass = (item: UserLoginActivity) => {
if (item.ended_at) return "is-ended";
return isRecent(item.last_seen_at) ? "is-online" : "is-offline";
const status = resolvedActivityStatus(item);
if (status === "ENDED") return "is-ended";
return status === "ONLINE" ? "is-online" : "is-offline";
};
const activityStatusDescription = (item: UserLoginActivity) => {
const status = resolvedActivityStatus(item);
if (status === "ENDED") return "服务端已收到明确的退出请求";
if (status === "ONLINE") return "最近心跳仍在服务端在线判定时间内";
return "未明确退出,但最近心跳已超过服务端在线判定时间";
};
const clientDescription = (item: UserLoginActivity) =>
[item.client_platform, item.client_version].filter(Boolean).join(" · ") || "--";
const activitySourceKey = (item: UserLoginActivity) => {
if (!item.login_ip) return `session:${item.id}`;
return [item.login_ip, item.client_type, item.client_platform || "", item.client_source || ""].join("|");
};
const sourceActivities = computed<DisplayLoginActivity[]>(() => {
const rows: DisplayLoginActivity[] = [];
const groupedHistory = new Map<string, DisplayLoginActivity>();
activities.value.forEach((item) => {
const row: DisplayLoginActivity = { ...item, grouped_count: 1 };
if (resolvedActivityStatus(item) === "ONLINE") {
rows.push(row);
return;
}
const key = activitySourceKey(item);
const existing = groupedHistory.get(key);
if (existing) {
existing.grouped_count += 1;
return;
}
groupedHistory.set(key, row);
rows.push(row);
});
return rows;
});
const displayActivities = computed<DisplayLoginActivity[]>(() =>
activityViewMode.value === "source"
? sourceActivities.value
: activities.value.map((item) => ({ ...item, grouped_count: 1 })),
);
const loadActivities = async () => {
if (!props.user) return;
@@ -96,7 +166,10 @@ const loadActivities = async () => {
watch(
() => props.modelValue,
(isOpen) => {
if (isOpen) void loadActivities();
if (isOpen) {
activityViewMode.value = "source";
void loadActivities();
}
},
{ immediate: true },
);
@@ -209,6 +282,32 @@ watch(
overflow: hidden;
}
.activity-view-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 2px;
}
.activity-view-toolbar > div {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.activity-view-toolbar strong {
color: #334155;
font-size: 13px;
}
.activity-view-toolbar span {
color: #64748b;
font-size: 11.5px;
line-height: 1.45;
}
.login-activity-table :deep(.el-table__header-wrapper) th {
background-color: #f8fafc !important;
color: #475569 !important;
@@ -224,6 +323,7 @@ watch(
}
.client-cell,
.source-cell,
.time-cell {
display: flex;
flex-direction: column;
@@ -237,12 +337,35 @@ watch(
}
.client-cell span,
.source-cell span,
.time-cell span {
color: #64748b;
font-size: 11.5px;
}
.client-cell .el-tag {
align-self: flex-start;
}
.source-cell strong {
color: #334155;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11.5px;
font-weight: 600;
}
.time-cell span {
line-height: 1.4;
}
@media (max-width: 680px) {
.activity-view-toolbar {
align-items: stretch;
flex-direction: column;
}
.activity-view-toolbar :deep(.el-segmented) {
align-self: flex-start;
}
}
</style>
@@ -5,7 +5,7 @@ import { resolve } from "node:path";
const readSource = (path: string) => readFileSync(resolve(__dirname, path), "utf8");
describe("admin user login status", () => {
it("shows server-provided session state and opens a privacy-safe activity drawer", () => {
it("shows server-provided session state with login source details and grouped history", () => {
const users = readSource("./Users.vue");
const drawer = readSource("./UserLoginActivitiesDrawer.vue");
@@ -15,8 +15,21 @@ describe("admin user login status", () => {
expect(users).toContain("openLoginActivities");
expect(users).toContain("UserLoginActivitiesDrawer");
expect(drawer).toContain("登录记录");
expect(drawer).not.toContain("不会展示 Token、密码或原始 IP");
expect(drawer).toContain("登录 IP(位置)");
expect(drawer).toContain("scope.row.login_ip");
expect(drawer).toContain("scope.row.ip_location");
expect(drawer).toContain("activity_status");
expect(drawer).toContain("resolvedActivityStatus");
expect(drawer).toContain("最近来源");
expect(drawer).toContain("全部会话");
expect(drawer).toContain("activitySourceKey");
expect(drawer).toContain("grouped_count");
expect(drawer).toContain("scope.row.ended_at");
expect(drawer).not.toContain("const isRecent");
expect(drawer).not.toContain("access_token");
const usersApi = readSource("../../api/users.ts");
expect(usersApi).toContain("limit = 100");
});
it("offers responsive account and login-status filters with clear reset feedback", () => {
@@ -93,6 +93,7 @@ describe("DocumentDetail breadcrumbs", () => {
expect(source).toContain("study.setViewContext({");
expect(source).toContain("siteName: displaySite(detail.site_id)");
expect(source).toContain("pageTitle: detail.title || TEXT.modules.fileVersionManagement.title");
expect(source).toContain("objectType: detail.doc_type ? displayText(detail.doc_type, TEXT.enums.documentType) : undefined");
});
});
@@ -478,6 +478,7 @@ const syncBreadcrumbContext = () => {
study.setViewContext({
siteName: displaySite(detail.site_id),
pageTitle: detail.title || TEXT.modules.fileVersionManagement.title,
objectType: detail.doc_type ? displayText(detail.doc_type, TEXT.enums.documentType) : undefined,
});
};