d5279b124f
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
888 lines
25 KiB
Vue
888 lines
25 KiB
Vue
<template>
|
|
<div class="page page--flush users-page">
|
|
<div class="main-content-flat unified-shell">
|
|
<!-- 独立状态概览 -->
|
|
<section class="account-overview" aria-labelledby="account-overview-title">
|
|
<div class="overview-heading">
|
|
<div>
|
|
<h2 id="account-overview-title">账号概览</h2>
|
|
</div>
|
|
<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>
|
|
</section>
|
|
|
|
<!-- 筛选栏 -->
|
|
<div class="filter-container unified-action-bar">
|
|
<div class="filter-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"
|
|
clearable
|
|
class="filter-input-comp"
|
|
:prefix-icon="Search"
|
|
@keyup.enter="applyFilters"
|
|
/>
|
|
</div>
|
|
<div class="filter-item-form">
|
|
<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>
|
|
</div>
|
|
|
|
<!-- 用户表格 -->
|
|
<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">
|
|
<template #default="scope">
|
|
<div class="user-cell">
|
|
<div class="user-info">
|
|
<span class="user-name">{{ scope.row.full_name }}</span>
|
|
<span class="user-email">{{ scope.row.email }}</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</el-table-column>
|
|
<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="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">
|
|
<template #default="scope">
|
|
<div class="login-status-cell">
|
|
<span class="status-dot" :class="'dot--' + (scope.row.login_status || 'OFFLINE').toLowerCase()"></span>
|
|
<span>{{ loginStatusLabel(scope.row) }}</span>
|
|
<small>{{ scope.row.last_client_type === 'desktop' ? '桌面端' : scope.row.last_client_type === 'web' ? '网页端' : '暂无会话' }}</small>
|
|
</div>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="最近登录" min-width="150">
|
|
<template #default="scope">
|
|
<div class="login-time-cell">
|
|
<span>{{ scope.row.last_login_at ? displayDateTime(scope.row.last_login_at) : '从未登录' }}</span>
|
|
<small v-if="scope.row.last_seen_at">活动 {{ displayDateTime(scope.row.last_seen_at) }}</small>
|
|
</div>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column :label="TEXT.common.labels.actions" align="center" width="176" class-name="actions-column">
|
|
<template #default="scope">
|
|
<div class="action-row">
|
|
<el-tooltip content="登录记录" placement="top">
|
|
<el-button link type="info" :icon="Clock" class="action-btn" aria-label="查看登录记录" @click="openLoginActivities(scope.row)" />
|
|
</el-tooltip>
|
|
<el-tooltip :content="TEXT.common.actions.edit" placement="top">
|
|
<el-button link type="primary" :icon="Edit" class="action-btn" @click="openEdit(scope.row)" />
|
|
</el-tooltip>
|
|
<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" 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)"
|
|
/>
|
|
</el-tooltip>
|
|
</div>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
<div class="pagination-wrap" v-if="total > 0">
|
|
<el-pagination
|
|
v-model:current-page="page"
|
|
v-model:page-size="pageSize"
|
|
:page-sizes="[5, 10, 20]"
|
|
:total="total"
|
|
layout="prev, pager, next, sizes, total"
|
|
small
|
|
@current-change="loadUsers"
|
|
@size-change="onPageSizeChange"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<UserForm v-if="formVisible" v-model:visible="formVisible" :user="editingUser" :admin-count="activeAdminCount" @saved="loadUsers" />
|
|
<UserResetPassword v-if="resetVisible" v-model:visible="resetVisible" :user="resetUser" @reset="loadUsers" />
|
|
<UserLoginActivitiesDrawer v-if="loginActivitiesVisible" v-model="loginActivitiesVisible" :user="loginActivitiesUser" />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|
import { ElMessage, ElMessageBox } from "element-plus";
|
|
import { Edit, Delete, Key, Lock, Unlock, Search, Plus, Clock, RefreshLeft, Filter } from "@element-plus/icons-vue";
|
|
import { fetchUsers, updateUser, deleteUser } from "../../api/users";
|
|
import { fetchStudies } from "../../api/studies";
|
|
import { listMembers } from "../../api/members";
|
|
import type { UserInfo } from "../../types/api";
|
|
import UserForm from "./UserForm.vue";
|
|
import UserResetPassword from "./UserResetPassword.vue";
|
|
import UserLoginActivitiesDrawer from "./UserLoginActivitiesDrawer.vue";
|
|
import { useAuthStore } from "../../store/auth";
|
|
import { displayDateTime } from "../../utils/display";
|
|
import { TEXT } from "../../locales";
|
|
|
|
const users = ref<UserInfo[]>([]);
|
|
const allUsers = ref<UserInfo[]>([]);
|
|
const total = ref(0);
|
|
const page = ref(1);
|
|
const pageSize = ref(10);
|
|
const loading = ref(false);
|
|
const activeAdminCount = ref(0);
|
|
const formVisible = ref(false);
|
|
const resetVisible = ref(false);
|
|
const searchKeyword = ref("");
|
|
const statusFilter = ref("");
|
|
const loginStatusFilter = ref("");
|
|
const editingUser = ref<UserInfo | null>(null);
|
|
const resetUser = ref<UserInfo | null>(null);
|
|
const loginActivitiesUser = ref<UserInfo | null>(null);
|
|
const loginActivitiesVisible = ref(false);
|
|
const auth = useAuthStore();
|
|
let keywordSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const activeCount = computed(() => allUsers.value.filter(u => u.status === 'ACTIVE').length);
|
|
const disabledCount = computed(() => allUsers.value.filter(u => u.status === 'DISABLED').length);
|
|
const onlineCount = computed(() => allUsers.value.filter(u => u.login_status === 'ONLINE').length);
|
|
const hasActiveFilters = computed(() => Boolean(searchKeyword.value.trim() || statusFilter.value || loginStatusFilter.value));
|
|
|
|
const statusLabel = (status: string) => {
|
|
switch (status) {
|
|
case "ACTIVE": return TEXT.enums.userStatus.ACTIVE;
|
|
case "DISABLED": return TEXT.enums.userStatus.DISABLED;
|
|
default: return status || TEXT.common.fallback;
|
|
}
|
|
};
|
|
|
|
const loginStatusLabel = (row: UserInfo) => {
|
|
if (row.status !== "ACTIVE") return "账号不可用";
|
|
if (row.login_status === "ONLINE") return row.active_session_count && row.active_session_count > 1 ? `${row.active_session_count} 个会话` : "在线";
|
|
return "离线";
|
|
};
|
|
|
|
const isLastAdmin = (row: UserInfo) =>
|
|
row.is_admin && row.status === "ACTIVE" && activeAdminCount.value <= 1;
|
|
|
|
const loadUsers = async () => {
|
|
loading.value = true;
|
|
try {
|
|
const [{ data }, { data: adminData }] = await Promise.all([
|
|
fetchUsers({
|
|
skip: (page.value - 1) * pageSize.value,
|
|
limit: pageSize.value,
|
|
keyword: searchKeyword.value || undefined,
|
|
status: statusFilter.value || undefined,
|
|
login_status: loginStatusFilter.value || undefined,
|
|
}),
|
|
fetchUsers({ skip: 0, limit: 10000 }),
|
|
]);
|
|
const items = (data as any).items || [];
|
|
const allItems = (adminData as any).items || [];
|
|
users.value = items;
|
|
allUsers.value = allItems;
|
|
total.value = (data as any).total || items.length;
|
|
activeAdminCount.value = allItems.filter((u: UserInfo) => u.is_admin && u.status === "ACTIVE").length;
|
|
} catch (e: any) {
|
|
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminUsers.loadFailed);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const applyFilters = () => {
|
|
clearKeywordSearchTimer();
|
|
page.value = 1;
|
|
loadUsers();
|
|
};
|
|
|
|
const resetFilters = () => {
|
|
searchKeyword.value = "";
|
|
statusFilter.value = "";
|
|
loginStatusFilter.value = "";
|
|
applyFilters();
|
|
};
|
|
|
|
const clearKeywordSearchTimer = () => {
|
|
if (!keywordSearchTimer) return;
|
|
clearTimeout(keywordSearchTimer);
|
|
keywordSearchTimer = null;
|
|
};
|
|
|
|
const scheduleKeywordSearch = () => {
|
|
clearKeywordSearchTimer();
|
|
keywordSearchTimer = setTimeout(() => {
|
|
keywordSearchTimer = null;
|
|
page.value = 1;
|
|
loadUsers();
|
|
}, 300);
|
|
};
|
|
|
|
watch(searchKeyword, () => {
|
|
scheduleKeywordSearch();
|
|
}, { flush: "sync" });
|
|
|
|
const onPageSizeChange = (size: number) => {
|
|
pageSize.value = size;
|
|
page.value = 1;
|
|
loadUsers();
|
|
};
|
|
|
|
const openCreate = () => {
|
|
editingUser.value = null;
|
|
formVisible.value = true;
|
|
};
|
|
|
|
const openEdit = (row: UserInfo) => {
|
|
editingUser.value = row;
|
|
formVisible.value = true;
|
|
};
|
|
|
|
const toggleStatus = async (row: UserInfo) => {
|
|
if (isLastAdmin(row)) {
|
|
ElMessage.warning(TEXT.modules.adminUsers.keepAdminWarning);
|
|
return;
|
|
}
|
|
const disable = row.status === "ACTIVE";
|
|
const ok = await ElMessageBox.confirm(
|
|
disable ? TEXT.modules.adminUsers.disableConfirm : TEXT.modules.adminUsers.enableConfirm,
|
|
disable ? TEXT.modules.adminUsers.disableTitle : TEXT.modules.adminUsers.enableTitle,
|
|
{
|
|
type: disable ? "warning" : "info",
|
|
confirmButtonText: TEXT.common.actions.confirm,
|
|
cancelButtonText: TEXT.common.actions.cancel,
|
|
}
|
|
).catch(() => null);
|
|
if (!ok) return;
|
|
try {
|
|
await updateUser(row.id, { status: disable ? "DISABLED" : "ACTIVE", is_active: !disable });
|
|
ElMessage.success(disable ? TEXT.modules.adminUsers.disableSuccess : TEXT.modules.adminUsers.enableSuccess);
|
|
loadUsers();
|
|
} catch (e: any) {
|
|
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.actionFailed);
|
|
}
|
|
};
|
|
|
|
const openReset = (row: UserInfo) => {
|
|
resetUser.value = row;
|
|
resetVisible.value = true;
|
|
};
|
|
|
|
const openLoginActivities = (row: UserInfo) => {
|
|
loginActivitiesUser.value = row;
|
|
loginActivitiesVisible.value = true;
|
|
};
|
|
|
|
const onDelete = async (row: UserInfo) => {
|
|
const { action } = await ElMessageBox.prompt(
|
|
TEXT.modules.adminUsers.deleteConfirm,
|
|
TEXT.modules.adminUsers.deleteTitle,
|
|
{
|
|
inputPlaceholder: TEXT.modules.adminUsers.deleteConfirmPlaceholder,
|
|
confirmButtonText: TEXT.common.actions.confirm,
|
|
confirmButtonClass: "el-button--danger",
|
|
cancelButtonText: TEXT.common.actions.cancel,
|
|
inputPattern: /^确认删除$/,
|
|
inputErrorMessage: TEXT.modules.adminUsers.deleteConfirmInputError,
|
|
}
|
|
).catch(() => ({ action: "cancel" }));
|
|
if (action !== "confirm") return;
|
|
try {
|
|
await deleteUser(row.id);
|
|
ElMessage.success(TEXT.modules.adminUsers.deleteSuccess);
|
|
loadUsers();
|
|
} catch (e: any) {
|
|
const detail = e?.response?.data?.detail || e?.response?.data?.message;
|
|
if (detail && detail.includes(TEXT.modules.adminProjectMembers.memberLabel)) {
|
|
ElMessage.closeAll();
|
|
const blockingProjects = await findUserProjects(row.id);
|
|
if (blockingProjects.length > 0) {
|
|
ElMessage.error(`${detail},该用户仍在以下项目中:${blockingProjects.join("、")}`);
|
|
} else {
|
|
ElMessage.error(detail);
|
|
}
|
|
} else {
|
|
ElMessage.error(detail || TEXT.modules.adminUsers.deleteFailed);
|
|
}
|
|
}
|
|
};
|
|
|
|
const findUserProjects = async (userId: string) => {
|
|
try {
|
|
const { data } = await fetchStudies();
|
|
const studies = Array.isArray(data) ? data : (data as any).items || [];
|
|
const blocking: string[] = [];
|
|
await Promise.all(studies.map(async (study: any) => {
|
|
try {
|
|
const { data: membersData } = await listMembers(study.id, { limit: 1000, include_inactive: true });
|
|
const members = Array.isArray(membersData) ? membersData : (membersData as any).items || [];
|
|
if (members.some((m: any) => m.user_id === userId)) {
|
|
blocking.push(study.name);
|
|
}
|
|
} catch {
|
|
// skip
|
|
}
|
|
}));
|
|
return blocking;
|
|
} catch {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
onMounted(() => {
|
|
loadUsers();
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
clearKeywordSearchTimer();
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.page,
|
|
.main-content-flat,
|
|
.user-table-section {
|
|
min-width: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.main-content-flat {
|
|
width: 100%;
|
|
max-width: 100%;
|
|
}
|
|
|
|
:global(.web-layout-container .content-wrapper:has(.users-page)) {
|
|
padding: 0;
|
|
}
|
|
|
|
.account-overview {
|
|
padding: 0;
|
|
border-bottom: 1px solid var(--unified-shell-divider);
|
|
background: linear-gradient(180deg, var(--ctms-bg-card) 0%, color-mix(in srgb, var(--ctms-neutral-100) 55%, var(--ctms-bg-card)) 100%);
|
|
}
|
|
|
|
.overview-heading {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
margin-bottom: 0;
|
|
padding: 8px 16px 6px;
|
|
}
|
|
|
|
.overview-heading h2 {
|
|
margin: 0;
|
|
color: var(--ctms-text-main);
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.overview-live {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 3px 8px;
|
|
border: 1px solid rgba(34, 166, 99, 0.18);
|
|
border-radius: 999px;
|
|
background: rgba(34, 166, 99, 0.07);
|
|
color: #188454;
|
|
font-size: 11px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.overview-live i {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background: #22a663;
|
|
box-shadow: 0 0 0 3px rgba(34, 166, 99, 0.12);
|
|
}
|
|
|
|
.stats-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 0;
|
|
overflow: hidden;
|
|
border-top: 1px solid var(--unified-shell-divider);
|
|
border-right: 0;
|
|
border-bottom: 0;
|
|
border-left: 0;
|
|
border-radius: 0;
|
|
background: var(--ctms-bg-card);
|
|
box-shadow: none;
|
|
}
|
|
|
|
.stat-card {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
min-height: 48px;
|
|
padding: 6px 14px;
|
|
background: transparent;
|
|
width: 100%;
|
|
}
|
|
|
|
.stat-card + .stat-card {
|
|
border-left: 1px solid var(--unified-shell-divider);
|
|
}
|
|
|
|
.stat-icon {
|
|
width: 30px;
|
|
height: 30px;
|
|
border-radius: 8px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.stat-icon svg {
|
|
width: 17px;
|
|
height: 17px;
|
|
}
|
|
|
|
.stat-card--total .stat-icon {
|
|
background: #e8edf3;
|
|
color: var(--ctms-primary);
|
|
}
|
|
|
|
.stat-card--active .stat-icon {
|
|
background: #e6f4ed;
|
|
color: var(--ctms-success);
|
|
}
|
|
|
|
.stat-card--disabled .stat-icon {
|
|
background: #fde8e8;
|
|
color: var(--ctms-danger);
|
|
}
|
|
|
|
.stat-card--online .stat-icon {
|
|
background: #e8f6ef;
|
|
color: #1f9d63;
|
|
}
|
|
|
|
.stat-body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
/* 筛选栏 */
|
|
.filter-container {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 8;
|
|
background: color-mix(in srgb, var(--ctms-bg-card) 94%, transparent);
|
|
box-shadow: 0 5px 14px rgba(15, 23, 42, 0.05);
|
|
backdrop-filter: blur(12px);
|
|
}
|
|
|
|
.filter-form {
|
|
display: flex;
|
|
width: 100%;
|
|
gap: 10px;
|
|
align-items: center;
|
|
min-width: 0;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.filter-heading {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
color: var(--ctms-text-secondary);
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.filter-item-form {
|
|
margin-bottom: 0 !important;
|
|
margin-right: 0 !important;
|
|
min-width: 0;
|
|
}
|
|
|
|
.filter-item-form--search {
|
|
flex: 0 1 320px;
|
|
}
|
|
|
|
.filter-input-comp {
|
|
width: 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;
|
|
}
|
|
|
|
/* 用户单元格 */
|
|
.user-cell {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.user-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: 0;
|
|
}
|
|
|
|
.user-name {
|
|
font-weight: 600;
|
|
font-size: 12px;
|
|
color: var(--ctms-text-main);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.user-email {
|
|
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;
|
|
width: 7px;
|
|
height: 7px;
|
|
border-radius: 50%;
|
|
margin-right: 6px;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.status-dot.dot--active {
|
|
background: var(--ctms-success);
|
|
box-shadow: 0 0 0 3px rgba(63, 143, 107, 0.15);
|
|
}
|
|
|
|
.status-dot.dot--disabled {
|
|
background: var(--ctms-text-disabled);
|
|
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.15);
|
|
}
|
|
|
|
.status-dot.dot--online {
|
|
background: #22a663;
|
|
box-shadow: 0 0 0 3px rgba(34, 166, 99, 0.15);
|
|
}
|
|
|
|
.status-dot.dot--offline {
|
|
background: #94a3b8;
|
|
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.15);
|
|
}
|
|
|
|
.login-status-cell,
|
|
.login-time-cell {
|
|
display: grid;
|
|
grid-template-columns: auto minmax(0, 1fr);
|
|
align-items: center;
|
|
column-gap: 6px;
|
|
color: var(--ctms-text-main);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.login-status-cell small,
|
|
.login-time-cell small {
|
|
grid-column: 2;
|
|
margin-top: 0;
|
|
color: var(--ctms-text-secondary);
|
|
font-size: 10px;
|
|
}
|
|
|
|
.login-time-cell {
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.login-time-cell small {
|
|
grid-column: 1;
|
|
}
|
|
|
|
.text-muted {
|
|
color: var(--ctms-text-secondary);
|
|
font-size: 12px;
|
|
}
|
|
|
|
/* 操作按钮 */
|
|
.action-row {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 4px;
|
|
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);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.action-btn {
|
|
width: 26px;
|
|
height: 26px;
|
|
min-height: 26px;
|
|
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;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.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 !important;
|
|
max-width: 100%;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
.user-table :deep(.el-table__inner-wrapper::before) {
|
|
display: none;
|
|
}
|
|
|
|
.user-table :deep(.el-scrollbar__bar.is-horizontal) {
|
|
display: none;
|
|
}
|
|
|
|
.user-table :deep(th.el-table__cell) {
|
|
padding-top: 7px;
|
|
padding-bottom: 7px;
|
|
}
|
|
|
|
.user-table :deep(td.el-table__cell) {
|
|
padding-top: 5px;
|
|
padding-bottom: 5px;
|
|
}
|
|
|
|
.user-table :deep(.cell) {
|
|
line-height: 1.25;
|
|
}
|
|
|
|
.pagination-wrap {
|
|
margin-top: 10px;
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
padding: 8px 16px;
|
|
max-width: 100%;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.pagination-wrap :deep(.el-pagination) {
|
|
min-width: 0;
|
|
flex-wrap: wrap;
|
|
justify-content: flex-end;
|
|
row-gap: 6px;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.stats-row {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
|
|
.stat-card:nth-child(3) {
|
|
border-left: 0;
|
|
}
|
|
|
|
.stat-card:nth-child(n + 3) {
|
|
border-top: 1px solid var(--unified-shell-divider);
|
|
}
|
|
|
|
.filter-heading {
|
|
width: 100%;
|
|
}
|
|
|
|
.filter-spacer {
|
|
display: none;
|
|
}
|
|
|
|
.filter-item-form--search {
|
|
flex: 1 1 100%;
|
|
}
|
|
|
|
.filter-select-comp {
|
|
width: 100%;
|
|
}
|
|
|
|
.filter-item-form:not(.filter-item-form--search) {
|
|
flex: 1 1 calc(50% - 5px);
|
|
}
|
|
|
|
.filter-form > .el-button--primary {
|
|
width: 100%;
|
|
}
|
|
}
|
|
</style>
|