修复(权限管理):统一 PM 系统导航与权限校验
This commit is contained in:
@@ -5,20 +5,26 @@ import { resolve } from "node:path";
|
||||
const readEntryView = () => readFileSync(resolve(__dirname, "./DesktopProjectEntry.vue"), "utf8");
|
||||
|
||||
describe("DesktopProjectEntry", () => {
|
||||
it("offers admin backend and explicit project entry without direct in-project switching", () => {
|
||||
it("offers system management access only to admins and authorized project PMs", () => {
|
||||
const source = readEntryView();
|
||||
|
||||
expect(source).toContain("工作台总控");
|
||||
expect(source).toContain("选择目标项目");
|
||||
expect(source).toContain("Desktop Workbench");
|
||||
expect(source).toContain('class="entry-background-grid"');
|
||||
expect(source).toContain("管理后台");
|
||||
expect(source).toContain("系统管理");
|
||||
expect(source).toContain("ADMIN SYSTEM");
|
||||
expect(source).toContain("SYSTEM MANAGEMENT");
|
||||
expect(source).toContain("<strong>系统管理</strong>");
|
||||
expect(source).toContain('v-if="canEnterManagement"');
|
||||
expect(source).toContain('projects.value.some((project) => project.role_in_study === "PM")');
|
||||
expect(source).toContain("project-cards-grid");
|
||||
expect(source).toContain("card-action-bar");
|
||||
expect(source).toContain("进入项目工作空间");
|
||||
expect(source).toContain('router.push("/admin/users")');
|
||||
expect(source).toContain('router.push(isAdmin.value ? "/admin/users" : "/admin/projects")');
|
||||
expect(source).toContain("studyStore.clearCurrentStudy()");
|
||||
expect(source).toContain("studyStore.setCurrentStudy(pmProject)");
|
||||
expect(source).toContain("projects.value.find((project) => project.role_in_study === \"PM\")");
|
||||
expect(source).toContain("fetchStudies()");
|
||||
expect(source).toContain("studyStore.setCurrentStudy(project)");
|
||||
expect(source).toContain("studyStore.loadCurrentStudyPermissions()");
|
||||
|
||||
@@ -32,20 +32,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 管理后台控制卡片 - 常驻左侧底部 -->
|
||||
<div v-if="isAdmin" class="sidebar-admin">
|
||||
<button class="admin-action-card" type="button" @click="enterAdmin">
|
||||
<!-- ADMIN / PM 可进入系统管理,其他角色仅可选择项目 -->
|
||||
<div v-if="canEnterManagement" class="sidebar-admin">
|
||||
<button class="admin-action-card" type="button" @click="enterManagement">
|
||||
<span class="admin-glow-layer" aria-hidden="true"></span>
|
||||
<div class="admin-card-head">
|
||||
<div class="admin-icon-box">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
</div>
|
||||
<div class="admin-text-box">
|
||||
<span class="admin-kicker">ADMIN SYSTEM</span>
|
||||
<strong>管理后台</strong>
|
||||
<span class="admin-kicker">{{ isAdmin ? "ADMIN SYSTEM" : "SYSTEM MANAGEMENT" }}</span>
|
||||
<strong>系统管理</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p class="admin-desc">人员账号管理、项目授权、审计及系统配置</p>
|
||||
<p class="admin-desc">
|
||||
{{ isAdmin ? "人员账号管理、项目授权、审计及系统配置" : "查看并维护您负责项目的授权与范围配置" }}
|
||||
</p>
|
||||
<div class="admin-foot">
|
||||
<span>进入控制台</span>
|
||||
<el-icon><ArrowRight /></el-icon>
|
||||
@@ -170,13 +172,16 @@ const loading = ref(false);
|
||||
const loggingOut = ref(false);
|
||||
const isTransitioning = ref(false);
|
||||
const isAdmin = computed(() => isSystemAdmin(auth.user));
|
||||
const canEnterManagement = computed(() =>
|
||||
isAdmin.value || projects.value.some((project) => project.role_in_study === "PM"),
|
||||
);
|
||||
|
||||
const userDisplayName = computed(
|
||||
() => auth.user?.full_name || auth.user?.username || auth.user?.email || TEXT.common.labels.userFallback,
|
||||
);
|
||||
const userInitial = computed(() => userDisplayName.value.charAt(0).toUpperCase() || TEXT.common.labels.userInitialFallback);
|
||||
const entrySubtitle = computed(() =>
|
||||
isAdmin.value ? "进入管理后台,或选择一个项目开始桌面工作。" : "请选择本次要进入的项目。",
|
||||
canEnterManagement.value ? "进入系统管理,或选择一个项目开始桌面工作。" : "请选择本次要进入的项目。",
|
||||
);
|
||||
|
||||
const statusLabel = (status: string | undefined) =>
|
||||
@@ -194,12 +199,20 @@ const loadProjects = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const enterAdmin = () => {
|
||||
if (!isAdmin.value) return;
|
||||
const enterManagement = () => {
|
||||
if (!canEnterManagement.value) return;
|
||||
isTransitioning.value = true;
|
||||
window.setTimeout(() => {
|
||||
studyStore.clearCurrentStudy();
|
||||
router.push("/admin/users");
|
||||
window.setTimeout(async () => {
|
||||
if (isAdmin.value) {
|
||||
studyStore.clearCurrentStudy();
|
||||
} else {
|
||||
const pmProject = projects.value.find((project) => project.role_in_study === "PM");
|
||||
if (pmProject) {
|
||||
studyStore.setCurrentStudy(pmProject);
|
||||
await studyStore.loadCurrentStudyPermissions().catch(() => {});
|
||||
}
|
||||
}
|
||||
await router.push(isAdmin.value ? "/admin/users" : "/admin/projects");
|
||||
}, 300);
|
||||
};
|
||||
|
||||
@@ -414,7 +427,7 @@ onMounted(() => {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* 常驻管理后台卡片(精致浅色流光) */
|
||||
/* 常驻系统管理卡片(精致浅色流光) */
|
||||
.sidebar-admin {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,13 @@ describe("WebWorkbenchEntry", () => {
|
||||
expect(source).toContain("项目内不直接切换项目");
|
||||
expect(source).toContain("项目工作区");
|
||||
expect(source).toContain("fetchStudies()");
|
||||
expect(source).toContain('projects.value.some((project) => project.role_in_study === "PM")');
|
||||
expect(source).toContain('v-if="canEnterManagement"');
|
||||
expect(source).toContain('<strong>系统管理</strong>');
|
||||
expect(source).toContain('const managementActionLabel = computed(() => canEnterManagement.value ? "进入系统管理" : "");');
|
||||
expect(source).toContain("studyStore.clearCurrentStudy()");
|
||||
expect(source).toContain("studyStore.setCurrentStudy(pmProject)");
|
||||
expect(source).toContain("projects.value.find((project) => project.role_in_study === \"PM\")");
|
||||
expect(source).toContain('router.push(isAdmin.value ? "/admin/users" : "/admin/projects")');
|
||||
expect(source).toContain("studyStore.setCurrentStudy(project)");
|
||||
expect(source).toContain("studyStore.loadCurrentStudyPermissions()");
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 管理后台/项目管理控制卡片 - 常驻左侧底部 -->
|
||||
<div class="sidebar-admin">
|
||||
<!-- ADMIN / PM 可进入系统管理,其他角色仅可选择项目 -->
|
||||
<div v-if="canEnterManagement" class="sidebar-admin">
|
||||
<button class="admin-action-card" type="button" @click="enterManagement">
|
||||
<span class="admin-glow-layer" aria-hidden="true"></span>
|
||||
<div class="admin-card-head">
|
||||
@@ -41,8 +41,8 @@
|
||||
<el-icon><Monitor /></el-icon>
|
||||
</div>
|
||||
<div class="admin-text-box">
|
||||
<span class="admin-kicker">{{ isAdmin ? "ADMIN SYSTEM" : "STUDY PORTAL" }}</span>
|
||||
<strong>{{ isAdmin ? "管理后台" : "项目管理" }}</strong>
|
||||
<span class="admin-kicker">{{ isAdmin ? "ADMIN SYSTEM" : "SYSTEM MANAGEMENT" }}</span>
|
||||
<strong>系统管理</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p class="admin-desc">
|
||||
@@ -206,18 +206,22 @@ const loading = ref(false);
|
||||
const loggingOut = ref(false);
|
||||
const isTransitioning = ref(false);
|
||||
const isAdmin = computed(() => isSystemAdmin(auth.user));
|
||||
const canEnterManagement = computed(() =>
|
||||
isAdmin.value || projects.value.some((project) => project.role_in_study === "PM"),
|
||||
);
|
||||
|
||||
const userDisplayName = computed(
|
||||
() => auth.user?.full_name || auth.user?.username || auth.user?.email || TEXT.common.labels.userFallback,
|
||||
);
|
||||
const userInitial = computed(() => userDisplayName.value.charAt(0).toUpperCase() || TEXT.common.labels.userInitialFallback);
|
||||
|
||||
const entryDescription = computed(() =>
|
||||
isAdmin.value
|
||||
? "进入管理后台处理账号、授权 and 系统配置,或选择项目进入项目工作区。"
|
||||
: "选择本次要进入的项目,或进入项目管理查看您已授权的项目范围。",
|
||||
);
|
||||
const managementActionLabel = computed(() => isAdmin.value ? "进入管理后台" : "进入项目管理");
|
||||
const entryDescription = computed(() => {
|
||||
if (!canEnterManagement.value) return "选择本次要进入的项目进入项目工作区。";
|
||||
return isAdmin.value
|
||||
? "进入系统管理处理账号、授权及系统配置,或选择项目进入项目工作区。"
|
||||
: "进入系统管理查看您已授权的项目范围,或选择项目进入项目工作区。";
|
||||
});
|
||||
const managementActionLabel = computed(() => canEnterManagement.value ? "进入系统管理" : "");
|
||||
|
||||
const statusLabel = (status: string | undefined) =>
|
||||
status ? TEXT.enums.projectStatus[status as keyof typeof TEXT.enums.projectStatus] || status : "未知";
|
||||
@@ -235,9 +239,18 @@ const loadProjects = async () => {
|
||||
};
|
||||
|
||||
const enterManagement = async () => {
|
||||
if (!canEnterManagement.value) return;
|
||||
isTransitioning.value = true;
|
||||
window.setTimeout(async () => {
|
||||
studyStore.clearCurrentStudy();
|
||||
if (isAdmin.value) {
|
||||
studyStore.clearCurrentStudy();
|
||||
} else {
|
||||
const pmProject = projects.value.find((project) => project.role_in_study === "PM");
|
||||
if (pmProject) {
|
||||
studyStore.setCurrentStudy(pmProject);
|
||||
await studyStore.loadCurrentStudyPermissions().catch(() => {});
|
||||
}
|
||||
}
|
||||
await router.push(isAdmin.value ? "/admin/users" : "/admin/projects").catch(() => {});
|
||||
isTransitioning.value = false;
|
||||
}, 300);
|
||||
@@ -461,7 +474,7 @@ onMounted(() => {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* 常驻管理后台卡片(精致浅色流光) */
|
||||
/* 常驻系统管理卡片(精致浅色流光) */
|
||||
.sidebar-admin {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -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,16 +75,16 @@ describe("audit logs access", () => {
|
||||
expect(source).not.toContain("limit: 2000");
|
||||
});
|
||||
|
||||
it("surfaces request source, IP and location filters for audit troubleshooting", () => {
|
||||
it("keeps the filter bar focused without request-source or IP-location inputs", () => {
|
||||
const source = readAuditLogsView();
|
||||
|
||||
expect(source).toContain("filters.clientType");
|
||||
expect(source).toContain("filters.ipKeyword");
|
||||
expect(source).toContain("TEXT.modules.adminAuditLogs.filterSource");
|
||||
expect(source).toContain("TEXT.modules.adminAuditLogs.filterIpLocation");
|
||||
expect(source).toContain("TEXT.modules.adminAuditLogs.columns.source");
|
||||
expect(source).toContain("formatClientIpLine(scope.row)");
|
||||
expect(source).toContain("client_ip: ipKeyword && isIpSearchToken(ipKeyword) ? ipKeyword : undefined");
|
||||
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", () => {
|
||||
@@ -108,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", () => {
|
||||
@@ -133,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>
|
||||
@@ -39,15 +44,30 @@
|
||||
<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">
|
||||
@@ -59,22 +79,6 @@
|
||||
<el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-item-form">
|
||||
<el-select v-model="filters.clientType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterSource" @change="onServerFilterChange" class="filter-select-source">
|
||||
<el-option v-for="opt in sourceTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-item-form">
|
||||
<el-input
|
||||
v-model="filters.ipKeyword"
|
||||
clearable
|
||||
:placeholder="TEXT.modules.adminAuditLogs.filterIpLocation"
|
||||
class="filter-input-ip"
|
||||
@input="onLocalFilterChange"
|
||||
@clear="onServerFilterChange"
|
||||
@change="onServerFilterChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-item-form">
|
||||
<el-select v-model="filters.result" clearable :placeholder="TEXT.modules.adminAuditLogs.filterResult" @change="onLocalFilterChange" class="filter-select-result">
|
||||
<el-option :label="TEXT.audit.resultSuccess" value="SUCCESS" />
|
||||
@@ -94,30 +98,19 @@
|
||||
@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>
|
||||
@@ -125,21 +118,12 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.source" width="230">
|
||||
<template #default="scope">
|
||||
<div class="source-cell">
|
||||
<span class="source-main">{{ scope.row.clientSourceLabel }}</span>
|
||||
<span class="source-meta">{{ formatClientMeta(scope.row) }}</span>
|
||||
<span class="source-ip">{{ formatClientIpLine(scope.row) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<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">
|
||||
@@ -161,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 }}
|
||||
@@ -237,35 +221,6 @@
|
||||
<span class="meta-value">{{ selectedLog.actionText || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-meta-grid">
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">请求来源</span>
|
||||
<span class="meta-value">{{ selectedLog.clientSourceLabel || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">来源 IP</span>
|
||||
<span class="meta-value">{{ selectedLog.clientIp || "未知 IP" }}</span>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">IP 位置</span>
|
||||
<span class="meta-value">{{ selectedLog.ipLocation || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">客户端</span>
|
||||
<span class="meta-value">{{ formatClientMeta(selectedLog) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 0 20"/><path d="M12 2a15.3 15.3 0 0 0 0 20"/></svg>
|
||||
访问上下文
|
||||
</div>
|
||||
<div class="context-lines">
|
||||
<div><span>User-Agent</span><code>{{ selectedLog.userAgent || TEXT.common.fallback }}</code></div>
|
||||
<div><span>构建信息</span><code>{{ formatBuildMeta(selectedLog) }}</code></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 变更明细 -->
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title">
|
||||
@@ -310,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";
|
||||
@@ -349,8 +304,6 @@ const DIFF_PREVIEW_LIMIT = 2;
|
||||
const filters = ref({
|
||||
eventType: "",
|
||||
operatorId: "",
|
||||
clientType: "",
|
||||
ipKeyword: "",
|
||||
result: "",
|
||||
range: [],
|
||||
});
|
||||
@@ -358,6 +311,12 @@ 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 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;
|
||||
@@ -430,11 +389,6 @@ const eventTypeOptions = Object.entries(auditDict).map(([value, cfg]) => ({
|
||||
value,
|
||||
label: cfg.label,
|
||||
}));
|
||||
const sourceTypeOptions = [
|
||||
{ value: "web", label: "网页端" },
|
||||
{ value: "desktop", label: "桌面端" },
|
||||
{ value: "unknown", label: "未知/异常" },
|
||||
];
|
||||
const resolveUserDisplayName = (u: any): string => {
|
||||
return u?.full_name || u?.display_name || u?.username || u?.email || u?.id || TEXT.common.fallback;
|
||||
};
|
||||
@@ -530,34 +484,13 @@ const fetchAuditLogPages = async (baseParams: Record<string, any> = {}) => {
|
||||
return allItems;
|
||||
};
|
||||
|
||||
const isIpSearchToken = (value: string) => /^[0-9a-f:.]+$/i.test(value.trim());
|
||||
|
||||
const buildAuditServerParams = () => {
|
||||
const ipKeyword = String(filters.value.ipKeyword || "").trim();
|
||||
return {
|
||||
action: filters.value.eventType || undefined,
|
||||
operator_id: filters.value.operatorId || undefined,
|
||||
client_type: filters.value.clientType || undefined,
|
||||
client_ip: ipKeyword && isIpSearchToken(ipKeyword) ? ipKeyword : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const formatClientMeta = (log: any) => {
|
||||
const meta = [log.clientType, log.clientVersion, log.clientPlatform].filter(Boolean).join("/");
|
||||
if (meta) return meta;
|
||||
return log.userAgent ? "未标识客户端" : TEXT.common.fallback;
|
||||
};
|
||||
|
||||
const formatBuildMeta = (log: any) => {
|
||||
const meta = [log.buildChannel, log.buildCommit].filter(Boolean).join("/");
|
||||
return meta || TEXT.common.fallback;
|
||||
};
|
||||
|
||||
const formatClientIpLine = (log: any) => {
|
||||
const parts = [log.clientIp || "未知 IP", log.ipLocation].filter(Boolean);
|
||||
return parts.join(" / ");
|
||||
};
|
||||
|
||||
const loadLogs = async () => {
|
||||
if (!selectedStudyId.value) return;
|
||||
loading.value = true;
|
||||
@@ -595,24 +528,6 @@ const enrichLogs = (items: any[]) => {
|
||||
const filterLogs = (items: any[]) =>
|
||||
items.filter((log) => {
|
||||
if (filters.value.result && (log.result || "SUCCESS") !== filters.value.result) return false;
|
||||
const ipKeyword = String(filters.value.ipKeyword || "").trim().toLowerCase();
|
||||
if (ipKeyword) {
|
||||
const haystack = [
|
||||
log.clientIp,
|
||||
log.ipLocation,
|
||||
log.ipCountry,
|
||||
log.ipProvince,
|
||||
log.ipCity,
|
||||
log.ipIsp,
|
||||
log.clientSourceLabel,
|
||||
log.clientType,
|
||||
log.clientPlatform,
|
||||
log.userAgent,
|
||||
log.actorName,
|
||||
log.actorAccount,
|
||||
];
|
||||
if (!haystack.some((value) => String(value || "").toLowerCase().includes(ipKeyword))) return false;
|
||||
}
|
||||
if (filters.value.range?.length === 2) {
|
||||
const ts = new Date(log.timestamp);
|
||||
const start = new Date(filters.value.range[0]);
|
||||
@@ -641,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 = [];
|
||||
@@ -697,10 +620,6 @@ const fetchAllForExport = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportCommand = (command: string) => {
|
||||
if (command === "project") confirmExport();
|
||||
};
|
||||
|
||||
const confirmExport = async () => {
|
||||
const currentStudy = selectedStudy.value;
|
||||
if (!currentStudy) return;
|
||||
@@ -736,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;
|
||||
@@ -770,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 {
|
||||
@@ -799,31 +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-source { width: 112px; }
|
||||
.filter-input-ip { width: 170px; }
|
||||
.filter-select-result { width: 96px; }
|
||||
.date-range-picker-comp { width: 248px !important; }
|
||||
.filter-spacer { flex: 1; min-width: 16px; }
|
||||
|
||||
.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;
|
||||
@@ -862,38 +867,6 @@ onMounted(async () => {
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.source-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.source-main {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
max-width: 100%;
|
||||
padding: 1px 7px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #155e75;
|
||||
background: #ecfeff;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-meta,
|
||||
.source-ip {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--ctms-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.event-tag {
|
||||
display: inline-flex;
|
||||
padding: 2px 8px;
|
||||
@@ -942,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; }
|
||||
@@ -1058,35 +1037,6 @@ onMounted(async () => {
|
||||
color: var(--ctms-primary);
|
||||
}
|
||||
|
||||
.context-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.context-lines div {
|
||||
display: grid;
|
||||
grid-template-columns: 92px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.context-lines span {
|
||||
color: var(--ctms-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.context-lines code {
|
||||
display: block;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
background: var(--ctms-neutral-100);
|
||||
color: var(--ctms-text-main);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.diff-count {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
@@ -1221,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();
|
||||
|
||||
@@ -133,7 +144,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 +155,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 +379,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 +395,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 +421,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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ describe("project management access", () => {
|
||||
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"');
|
||||
});
|
||||
@@ -91,12 +91,26 @@ describe("project management access", () => {
|
||||
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,15 +60,8 @@
|
||||
<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">
|
||||
@@ -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;
|
||||
@@ -545,14 +576,18 @@ onMounted(() => {
|
||||
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;
|
||||
@@ -575,7 +610,7 @@ onMounted(() => {
|
||||
|
||||
/* 表格 */
|
||||
.project-table-section {
|
||||
padding: 0;
|
||||
padding: 0 !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -587,9 +622,28 @@ onMounted(() => {
|
||||
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>
|
||||
|
||||
@@ -12,8 +12,14 @@ describe("SystemMonitoringPage desktop shell", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,14 +15,22 @@ const isDesktop = isTauriRuntime();
|
||||
.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
|
||||
@@ -52,7 +60,7 @@ const isDesktop = isTauriRuntime();
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.system-monitoring-page--desktop :deep(.metric-strip) {
|
||||
.system-monitoring-page--desktop :deep(.overview-hero-card > .metric-strip) {
|
||||
flex-basis: 100%;
|
||||
order: 3;
|
||||
padding-top: 6px;
|
||||
|
||||
@@ -7,22 +7,6 @@
|
||||
: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;
|
||||
}
|
||||
|
||||
@@ -1,65 +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 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>
|
||||
</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"
|
||||
@@ -70,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>
|
||||
@@ -93,11 +113,17 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="clinical_department" :label="TEXT.modules.adminUsers.clinicalDepartmentLabel" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column :label="TEXT.common.fields.status" min-width="100">
|
||||
<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 :label="TEXT.common.fields.status" min-width="100" class-name="account-status-column">
|
||||
<template #default="scope">
|
||||
<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="登录状态" min-width="140">
|
||||
@@ -117,7 +143,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" align="center" width="168">
|
||||
<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">
|
||||
@@ -176,7 +202,7 @@
|
||||
<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, Clock } 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";
|
||||
@@ -199,6 +225,7 @@ 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);
|
||||
@@ -209,6 +236,7 @@ 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) {
|
||||
@@ -236,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 }),
|
||||
]);
|
||||
@@ -258,6 +287,13 @@ const applyFilters = () => {
|
||||
loadUsers();
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
searchKeyword.value = "";
|
||||
statusFilter.value = "";
|
||||
loginStatusFilter.value = "";
|
||||
applyFilters();
|
||||
};
|
||||
|
||||
const clearKeywordSearchTimer = () => {
|
||||
if (!keywordSearchTimer) return;
|
||||
clearTimeout(keywordSearchTimer);
|
||||
@@ -275,7 +311,7 @@ const scheduleKeywordSearch = () => {
|
||||
|
||||
watch(searchKeyword, () => {
|
||||
scheduleKeywordSearch();
|
||||
});
|
||||
}, { flush: "sync" });
|
||||
|
||||
const onPageSizeChange = (size: number) => {
|
||||
pageSize.value = size;
|
||||
@@ -406,32 +442,85 @@ onBeforeUnmount(() => {
|
||||
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(4, 1fr);
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--unified-shell-divider);
|
||||
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;
|
||||
@@ -440,8 +529,8 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.stat-icon svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.stat-card--total .stat-icon {
|
||||
@@ -470,19 +559,28 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.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%;
|
||||
@@ -492,17 +590,55 @@ onBeforeUnmount(() => {
|
||||
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;
|
||||
@@ -522,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;
|
||||
@@ -530,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;
|
||||
@@ -574,15 +723,15 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
column-gap: 6px;
|
||||
color: var(--ctms-text-main);
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.login-status-cell small,
|
||||
.login-time-cell small {
|
||||
grid-column: 2;
|
||||
margin-top: 2px;
|
||||
margin-top: 0;
|
||||
color: var(--ctms-text-secondary);
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.login-time-cell {
|
||||
@@ -605,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);
|
||||
@@ -613,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;
|
||||
@@ -629,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;
|
||||
@@ -648,7 +804,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
/* 表格区域 */
|
||||
.user-table-section {
|
||||
padding: 0;
|
||||
padding: 0 !important;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
@@ -662,13 +818,17 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -692,8 +852,36 @@ onBeforeUnmount(() => {
|
||||
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>
|
||||
|
||||
@@ -19,6 +19,27 @@ describe("admin user login status", () => {
|
||||
expect(drawer).not.toContain("access_token");
|
||||
});
|
||||
|
||||
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");
|
||||
@@ -27,6 +48,8 @@ describe("admin user login status", () => {
|
||||
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", () => {
|
||||
|
||||
Reference in New Issue
Block a user