release(main): 同步 dev 最新候选改动
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
This commit is contained in:
@@ -1,71 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import ApiPermissions from "@/views/admin/ApiPermissions.vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
|
||||
vi.mock("vue-router", async () => {
|
||||
const actual = await vi.importActual<typeof import("vue-router")>("vue-router");
|
||||
return {
|
||||
...actual,
|
||||
useRoute: () => ({ params: { id: "study-1" } }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/api/projectPermissions", () => ({
|
||||
fetchApiEndpointPermissions: vi.fn().mockResolvedValue({ data: {} }),
|
||||
updateApiEndpointPermissions: vi.fn().mockResolvedValue({ data: {} }),
|
||||
fetchPermissionMetrics: vi.fn().mockResolvedValue({ data: {} }),
|
||||
fetchCacheStats: vi.fn().mockResolvedValue({ data: {} }),
|
||||
fetchPermissionAlerts: vi.fn().mockResolvedValue({ data: { total: 0, alerts: [] } }),
|
||||
fetchPermissionHealth: vi.fn().mockResolvedValue({ data: {} }),
|
||||
resetPermissionMetrics: vi.fn().mockResolvedValue({ data: undefined }),
|
||||
}));
|
||||
|
||||
describe("ApiPermissions.vue", () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: window.localStorage,
|
||||
configurable: true,
|
||||
});
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it("renders permission management page", () => {
|
||||
const wrapper = mount(ApiPermissions, {
|
||||
global: {
|
||||
stubs: {
|
||||
ApiEndpointPermissions: true,
|
||||
PermissionMonitoring: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.find(".permission-page").exists()).toBe(true);
|
||||
expect(wrapper.find(".permission-title h2").text()).toBe("权限管理");
|
||||
});
|
||||
|
||||
it("renders tabs", () => {
|
||||
const source = readFileSync(resolve(__dirname, "./ApiPermissions.vue"), "utf8");
|
||||
|
||||
expect(source).toContain('label="角色权限"');
|
||||
expect(source).not.toContain('label="权限监控"');
|
||||
expect(source).not.toContain("<PermissionMonitoring");
|
||||
});
|
||||
|
||||
it("has save button disabled when not dirty", () => {
|
||||
const source = readFileSync(resolve(__dirname, "./ApiPermissions.vue"), "utf8");
|
||||
|
||||
expect(source).toContain(':disabled="!dirty"');
|
||||
});
|
||||
});
|
||||
@@ -1,229 +0,0 @@
|
||||
<template>
|
||||
<div class="permission-page">
|
||||
<div class="permission-shell unified-shell" v-loading="loading">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="permission-header unified-action-bar">
|
||||
<div class="permission-title">
|
||||
<el-icon><Key /></el-icon>
|
||||
<h2>权限管理</h2>
|
||||
</div>
|
||||
<div class="permission-project-meta">
|
||||
<span class="meta-item">
|
||||
<span class="meta-label">项目编号</span>
|
||||
<strong>{{ project?.code || "-" }}</strong>
|
||||
</span>
|
||||
<span class="meta-separator" />
|
||||
<span class="meta-item">
|
||||
<span class="meta-label">项目名称</span>
|
||||
<strong>{{ project?.name || "-" }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div class="permission-actions">
|
||||
<el-button type="primary" :loading="saving" :disabled="!dirty" @click="save">
|
||||
<el-icon><Check /></el-icon>
|
||||
保存
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签页 -->
|
||||
<el-tabs v-model="activeTab">
|
||||
<!-- 接口级权限 -->
|
||||
<el-tab-pane label="角色权限" name="api">
|
||||
<PermissionTemplateSelector
|
||||
:study-id="studyId"
|
||||
:current-permissions="currentPermissionsForTemplate"
|
||||
@applied="onTemplateApplied"
|
||||
/>
|
||||
<el-divider />
|
||||
<ApiEndpointPermissions
|
||||
:project="project"
|
||||
:matrix="apiMatrix"
|
||||
@update="onApiMatrixUpdate"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Key, Check } from "@element-plus/icons-vue";
|
||||
import type {
|
||||
ApiEndpointPermissionsResponse,
|
||||
} from "@/types/api";
|
||||
import {
|
||||
fetchApiEndpointPermissions,
|
||||
updateApiEndpointPermissions,
|
||||
} from "@/api/projectPermissions";
|
||||
import { useStudyStore } from "@/store/study";
|
||||
import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue";
|
||||
import PermissionTemplateSelector from "@/components/PermissionTemplateSelector.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const studyStore = useStudyStore();
|
||||
|
||||
const activeTab = ref<"api">("api");
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
const project = computed(() => studyStore.currentStudy);
|
||||
const studyId = computed(() => {
|
||||
const id = route.params.id;
|
||||
return typeof id === "string" ? id : (id as string[])[0];
|
||||
});
|
||||
|
||||
// 权限数据
|
||||
const apiMatrix = ref<ApiEndpointPermissionsResponse | null>(null);
|
||||
|
||||
// 脏值检测
|
||||
const dirty = ref(false);
|
||||
|
||||
const loadPermissionData = async () => {
|
||||
if (!studyId.value) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const apiRes = await fetchApiEndpointPermissions(studyId.value);
|
||||
apiMatrix.value = apiRes.data;
|
||||
dirty.value = false;
|
||||
} catch (error) {
|
||||
ElMessage.error("加载权限数据失败");
|
||||
console.error(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onApiMatrixUpdate = (newMatrix: ApiEndpointPermissionsResponse) => {
|
||||
apiMatrix.value = newMatrix;
|
||||
dirty.value = true;
|
||||
};
|
||||
|
||||
// 将当前 apiMatrix 转换为模板所需的 {role: {endpoint_key: bool}} 格式
|
||||
const currentPermissionsForTemplate = computed(() => {
|
||||
if (!apiMatrix.value) return undefined;
|
||||
const result: Record<string, Record<string, boolean>> = {};
|
||||
for (const [role, endpoints] of Object.entries(apiMatrix.value)) {
|
||||
result[role] = {};
|
||||
for (const [key, val] of Object.entries(endpoints)) {
|
||||
result[role][key] = typeof val === "boolean" ? val : val.allowed;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const onTemplateApplied = async (permissions: Record<string, Record<string, { allowed: boolean }>>) => {
|
||||
// 模板应用后刷新权限矩阵
|
||||
await loadPermissionData();
|
||||
ElMessage.success("权限已更新");
|
||||
};
|
||||
|
||||
const flattenMatrix = (matrix: ApiEndpointPermissionsResponse): Record<string, Record<string, boolean>> => {
|
||||
const result: Record<string, Record<string, boolean>> = {};
|
||||
for (const [role, endpoints] of Object.entries(matrix)) {
|
||||
result[role] = {};
|
||||
for (const [key, val] of Object.entries(endpoints)) {
|
||||
result[role][key] = typeof val === "boolean" ? val : val.allowed;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!studyId.value || !dirty.value) return;
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
if (apiMatrix.value) {
|
||||
const res = await updateApiEndpointPermissions(studyId.value, flattenMatrix(apiMatrix.value));
|
||||
apiMatrix.value = res.data;
|
||||
}
|
||||
|
||||
dirty.value = false;
|
||||
ElMessage.success("权限已保存");
|
||||
|
||||
await loadPermissionData();
|
||||
} catch (error) {
|
||||
ElMessage.error("保存权限失败");
|
||||
console.error(error);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadPermissionData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.permission-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.permission-shell {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.permission-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.permission-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.permission-project-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.meta-separator {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: #dcdfe6;
|
||||
}
|
||||
|
||||
.permission-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
:deep(.el-tabs) {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -8,13 +8,21 @@ describe("audit logs access", () => {
|
||||
it("keeps the audit summary header compact", () => {
|
||||
const source = readAuditLogsView();
|
||||
|
||||
expect(source).toContain("padding: 10px 16px;");
|
||||
expect(source).toContain("padding: 9px 12px;");
|
||||
expect(source).toContain("width: 32px;");
|
||||
expect(source).toContain("height: 32px;");
|
||||
expect(source).toContain(".stat-icon svg { width: 18px; height: 18px; }");
|
||||
expect(source).toContain(".stat-value { font-size: 20px;");
|
||||
expect(source).toContain(".stat-label { font-size: 11px;");
|
||||
expect(source).toContain('class="audit-overview"');
|
||||
expect(source).toContain("min-height: 48px;");
|
||||
expect(source).toContain("width: 30px;");
|
||||
expect(source).toContain("height: 30px;");
|
||||
expect(source).toContain(".stat-icon svg { width: 17px; height: 17px; }");
|
||||
expect(source).toContain(".stat-value { font-size: 18px;");
|
||||
expect(source).toContain(".stat-label { font-size: 10px;");
|
||||
expect(source).toContain("content-wrapper:has(.audit-logs-page)");
|
||||
expect(source).toContain(".table-section { padding: 0 !important; }");
|
||||
expect(source).toContain("position: sticky;");
|
||||
expect(source).toContain('class="filter-item-form project-filter-group"');
|
||||
expect(source).toContain('class="project-export-button"');
|
||||
expect(source).toContain('@click="confirmExport"');
|
||||
expect(source).not.toContain('class="audit-export-btn"');
|
||||
expect(source).not.toContain("handleExportCommand");
|
||||
});
|
||||
|
||||
it("loads audit logs from the selected project context", () => {
|
||||
@@ -67,6 +75,30 @@ describe("audit logs access", () => {
|
||||
expect(source).not.toContain("limit: 2000");
|
||||
});
|
||||
|
||||
it("keeps the filter bar focused without request-source or IP-location inputs", () => {
|
||||
const source = readAuditLogsView();
|
||||
|
||||
expect(source).not.toContain("filters.clientType");
|
||||
expect(source).not.toContain("filters.ipKeyword");
|
||||
expect(source).not.toContain("TEXT.modules.adminAuditLogs.filterSource");
|
||||
expect(source).not.toContain("TEXT.modules.adminAuditLogs.filterIpLocation");
|
||||
expect(source).not.toContain("TEXT.modules.adminAuditLogs.columns.source");
|
||||
expect(source).not.toContain("formatClientIpLine(scope.row)");
|
||||
expect(source).not.toContain("isIpSearchToken");
|
||||
});
|
||||
|
||||
it("includes request source context in audit exports", () => {
|
||||
const columns = readFileSync(resolve(__dirname, "../../audit/export/auditExportColumns.ts"), "utf8");
|
||||
const formatter = readFileSync(resolve(__dirname, "../../audit/export/auditExportFormatter.ts"), "utf8");
|
||||
|
||||
expect(columns).toContain('key: "ipLocation"');
|
||||
expect(columns).toContain('key: "clientSource"');
|
||||
expect(columns).toContain('key: "userAgent"');
|
||||
expect(formatter).toContain("ip: event.clientIp || \"\"");
|
||||
expect(formatter).toContain("clientSource: event.clientSourceLabel || \"\"");
|
||||
expect(formatter).toContain("userAgent: event.userAgent || \"\"");
|
||||
});
|
||||
|
||||
it("keeps the audit table compact by removing the duplicate content column", () => {
|
||||
const source = readAuditLogsView();
|
||||
|
||||
@@ -84,13 +116,16 @@ describe("audit logs access", () => {
|
||||
expect(source).not.toContain("getInitials(scope.row.actorName)");
|
||||
});
|
||||
|
||||
it("lets target and detail columns absorb remaining table width", () => {
|
||||
it("allocates audit columns by typical content length", () => {
|
||||
const source = readAuditLogsView();
|
||||
|
||||
expect(source).toContain('<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" min-width="240">');
|
||||
expect(source).toContain('prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" width="128"');
|
||||
expect(source).toContain('prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="155"');
|
||||
expect(source).toContain('prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" width="170"');
|
||||
expect(source).toContain('<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" width="280">');
|
||||
expect(source).toContain('<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff" min-width="360">');
|
||||
expect(source).not.toContain(':label="TEXT.modules.adminAuditLogs.columns.target" width="240"');
|
||||
expect(source).not.toContain(':label="TEXT.modules.adminAuditLogs.columns.diff" width="360"');
|
||||
expect(source).toContain('width="72" align="center" class-name="result-column"');
|
||||
expect(source).toContain("white-space: nowrap;");
|
||||
});
|
||||
|
||||
it("parses diff lines by the last field separator before the arrow", () => {
|
||||
@@ -109,6 +144,18 @@ describe("audit logs access", () => {
|
||||
expect(source).not.toContain("meta-card-full");
|
||||
});
|
||||
|
||||
it("keeps the audit detail focused on business changes instead of technical request context", () => {
|
||||
const source = readAuditLogsView();
|
||||
|
||||
expect(source).not.toContain('<span class="meta-label">请求来源</span>');
|
||||
expect(source).not.toContain('<span class="meta-label">来源 IP</span>');
|
||||
expect(source).not.toContain('<span class="meta-label">IP 位置</span>');
|
||||
expect(source).not.toContain('<span class="meta-label">客户端</span>');
|
||||
expect(source).not.toContain("访问上下文");
|
||||
expect(source).not.toContain("formatClientMeta");
|
||||
expect(source).not.toContain("formatBuildMeta");
|
||||
});
|
||||
|
||||
it("groups setup diff detail rows by business item path", () => {
|
||||
const source = readAuditLogsView();
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
<template>
|
||||
<div class="page page--flush">
|
||||
<div class="page page--flush audit-logs-page">
|
||||
<div class="main-content-flat unified-shell">
|
||||
<!-- 统计概览 -->
|
||||
<div class="stats-row">
|
||||
<section class="audit-overview" aria-labelledby="audit-overview-title">
|
||||
<div class="overview-heading">
|
||||
<h2 id="audit-overview-title">审计概览</h2>
|
||||
<span class="overview-status"><i></i>日志汇总</span>
|
||||
</div>
|
||||
<div class="stats-row">
|
||||
<div class="stat-card stat-card--total">
|
||||
<div class="stat-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||
@@ -35,19 +40,34 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-value">{{ operatorCount }}</span>
|
||||
<span class="stat-label">操作人数</span>
|
||||
<span class="stat-value">{{ uniqueIpCount }}</span>
|
||||
<span class="stat-label">来源 IP</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="audit-toolbar unified-action-bar">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<div class="filter-item-form">
|
||||
<div class="filter-heading">
|
||||
<el-icon><Filter /></el-icon>
|
||||
<span>筛选</span>
|
||||
</div>
|
||||
<div class="filter-item-form project-filter-group">
|
||||
<el-select v-model="selectedStudyId" filterable :placeholder="TEXT.common.fields.projectName" @change="onProjectChange" class="filter-select-project">
|
||||
<el-option v-for="project in studies" :key="project.id" :label="project.name" :value="project.id" />
|
||||
</el-select>
|
||||
<el-tooltip content="导出当前项目日志" placement="top">
|
||||
<el-button
|
||||
v-if="canProjectExport"
|
||||
:icon="Download"
|
||||
class="project-export-button"
|
||||
:loading="exportLoading"
|
||||
aria-label="导出当前项目日志"
|
||||
@click="confirmExport"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div class="filter-item-form">
|
||||
<el-select v-model="filters.eventType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEvent" @change="onServerFilterChange" class="filter-select-comp">
|
||||
@@ -78,42 +98,32 @@
|
||||
@change="onLocalFilterChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-spacer"></div>
|
||||
<el-dropdown trigger="click" @command="handleExportCommand" class="audit-export-dropdown">
|
||||
<el-button plain class="audit-export-btn" :loading="exportLoading">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:4px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
导出 <el-icon class="el-icon--right"><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-if="canProjectExport" command="project">{{ TEXT.modules.adminAuditLogs.exportProject }}</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<el-button v-if="hasActiveFilters" text :icon="RefreshLeft" class="reset-filter-button" @click="resetFilters">重置</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 日志表格 -->
|
||||
<div class="unified-section table-section">
|
||||
<el-table :data="logs" v-loading="loading" class="audit-table" style="width: 100%" table-layout="fixed">
|
||||
<el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" width="150">
|
||||
<el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" width="128">
|
||||
<template #default="scope">
|
||||
<span class="time-text">{{ displayDateTime(scope.row.timestamp) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="120">
|
||||
<el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="155">
|
||||
<template #default="scope">
|
||||
<div class="actor-cell">
|
||||
<span class="actor-name">{{ scope.row.actorName }}</span>
|
||||
<span v-if="scope.row.actorAccount" class="actor-account">{{ scope.row.actorAccount }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" width="145">
|
||||
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" width="170">
|
||||
<template #default="scope">
|
||||
<span class="event-tag">{{ scope.row.eventLabel }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" min-width="240">
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" width="280">
|
||||
<template #default="scope">
|
||||
<div class="target-cell">
|
||||
<span v-if="scope.row.targetTypeLabel" class="target-text">
|
||||
@@ -135,7 +145,7 @@
|
||||
<span v-else class="text-muted">{{ TEXT.audit.emptyValue }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="76" align="center">
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="72" align="center" class-name="result-column">
|
||||
<template #default="scope">
|
||||
<span class="result-badge" :class="scope.row.result === 'SUCCESS' ? 'result--success' : 'result--fail'">
|
||||
{{ scope.row.resultLabel }}
|
||||
@@ -211,7 +221,6 @@
|
||||
<span class="meta-value">{{ selectedLog.actionText || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 变更明细 -->
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title">
|
||||
@@ -256,7 +265,7 @@
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { ArrowDown } from "@element-plus/icons-vue";
|
||||
import { Download, Filter, RefreshLeft } from "@element-plus/icons-vue";
|
||||
import { createAuditEvent, fetchAuditLogs } from "../../api/auditLogs";
|
||||
import { fetchStudies } from "../../api/studies";
|
||||
import { fetchApiEndpointPermissions } from "../../api/projectPermissions";
|
||||
@@ -265,7 +274,6 @@ import { listMembers } from "../../api/members";
|
||||
import { auditDict, normalizeAuditEvent } from "../../audit";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { roleDict, getDictLabel } from "../../dictionaries";
|
||||
import { exportAuditCsv } from "../../audit/export/auditExportService";
|
||||
import { displayDateTime } from "../../utils/display";
|
||||
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
|
||||
@@ -302,7 +310,13 @@ const filters = ref({
|
||||
|
||||
const successCount = computed(() => allLogs.value.filter(l => (l.result || 'SUCCESS') === 'SUCCESS').length);
|
||||
const failCount = computed(() => allLogs.value.filter(l => l.result === 'FAIL').length);
|
||||
const operatorCount = computed(() => new Set(allLogs.value.map(l => l.actorId || l.actorName)).size);
|
||||
const uniqueIpCount = computed(() => new Set(allLogs.value.map(l => l.clientIp || "未知 IP")).size);
|
||||
const hasActiveFilters = computed(() => Boolean(
|
||||
filters.value.eventType ||
|
||||
filters.value.operatorId ||
|
||||
filters.value.result ||
|
||||
filters.value.range?.length
|
||||
));
|
||||
|
||||
const formatDiffLine = (line: any): string => {
|
||||
if (typeof line === 'string') return line;
|
||||
@@ -470,6 +484,13 @@ const fetchAuditLogPages = async (baseParams: Record<string, any> = {}) => {
|
||||
return allItems;
|
||||
};
|
||||
|
||||
const buildAuditServerParams = () => {
|
||||
return {
|
||||
action: filters.value.eventType || undefined,
|
||||
operator_id: filters.value.operatorId || undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const loadLogs = async () => {
|
||||
if (!selectedStudyId.value) return;
|
||||
loading.value = true;
|
||||
@@ -477,10 +498,7 @@ const loadLogs = async () => {
|
||||
if (!permissionMatrix.value) {
|
||||
await loadPermissionMatrix();
|
||||
}
|
||||
const allItems = await fetchAuditLogPages({
|
||||
action: filters.value.eventType || undefined,
|
||||
operator_id: filters.value.operatorId || undefined,
|
||||
});
|
||||
const allItems = await fetchAuditLogPages(buildAuditServerParams());
|
||||
enrichLogs(allItems);
|
||||
refreshPagedLogs();
|
||||
} catch (e: any) {
|
||||
@@ -498,15 +516,13 @@ const enrichLogs = (items: any[]) => {
|
||||
allLogs.value = items
|
||||
.map((log) => normalizeAuditEvent(log, userMap))
|
||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
if (users.value.length === 0) {
|
||||
const byActor = new Map<string, string>();
|
||||
allLogs.value.forEach((log) => {
|
||||
if (!byActor.has(log.actorId)) {
|
||||
byActor.set(log.actorId, log.actorName || log.actorId);
|
||||
}
|
||||
});
|
||||
users.value = Array.from(byActor.entries()).map(([id, full_name]) => ({ id, full_name }));
|
||||
}
|
||||
const byActor = new Map(users.value.map((user) => [user.id, resolveUserDisplayName(user)]));
|
||||
allLogs.value.forEach((log) => {
|
||||
if (!byActor.has(log.actorId)) {
|
||||
byActor.set(log.actorId, log.actorName || log.actorAccount || log.actorId);
|
||||
}
|
||||
});
|
||||
users.value = Array.from(byActor.entries()).map(([id, full_name]) => ({ id, full_name }));
|
||||
};
|
||||
|
||||
const filterLogs = (items: any[]) =>
|
||||
@@ -540,6 +556,14 @@ const onLocalFilterChange = () => {
|
||||
refreshPagedLogs();
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.value.eventType = "";
|
||||
filters.value.operatorId = "";
|
||||
filters.value.result = "";
|
||||
filters.value.range = [];
|
||||
onServerFilterChange();
|
||||
};
|
||||
|
||||
const onProjectChange = async () => {
|
||||
permissionMatrix.value = null;
|
||||
users.value = [];
|
||||
@@ -582,24 +606,12 @@ const fetchAllForExport = async () => {
|
||||
if (!selectedStudyId.value) return [];
|
||||
exportLoading.value = true;
|
||||
try {
|
||||
const items = await fetchAuditLogPages({
|
||||
action: filters.value.eventType || undefined,
|
||||
operator_id: filters.value.operatorId || undefined,
|
||||
});
|
||||
const items = await fetchAuditLogPages(buildAuditServerParams());
|
||||
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
|
||||
acc[cur.id] = resolveUserDisplayName(cur);
|
||||
return acc;
|
||||
}, {});
|
||||
const filtered = items.filter((log: any) => {
|
||||
if (filters.value.range?.length === 2) {
|
||||
const ts = new Date(log.created_at);
|
||||
const start = new Date(filters.value.range[0]);
|
||||
const end = new Date(filters.value.range[1]);
|
||||
if (ts < start || ts > end) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return filtered.map((log: any) => normalizeAuditEvent(log, userMap));
|
||||
return filterLogs(items.map((log: any) => normalizeAuditEvent(log, userMap)));
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminAuditLogs.exportLoadFailed);
|
||||
return [];
|
||||
@@ -608,10 +620,6 @@ const fetchAllForExport = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportCommand = (command: string) => {
|
||||
if (command === "project") confirmExport();
|
||||
};
|
||||
|
||||
const confirmExport = async () => {
|
||||
const currentStudy = selectedStudy.value;
|
||||
if (!currentStudy) return;
|
||||
@@ -647,33 +655,77 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:global(.web-layout-container .content-wrapper:has(.audit-logs-page)) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.audit-overview {
|
||||
padding: 0;
|
||||
border-bottom: 1px solid var(--unified-shell-divider);
|
||||
background: linear-gradient(180deg, var(--ctms-bg-card) 0%, color-mix(in srgb, var(--ctms-neutral-100) 55%, var(--ctms-bg-card)) 100%);
|
||||
}
|
||||
|
||||
.overview-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 38px;
|
||||
padding: 6px 16px;
|
||||
}
|
||||
|
||||
.overview-heading h2 {
|
||||
margin: 0;
|
||||
color: var(--ctms-text-main);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.overview-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid rgba(63, 143, 107, 0.18);
|
||||
border-radius: 999px;
|
||||
background: rgba(63, 143, 107, 0.07);
|
||||
color: var(--ctms-success);
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overview-status i {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--unified-shell-divider);
|
||||
gap: 0;
|
||||
border-top: 1px solid var(--unified-shell-divider);
|
||||
background: var(--ctms-bg-card);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 10px;
|
||||
background: var(--ctms-neutral-100);
|
||||
transition: var(--ctms-transition);
|
||||
min-height: 48px;
|
||||
padding: 6px 14px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--ctms-shadow-sm);
|
||||
.stat-card + .stat-card {
|
||||
border-left: 1px solid var(--unified-shell-divider);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -681,19 +733,27 @@ onMounted(async () => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-icon svg { width: 18px; height: 18px; }
|
||||
.stat-icon svg { width: 17px; height: 17px; }
|
||||
.stat-card--total .stat-icon { background: #e8edf3; color: var(--ctms-primary); }
|
||||
.stat-card--success .stat-icon { background: #e6f4ed; color: var(--ctms-success); }
|
||||
.stat-card--fail .stat-icon { background: #fde8e8; color: var(--ctms-danger); }
|
||||
.stat-card--operators .stat-icon { background: #eef2f6; color: var(--ctms-info); }
|
||||
|
||||
.stat-body { display: flex; flex-direction: column; }
|
||||
.stat-value { font-size: 20px; font-weight: 700; line-height: 1.2; color: var(--ctms-text-main); }
|
||||
.stat-label { font-size: 11px; color: var(--ctms-text-secondary); margin-top: 2px; }
|
||||
.stat-value { font-size: 18px; font-weight: 700; line-height: 1.2; color: var(--ctms-text-main); }
|
||||
.stat-label { font-size: 10px; color: var(--ctms-text-secondary); margin-top: 2px; }
|
||||
|
||||
/* 筛选栏 */
|
||||
.audit-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 8;
|
||||
border-bottom: 1px solid var(--unified-shell-divider);
|
||||
background: color-mix(in srgb, var(--ctms-bg-card) 95%, transparent);
|
||||
box-shadow: 0 5px 14px rgba(15, 23, 42, 0.05);
|
||||
backdrop-filter: blur(12px);
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
@@ -701,7 +761,7 @@ onMounted(async () => {
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-item-form {
|
||||
@@ -710,29 +770,65 @@ onMounted(async () => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.project-filter-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.filter-select-project :deep(.el-select__wrapper) {
|
||||
border-radius: 8px 0 0 8px;
|
||||
}
|
||||
|
||||
.project-export-button {
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
padding: 0;
|
||||
margin-left: -1px;
|
||||
border-radius: 0 8px 8px 0;
|
||||
color: var(--ctms-primary);
|
||||
border-color: color-mix(in srgb, var(--ctms-primary) 24%, var(--ctms-border-color));
|
||||
background: color-mix(in srgb, var(--ctms-primary) 7%, var(--ctms-bg-card));
|
||||
}
|
||||
|
||||
.filter-heading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--ctms-text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-form :deep(.el-input__inner),
|
||||
.filter-form :deep(.el-select__placeholder),
|
||||
.filter-form :deep(.el-select__selected-item),
|
||||
.filter-form :deep(.el-range-input),
|
||||
.filter-form :deep(.el-button) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.filter-form :deep(.el-select__wrapper),
|
||||
.filter-form :deep(.el-input__wrapper),
|
||||
.filter-form :deep(.el-date-editor) {
|
||||
min-height: 32px;
|
||||
box-shadow: 0 0 0 1px var(--ctms-border-color) inset;
|
||||
}
|
||||
|
||||
.reset-filter-button {
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.filter-select-comp { width: 132px; }
|
||||
.filter-select-project { width: 180px; }
|
||||
.filter-select-result { width: 96px; }
|
||||
.date-range-picker-comp { width: 248px !important; }
|
||||
.filter-spacer { flex: 1; }
|
||||
|
||||
.audit-export-dropdown { flex-shrink: 0; }
|
||||
.audit-export-btn {
|
||||
border-radius: 8px !important;
|
||||
font-weight: 600;
|
||||
height: 32px;
|
||||
color: var(--ctms-primary) !important;
|
||||
border-color: #c0cdd7 !important;
|
||||
background: #f0f6ff !important;
|
||||
}
|
||||
.audit-export-btn:hover {
|
||||
color: var(--ctms-primary-hover) !important;
|
||||
border-color: #9bb1c2 !important;
|
||||
background: #e2edfa !important;
|
||||
}
|
||||
|
||||
/* 表格 */
|
||||
.table-section { padding: 0; }
|
||||
.table-section { padding: 0 !important; }
|
||||
.audit-table :deep(.el-table__inner-wrapper::before) { display: none; }
|
||||
.audit-table :deep(.el-table__cell) {
|
||||
padding: 8px 0;
|
||||
@@ -748,17 +844,29 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.actor-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.actor-name {
|
||||
.actor-name,
|
||||
.actor-account {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.actor-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.actor-account {
|
||||
font-size: 11px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.event-tag {
|
||||
display: inline-flex;
|
||||
padding: 2px 8px;
|
||||
@@ -807,6 +915,12 @@ onMounted(async () => {
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.audit-table :deep(.result-column .cell) {
|
||||
padding-right: 4px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.result--success { background: #e6f4ed; color: #166534; }
|
||||
@@ -1057,6 +1171,9 @@ onMounted(async () => {
|
||||
@media (max-width: 768px) {
|
||||
.stats-row { grid-template-columns: repeat(2, 1fr); }
|
||||
.filter-form { flex-wrap: wrap; }
|
||||
.stat-card:nth-child(3) { border-left: 0; }
|
||||
.stat-card:nth-child(n + 3) { border-top: 1px solid var(--unified-shell-divider); }
|
||||
.filter-heading { width: 100%; }
|
||||
.diff-item-row { grid-template-columns: 1fr; gap: 4px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,15 +13,15 @@ describe("permission management custom roles", () => {
|
||||
const listTabEnd = source.indexOf("<!-- 标签页:生效管理 -->", listTabStart);
|
||||
const listTabSource = source.slice(listTabStart, listTabEnd);
|
||||
|
||||
expect(source).toContain('title="角色管理"');
|
||||
expect(source).toContain('label="角色列表"');
|
||||
expect(source).toContain('title="角色与权限"');
|
||||
expect(source).toContain('label="角色定义"');
|
||||
expect(listTabSource).not.toContain('placeholder="角色类型"');
|
||||
expect(listTabSource).not.toContain("typeFilter");
|
||||
expect(listTabSource).not.toContain("loadTemplates");
|
||||
expect(listTabSource).not.toContain('<el-option label="预设角色" value="ROLE" />');
|
||||
expect(listTabSource).not.toContain('<el-option label="自定义角色" value="CUSTOM" />');
|
||||
expect(listTabSource).not.toContain('<el-option label="场景角色" value="SCENARIO" />');
|
||||
expect(source).toContain("新增角色");
|
||||
expect(source).toContain("创建角色");
|
||||
expect(source).toContain('label="角色名称"');
|
||||
expect(source).toContain("roleDisplayName(row)");
|
||||
expect(source).not.toContain('title="权限模板管理"');
|
||||
@@ -31,6 +31,17 @@ describe("permission management custom roles", () => {
|
||||
expect(source).toContain("const res = await fetchPermissionTemplates();");
|
||||
});
|
||||
|
||||
it("supports efficient role permission editing with change summaries and module actions", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("roleEditorChangeCount");
|
||||
expect(source).toContain("新增授权 {{ roleEditorGrantedCount }} 项");
|
||||
expect(source).toContain("取消授权 {{ roleEditorRevokedCount }} 项");
|
||||
expect(source).toContain("setRoleEditorPermissions(roleEditorModuleOps(sections), true)");
|
||||
expect(source).toContain("setRoleEditorPermissions(roleEditorModuleOps(sections), false)");
|
||||
expect(source).toContain(':disabled="!canEditSelectedRole || !roleEditorDirtyGuard.isDirty.value"');
|
||||
});
|
||||
|
||||
it("keeps active roles configurable for permissions and members without inline role creation", () => {
|
||||
const source = readSource();
|
||||
|
||||
@@ -45,7 +56,8 @@ describe("permission management custom roles", () => {
|
||||
expect(source).toContain("const roleDisplayName = (row: PermissionTemplate) => row.name;");
|
||||
expect(source).toContain("await loadPermissionData();");
|
||||
expect(source).toContain('Object.keys(assignableRoleLabels.value)[0] || ""');
|
||||
expect(source).toContain('role === "ADMIN"');
|
||||
expect(source).toContain("const canAssignProjectRole");
|
||||
expect(source).toContain("(ROLE_RANK[role] ?? 0) < ROLE_RANK.PM");
|
||||
expect(source).not.toContain("const ALL_ROLES = [");
|
||||
expect(source).not.toContain("const ROLE_LABELS");
|
||||
});
|
||||
@@ -133,7 +145,9 @@ describe("permission management custom roles", () => {
|
||||
expect(source).toContain("{{ role.label }}");
|
||||
expect(source).toContain("{{ role.desc }}");
|
||||
expect(source).toContain("保存 {{ roleLabel(editingRole) }} 权限");
|
||||
expect(source).toContain("await updateMember(selectedStudyId.value, memberId, { role_in_study: role });");
|
||||
expect(source).toContain("await updateMember(selectedStudyId.value, member.id, { role_in_study: role });");
|
||||
expect(source).toContain("const confirmMemberRoleChange = async");
|
||||
expect(source).toContain('"调整项目角色"');
|
||||
expect(source).toContain("await addMember(selectedStudyId.value, addMemberForm);");
|
||||
});
|
||||
|
||||
@@ -142,6 +156,8 @@ describe("permission management custom roles", () => {
|
||||
|
||||
expect(source).toContain('selectedProjectPermissionAllowed("project_members:read")');
|
||||
expect(source).toContain('selectedProjectPermissionAllowed("project_members:read")');
|
||||
expect(source).toContain("!u.is_admin && !ids.has(u.id)");
|
||||
expect(source).toContain("请选择可添加的项目成员");
|
||||
expect(source).toContain('selectedProjectPermissionAllowed("project_members:create")');
|
||||
expect(source).toContain('selectedProjectPermissionAllowed("project_members:update")');
|
||||
expect(source).toContain('selectedProjectPermissionAllowed("project_members:delete")');
|
||||
@@ -364,6 +380,15 @@ describe("permission management custom roles", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("systemPermissionsByModule");
|
||||
expect(source).toContain('class="system-stats" aria-label="系统级权限概览"');
|
||||
expect(source).toContain('class="system-permissions-card"');
|
||||
expect(source).toContain('class="perm-body perm-body--system"');
|
||||
expect(source).toMatch(/\.system-perm-container \{[\s\S]*?padding: 0;[\s\S]*?gap: 8px;/);
|
||||
expect(source).toMatch(/\.system-perm-row \{[\s\S]*?min-height: 36px;[\s\S]*?padding: 4px 12px;/);
|
||||
expect(source).toContain(".system-module-block + .system-module-block");
|
||||
expect(source).toContain(".system-perm-row:last-child");
|
||||
expect(source).not.toContain("system-perm-row--alt");
|
||||
expect(source).not.toContain("system-stat-divider");
|
||||
expect(source).not.toContain("<h2 class=\"perm-title\">系统级权限</h2>");
|
||||
expect(source).not.toContain("只读展示,所有系统管理操作仅限 ADMIN 角色执行");
|
||||
});
|
||||
@@ -371,10 +396,21 @@ describe("permission management custom roles", () => {
|
||||
it("removes the vertical gap between permission headers and body content", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("padding: 0 0 20px;");
|
||||
expect(source).toContain("padding: 0 0 8px;");
|
||||
expect(source).not.toContain("padding: 20px 0 20px;");
|
||||
});
|
||||
|
||||
it("uses compact, legible typography for the selected project", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain(".project-selector :deep(.el-select__selected-item)");
|
||||
expect(source).toContain('popper-class="project-selector-dropdown"');
|
||||
expect(source).toContain(".project-selector-dropdown .el-select-dropdown__item");
|
||||
expect(source).toContain("color: #193b5a !important;");
|
||||
expect(source).toContain("font-size: 13px;");
|
||||
expect(source).toContain("font-variant-numeric: tabular-nums;");
|
||||
});
|
||||
|
||||
it("limits project PM member management to subordinate project roles", () => {
|
||||
const source = readSource();
|
||||
|
||||
@@ -386,4 +422,17 @@ describe("permission management custom roles", () => {
|
||||
expect(source).toContain('addMemberForm.role_in_study = Object.keys(assignableRoleLabels.value)[0] || "";');
|
||||
expect(source).not.toContain('addMemberForm.role_in_study = activeRolesInStudy.value.includes("PM") ? "PM"');
|
||||
});
|
||||
|
||||
it("separates role configuration from matrix comparison and supports member filtering", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('v-model="permissionView"');
|
||||
expect(source).toContain('value="roles">角色配置');
|
||||
expect(source).toContain('value="matrix">权限对比');
|
||||
expect(source).toContain('v-if="permissionView === \'roles\'"');
|
||||
expect(source).toContain("const filteredMemberRows = computed");
|
||||
expect(source).toContain("const memberRoleSummary = computed");
|
||||
expect(source).toContain('placeholder="搜索姓名或邮箱"');
|
||||
expect(source).toContain(">账号已停用</el-tag>");
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1833,77 +1833,6 @@
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<el-drawer
|
||||
v-if="siteEnrollmentEditorVisible"
|
||||
v-model="siteEnrollmentEditorVisible"
|
||||
direction="rtl"
|
||||
size="480px"
|
||||
:close-on-click-modal="true"
|
||||
:before-close="siteEnrollmentEditorDirtyGuard.beforeClose"
|
||||
:show-close="false"
|
||||
class="setup-milestone-editor-drawer"
|
||||
>
|
||||
<template #header>
|
||||
<div class="sme-header">
|
||||
<div class="sme-header-title">编辑中心入组计划</div>
|
||||
<div class="sme-header-subtitle">配置中心入组目标与时间范围</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-form label-position="top" class="sme-form">
|
||||
<!-- 中心选择 -->
|
||||
<div class="sme-group">
|
||||
<div class="sme-group-title"><span class="sme-dot sme-dot-blue"></span>中心选择</div>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="中心">
|
||||
<el-select v-model="siteEnrollmentEditorForm.siteId" filterable clearable placeholder="选择中心" class="w-full">
|
||||
<el-option
|
||||
v-for="site in siteSelectOptions"
|
||||
:key="site.id"
|
||||
:label="site.label"
|
||||
:value="site.id"
|
||||
:disabled="isSiteEnrollmentSiteTaken(site.id)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="计划例数">
|
||||
<el-input-number v-model="siteEnrollmentEditorForm.target" :min="0" controls-position="right" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<!-- 时间计划 -->
|
||||
<div class="sme-group">
|
||||
<div class="sme-group-title"><span class="sme-dot sme-dot-amber"></span>时间计划</div>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="启动日期">
|
||||
<el-date-picker v-model="siteEnrollmentEditorForm.startDate" type="date" value-format="YYYY-MM-DD" class="w-full" placeholder="选择日期" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="完成日期">
|
||||
<el-date-picker v-model="siteEnrollmentEditorForm.endDate" type="date" value-format="YYYY-MM-DD" class="w-full" placeholder="选择日期" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<!-- 备注 -->
|
||||
<div class="sme-group">
|
||||
<div class="sme-group-title"><span class="sme-dot sme-dot-gray"></span>备注</div>
|
||||
<el-input v-model="siteEnrollmentEditorForm.note" type="textarea" :rows="3" placeholder="请输入备注信息" />
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="sme-footer">
|
||||
<el-button @click="siteEnrollmentEditorVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveSiteEnrollmentEditor">保存</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1920,7 +1849,6 @@ import { listMembers } from "../../api/members";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import type { Site, Study } from "../../types/api";
|
||||
import type {
|
||||
CenterConfirmDraft,
|
||||
ProjectPublishSnapshot,
|
||||
ProjectMilestoneDraft,
|
||||
SetupConfigDraft,
|
||||
@@ -1954,7 +1882,7 @@ import {
|
||||
type SetupWorkflowTagMeta,
|
||||
} from "../../utils/setupPublishWorkflow";
|
||||
import { useSetupConfig } from "../../composables/useSetupConfig";
|
||||
import { saveFile } from "../../runtime";
|
||||
import { saveFileWithFeedback } from "../../utils/fileTaskFeedback";
|
||||
|
||||
type SetupStepKey =
|
||||
| "project-info"
|
||||
@@ -2117,8 +2045,6 @@ const projectMilestoneEditorVisible = ref(false);
|
||||
const projectMilestoneEditingIndex = ref<number>(-1);
|
||||
const siteMilestoneEditorVisible = ref(false);
|
||||
const siteMilestoneEditingIndex = ref<number>(-1);
|
||||
const siteEnrollmentEditorVisible = ref(false);
|
||||
const siteEnrollmentEditingIndex = ref<number>(-1);
|
||||
|
||||
type IndexedEditorController = {
|
||||
visible: Ref<boolean>;
|
||||
@@ -2132,10 +2058,6 @@ const siteMilestoneEditor: IndexedEditorController = {
|
||||
visible: siteMilestoneEditorVisible,
|
||||
index: siteMilestoneEditingIndex,
|
||||
};
|
||||
const siteEnrollmentEditor: IndexedEditorController = {
|
||||
visible: siteEnrollmentEditorVisible,
|
||||
index: siteEnrollmentEditingIndex,
|
||||
};
|
||||
|
||||
const projectMilestoneEditorForm = reactive<ProjectMilestoneDraft>({
|
||||
id: "",
|
||||
@@ -2156,19 +2078,8 @@ const siteMilestoneEditorForm = reactive<SiteMilestoneDraft>({
|
||||
remark: "",
|
||||
status: "未开始",
|
||||
});
|
||||
const siteEnrollmentEditorForm = reactive<SiteEnrollmentPlanDraft>({
|
||||
id: "",
|
||||
siteId: "",
|
||||
siteName: "",
|
||||
target: 0,
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
note: "",
|
||||
stageBreakdown: "",
|
||||
});
|
||||
const projectMilestoneEditorDirtyGuard = useDrawerDirtyGuard(() => projectMilestoneEditorForm);
|
||||
const siteMilestoneEditorDirtyGuard = useDrawerDirtyGuard(() => siteMilestoneEditorForm);
|
||||
const siteEnrollmentEditorDirtyGuard = useDrawerDirtyGuard(() => siteEnrollmentEditorForm);
|
||||
|
||||
|
||||
type EnrollmentCycle = "month" | "quarter";
|
||||
@@ -2494,7 +2405,6 @@ const currentStepTitle = computed(() => `第${activeStep.value + 1}步:${steps
|
||||
const setupPublishedVersionText = computed(() => {
|
||||
return setupPublishedVersion.value || "v0";
|
||||
});
|
||||
const hasProjectPendingChanges = computed(() => hasFormUnsavedChanges.value);
|
||||
const draftSyncStatus = computed<DraftSyncStatus>(() => {
|
||||
const formDirty = Boolean(formBaselineSnapshot.value && serializeFormForCompare() !== formBaselineSnapshot.value);
|
||||
if (formDirty || setupDirtySinceLastPersist.value || Boolean(autoSaveTimer.value)) {
|
||||
@@ -2623,7 +2533,6 @@ const buildProjectPublishSnapshot = (): ProjectPublishSnapshot =>
|
||||
status: form.value.status,
|
||||
visit_schedule: normalizeVisitSchedule(form.value.visit_schedule),
|
||||
});
|
||||
const serializeProjectForPublishCompare = (): string => JSON.stringify(buildProjectPublishSnapshot());
|
||||
const hasFormUnsavedChanges = computed(() => Boolean(formBaselineSnapshot.value && serializeFormForCompare() !== formBaselineSnapshot.value));
|
||||
const hasSetupDiffFromServer = (): boolean =>
|
||||
Boolean(setupServerSnapshot.value && serializeSetupForCompare() !== setupServerSnapshot.value);
|
||||
@@ -2654,9 +2563,6 @@ const reconcileDraftSyncStateBeforePublish = () => {
|
||||
clearLocalProjectDraft();
|
||||
}
|
||||
};
|
||||
const hasUnsavedChanges = computed(
|
||||
() => hasFormUnsavedChanges.value || setupDirtySinceLastPersist.value || Boolean(autoSaveTimer.value)
|
||||
);
|
||||
const hasSetupDraftUnsavedChanges = computed(() => setupDirtySinceLastPersist.value || Boolean(autoSaveTimer.value));
|
||||
const hasLeaveGuardChanges = computed(
|
||||
() => hasFormUnsavedChanges.value || hasSetupDraftUnsavedChanges.value || draftSyncStatus.value !== "SYNCED"
|
||||
@@ -3088,7 +2994,6 @@ const toDateOnly = (value: string | null | undefined): Date | null => {
|
||||
}
|
||||
return date;
|
||||
};
|
||||
const pad2 = (value: number): string => String(value).padStart(2, "0");
|
||||
const getQuarter = (month: number): number => Math.floor((month - 1) / 3) + 1;
|
||||
const toYearMonthIndex = (year: number, month: number): number => year * 12 + (month - 1);
|
||||
const buildYearMonthKey = (year: number, month: number): string => `${year}-${String(month).padStart(2, "0")}`;
|
||||
@@ -3324,20 +3229,6 @@ const centerConfiguredSiteCount = computed(() => {
|
||||
const centerPlannedCount = computed(() => {
|
||||
return Array.from(centerEnrollmentTargetMap.value.values()).reduce((sum, target) => sum + target, 0);
|
||||
});
|
||||
const centerPendingCount = computed(() => {
|
||||
const totalTarget = normalizeEnrollmentTarget(currentSetupDraft.value.enrollmentPlan.totalTarget);
|
||||
return Math.max(totalTarget - centerPlannedCount.value, 0);
|
||||
});
|
||||
const centerOverflowCount = computed(() => {
|
||||
const totalTarget = normalizeEnrollmentTarget(currentSetupDraft.value.enrollmentPlan.totalTarget);
|
||||
return Math.max(centerPlannedCount.value - totalTarget, 0);
|
||||
});
|
||||
const centerUnplannedSiteCount = computed(() => {
|
||||
const activeSites = siteOptions.value.filter((site) => site.is_active !== false);
|
||||
if (!activeSites.length) return 0;
|
||||
const plannedSiteIds = centerEnrollmentTargetMap.value;
|
||||
return activeSites.filter((site) => !plannedSiteIds.has(site.id)).length;
|
||||
});
|
||||
const getSiteEnrollmentTargetsByCycle = (row: SiteEnrollmentPlanDraft): Record<EnrollmentCycle, Record<string, number>> => {
|
||||
const parsed = parseSiteEnrollmentStageBreakdown(row.stageBreakdown);
|
||||
return {
|
||||
@@ -3601,14 +3492,6 @@ const getSiteOptionLabel = (site: Partial<Site> | null | undefined): string => {
|
||||
if (siteId) return `中心-${siteId.slice(0, 8)}`;
|
||||
return "未命名中心";
|
||||
};
|
||||
const siteSelectOptions = computed(() =>
|
||||
siteOptions.value
|
||||
.map((site) => ({
|
||||
id: normalizeSiteId(String(site.id || "")),
|
||||
label: getSiteOptionLabel(site),
|
||||
}))
|
||||
.filter((site) => Boolean(site.id))
|
||||
);
|
||||
const getSiteName = (siteId: string): string => {
|
||||
const normalizedSiteId = normalizeSiteId(siteId);
|
||||
const matched = siteOptions.value.find((s) => normalizeSiteId(String(s.id || "")) === normalizedSiteId);
|
||||
@@ -3760,18 +3643,6 @@ const createSelectedSiteEnrollmentPlan = () => {
|
||||
stageBreakdown: "",
|
||||
});
|
||||
};
|
||||
const getSiteEnrollmentEditorIndex = () => getEditingIndex(siteEnrollmentEditor, setupDraft.siteEnrollmentPlans.length);
|
||||
const isSiteEnrollmentDuplicate = (siteId: string, excludeIndex: number): boolean => {
|
||||
const normalizedSiteId = normalizeSiteId(siteId);
|
||||
if (!normalizedSiteId) return false;
|
||||
return setupDraft.siteEnrollmentPlans.some(
|
||||
(row, rowIndex) => rowIndex !== excludeIndex && normalizeSiteId(row.siteId) === normalizedSiteId
|
||||
);
|
||||
};
|
||||
const isSiteEnrollmentSiteTaken = (siteId: string): boolean => {
|
||||
const editingIndex = getSiteEnrollmentEditorIndex();
|
||||
return isSiteEnrollmentDuplicate(siteId, editingIndex);
|
||||
};
|
||||
watch(
|
||||
[siteOptions, () => currentSetupDraft.value.siteEnrollmentPlans.map((row) => normalizeSiteId(row.siteId)).join("|")],
|
||||
syncSelectedSiteEnrollmentPlanSiteId,
|
||||
@@ -5345,7 +5216,7 @@ ${buildExcelWorksheetXml("第6步-中心确认", metaRows, step6Headers, step6Ro
|
||||
const blob = new Blob([`\uFEFF${workbookXml}`], { type: "application/vnd.ms-excel;charset=utf-8;" });
|
||||
const safeVersionLabel = displayVersion.replace(/[^\w.-]/g, "_");
|
||||
const filename = `setup-config-${safeVersionLabel}-${project.value.code || project.value.id}.xls`;
|
||||
await saveFile({
|
||||
await saveFileWithFeedback({
|
||||
suggestedName: filename,
|
||||
mimeType: "application/vnd.ms-excel;charset=utf-8",
|
||||
data: blob,
|
||||
@@ -5841,80 +5712,6 @@ const removeSiteEnrollment = (index: number) => {
|
||||
if (!canMutateDraft()) return;
|
||||
setupDraft.siteEnrollmentPlans.splice(index, 1);
|
||||
};
|
||||
const openSiteEnrollmentEditor = (index: number) => {
|
||||
const opened = openIndexedEditor(setupDraft.siteEnrollmentPlans, index, siteEnrollmentEditor, (row) => {
|
||||
siteEnrollmentEditorForm.id = row.id || "";
|
||||
siteEnrollmentEditorForm.siteId = row.siteId || "";
|
||||
siteEnrollmentEditorForm.siteName = row.siteName || "";
|
||||
siteEnrollmentEditorForm.target = row.target ?? 0;
|
||||
siteEnrollmentEditorForm.startDate = row.startDate || "";
|
||||
siteEnrollmentEditorForm.endDate = row.endDate || "";
|
||||
siteEnrollmentEditorForm.note = row.note || "";
|
||||
siteEnrollmentEditorForm.stageBreakdown = row.stageBreakdown || "";
|
||||
});
|
||||
if (opened) siteEnrollmentEditorDirtyGuard.syncBaseline();
|
||||
};
|
||||
const saveSiteEnrollmentEditor = () => {
|
||||
if (!canMutateDraft()) return;
|
||||
const index = getEditingIndex(siteEnrollmentEditor, setupDraft.siteEnrollmentPlans.length);
|
||||
if (index < 0) return;
|
||||
if (!siteEnrollmentEditorForm.siteId) {
|
||||
ElMessage.warning("请选择中心");
|
||||
return;
|
||||
}
|
||||
if (isSiteEnrollmentDuplicate(siteEnrollmentEditorForm.siteId, index)) {
|
||||
ElMessage.warning("该中心已配置入组计划,请勿重复配置");
|
||||
return;
|
||||
}
|
||||
if (!siteEnrollmentEditorForm.startDate || !siteEnrollmentEditorForm.endDate) {
|
||||
ElMessage.warning("请填写启动日期和完成日期");
|
||||
return;
|
||||
}
|
||||
if (siteEnrollmentEditorForm.startDate > siteEnrollmentEditorForm.endDate) {
|
||||
ElMessage.warning("完成日期不能早于启动日期");
|
||||
return;
|
||||
}
|
||||
const { start: planStart, end: planEnd } = getProjectPlanWindow();
|
||||
const startDate = normalizePlanDate(siteEnrollmentEditorForm.startDate);
|
||||
const endDate = normalizePlanDate(siteEnrollmentEditorForm.endDate);
|
||||
if (planStart && startDate < planStart) {
|
||||
ElMessage.warning("中心启动日期不能早于项目计划开始日期");
|
||||
return;
|
||||
}
|
||||
if (planEnd && startDate > planEnd) {
|
||||
ElMessage.warning("中心启动日期不能晚于项目计划结束日期");
|
||||
return;
|
||||
}
|
||||
if (planStart && endDate < planStart) {
|
||||
ElMessage.warning("中心完成日期不能早于项目计划开始日期");
|
||||
return;
|
||||
}
|
||||
if (planEnd && endDate > planEnd) {
|
||||
ElMessage.warning("中心完成日期不能晚于项目计划结束日期");
|
||||
return;
|
||||
}
|
||||
setupDraft.siteEnrollmentPlans[index] = {
|
||||
id: siteEnrollmentEditorForm.id || makeId(),
|
||||
siteId: siteEnrollmentEditorForm.siteId,
|
||||
siteName: getSiteName(siteEnrollmentEditorForm.siteId),
|
||||
target: Math.max(0, Number(siteEnrollmentEditorForm.target || 0)),
|
||||
startDate: siteEnrollmentEditorForm.startDate,
|
||||
endDate: siteEnrollmentEditorForm.endDate,
|
||||
note: (siteEnrollmentEditorForm.note || "").trim(),
|
||||
stageBreakdown: siteEnrollmentEditorForm.stageBreakdown || "",
|
||||
};
|
||||
closeIndexedEditor(siteEnrollmentEditor, "中心入组计划已更新");
|
||||
};
|
||||
|
||||
const addCenterConfirm = () => {
|
||||
if (!canMutateDraft()) return;
|
||||
setupDraft.centerConfirm.push({ id: makeId(), siteId: "", siteName: "", confirmer: "", confirmStatus: "待确认", confirmDate: "", note: "" });
|
||||
};
|
||||
const removeCenterConfirm = (index: number) => {
|
||||
if (!canMutateDraft()) return;
|
||||
setupDraft.centerConfirm.splice(index, 1);
|
||||
};
|
||||
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!hasLeaveGuardChanges.value) return;
|
||||
event.preventDefault();
|
||||
|
||||
@@ -1,29 +1,12 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
append-to=".layout-main .content-wrapper"
|
||||
append-to="body"
|
||||
:title="project ? TEXT.modules.adminProjects.editTitle : TEXT.modules.adminProjects.newTitle"
|
||||
width="560px"
|
||||
v-model="visibleProxy"
|
||||
:close-on-click-modal="false"
|
||||
class="project-form-dialog"
|
||||
>
|
||||
<div class="form-header">
|
||||
<div class="form-icon" :class="project ? 'icon--edit' : 'icon--new'">
|
||||
<svg v-if="!project" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="22" height="22">
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>
|
||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="22" height="22">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="form-header-text">
|
||||
<span class="form-header-title">{{ project ? '编辑项目信息' : '创建新项目' }}</span>
|
||||
<span class="form-header-desc">{{ project ? form.name || '修改项目基本信息' : '填写以下信息创建临床试验项目' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" class="project-form-body">
|
||||
<el-form-item :label="TEXT.common.fields.projectName" prop="name">
|
||||
<el-input v-model="form.name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectName">
|
||||
@@ -198,52 +181,6 @@ const onSubmit = async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding-bottom: 18px;
|
||||
margin-bottom: 18px;
|
||||
border-bottom: 1px solid var(--ctms-border-color);
|
||||
}
|
||||
|
||||
.form-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-icon.icon--new {
|
||||
background: linear-gradient(135deg, #e8edf3, #d5dce5);
|
||||
color: var(--ctms-primary);
|
||||
}
|
||||
|
||||
.form-icon.icon--edit {
|
||||
background: linear-gradient(135deg, var(--ctms-primary), var(--ctms-primary-active));
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.form-header-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-header-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.form-header-desc {
|
||||
font-size: 12px;
|
||||
color: var(--ctms-text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.project-form-body :deep(.el-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@@ -1,419 +0,0 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="main-content-flat unified-shell">
|
||||
<div class="unified-action-bar actions-only-bar">
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button v-if="canAddMember" type="primary" @click="openAdd">
|
||||
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjectMembers.memberLabel }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="unified-section member-table-section">
|
||||
<el-table :data="memberRows" v-loading="loading" stripe class="member-table">
|
||||
<el-table-column prop="full_name" :label="TEXT.modules.adminProjectMembers.username" min-width="160" />
|
||||
<el-table-column prop="role_in_study" :label="TEXT.modules.adminProjectMembers.projectRole" min-width="140">
|
||||
<template #default="scope">
|
||||
<el-tag>{{ roleLabel(scope.row.role_in_study) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.status" width="100">
|
||||
<template #default="scope">
|
||||
<el-tooltip
|
||||
v-if="scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
|
||||
:content="TEXT.modules.adminProjectMembers.disabledGlobalHint"
|
||||
placement="top"
|
||||
>
|
||||
<el-tag type="danger">{{ TEXT.modules.adminProjectMembers.disabledGlobal }}</el-tag>
|
||||
</el-tooltip>
|
||||
<el-tag v-else :type="scope.row.is_active ? 'success' : 'danger'">
|
||||
{{ scope.row.is_active ? TEXT.common.actions.enable : TEXT.modules.adminProjectMembers.disabled }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="added_at" :label="TEXT.modules.adminProjectMembers.addedAt" min-width="180">
|
||||
<template #default="scope">{{ displayDateTime(scope.row.added_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="300" align="center" class-name="action-column">
|
||||
<template #default="scope">
|
||||
<el-select
|
||||
v-model="scope.row.role_in_study"
|
||||
size="small"
|
||||
style="width: 120px"
|
||||
@change="(val: string) => updateRole(scope.row.id, val)"
|
||||
:disabled="!canEditMember(scope.row) || !scope.row.is_active || scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
|
||||
>
|
||||
<el-option
|
||||
v-for="role in roleOptions"
|
||||
:key="role.value"
|
||||
:label="role.label"
|
||||
:value="role.value"
|
||||
:disabled="!canAssignRole(role.value)"
|
||||
/>
|
||||
</el-select>
|
||||
<span v-if="scope.row.effectiveStatus === 'DISABLED_GLOBAL'" class="hint">{{ TEXT.modules.adminProjectMembers.disabledGlobalDesc }}</span>
|
||||
<el-button link type="danger" size="small" :disabled="!canDeleteProjectMember(scope.row)" @click="onDelete(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
||||
<el-button
|
||||
link
|
||||
:type="scope.row.is_active ? 'danger' : 'primary'"
|
||||
size="small"
|
||||
:disabled="!canEditMember(scope.row) || scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
|
||||
@click="toggleActive(scope.row)"
|
||||
>
|
||||
{{ scope.row.is_active ? TEXT.common.actions.disable : TEXT.common.actions.enable }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-if="addVisible" append-to=".layout-main .content-wrapper" :title="TEXT.modules.adminProjectMembers.newTitle" width="520px" v-model="addVisible" :close-on-click-modal="false">
|
||||
<el-form :model="newMember" label-width="120px" ref="addFormRef" :rules="addRules">
|
||||
<el-form-item :label="TEXT.modules.adminProjectMembers.user" prop="user_id">
|
||||
<el-select v-model="newMember.user_id" filterable :placeholder="TEXT.modules.adminProjectMembers.userPlaceholder">
|
||||
<el-option
|
||||
v-for="user in availableUsers"
|
||||
:key="user.id"
|
||||
:label="user.full_name || TEXT.common.fallback"
|
||||
:value="user.id"
|
||||
:disabled="!user.is_active"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.modules.adminProjectMembers.projectRole" prop="role_in_study">
|
||||
<el-select v-model="newMember.role_in_study" :placeholder="TEXT.modules.adminProjectMembers.rolePlaceholder">
|
||||
<el-option
|
||||
v-for="role in roleOptions"
|
||||
:key="role.value"
|
||||
:label="role.label"
|
||||
:value="role.value"
|
||||
:disabled="!canAssignRole(role.value)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="addVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button type="primary" :loading="adding" @click="submitAdd">{{ TEXT.common.actions.save }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
|
||||
import { addMember, listMemberCandidates, listMembers, removeMember, updateMember } from "../../api/members";
|
||||
import { fetchStudyDetail } from "../../api/studies";
|
||||
import type { Study, StudyMember, UserInfo } from "../../types/api";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { evaluateAction } from "../../guards/actionGuard";
|
||||
import { usePermission } from "../../utils/permission";
|
||||
import { displayDateTime } from "../../utils/display";
|
||||
import { TEXT, requiredMessage } from "../../locales";
|
||||
import { useRoleTemplateMeta } from "../../composables/useRoleTemplateMeta";
|
||||
|
||||
const route = useRoute();
|
||||
const projectId = computed(() => route.params.projectId as string);
|
||||
const auth = useAuthStore();
|
||||
const permission = usePermission();
|
||||
const { roleLabel, roleOptionsFor, loadRoleTemplates } = useRoleTemplateMeta();
|
||||
|
||||
const project = ref<Study | null>(null);
|
||||
const members = ref<StudyMember[]>([]);
|
||||
const users = ref<UserInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
const addVisible = ref(false);
|
||||
const adding = ref(false);
|
||||
const addFormRef = ref<FormInstance>();
|
||||
const newMember = reactive({
|
||||
user_id: "",
|
||||
role_in_study: "PM",
|
||||
});
|
||||
const canListMembers = computed(() => permission.can("project.members.list"));
|
||||
const canListMemberCandidates = computed(() => permission.can("project.members.candidates"));
|
||||
const canCreateMember = computed(() => permission.can("project.members.create"));
|
||||
const canUpdateMember = computed(() => permission.can("project.members.update"));
|
||||
const canDeleteMember = computed(() => permission.can("project.members.delete"));
|
||||
const canAddMember = computed(() => canCreateMember.value && canListMemberCandidates.value);
|
||||
const projectRole = computed(() => project.value?.role_in_study || "");
|
||||
const ROLE_KEYS = ["PM", "CRA", "PV", "QA", "CTA", "ADMIN"];
|
||||
const roleOptions = computed(() => roleOptionsFor(ROLE_KEYS));
|
||||
const roleRank: Record<string, number> = {
|
||||
ADMIN: 100,
|
||||
PM: 80,
|
||||
PV: 50,
|
||||
QA: 60,
|
||||
CRA: 40,
|
||||
CTA: 40,
|
||||
};
|
||||
const currentRoleRank = computed(() => auth.user?.is_admin ? Number.POSITIVE_INFINITY : roleRank[projectRole.value] || 0);
|
||||
const canAssignRole = (role: string) => (auth.user?.is_admin ? true : (roleRank[role] || 0) <= currentRoleRank.value);
|
||||
const canMutateProjectMember = (row: StudyMember) => {
|
||||
if (row.user?.is_admin) return false;
|
||||
if (auth.user?.is_admin) return true;
|
||||
if (row.user_id === auth.user?.id) return false;
|
||||
return (roleRank[row.role_in_study] || 0) <= currentRoleRank.value;
|
||||
};
|
||||
const canEditMember = (row: StudyMember) => canUpdateMember.value && canMutateProjectMember(row);
|
||||
const canDeleteProjectMember = (row: StudyMember) => canDeleteMember.value && canMutateProjectMember(row);
|
||||
|
||||
const addRules = reactive<FormRules>({
|
||||
user_id: [{ required: true, message: requiredMessage(TEXT.modules.adminProjectMembers.user), trigger: "change" }],
|
||||
role_in_study: [{ required: true, message: requiredMessage(TEXT.modules.adminProjectMembers.projectRole), trigger: "change" }],
|
||||
});
|
||||
|
||||
const loadProject = async () => {
|
||||
if (!projectId.value) return;
|
||||
try {
|
||||
const { data } = await fetchStudyDetail(projectId.value);
|
||||
project.value = data;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const loadMembers = async () => {
|
||||
if (!canListMembers.value || !projectId.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await listMembers(projectId.value, { limit: 500, include_inactive: true });
|
||||
members.value = Array.isArray(data) ? data : data.items || [];
|
||||
if (!canListMemberCandidates.value) syncUsersFromMembers();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjectMembers.loadFailed);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const syncUsersFromMembers = () => {
|
||||
users.value = members.value
|
||||
.map((member) => member.user)
|
||||
.filter((user): user is UserInfo => Boolean(user?.id)) as UserInfo[];
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
if (!canListMemberCandidates.value || !projectId.value) {
|
||||
syncUsersFromMembers();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await listMemberCandidates(projectId.value, { limit: 500 });
|
||||
users.value = data || [];
|
||||
} catch {
|
||||
users.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const memberRows = computed(() =>
|
||||
members.value.map((m) => {
|
||||
const user = users.value.find((u) => u.id === m.user_id) || m.user;
|
||||
const effectiveStatus = user && user.is_active === false ? "DISABLED_GLOBAL" : m.is_active ? "ACTIVE" : "DISABLED";
|
||||
return {
|
||||
...m,
|
||||
full_name: user?.full_name || user?.username || m.user_id,
|
||||
effectiveStatus,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const openAdd = () => {
|
||||
if (!canAddMember.value) return;
|
||||
newMember.user_id = "";
|
||||
newMember.role_in_study = canAssignRole("PM") ? "PM" : "CRA";
|
||||
addVisible.value = true;
|
||||
};
|
||||
|
||||
const submitAdd = async () => {
|
||||
if (!projectId.value) return;
|
||||
if (!canAddMember.value) {
|
||||
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||
return;
|
||||
}
|
||||
const decision = evaluateAction({
|
||||
actorRole: auth.user?.is_admin ? "ADMIN" : null,
|
||||
requiredPermission: "project.members.create",
|
||||
target: { projectId: projectId.value },
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
||||
return;
|
||||
}
|
||||
if (!canAssignRole(newMember.role_in_study)) {
|
||||
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||
return;
|
||||
}
|
||||
await addFormRef.value?.validate();
|
||||
adding.value = true;
|
||||
try {
|
||||
await addMember(projectId.value, newMember);
|
||||
ElMessage.success(TEXT.modules.adminProjectMembers.addSuccess);
|
||||
addVisible.value = false;
|
||||
await Promise.all([loadMembers(), loadUsers()]);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjectMembers.addFailed);
|
||||
} finally {
|
||||
adding.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateRole = async (memberId: string, role: string) => {
|
||||
if (!projectId.value) return;
|
||||
const row = members.value.find((member) => member.id === memberId);
|
||||
if (!row || !canEditMember(row) || !canAssignRole(role)) {
|
||||
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||
loadMembers();
|
||||
return;
|
||||
}
|
||||
const decision = evaluateAction({
|
||||
actorRole: auth.user?.is_admin ? "ADMIN" : null,
|
||||
requiredPermission: "project.members.update",
|
||||
target: { projectId: projectId.value, memberId },
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateMember(projectId.value, memberId, { role_in_study: role });
|
||||
ElMessage.success(TEXT.modules.adminProjectMembers.roleUpdated);
|
||||
loadMembers();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.updateFailed);
|
||||
loadMembers();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleActive = async (row: StudyMember) => {
|
||||
if (!projectId.value) return;
|
||||
if (!canEditMember(row)) {
|
||||
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||
return;
|
||||
}
|
||||
const decision = evaluateAction({
|
||||
actorRole: auth.user?.is_admin ? "ADMIN" : null,
|
||||
requiredPermission: "project.members.update",
|
||||
target: { projectId: projectId.value, memberId: row.id },
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
||||
return;
|
||||
}
|
||||
if (row.is_active) {
|
||||
const ok = await ElMessageBox.confirm(TEXT.modules.adminProjectMembers.disableConfirm, TEXT.modules.adminProjectMembers.disableTitle, {
|
||||
type: "warning",
|
||||
confirmButtonText: TEXT.common.actions.confirm,
|
||||
}).catch(() => null);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await updateMember(projectId.value, row.id, { is_active: false });
|
||||
ElMessage.success(TEXT.modules.adminProjectMembers.disableSuccess);
|
||||
loadMembers();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.actionFailed);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await updateMember(projectId.value, row.id, { is_active: true });
|
||||
ElMessage.success(TEXT.modules.adminProjectMembers.enableSuccess);
|
||||
loadMembers();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.actionFailed);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async (row: StudyMember) => {
|
||||
if (!projectId.value) return;
|
||||
if (!canDeleteProjectMember(row)) {
|
||||
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||
return;
|
||||
}
|
||||
const decision = evaluateAction({
|
||||
actorRole: auth.user?.is_admin ? "ADMIN" : null,
|
||||
requiredPermission: "project.members.delete",
|
||||
target: { projectId: projectId.value, memberId: row.id },
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
||||
return;
|
||||
}
|
||||
const ok = await ElMessageBox.confirm(TEXT.modules.adminProjectMembers.removeConfirm, TEXT.modules.adminProjectMembers.removeTitle, {
|
||||
type: "warning",
|
||||
confirmButtonText: TEXT.common.actions.confirm,
|
||||
cancelButtonText: TEXT.common.actions.cancel,
|
||||
}).catch(() => null);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await removeMember(projectId.value, row.id);
|
||||
members.value = members.value.filter((m) => m.id !== row.id);
|
||||
await loadUsers();
|
||||
ElMessage.success(TEXT.modules.adminProjectMembers.removeSuccess);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const availableUsers = computed(() => {
|
||||
const memberUserIds = new Set(members.value.map((m) => m.user_id));
|
||||
return users.value.filter((u) => !memberUserIds.has(u.id));
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadRoleTemplates(), loadProject(), loadMembers()]);
|
||||
loadUsers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.main-content-flat {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.main-content-flat :deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.actions-only-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.member-table-section {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.member-table :deep(.el-table__inner-wrapper::before) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #999;
|
||||
margin: 0 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.el-table .action-column .cell {
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.action-column .el-select {
|
||||
margin-right: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -16,11 +16,12 @@ describe("project management access", () => {
|
||||
const layout = readLayout();
|
||||
const router = readRouter();
|
||||
|
||||
expect(layout).toContain('v-if="auth.user"');
|
||||
expect(layout).toContain('v-if="showAdminNavigation"');
|
||||
expect(layout).toContain("const showAdminNavigation = computed(() => Boolean(auth.user)");
|
||||
expect(layout).toContain('index="/admin/projects"');
|
||||
expect(router).toContain('name: "AdminProjects"');
|
||||
expect(router).toContain("meta: { title: TEXT.menu.projectManagement }");
|
||||
expect(projects).toContain('v-if="isAdmin" type="primary"');
|
||||
expect(projects).toContain('v-if="isAdmin" content="新增项目"');
|
||||
expect(projects).toContain('v-if="isAdmin && !scope.row.is_locked"');
|
||||
expect(projects).toContain('v-if="isAdmin" :content="TEXT.common.actions.delete"');
|
||||
});
|
||||
@@ -69,24 +70,47 @@ describe("project management access", () => {
|
||||
const source = readProjects();
|
||||
|
||||
expect(source).toContain('class="project-table" style="width: 100%" table-layout="fixed"');
|
||||
expect(source).toContain(':label="TEXT.common.fields.projectName"');
|
||||
expect(source).toContain(`:label="TEXT.common.fields.protocolNo || '方案号'" show-overflow-tooltip`);
|
||||
expect(source).toContain(':label="TEXT.common.fields.status" align="center"');
|
||||
expect(source).toContain('label="锁定状态" align="center"');
|
||||
expect(source).toContain(':label="TEXT.common.labels.actions" align="center"');
|
||||
expect(source).not.toContain('projectColumnWidth');
|
||||
expect(source).not.toContain('fixed="right"');
|
||||
expect(source).toContain(':label="TEXT.common.fields.projectName" min-width="260"');
|
||||
expect(source).toContain(`:label="TEXT.common.fields.protocolNo || '方案号'" min-width="220" show-overflow-tooltip`);
|
||||
expect(source).toContain('v-if="!isAdmin" label="我的角色" align="center" width="120"');
|
||||
expect(source).toContain(':label="TEXT.common.fields.status" align="center" width="150"');
|
||||
expect(source).toContain('label="锁定状态" align="center" width="150"');
|
||||
expect(source).toContain(':label="TEXT.common.labels.actions" align="center" width="300" fixed="right"');
|
||||
expect(source).toContain("min-width: 252px;");
|
||||
expect(source).toContain("min-width: 1060px;");
|
||||
});
|
||||
|
||||
it("reserves enough width for the densest project action set", () => {
|
||||
const source = readProjects();
|
||||
|
||||
expect(source).toContain('width="300" fixed="right"');
|
||||
expect(source).toContain("min-width: 252px;");
|
||||
expect(source).toContain("min-width: 1060px;");
|
||||
});
|
||||
|
||||
it("keeps the project summary header compact", () => {
|
||||
const source = readProjects();
|
||||
|
||||
expect(source).toContain("padding: 10px 16px;");
|
||||
expect(source).toContain("padding: 9px 12px;");
|
||||
expect(source).toContain("width: 32px;");
|
||||
expect(source).toContain("height: 32px;");
|
||||
expect(source).toContain("font-size: 20px;");
|
||||
expect(source).toContain("font-size: 11px;");
|
||||
expect(source).toContain('class="project-overview"');
|
||||
expect(source).toContain('class="create-project-button"');
|
||||
expect(source).toContain("min-height: 48px;");
|
||||
expect(source).toContain("width: 30px;");
|
||||
expect(source).toContain("height: 30px;");
|
||||
expect(source).toContain("font-size: 18px;");
|
||||
expect(source).toContain("font-size: 10px;");
|
||||
expect(source).toContain("content-wrapper:has(.projects-page)");
|
||||
});
|
||||
|
||||
it("mounts the project form in a container shared by web and desktop layouts", () => {
|
||||
const form = readFileSync(resolve(__dirname, "./ProjectForm.vue"), "utf8");
|
||||
const desktopStyles = readFileSync(resolve(__dirname, "../../styles/main.css"), "utf8");
|
||||
|
||||
expect(form).toContain('append-to="body"');
|
||||
expect(form).not.toContain('append-to=".layout-main .content-wrapper"');
|
||||
expect(form).not.toContain('class="form-header"');
|
||||
expect(form).not.toContain("填写以下信息创建临床试验项目");
|
||||
expect(desktopStyles).toContain("body.is-desktop-runtime .project-form-dialog");
|
||||
expect(desktopStyles).toContain("body.is-desktop-runtime .project-form-dialog .el-dialog__body");
|
||||
});
|
||||
|
||||
it("renders project names as plain text and moves setup configuration to a gear action", () => {
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
<template>
|
||||
<div class="page page--flush">
|
||||
<div class="page page--flush projects-page">
|
||||
<div class="main-content-flat unified-shell">
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-row">
|
||||
<!-- 项目概览 -->
|
||||
<section class="project-overview" aria-labelledby="project-overview-title">
|
||||
<div class="overview-heading">
|
||||
<div class="overview-title-wrap">
|
||||
<h2 id="project-overview-title">项目概览</h2>
|
||||
</div>
|
||||
<el-tooltip v-if="isAdmin" content="新增项目" placement="left">
|
||||
<el-button type="primary" :icon="Plus" circle class="create-project-button" aria-label="新增项目" @click="openCreate" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div class="stats-row">
|
||||
<div class="stat-card stat-card--total">
|
||||
<div class="stat-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
@@ -51,20 +60,13 @@
|
||||
<span class="stat-label">已锁定</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div class="unified-action-bar actions-only-bar">
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button v-if="isAdmin" type="primary" :icon="Plus" @click="openCreate">
|
||||
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjects.projectLabel }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 项目表格 -->
|
||||
<div class="unified-section project-table-section">
|
||||
<el-table :data="projects" v-loading="loading" class="project-table" style="width: 100%" table-layout="fixed">
|
||||
<el-table-column :label="TEXT.common.fields.projectName">
|
||||
<el-table-column :label="TEXT.common.fields.projectName" min-width="260">
|
||||
<template #default="scope">
|
||||
<div class="project-cell">
|
||||
<div class="project-icon" :class="'icon--' + (scope.row.status || 'draft').toLowerCase()">
|
||||
@@ -80,17 +82,17 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.protocolNo || '方案号'" show-overflow-tooltip>
|
||||
<el-table-column :label="TEXT.common.fields.protocolNo || '方案号'" min-width="220" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<span class="text-muted">{{ scope.row.protocol_no || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="!isAdmin" label="我的角色" align="center">
|
||||
<el-table-column v-if="!isAdmin" label="我的角色" align="center" width="120">
|
||||
<template #default="scope">
|
||||
<span class="role-badge">{{ roleLabel(scope.row.role_in_study) || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.status" align="center">
|
||||
<el-table-column :label="TEXT.common.fields.status" align="center" width="150">
|
||||
<template #default="scope">
|
||||
<span class="status-badge" :class="'badge--' + (scope.row.status || 'draft').toLowerCase()">
|
||||
<span class="badge-dot"></span>
|
||||
@@ -98,7 +100,7 @@
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="锁定状态" align="center">
|
||||
<el-table-column label="锁定状态" align="center" width="150">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.is_locked" class="lock-indicator locked">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13">
|
||||
@@ -116,7 +118,7 @@
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" align="center">
|
||||
<el-table-column :label="TEXT.common.labels.actions" align="center" width="300" fixed="right">
|
||||
<template #default="scope">
|
||||
<div class="action-row">
|
||||
<el-tooltip v-if="canProject(scope.row, 'project_members', 'read')" :content="TEXT.modules.adminProjects.members" placement="top">
|
||||
@@ -372,31 +374,70 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:global(.web-layout-container .content-wrapper:has(.projects-page)) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.project-overview {
|
||||
padding: 0;
|
||||
border-bottom: 1px solid var(--unified-shell-divider);
|
||||
background: linear-gradient(180deg, var(--ctms-bg-card) 0%, color-mix(in srgb, var(--ctms-neutral-100) 55%, var(--ctms-bg-card)) 100%);
|
||||
}
|
||||
|
||||
.overview-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 38px;
|
||||
padding: 6px 16px;
|
||||
}
|
||||
|
||||
.overview-title-wrap {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.overview-title-wrap h2 {
|
||||
margin: 0;
|
||||
color: var(--ctms-text-main);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.create-project-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
gap: 0;
|
||||
border-top: 1px solid var(--unified-shell-divider);
|
||||
background: var(--ctms-bg-card);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 10px;
|
||||
background: var(--ctms-neutral-100);
|
||||
transition: var(--ctms-transition);
|
||||
min-height: 48px;
|
||||
padding: 6px 14px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--ctms-shadow-sm);
|
||||
.stat-card + .stat-card {
|
||||
border-left: 1px solid var(--unified-shell-divider);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -405,8 +446,8 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.stat-icon svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.stat-card--total .stat-icon { background: #e8edf3; color: var(--ctms-primary); }
|
||||
@@ -420,28 +461,18 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
color: var(--ctms-text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.actions-only-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 项目单元格 */
|
||||
.project-cell {
|
||||
display: flex;
|
||||
@@ -541,16 +572,22 @@ onMounted(() => {
|
||||
.action-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-width: 252px;
|
||||
max-width: 100%;
|
||||
padding: 1px 3px;
|
||||
border-radius: 8px;
|
||||
background: rgba(248, 250, 252, 0.72);
|
||||
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.14);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
font-size: 16px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
min-height: 26px;
|
||||
font-size: 15px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border-radius: 6px;
|
||||
@@ -573,16 +610,40 @@ onMounted(() => {
|
||||
|
||||
/* 表格 */
|
||||
.project-table-section {
|
||||
padding: 0;
|
||||
padding: 0 !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.project-table {
|
||||
min-width: 1060px;
|
||||
}
|
||||
|
||||
.project-table :deep(.el-table__inner-wrapper::before) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.project-table :deep(th.el-table__cell) {
|
||||
padding-top: 7px;
|
||||
padding-bottom: 7px;
|
||||
}
|
||||
|
||||
.project-table :deep(td.el-table__cell) {
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.stat-card:nth-child(3) {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.stat-card:nth-child(n + 3) {
|
||||
border-top: 1px solid var(--unified-shell-divider);
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
<template>
|
||||
<el-dialog v-if="visibleProxy" append-to=".layout-main .content-wrapper" :title="TEXT.modules.adminSites.craBindTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
|
||||
<div class="tip">{{ TEXT.modules.adminSites.sitePrefix }}{{ site?.name }}</div>
|
||||
<el-form label-width="120px">
|
||||
<el-form-item :label="TEXT.modules.adminSites.craSelect">
|
||||
<el-select v-model="selectedCras" multiple filterable :placeholder="TEXT.modules.adminSites.craSelectPlaceholder" style="width: 100%">
|
||||
<el-option v-for="user in craUsers" :key="user.id" :label="user.username" :value="user.id" />
|
||||
</el-select>
|
||||
<div class="hint">{{ TEXT.modules.adminSites.craHint }}</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visibleProxy = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="onSave">{{ TEXT.modules.adminSites.craSave }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { updateSite } from "../../api/sites";
|
||||
import type { Site, UserInfo } from "../../types/api";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { evaluateAction } from "../../guards/actionGuard";
|
||||
import { TEXT } from "../../locales";
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
studyId: string;
|
||||
site: Site | null;
|
||||
craUsers: UserInfo[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:visible", value: boolean): void;
|
||||
(e: "saved"): void;
|
||||
}>();
|
||||
|
||||
const auth = useAuthStore();
|
||||
const visibleProxy = computed({
|
||||
get: () => props.visible,
|
||||
set: (val: boolean) => emit("update:visible", val),
|
||||
});
|
||||
|
||||
const selectedCras = ref<string[]>([]);
|
||||
const saving = ref(false);
|
||||
|
||||
const loadSelected = () => {
|
||||
if (!props.site) {
|
||||
selectedCras.value = [];
|
||||
return;
|
||||
}
|
||||
const raw = props.site.contact || "";
|
||||
const tokens = raw
|
||||
.split(",")
|
||||
.map((i) => i.trim())
|
||||
.filter(Boolean);
|
||||
selectedCras.value = tokens
|
||||
.map((token) => {
|
||||
const byId = props.craUsers.find((u) => u.id === token);
|
||||
if (byId) return byId.id;
|
||||
const byName = props.craUsers.find((u) => u.username === token);
|
||||
return byName?.id;
|
||||
})
|
||||
.filter((v): v is string => !!v);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
loadSelected();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const onSave = async () => {
|
||||
if (!props.site) return;
|
||||
const decision = evaluateAction({
|
||||
actorRole: auth.user?.is_admin ? "ADMIN" : null,
|
||||
requiredPermission: "site.cra.bind",
|
||||
target: { siteId: props.site.id, studyId: props.studyId },
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const names = selectedCras.value
|
||||
.map((id) => props.craUsers.find((u) => u.id === id)?.username || id)
|
||||
.filter(Boolean);
|
||||
await updateSite(props.studyId, props.site.id, { contact: names.join(",") });
|
||||
ElMessage.success(TEXT.modules.adminSites.craSaveSuccess);
|
||||
emit("saved");
|
||||
visibleProxy.value = false;
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tip {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.hint {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSystemMonitoringPage = () => readFileSync(resolve(__dirname, "./SystemMonitoringPage.vue"), "utf8");
|
||||
|
||||
describe("SystemMonitoringPage desktop shell", () => {
|
||||
it("reuses the web monitoring component and fits it within the desktop workspace", () => {
|
||||
const source = readSystemMonitoringPage();
|
||||
|
||||
expect(source).toContain('<PermissionMonitoring :is-admin="true" />');
|
||||
expect(source).toContain('import { isTauriRuntime } from "@/runtime";');
|
||||
expect(source).toContain("const isDesktop = isTauriRuntime();");
|
||||
expect(source).toContain("system-monitoring-page--desktop");
|
||||
expect(source).toContain("height: calc(100dvh - 52px);");
|
||||
expect(source).toContain("height: 100%;");
|
||||
expect(source).toContain("content-wrapper:has(.system-monitoring-page)");
|
||||
expect(source).toContain("height: calc(100dvh - 52px);");
|
||||
expect(source).toContain("overflow: hidden;");
|
||||
expect(source).toContain(".overview-hero-card");
|
||||
expect(source).toContain(":deep(.overview-hero-card > .metric-strip)");
|
||||
expect(source).not.toContain(".system-monitoring-page--desktop :deep(.metric-strip)");
|
||||
expect(source).toContain(".access-logs .audit-filters");
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,78 @@
|
||||
<template>
|
||||
<div class="system-monitoring-page">
|
||||
<div class="system-monitoring-page" :class="{ 'system-monitoring-page--desktop': isDesktop }">
|
||||
<PermissionMonitoring :is-admin="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
|
||||
import { isTauriRuntime } from "@/runtime";
|
||||
|
||||
const isDesktop = isTauriRuntime();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.system-monitoring-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 48px);
|
||||
height: calc(100dvh - 48px);
|
||||
height: calc(100vh - 52px);
|
||||
height: calc(100dvh - 52px);
|
||||
min-height: 0;
|
||||
margin: -6px -8px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
:global(.web-layout-container .content-wrapper:has(.system-monitoring-page)) {
|
||||
height: calc(100vh - 52px);
|
||||
height: calc(100dvh - 52px);
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/*
|
||||
* DesktopLayout already reserves space for its title toolbar and workspace
|
||||
* tabs. Keep the shared monitoring view inside that remaining area instead
|
||||
* of sizing it from the browser viewport as the web shell does.
|
||||
*/
|
||||
.system-monitoring-page--desktop {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1450px) {
|
||||
/*
|
||||
* At the minimum desktop window width, the sidebar leaves the monitoring
|
||||
* surface with roughly 900px. These rules preserve the web information
|
||||
* hierarchy while giving the shared cards room to wrap cleanly.
|
||||
*/
|
||||
.system-monitoring-page--desktop :deep(.overview-hero-card) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.system-monitoring-page--desktop :deep(.overview-title-group) {
|
||||
flex: 1 1 112px;
|
||||
}
|
||||
|
||||
.system-monitoring-page--desktop :deep(.overview-hero-actions) {
|
||||
flex: 0 1 auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.system-monitoring-page--desktop :deep(.overview-hero-card > .metric-strip) {
|
||||
flex-basis: 100%;
|
||||
order: 3;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid #edf1f6;
|
||||
}
|
||||
|
||||
.system-monitoring-page--desktop :deep(.access-logs .audit-filters) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.system-monitoring-page--desktop :deep(.access-logs .filter-keyword) {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,28 +1,12 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
append-to=".layout-main .content-wrapper"
|
||||
append-to="body"
|
||||
:title="user ? TEXT.modules.adminUsers.editTitle : TEXT.modules.adminUsers.newTitle"
|
||||
width="540px"
|
||||
v-model="visibleProxy"
|
||||
:close-on-click-modal="false"
|
||||
class="user-form-dialog"
|
||||
>
|
||||
<div class="form-header">
|
||||
<div class="form-avatar" :class="user ? 'avatar--edit' : 'avatar--new'">
|
||||
<svg v-if="!user" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="24" height="24">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="8.5" cy="7" r="4"/>
|
||||
<line x1="20" y1="8" x2="20" y2="14"/>
|
||||
<line x1="23" y1="11" x2="17" y2="11"/>
|
||||
</svg>
|
||||
<span v-else class="avatar-initials">{{ getInitials(form.full_name) }}</span>
|
||||
</div>
|
||||
<div class="form-header-text">
|
||||
<span class="form-header-title">{{ user ? form.full_name || '编辑用户' : '创建新用户' }}</span>
|
||||
<span class="form-header-desc">{{ user ? form.email : '填写以下信息创建系统账号' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" autocomplete="off" class="user-form-body">
|
||||
<el-form-item :label="TEXT.common.fields.email" prop="email">
|
||||
<el-input v-model="form.email" :disabled="!!user" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.email">
|
||||
@@ -129,13 +113,6 @@ const form = reactive({
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
if (!name) return '?';
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
const rules = reactive<FormRules>({
|
||||
email: [
|
||||
{ required: true, message: requiredMessage(TEXT.common.fields.email), trigger: "blur" },
|
||||
@@ -232,57 +209,6 @@ const onSubmit = async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding-bottom: 18px;
|
||||
margin-bottom: 18px;
|
||||
border-bottom: 1px solid var(--ctms-border-color);
|
||||
}
|
||||
|
||||
.form-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-avatar.avatar--new {
|
||||
background: linear-gradient(135deg, #e8edf3, #d5dce5);
|
||||
color: var(--ctms-primary);
|
||||
}
|
||||
|
||||
.form-avatar.avatar--edit {
|
||||
background: linear-gradient(135deg, var(--ctms-primary), var(--ctms-primary-active));
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.avatar-initials {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.form-header-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-header-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.form-header-desc {
|
||||
font-size: 12px;
|
||||
color: var(--ctms-text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.user-form-body :deep(.el-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
v-model="visibleProxy"
|
||||
title="登录记录"
|
||||
direction="rtl"
|
||||
size="min(760px, 96vw)"
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="login-activity-drawer">
|
||||
<div v-if="user" class="drawer-user-summary">
|
||||
<div class="user-meta-info">
|
||||
<span class="user-fullname">{{ user.full_name }}</span>
|
||||
<span class="user-email-text">{{ user.email }}</span>
|
||||
</div>
|
||||
<span class="status-badge" :class="user.login_status === 'ONLINE' ? 'is-online' : 'is-offline'">
|
||||
<span class="status-badge-dot"></span>
|
||||
<span>{{ user.login_status === 'ONLINE' ? `在线 (${user.active_session_count || 1})` : '离线' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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="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="登录 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>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { fetchUserLoginActivities } from "../../api/users";
|
||||
import type { UserInfo, UserLoginActivity } from "../../types/api";
|
||||
import { displayDateTime } from "../../utils/display";
|
||||
import {
|
||||
groupLoginActivitiesBySource,
|
||||
type DisplayLoginActivity,
|
||||
} from "./loginActivitySources";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
user: UserInfo | null;
|
||||
}>();
|
||||
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 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) => {
|
||||
const status = resolvedActivityStatus(item);
|
||||
if (status === "ENDED") return "已退出";
|
||||
return status === "ONLINE" ? "在线" : "已离线";
|
||||
};
|
||||
const activityStatusClass = (item: UserLoginActivity) => {
|
||||
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 sourceActivities = computed<DisplayLoginActivity[]>(() =>
|
||||
groupLoginActivitiesBySource(activities.value, resolvedActivityStatus),
|
||||
);
|
||||
const displayActivities = computed<DisplayLoginActivity[]>(() =>
|
||||
activityViewMode.value === "source"
|
||||
? sourceActivities.value
|
||||
: activities.value.map((item) => ({ ...item, grouped_count: 1 })),
|
||||
);
|
||||
|
||||
const loadActivities = async () => {
|
||||
if (!props.user) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await fetchUserLoginActivities(props.user.id);
|
||||
activities.value = data;
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.response?.data?.detail || "登录记录加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(isOpen) => {
|
||||
if (isOpen) {
|
||||
activityViewMode.value = "source";
|
||||
void loadActivities();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-activity-drawer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.drawer-user-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding: 14px 18px;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.03);
|
||||
}
|
||||
|
||||
.user-meta-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.user-fullname {
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.user-email-text {
|
||||
color: #64748b;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
/* 状态徽标 */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.status-badge-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-badge.is-online {
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
.status-badge.is-online .status-badge-dot {
|
||||
background-color: #10b981;
|
||||
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.25);
|
||||
}
|
||||
|
||||
.status-badge.is-offline {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
border: 1px solid #cbd5e1;
|
||||
}
|
||||
|
||||
.status-badge.is-offline .status-badge-dot {
|
||||
background-color: #64748b;
|
||||
}
|
||||
|
||||
.status-badge.is-ended {
|
||||
background: #f8fafc;
|
||||
color: #94a3b8;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.status-badge.is-ended .status-badge-dot {
|
||||
background-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.status-badge.compact {
|
||||
padding: 2.5px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.status-badge.compact .status-badge-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.login-activity-table {
|
||||
width: 100%;
|
||||
border: 1px solid #f1f5f9;
|
||||
border-radius: 8px;
|
||||
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;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
height: 38px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.login-activity-table :deep(.el-table__row) td {
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.client-cell,
|
||||
.source-cell,
|
||||
.time-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.client-cell strong {
|
||||
color: #334155;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
append-to=".layout-main .content-wrapper"
|
||||
append-to="body"
|
||||
:title="TEXT.modules.adminUsers.resetTitle"
|
||||
width="540px"
|
||||
v-model="visibleProxy"
|
||||
|
||||
@@ -1,52 +1,77 @@
|
||||
<template>
|
||||
<div class="page page--flush">
|
||||
<div class="page page--flush users-page">
|
||||
<div class="main-content-flat unified-shell">
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card stat-card--total">
|
||||
<div class="stat-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
<!-- 独立状态概览 -->
|
||||
<section class="account-overview" aria-labelledby="account-overview-title">
|
||||
<div class="overview-heading">
|
||||
<div>
|
||||
<h2 id="account-overview-title">账号概览</h2>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-value">{{ total }}</span>
|
||||
<span class="stat-label">总用户</span>
|
||||
<span class="overview-live"><i></i>状态汇总</span>
|
||||
</div>
|
||||
<div class="stats-row">
|
||||
<div class="stat-card stat-card--total">
|
||||
<div class="stat-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-value">{{ allUsers.length }}</span>
|
||||
<span class="stat-label">总用户</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card--active">
|
||||
<div class="stat-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-value">{{ activeCount }}</span>
|
||||
<span class="stat-label">{{ TEXT.enums.userStatus.ACTIVE }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card--disabled">
|
||||
<div class="stat-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-value">{{ disabledCount }}</span>
|
||||
<span class="stat-label">{{ TEXT.enums.userStatus.DISABLED }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card--online">
|
||||
<div class="stat-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M5 12a7 7 0 0 1 14 0"/>
|
||||
<path d="M8 12a4 4 0 0 1 8 0"/>
|
||||
<circle cx="12" cy="16" r="1"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-value">{{ onlineCount }}</span>
|
||||
<span class="stat-label">当前在线</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card--active">
|
||||
<div class="stat-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-value">{{ activeCount }}</span>
|
||||
<span class="stat-label">{{ TEXT.enums.userStatus.ACTIVE }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card--disabled">
|
||||
<div class="stat-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-value">{{ disabledCount }}</span>
|
||||
<span class="stat-label">{{ TEXT.enums.userStatus.DISABLED }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="filter-container unified-action-bar">
|
||||
<div class="filter-form">
|
||||
<div class="filter-item-form">
|
||||
<div class="filter-heading">
|
||||
<el-icon><Filter /></el-icon>
|
||||
<span>筛选</span>
|
||||
</div>
|
||||
<div class="filter-item-form filter-item-form--search">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
:placeholder="TEXT.common.placeholders.keyword"
|
||||
@@ -57,11 +82,19 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-item-form">
|
||||
<el-select v-model="statusFilter" :placeholder="TEXT.common.fields.status" style="width: 140px" clearable class="filter-select-comp" @change="applyFilters">
|
||||
<el-select v-model="statusFilter" placeholder="账号状态" clearable class="filter-select-comp" @change="applyFilters">
|
||||
<el-option :label="TEXT.enums.userStatus.ACTIVE" value="ACTIVE" />
|
||||
<el-option :label="TEXT.enums.userStatus.DISABLED" value="DISABLED" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-item-form">
|
||||
<el-select v-model="loginStatusFilter" placeholder="登录状态" clearable class="filter-select-comp" @change="applyFilters">
|
||||
<el-option label="在线" value="ONLINE" />
|
||||
<el-option label="离线" value="OFFLINE" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-button v-if="hasActiveFilters" text :icon="RefreshLeft" class="reset-filter-button" @click="resetFilters">重置筛选</el-button>
|
||||
<span class="filter-result" aria-live="polite">{{ hasActiveFilters ? `筛选结果 ${total} 人` : `共 ${total} 人` }}</span>
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminUsers.userLabel }}</el-button>
|
||||
</div>
|
||||
@@ -70,7 +103,7 @@
|
||||
<!-- 用户表格 -->
|
||||
<div class="unified-section user-table-section">
|
||||
<el-table :data="users" v-loading="loading" class="user-table" style="width: 100%" table-layout="fixed">
|
||||
<el-table-column :label="TEXT.common.fields.name" width="360">
|
||||
<el-table-column :label="TEXT.common.fields.name" min-width="200">
|
||||
<template #default="scope">
|
||||
<div class="user-cell">
|
||||
<div class="user-info">
|
||||
@@ -80,21 +113,42 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="clinical_department" :label="TEXT.modules.adminUsers.clinicalDepartmentLabel" show-overflow-tooltip />
|
||||
<el-table-column :label="TEXT.common.fields.status">
|
||||
<el-table-column prop="clinical_department" :label="TEXT.modules.adminUsers.clinicalDepartmentLabel" min-width="120" class-name="department-column" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<span class="status-dot" :class="'dot--' + (scope.row.status || '').toLowerCase()"></span>
|
||||
{{ statusLabel(scope.row.status) }}
|
||||
<span class="department-text">{{ scope.row.clinical_department }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" show-overflow-tooltip>
|
||||
<el-table-column :label="TEXT.common.fields.status" min-width="100" class-name="account-status-column">
|
||||
<template #default="scope">
|
||||
<span class="text-muted">{{ displayDateTime(scope.row.created_at) }}</span>
|
||||
<div class="account-status-cell">
|
||||
<span class="status-dot" :class="'dot--' + (scope.row.status || '').toLowerCase()"></span>
|
||||
<span>{{ statusLabel(scope.row.status) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" align="center">
|
||||
<el-table-column label="登录状态" min-width="140">
|
||||
<template #default="scope">
|
||||
<div class="login-status-cell">
|
||||
<span class="status-dot" :class="'dot--' + (scope.row.login_status || 'OFFLINE').toLowerCase()"></span>
|
||||
<span>{{ loginStatusLabel(scope.row) }}</span>
|
||||
<small>{{ scope.row.last_client_type === 'desktop' ? '桌面端' : scope.row.last_client_type === 'web' ? '网页端' : '暂无会话' }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近登录" min-width="150">
|
||||
<template #default="scope">
|
||||
<div class="login-time-cell">
|
||||
<span>{{ scope.row.last_login_at ? displayDateTime(scope.row.last_login_at) : '从未登录' }}</span>
|
||||
<small v-if="scope.row.last_seen_at">活动 {{ displayDateTime(scope.row.last_seen_at) }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" align="center" width="176" class-name="actions-column">
|
||||
<template #default="scope">
|
||||
<div class="action-row">
|
||||
<el-tooltip content="登录记录" placement="top">
|
||||
<el-button link type="info" :icon="Clock" class="action-btn" aria-label="查看登录记录" @click="openLoginActivities(scope.row)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="TEXT.common.actions.edit" placement="top">
|
||||
<el-button link type="primary" :icon="Edit" class="action-btn" @click="openEdit(scope.row)" />
|
||||
</el-tooltip>
|
||||
@@ -141,19 +195,21 @@
|
||||
</div>
|
||||
<UserForm v-if="formVisible" v-model:visible="formVisible" :user="editingUser" :admin-count="activeAdminCount" @saved="loadUsers" />
|
||||
<UserResetPassword v-if="resetVisible" v-model:visible="resetVisible" :user="resetUser" @reset="loadUsers" />
|
||||
<UserLoginActivitiesDrawer v-if="loginActivitiesVisible" v-model="loginActivitiesVisible" :user="loginActivitiesUser" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Edit, Delete, Key, Lock, Unlock, Search, Plus } from "@element-plus/icons-vue";
|
||||
import { Edit, Delete, Key, Lock, Unlock, Search, Plus, Clock, RefreshLeft, Filter } from "@element-plus/icons-vue";
|
||||
import { fetchUsers, updateUser, deleteUser } from "../../api/users";
|
||||
import { fetchStudies } from "../../api/studies";
|
||||
import { listMembers } from "../../api/members";
|
||||
import type { UserInfo } from "../../types/api";
|
||||
import UserForm from "./UserForm.vue";
|
||||
import UserResetPassword from "./UserResetPassword.vue";
|
||||
import UserLoginActivitiesDrawer from "./UserLoginActivitiesDrawer.vue";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { displayDateTime } from "../../utils/display";
|
||||
import { TEXT } from "../../locales";
|
||||
@@ -169,13 +225,18 @@ const formVisible = ref(false);
|
||||
const resetVisible = ref(false);
|
||||
const searchKeyword = ref("");
|
||||
const statusFilter = ref("");
|
||||
const loginStatusFilter = ref("");
|
||||
const editingUser = ref<UserInfo | null>(null);
|
||||
const resetUser = ref<UserInfo | null>(null);
|
||||
const loginActivitiesUser = ref<UserInfo | null>(null);
|
||||
const loginActivitiesVisible = ref(false);
|
||||
const auth = useAuthStore();
|
||||
let keywordSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const activeCount = computed(() => allUsers.value.filter(u => u.status === 'ACTIVE').length);
|
||||
const disabledCount = computed(() => allUsers.value.filter(u => u.status === 'DISABLED').length);
|
||||
const onlineCount = computed(() => allUsers.value.filter(u => u.login_status === 'ONLINE').length);
|
||||
const hasActiveFilters = computed(() => Boolean(searchKeyword.value.trim() || statusFilter.value || loginStatusFilter.value));
|
||||
|
||||
const statusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
@@ -185,6 +246,12 @@ const statusLabel = (status: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const loginStatusLabel = (row: UserInfo) => {
|
||||
if (row.status !== "ACTIVE") return "账号不可用";
|
||||
if (row.login_status === "ONLINE") return row.active_session_count && row.active_session_count > 1 ? `${row.active_session_count} 个会话` : "在线";
|
||||
return "离线";
|
||||
};
|
||||
|
||||
const isLastAdmin = (row: UserInfo) =>
|
||||
row.is_admin && row.status === "ACTIVE" && activeAdminCount.value <= 1;
|
||||
|
||||
@@ -197,6 +264,7 @@ const loadUsers = async () => {
|
||||
limit: pageSize.value,
|
||||
keyword: searchKeyword.value || undefined,
|
||||
status: statusFilter.value || undefined,
|
||||
login_status: loginStatusFilter.value || undefined,
|
||||
}),
|
||||
fetchUsers({ skip: 0, limit: 10000 }),
|
||||
]);
|
||||
@@ -219,6 +287,13 @@ const applyFilters = () => {
|
||||
loadUsers();
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
searchKeyword.value = "";
|
||||
statusFilter.value = "";
|
||||
loginStatusFilter.value = "";
|
||||
applyFilters();
|
||||
};
|
||||
|
||||
const clearKeywordSearchTimer = () => {
|
||||
if (!keywordSearchTimer) return;
|
||||
clearTimeout(keywordSearchTimer);
|
||||
@@ -236,7 +311,7 @@ const scheduleKeywordSearch = () => {
|
||||
|
||||
watch(searchKeyword, () => {
|
||||
scheduleKeywordSearch();
|
||||
});
|
||||
}, { flush: "sync" });
|
||||
|
||||
const onPageSizeChange = (size: number) => {
|
||||
pageSize.value = size;
|
||||
@@ -284,6 +359,11 @@ const openReset = (row: UserInfo) => {
|
||||
resetVisible.value = true;
|
||||
};
|
||||
|
||||
const openLoginActivities = (row: UserInfo) => {
|
||||
loginActivitiesUser.value = row;
|
||||
loginActivitiesVisible.value = true;
|
||||
};
|
||||
|
||||
const onDelete = async (row: UserInfo) => {
|
||||
const { action } = await ElMessageBox.prompt(
|
||||
TEXT.modules.adminUsers.deleteConfirm,
|
||||
@@ -350,32 +430,97 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page,
|
||||
.main-content-flat,
|
||||
.user-table-section {
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.main-content-flat {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
:global(.web-layout-container .content-wrapper:has(.users-page)) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.account-overview {
|
||||
padding: 0;
|
||||
border-bottom: 1px solid var(--unified-shell-divider);
|
||||
background: linear-gradient(180deg, var(--ctms-bg-card) 0%, color-mix(in srgb, var(--ctms-neutral-100) 55%, var(--ctms-bg-card)) 100%);
|
||||
}
|
||||
|
||||
.overview-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 0;
|
||||
padding: 8px 16px 6px;
|
||||
}
|
||||
|
||||
.overview-heading h2 {
|
||||
margin: 0;
|
||||
color: var(--ctms-text-main);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.overview-live {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid rgba(34, 166, 99, 0.18);
|
||||
border-radius: 999px;
|
||||
background: rgba(34, 166, 99, 0.07);
|
||||
color: #188454;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overview-live i {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #22a663;
|
||||
box-shadow: 0 0 0 3px rgba(34, 166, 99, 0.12);
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--unified-shell-divider);
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid var(--unified-shell-divider);
|
||||
border-right: 0;
|
||||
border-bottom: 0;
|
||||
border-left: 0;
|
||||
border-radius: 0;
|
||||
background: var(--ctms-bg-card);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 10px;
|
||||
background: var(--ctms-neutral-100);
|
||||
transition: var(--ctms-transition);
|
||||
min-height: 48px;
|
||||
padding: 6px 14px;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--ctms-shadow-sm);
|
||||
.stat-card + .stat-card {
|
||||
border-left: 1px solid var(--unified-shell-divider);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -384,8 +529,8 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.stat-icon svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.stat-card--total .stat-icon {
|
||||
@@ -403,43 +548,100 @@ onBeforeUnmount(() => {
|
||||
color: var(--ctms-danger);
|
||||
}
|
||||
|
||||
.stat-card--online .stat-icon {
|
||||
background: #e8f6ef;
|
||||
color: #1f9d63;
|
||||
}
|
||||
|
||||
.stat-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
color: var(--ctms-text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* 筛选栏 */
|
||||
.filter-container {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 8;
|
||||
background: color-mix(in srgb, var(--ctms-bg-card) 94%, transparent);
|
||||
box-shadow: 0 5px 14px rgba(15, 23, 42, 0.05);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-heading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--ctms-text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-item-form {
|
||||
margin-bottom: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filter-item-form--search {
|
||||
flex: 0 1 320px;
|
||||
}
|
||||
|
||||
.filter-input-comp {
|
||||
width: 220px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.filter-form :deep(.el-input__inner),
|
||||
.filter-form :deep(.el-select__placeholder),
|
||||
.filter-form :deep(.el-select__selected-item) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.filter-form :deep(.el-button) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.filter-select-comp {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.reset-filter-button {
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.filter-result {
|
||||
color: var(--ctms-text-secondary);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 用户单元格 */
|
||||
@@ -456,7 +658,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
color: var(--ctms-text-main);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -464,13 +666,26 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.user-email {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--ctms-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.department-text,
|
||||
.account-status-cell {
|
||||
color: var(--ctms-text-main);
|
||||
font-size: 12px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.account-status-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 状态点 */
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
@@ -491,6 +706,43 @@ onBeforeUnmount(() => {
|
||||
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
|
||||
.status-dot.dot--online {
|
||||
background: #22a663;
|
||||
box-shadow: 0 0 0 3px rgba(34, 166, 99, 0.15);
|
||||
}
|
||||
|
||||
.status-dot.dot--offline {
|
||||
background: #94a3b8;
|
||||
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
|
||||
.login-status-cell,
|
||||
.login-time-cell {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
column-gap: 6px;
|
||||
color: var(--ctms-text-main);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.login-status-cell small,
|
||||
.login-time-cell small {
|
||||
grid-column: 2;
|
||||
margin-top: 0;
|
||||
color: var(--ctms-text-secondary);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.login-time-cell {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.login-time-cell small {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--ctms-text-secondary);
|
||||
font-size: 12px;
|
||||
@@ -502,7 +754,7 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 2px;
|
||||
padding: 1px;
|
||||
border-radius: 8px;
|
||||
background: rgba(248, 250, 252, 0.72);
|
||||
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.14);
|
||||
@@ -510,9 +762,9 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
min-height: 26px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border-radius: 7px;
|
||||
@@ -526,6 +778,13 @@ onBeforeUnmount(() => {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.user-table :deep(td.actions-column .cell) {
|
||||
padding-right: 6px;
|
||||
padding-left: 6px;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.action-row :deep(.el-button .el-icon) {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
@@ -545,21 +804,31 @@ onBeforeUnmount(() => {
|
||||
|
||||
/* 表格区域 */
|
||||
.user-table-section {
|
||||
padding: 0;
|
||||
padding: 0 !important;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.user-table :deep(.el-table__inner-wrapper::before) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-table :deep(.el-scrollbar__bar.is-horizontal) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-table :deep(th.el-table__cell) {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
padding-top: 7px;
|
||||
padding-bottom: 7px;
|
||||
}
|
||||
|
||||
.user-table :deep(td.el-table__cell) {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.user-table :deep(.cell) {
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
@@ -567,11 +836,52 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 8px 16px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pagination-wrap :deep(.el-pagination) {
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
row-gap: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.stat-card:nth-child(3) {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.stat-card:nth-child(n + 3) {
|
||||
border-top: 1px solid var(--unified-shell-divider);
|
||||
}
|
||||
|
||||
.filter-heading {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.filter-item-form--search {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.filter-select-comp {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-item-form:not(.filter-item-form--search) {
|
||||
flex: 1 1 calc(50% - 5px);
|
||||
}
|
||||
|
||||
.filter-form > .el-button--primary {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
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 with login source details and grouped history", () => {
|
||||
const users = readSource("./Users.vue");
|
||||
const drawer = readSource("./UserLoginActivitiesDrawer.vue");
|
||||
|
||||
expect(users).toContain("当前在线");
|
||||
expect(users).toContain("login_status");
|
||||
expect(users).toContain("last_login_at");
|
||||
expect(users).toContain("openLoginActivities");
|
||||
expect(users).toContain("UserLoginActivitiesDrawer");
|
||||
expect(drawer).toContain("登录记录");
|
||||
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("groupLoginActivitiesBySource");
|
||||
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", () => {
|
||||
const users = readSource("./Users.vue");
|
||||
|
||||
expect(users).toContain('placeholder="账号状态"');
|
||||
expect(users).toContain('placeholder="登录状态"');
|
||||
expect(users).toContain('login_status: loginStatusFilter.value || undefined');
|
||||
expect(users).toContain('class="account-overview"');
|
||||
expect(users).toContain("position: sticky");
|
||||
expect(users).not.toContain("filterByStat");
|
||||
expect(users).not.toContain("账号与登录会话状态");
|
||||
expect(users).toContain("min-height: 48px");
|
||||
expect(users).toContain('class-name="actions-column"');
|
||||
expect(users).toContain("text-overflow: clip");
|
||||
expect(users).toContain(".department-text,\n.account-status-cell");
|
||||
expect(users).toContain("padding: 0 !important;");
|
||||
expect(users).toContain("border-radius: 0;");
|
||||
expect(users).toContain("content-wrapper:has(.users-page)");
|
||||
expect(users).toContain("resetFilters");
|
||||
expect(users).toContain("筛选结果");
|
||||
});
|
||||
|
||||
it("mounts account edit and emergency-reset dialogs to a container shared by web and desktop layouts", () => {
|
||||
const userForm = readSource("./UserForm.vue");
|
||||
const resetPassword = readSource("./UserResetPassword.vue");
|
||||
|
||||
expect(userForm).toContain('append-to="body"');
|
||||
expect(resetPassword).toContain('append-to="body"');
|
||||
expect(userForm).not.toContain('append-to=".layout-main .content-wrapper"');
|
||||
expect(resetPassword).not.toContain('append-to=".layout-main .content-wrapper"');
|
||||
expect(userForm).not.toContain('class="form-header"');
|
||||
expect(userForm).not.toContain("填写以下信息创建系统账号");
|
||||
});
|
||||
|
||||
it("uses the desktop dialog treatment without an additional outer card for account actions", () => {
|
||||
const desktopStyles = readFileSync(resolve(__dirname, "../../styles/main.css"), "utf8");
|
||||
|
||||
expect(desktopStyles).toContain("body.is-desktop-runtime .user-form-dialog");
|
||||
expect(desktopStyles).toContain("body.is-desktop-runtime .reset-password-dialog");
|
||||
expect(desktopStyles).toContain("background: transparent !important;");
|
||||
expect(desktopStyles).toContain("box-shadow: none !important;");
|
||||
expect(desktopStyles).toContain(".user-form-dialog .el-dialog__body");
|
||||
expect(desktopStyles).toContain(".reset-password-dialog .el-dialog__body");
|
||||
expect(desktopStyles).toContain("border-radius: 8px !important;");
|
||||
expect(desktopStyles).toContain("padding: 0 !important;");
|
||||
expect(desktopStyles).toContain(".user-form-dialog .el-dialog__headerbtn");
|
||||
expect(desktopStyles).toContain("top: 5px;");
|
||||
expect(desktopStyles).toContain("right: 7px;");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UserLoginActivity } from "../../types/api";
|
||||
import { activitySourceKey, groupLoginActivitiesBySource } from "./loginActivitySources";
|
||||
|
||||
const activity = (overrides: Partial<UserLoginActivity>): UserLoginActivity => ({
|
||||
id: "session-1",
|
||||
client_type: "web",
|
||||
client_platform: "macos",
|
||||
client_version: "0.1.0",
|
||||
login_ip: "192.168.97.1",
|
||||
login_at: "2026-07-16T02:23:04Z",
|
||||
last_seen_at: "2026-07-16T03:08:32Z",
|
||||
activity_status: "ENDED",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("login activity sources", () => {
|
||||
it("uses only client type and a recorded IP as the source identity", () => {
|
||||
expect(activitySourceKey(activity({ client_platform: "windows", client_source: "installer" }))).toBe(
|
||||
"web|192.168.97.1",
|
||||
);
|
||||
expect(activitySourceKey(activity({ id: "legacy", login_ip: null }))).toBe("session:legacy");
|
||||
});
|
||||
|
||||
it("merges online and historical sessions from the same client and IP", () => {
|
||||
const rows = groupLoginActivitiesBySource(
|
||||
[
|
||||
activity({
|
||||
id: "current",
|
||||
login_at: "2026-07-16T07:22:42Z",
|
||||
last_seen_at: "2026-07-16T07:53:00Z",
|
||||
activity_status: "ONLINE",
|
||||
ended_at: null,
|
||||
}),
|
||||
activity({ id: "history", ended_at: "2026-07-16T03:08:32Z" }),
|
||||
activity({
|
||||
id: "desktop",
|
||||
client_type: "desktop",
|
||||
login_at: "2026-07-15T08:42:02Z",
|
||||
last_seen_at: "2026-07-16T07:53:42Z",
|
||||
activity_status: "ONLINE",
|
||||
ended_at: null,
|
||||
}),
|
||||
],
|
||||
(item) => item.activity_status!,
|
||||
);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toMatchObject({ id: "desktop", grouped_count: 1, activity_status: "ONLINE" });
|
||||
expect(rows[1]).toMatchObject({
|
||||
id: "current",
|
||||
grouped_count: 2,
|
||||
login_at: "2026-07-16T02:23:04Z",
|
||||
last_seen_at: "2026-07-16T07:53:00Z",
|
||||
activity_status: "ONLINE",
|
||||
ended_at: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not merge sessions whose IP was not recorded", () => {
|
||||
const rows = groupLoginActivitiesBySource(
|
||||
[activity({ id: "legacy-1", login_ip: null }), activity({ id: "legacy-2", login_ip: null })],
|
||||
(item) => item.activity_status!,
|
||||
);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { UserLoginActivity } from "../../types/api";
|
||||
|
||||
export type DisplayLoginActivity = UserLoginActivity & { grouped_count: number };
|
||||
|
||||
type ActivityStatus = NonNullable<UserLoginActivity["activity_status"]>;
|
||||
|
||||
const activityTime = (value: string) => new Date(value).getTime();
|
||||
|
||||
export const activitySourceKey = (item: UserLoginActivity) => {
|
||||
if (!item.login_ip) return `session:${item.id}`;
|
||||
return [item.client_type, item.login_ip].join("|");
|
||||
};
|
||||
|
||||
export const groupLoginActivitiesBySource = (
|
||||
activities: UserLoginActivity[],
|
||||
resolveStatus: (item: UserLoginActivity) => ActivityStatus,
|
||||
): DisplayLoginActivity[] => {
|
||||
const grouped = new Map<
|
||||
string,
|
||||
{ row: DisplayLoginActivity; firstLoginAt: string; hasOnlineSession: boolean }
|
||||
>();
|
||||
|
||||
activities.forEach((item) => {
|
||||
const key = activitySourceKey(item);
|
||||
const status = resolveStatus(item);
|
||||
const existing = grouped.get(key);
|
||||
if (!existing) {
|
||||
grouped.set(key, {
|
||||
row: { ...item, activity_status: status, grouped_count: 1 },
|
||||
firstLoginAt: item.login_at,
|
||||
hasOnlineSession: status === "ONLINE",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
existing.row.grouped_count += 1;
|
||||
existing.hasOnlineSession ||= status === "ONLINE";
|
||||
if (activityTime(item.login_at) < activityTime(existing.firstLoginAt)) {
|
||||
existing.firstLoginAt = item.login_at;
|
||||
}
|
||||
if (activityTime(item.last_seen_at) > activityTime(existing.row.last_seen_at)) {
|
||||
const groupedCount = existing.row.grouped_count;
|
||||
existing.row = { ...item, activity_status: status, grouped_count: groupedCount };
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(grouped.values())
|
||||
.map(({ row, firstLoginAt, hasOnlineSession }) => ({
|
||||
...row,
|
||||
login_at: firstLoginAt,
|
||||
activity_status: hasOnlineSession ? "ONLINE" : row.activity_status,
|
||||
ended_at: hasOnlineSession ? null : row.ended_at,
|
||||
end_reason: hasOnlineSession ? null : row.end_reason,
|
||||
}))
|
||||
.sort((left, right) => activityTime(right.last_seen_at) - activityTime(left.last_seen_at));
|
||||
};
|
||||
Reference in New Issue
Block a user