327 lines
11 KiB
Vue
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>
|