feat(桌面与监控): 完善工作台导航和登录活动定位
- 优化桌面标签、上下文标题、前进后退、导航栏隐藏和原生菜单体验 - 补充登录会话 IP 采集、地理位置回退、管理端展示及数据库迁移 - 更新桌面发布检查、运维文档和前后端测试覆盖
This commit is contained in:
@@ -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)");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"');
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user