优化后台界面与无操作退出体验

This commit is contained in:
Cheng Zhou
2026-06-10 15:20:06 +08:00
parent ea3f19e241
commit 84e55af8fe
39 changed files with 793 additions and 608 deletions
@@ -5,6 +5,18 @@ import { resolve } from "node:path";
const readAuditLogsView = () => readFileSync(resolve(__dirname, "./AuditLogs.vue"), "utf8");
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;");
});
it("loads audit logs from the selected project context", () => {
const source = readAuditLogsView();
+11 -11
View File
@@ -651,17 +651,17 @@ onMounted(async () => {
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
padding: 16px;
gap: 10px;
padding: 10px 16px;
border-bottom: 1px solid var(--unified-shell-divider);
}
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 12px;
gap: 10px;
padding: 9px 12px;
border-radius: 10px;
background: var(--ctms-neutral-100);
transition: var(--ctms-transition);
}
@@ -672,24 +672,24 @@ onMounted(async () => {
}
.stat-icon {
width: 38px;
height: 38px;
border-radius: 10px;
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-icon svg { width: 20px; height: 20px; }
.stat-icon svg { width: 18px; height: 18px; }
.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: 22px; font-weight: 700; line-height: 1.2; color: var(--ctms-text-main); }
.stat-label { font-size: 12px; color: var(--ctms-text-secondary); margin-top: 2px; }
.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; }
/* 筛选栏 */
.audit-toolbar {
+11
View File
@@ -74,6 +74,17 @@ describe("project management access", () => {
expect(source).not.toContain('fixed="right"');
});
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;");
});
it("renders project names as plain text and moves setup configuration to a gear action", () => {
const source = readProjects();
+12 -12
View File
@@ -375,16 +375,16 @@ onMounted(() => {
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
padding: 16px;
gap: 10px;
padding: 10px 16px;
}
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 12px;
gap: 10px;
padding: 9px 12px;
border-radius: 10px;
background: var(--ctms-neutral-100);
transition: var(--ctms-transition);
}
@@ -395,9 +395,9 @@ onMounted(() => {
}
.stat-icon {
width: 38px;
height: 38px;
border-radius: 10px;
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
@@ -405,8 +405,8 @@ onMounted(() => {
}
.stat-icon svg {
width: 20px;
height: 20px;
width: 18px;
height: 18px;
}
.stat-card--total .stat-icon { background: #e8edf3; color: var(--ctms-primary); }
@@ -420,14 +420,14 @@ onMounted(() => {
}
.stat-value {
font-size: 22px;
font-size: 20px;
font-weight: 700;
line-height: 1.2;
color: var(--ctms-text-main);
}
.stat-label {
font-size: 12px;
font-size: 11px;
color: var(--ctms-text-secondary);
margin-top: 2px;
}
+38 -3
View File
@@ -17,14 +17,49 @@ describe("Admin users table labels", () => {
});
});
describe("Admin users table layout", () => {
it("uses a wider name column, even remaining columns, and compact icon actions", () => {
const viewSource = readUsersView();
expect(viewSource).toContain('table-layout="fixed"');
expect(viewSource).toContain(':label="TEXT.common.fields.name" width="360"');
expect(viewSource).not.toContain('width="20%"');
expect(viewSource).not.toContain('width="180"');
expect(viewSource).not.toContain('width="160"');
expect(viewSource).not.toContain('width="100"');
expect(viewSource).not.toContain('fixed="right"');
expect(viewSource).toContain('class="action-btn"');
expect(viewSource).toContain("width: 28px;");
expect(viewSource).toContain("gap: 4px;");
});
});
describe("Admin users summary header", () => {
it("keeps the management summary header compact", () => {
const viewSource = readUsersView();
expect(viewSource).toContain("padding: 10px 16px;");
expect(viewSource).toContain("padding: 9px 12px;");
expect(viewSource).toContain("width: 32px;");
expect(viewSource).toContain("height: 32px;");
expect(viewSource).toContain("font-size: 20px;");
expect(viewSource).toContain("font-size: 11px;");
});
});
describe("Admin users filters", () => {
it("applies keyword and status filters from the first page", () => {
it("applies keyword automatically and status filters from the first page", () => {
const viewSource = readUsersView();
expect(viewSource).toContain("@keyup.enter=\"applyFilters\"");
expect(viewSource).toContain("@clear=\"applyFilters\"");
expect(viewSource).toContain("@change=\"applyFilters\"");
expect(viewSource).toContain("@click=\"applyFilters\"");
expect(viewSource).not.toContain("@click=\"applyFilters\"");
expect(viewSource).not.toContain(":icon=\"Refresh\"");
expect(viewSource).not.toContain("Refresh } from");
expect(viewSource).toContain("watch(searchKeyword, () => {");
expect(viewSource).toContain("scheduleKeywordSearch();");
expect(viewSource).toContain("keywordSearchTimer = setTimeout(() => {");
expect(viewSource).toContain("}, 300);");
expect(viewSource).toContain("page.value = 1");
expect(viewSource).toContain("keyword: searchKeyword.value || undefined");
expect(viewSource).toContain("status: statusFilter.value || undefined");
+87 -25
View File
@@ -65,7 +65,6 @@
clearable
class="filter-input-comp"
:prefix-icon="Search"
@clear="applyFilters"
@keyup.enter="applyFilters"
/>
</div>
@@ -77,9 +76,6 @@
<el-option :label="TEXT.enums.userStatus.DISABLED" value="DISABLED" />
</el-select>
</div>
<div class="filter-item-form">
<el-button @click="applyFilters" :icon="Refresh">{{ TEXT.common.actions.search }}</el-button>
</div>
<div class="filter-spacer"></div>
<el-button type="primary" :icon="Plus" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminUsers.userLabel }}</el-button>
</div>
@@ -88,7 +84,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" min-width="200">
<el-table-column :label="TEXT.common.fields.name" width="360">
<template #default="scope">
<div class="user-cell">
<div class="user-avatar" :class="'avatar--' + (scope.row.status || '').toLowerCase()">
@@ -102,40 +98,42 @@
</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" width="100">
<el-table-column :label="TEXT.common.fields.status">
<template #default="scope">
<span class="status-dot" :class="'dot--' + (scope.row.status || '').toLowerCase()"></span>
{{ statusLabel(scope.row.status) }}
</template>
</el-table-column>
<el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" width="160" show-overflow-tooltip>
<el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" show-overflow-tooltip>
<template #default="scope">
<span class="text-muted">{{ displayDateTime(scope.row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="180" align="center" fixed="right">
<el-table-column :label="TEXT.common.labels.actions" align="center">
<template #default="scope">
<div class="action-row">
<el-tooltip :content="TEXT.common.actions.edit" placement="top">
<el-button link type="primary" :icon="Edit" @click="openEdit(scope.row)" />
<el-button link type="primary" :icon="Edit" class="action-btn" @click="openEdit(scope.row)" />
</el-tooltip>
<el-tooltip :content="scope.row.status === 'ACTIVE' ? TEXT.common.actions.disable : TEXT.common.actions.enable" placement="top">
<el-button
link
:type="scope.row.status === 'ACTIVE' ? 'warning' : 'success'"
:icon="scope.row.status === 'ACTIVE' ? Lock : Unlock"
class="action-btn"
:disabled="isLastAdmin(scope.row)"
@click="toggleStatus(scope.row)"
/>
</el-tooltip>
<el-tooltip :content="TEXT.modules.adminUsers.resetPassword" placement="top">
<el-button link type="warning" :icon="Key" @click="openReset(scope.row)" />
<el-button link type="warning" :icon="Key" class="action-btn" @click="openReset(scope.row)" />
</el-tooltip>
<el-tooltip :content="TEXT.common.actions.delete" placement="top">
<el-button
link
type="danger"
:icon="Delete"
class="action-btn"
:disabled="scope.row.id === auth.user?.id"
@click="onDelete(scope.row)"
/>
@@ -164,9 +162,9 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Edit, Delete, Key, Lock, Unlock, Search, Plus, Refresh } from "@element-plus/icons-vue";
import { Edit, Delete, Key, Lock, Unlock, Search, Plus } from "@element-plus/icons-vue";
import { fetchUsers, updateUser, deleteUser } from "../../api/users";
import { fetchStudies } from "../../api/studies";
import { listMembers } from "../../api/members";
@@ -191,6 +189,7 @@ const statusFilter = ref("");
const editingUser = ref<UserInfo | null>(null);
const resetUser = ref<UserInfo | null>(null);
const auth = useAuthStore();
let keywordSearchTimer: ReturnType<typeof setTimeout> | null = null;
const activeCount = computed(() => allUsers.value.filter(u => u.status === 'ACTIVE').length);
const pendingCount = computed(() => allUsers.value.filter(u => u.status === 'PENDING').length);
@@ -251,10 +250,30 @@ const loadUsers = async () => {
};
const applyFilters = () => {
clearKeywordSearchTimer();
page.value = 1;
loadUsers();
};
const clearKeywordSearchTimer = () => {
if (!keywordSearchTimer) return;
clearTimeout(keywordSearchTimer);
keywordSearchTimer = null;
};
const scheduleKeywordSearch = () => {
clearKeywordSearchTimer();
keywordSearchTimer = setTimeout(() => {
keywordSearchTimer = null;
page.value = 1;
loadUsers();
}, 300);
};
watch(searchKeyword, () => {
scheduleKeywordSearch();
});
const onPageSizeChange = (size: number) => {
pageSize.value = size;
page.value = 1;
@@ -360,23 +379,27 @@ const findUserProjects = async (userId: string) => {
onMounted(() => {
loadUsers();
});
onBeforeUnmount(() => {
clearKeywordSearchTimer();
});
</script>
<style scoped>
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
padding: 16px;
gap: 10px;
padding: 10px 16px;
border-bottom: 1px solid var(--unified-shell-divider);
}
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 12px;
gap: 10px;
padding: 9px 12px;
border-radius: 10px;
background: var(--ctms-neutral-100);
transition: var(--ctms-transition);
}
@@ -387,9 +410,9 @@ onMounted(() => {
}
.stat-icon {
width: 38px;
height: 38px;
border-radius: 10px;
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
@@ -397,8 +420,8 @@ onMounted(() => {
}
.stat-icon svg {
width: 20px;
height: 20px;
width: 18px;
height: 18px;
}
.stat-card--total .stat-icon {
@@ -427,14 +450,14 @@ onMounted(() => {
}
.stat-value {
font-size: 22px;
font-size: 20px;
font-weight: 700;
line-height: 1.2;
color: var(--ctms-text-main);
}
.stat-label {
font-size: 12px;
font-size: 11px;
color: var(--ctms-text-secondary);
margin-top: 2px;
}
@@ -559,10 +582,49 @@ onMounted(() => {
.action-row {
display: inline-flex;
align-items: center;
gap: 6px;
justify-content: center;
gap: 4px;
padding: 2px;
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;
padding: 0;
margin: 0;
border-radius: 7px;
font-size: 15px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(248, 250, 252, 0.82));
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06), inset 0 0 0 1px rgba(148, 163, 184, 0.16);
transition: transform 120ms ease, box-shadow 120ms ease, background-color 120ms ease;
}
.action-row :deep(.el-button + .el-button) {
margin-left: 0;
}
.action-row :deep(.el-button .el-icon) {
width: 15px;
height: 15px;
}
.action-btn:hover {
transform: translateY(-1px);
background: #fff;
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.1), inset 0 0 0 1px rgba(148, 163, 184, 0.22);
}
.action-btn.is-disabled {
transform: none;
opacity: 0.45;
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.12);
}
/* 表格区域 */
.user-table-section {
padding: 0;