Files
ctms/frontend/src/views/admin/Users.vue
T

327 lines
11 KiB
Vue

<template>
<div class="page">
<el-card shadow="never" class="main-content-card unified-shell">
<div class="filter-container unified-action-bar">
<div class="filter-form">
<el-form-item label="" class="filter-item-form">
<el-input
v-model="searchKeyword"
:placeholder="TEXT.common.placeholders.keyword"
clearable
class="filter-input-comp"
/>
</el-form-item>
<el-form-item label="" class="filter-item-form">
<el-select v-model="statusFilter" :placeholder="TEXT.common.fields.status" style="width: 140px" clearable class="filter-select-comp" @change="loadUsers">
<el-option :label="TEXT.enums.userStatus.PENDING" value="PENDING" />
<el-option :label="TEXT.enums.userStatus.ACTIVE" value="ACTIVE" />
<el-option :label="TEXT.enums.userStatus.REJECTED" value="REJECTED" />
<el-option :label="TEXT.enums.userStatus.DISABLED" value="DISABLED" />
</el-select>
</el-form-item>
<el-form-item class="filter-item-form">
<el-button @click="loadUsers">{{ TEXT.common.actions.search }}</el-button>
</el-form-item>
<div class="filter-spacer"></div>
<el-button type="primary" @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" stripe v-loading="loading" style="width: 100%">
<el-table-column prop="email" :label="TEXT.common.fields.email" min-width="200" />
<el-table-column prop="full_name" :label="TEXT.common.fields.name" min-width="140" />
<el-table-column prop="department" :label="TEXT.common.fields.department" min-width="140" />
<el-table-column :label="TEXT.common.fields.status" width="140">
<template #default="scope">
<el-tag :type="statusType(scope.row.status)">
{{ statusLabel(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" width="200">
<template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" min-width="200" align="center">
<template #default="scope">
<el-tooltip :content="TEXT.common.actions.edit" placement="top">
<el-button link type="primary" :icon="Edit" @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"
: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-tooltip>
<el-tooltip :content="TEXT.common.actions.delete" placement="top">
<el-button
link
type="danger"
:icon="Delete"
:disabled="scope.row.id === auth.user?.id"
@click="onDelete(scope.row)"
/>
</el-tooltip>
</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>
</el-card>
<UserForm v-model:visible="formVisible" :user="editingUser" :admin-count="activeAdminCount" @saved="loadUsers" />
<UserResetPassword v-model:visible="resetVisible" :user="resetUser" @reset="loadUsers" />
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Edit, Delete, Key, Lock, Unlock } 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 { useAuthStore } from "../../store/auth";
import { displayDateTime } from "../../utils/display";
import { TEXT } from "../../locales";
const users = 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 editingUser = ref<UserInfo | null>(null);
const resetUser = ref<UserInfo | null>(null);
const auth = useAuthStore();
const statusType = (status: string) => {
switch (status) {
case "ACTIVE":
return "success";
case "PENDING":
return "warning";
case "REJECTED":
return "danger";
default:
return "info";
}
};
const statusLabel = (status: string) => {
switch (status) {
case "ACTIVE":
return TEXT.enums.userStatus.ACTIVE;
case "PENDING":
return TEXT.enums.userStatus.PENDING;
case "REJECTED":
return TEXT.enums.userStatus.REJECTED;
case "DISABLED":
return TEXT.enums.userStatus.DISABLED;
default:
return status || TEXT.common.fallback;
}
};
const isLastAdmin = (row: UserInfo) =>
row.role === "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,
}),
fetchUsers({ skip: 0, limit: 10000 }),
]);
const items = (data as any).items || [];
const allItems = (adminData as any).items || [];
users.value = items;
total.value = (data as any).total || items.length;
activeAdminCount.value = allItems.filter((u: UserInfo) => u.role === "ADMIN" && u.status === "ACTIVE").length;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminUsers.loadFailed);
} finally {
loading.value = false;
}
};
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 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[] = [];
// Check each study for membership
// We run these in parallel but limited batch might be better if many studies.
// For now assuming reasonable number of studies.
await Promise.all(studies.map(async (study: any) => {
try {
const { data: membersData } = await listMembers(study.id, { limit: 1000, include_inactive: true }); // Large limit to catch all
const members = Array.isArray(membersData) ? membersData : (membersData as any).items || [];
if (members.some((m: any) => m.user_id === userId)) {
blocking.push(study.name);
}
} catch {
// Ignore errors checking specific study
}
}));
return blocking;
} catch {
return [];
}
};
onMounted(() => {
loadUsers();
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
}
.filter-form {
display: flex;
width: 100%;
gap: 12px;
align-items: center;
}
.filter-item-form {
margin-bottom: 0 !important;
margin-right: 0 !important;
}
.filter-input-comp {
width: 200px;
}
.filter-spacer {
flex: 1;
}
.user-table-section {
padding-top: 12px;
padding-bottom: 12px;
}
.pagination-wrap {
margin-top: 14px;
display: flex;
justify-content: flex-end;
}
</style>