移除用户系统级角色,权限完全迁移至项目级管理
- 用户注册/创建不再需要选择角色,账号管理只管资料和状态 - 移除前后端所有系统级 role 字段的暴露,改用 is_admin 标识管理员 - PM(项目负责人)角色自动拥有全部项目权限且不可修改 - 系统管理员在项目中的成员身份不可被删除 - 管理界面全面美化 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -138,7 +138,7 @@ const canReply = computed(() => {
|
||||
|
||||
const canDeleteReply = (reply: any) => {
|
||||
if (reply.is_deleted) return false;
|
||||
if (auth.user?.role === "ADMIN") return true;
|
||||
if (auth.user?.is_admin) return true;
|
||||
if (reply.created_by === auth.user?.id) return true;
|
||||
return item.value?.study_id ? can("faq.edit") : false;
|
||||
};
|
||||
@@ -150,7 +150,7 @@ const canSelectBest = computed(() => {
|
||||
|
||||
const canEditQuestion = computed(() => {
|
||||
if (!item.value) return false;
|
||||
if (auth.user?.role === "ADMIN") return true;
|
||||
if (auth.user?.is_admin) return true;
|
||||
if (item.value.created_by === auth.user?.id) return true;
|
||||
return can("faq.edit");
|
||||
});
|
||||
@@ -158,7 +158,7 @@ const canEditQuestion = computed(() => {
|
||||
const canConfirmResolved = computed(() => {
|
||||
if (!item.value) return false;
|
||||
if (item.value.status === "RESOLVED") return false;
|
||||
if (auth.user?.role === "ADMIN") return true;
|
||||
if (auth.user?.is_admin) return true;
|
||||
if (item.value.created_by === auth.user?.id) return true;
|
||||
return can("faq.edit");
|
||||
});
|
||||
|
||||
@@ -240,7 +240,7 @@ const onSubmit = async () => {
|
||||
await tryStoreBrowserCredential(form.email, form.password);
|
||||
const studyStore = useStudyStore();
|
||||
if (!studyStore.currentStudy) {
|
||||
if (auth.user?.role === "ADMIN") {
|
||||
if (auth.user?.is_admin) {
|
||||
await studyStore.ensureDefaultActiveStudy();
|
||||
} else {
|
||||
await studyStore.ensureDefaultStudy();
|
||||
@@ -248,7 +248,7 @@ const onSubmit = async () => {
|
||||
}
|
||||
if (studyStore.currentStudy) {
|
||||
router.push("/project/overview");
|
||||
} else if (auth.user?.role === "ADMIN") {
|
||||
} else if (auth.user?.is_admin) {
|
||||
router.push("/admin/users");
|
||||
} else {
|
||||
router.push("/workbench");
|
||||
|
||||
@@ -337,7 +337,6 @@ import type { RegisterRequest } from "../types/api";
|
||||
import { TEXT, requiredMessage } from "../locales";
|
||||
import { privacyPolicySections, serviceTermsSections } from "../content/authProtocol";
|
||||
|
||||
const defaultRole: RegisterRequest["role"] = "CRA";
|
||||
type ProtocolType = "terms" | "privacy";
|
||||
|
||||
// 步骤控制
|
||||
@@ -509,7 +508,6 @@ const handleSubmit = async () => {
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
full_name: form.full_name,
|
||||
role: defaultRole,
|
||||
clinical_department: form.clinical_department,
|
||||
});
|
||||
registrationSubmitted.value = true;
|
||||
|
||||
@@ -1,58 +1,91 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page page--flush">
|
||||
<div class="main-content-flat unified-shell">
|
||||
<div class="filter-container unified-action-bar">
|
||||
<!-- 顶部状态切换 -->
|
||||
<div class="approval-header unified-action-bar">
|
||||
<div class="filter-form">
|
||||
<el-tag type="info" effect="plain" class="filter-label-tag">{{ TEXT.common.fields.status }}</el-tag>
|
||||
<el-select v-model="status" style="width: 140px" @change="loadUsers" class="filter-select-comp">
|
||||
<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>
|
||||
<div class="filter-spacer"></div>
|
||||
<div class="status-tabs">
|
||||
<button
|
||||
v-for="s in statusOptions"
|
||||
:key="s.value"
|
||||
class="status-tab"
|
||||
:class="{ active: status === s.value }"
|
||||
@click="status = s.value; loadUsers()"
|
||||
>
|
||||
<span class="tab-dot" :class="'dot--' + s.value.toLowerCase()"></span>
|
||||
{{ s.label }}
|
||||
<span v-if="s.value === 'PENDING' && pendingCount > 0" class="tab-badge">{{ pendingCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="unified-section approval-table-section">
|
||||
<el-table :data="users" v-loading="loading" class="approval-table" style="width: 100%" table-layout="fixed">
|
||||
<el-table-column prop="email" :label="TEXT.common.fields.email" show-overflow-tooltip />
|
||||
<el-table-column prop="full_name" :label="TEXT.common.fields.name" show-overflow-tooltip />
|
||||
<el-table-column prop="role" :label="TEXT.common.labels.role">
|
||||
<template #default="scope">{{ displayEnum(TEXT.enums.userRole, scope.row.role) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="clinical_department" :label="TEXT.common.fields.clinicalDepartment" show-overflow-tooltip />
|
||||
<el-table-column prop="created_at" :label="TEXT.modules.adminUserApproval.createdAt" show-overflow-tooltip>
|
||||
<template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status">
|
||||
<template #default="scope">
|
||||
<el-tag :type="statusType(scope.row.status)">{{ displayEnum(TEXT.enums.userStatus, scope.row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="140" align="center">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
v-if="scope.row.status === 'PENDING'"
|
||||
type="success"
|
||||
link
|
||||
size="small"
|
||||
@click="onApprove(scope.row.id)"
|
||||
>
|
||||
{{ TEXT.modules.adminUserApproval.approve }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="scope.row.status === 'PENDING'"
|
||||
type="danger"
|
||||
link
|
||||
size="small"
|
||||
@click="onReject(scope.row.id)"
|
||||
>
|
||||
{{ TEXT.modules.adminUserApproval.reject }}
|
||||
</el-button>
|
||||
<span v-else>{{ TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="approval-content">
|
||||
<!-- 空状态 -->
|
||||
<div v-if="!loading && users.length === 0" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="empty-title">{{ status === 'PENDING' ? '暂无待审批用户' : '暂无数据' }}</p>
|
||||
<p class="empty-desc">{{ status === 'PENDING' ? '所有注册申请已处理完毕' : '当前筛选条件下没有用户' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 用户卡片列表 -->
|
||||
<div v-else class="user-cards" v-loading="loading">
|
||||
<div v-for="user in users" :key="user.id" class="user-card" :class="'card--' + (user.status || '').toLowerCase()">
|
||||
<div class="card-main">
|
||||
<div class="card-avatar" :class="'avatar--' + (user.status || '').toLowerCase()">
|
||||
{{ getInitials(user.full_name) }}
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<div class="card-name">{{ user.full_name }}</div>
|
||||
<div class="card-email">{{ user.email }}</div>
|
||||
<div class="card-meta">
|
||||
<span class="meta-item" v-if="user.clinical_department">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12">
|
||||
<path d="M19 21V5a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-1 4h1"/>
|
||||
</svg>
|
||||
{{ user.clinical_department }}
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>
|
||||
{{ displayDateTime(user.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<template v-if="user.status === 'PENDING'">
|
||||
<el-button type="success" @click="onApprove(user.id)" class="action-btn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="14" height="14">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
{{ TEXT.modules.adminUserApproval.approve }}
|
||||
</el-button>
|
||||
<el-button type="danger" plain @click="onReject(user.id)" class="action-btn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="14" height="14">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
{{ TEXT.modules.adminUserApproval.reject }}
|
||||
</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-tag :type="statusType(user.status)" effect="light" class="status-tag">
|
||||
{{ displayEnum(TEXT.enums.userStatus, user.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,17 +102,28 @@ import { TEXT } from "../../locales";
|
||||
const users = ref<UserInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
const status = ref<UserStatus>("PENDING");
|
||||
const pendingCount = ref(0);
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "PENDING" as UserStatus, label: TEXT.enums.userStatus.PENDING },
|
||||
{ value: "ACTIVE" as UserStatus, label: TEXT.enums.userStatus.ACTIVE },
|
||||
{ value: "REJECTED" as UserStatus, label: TEXT.enums.userStatus.REJECTED },
|
||||
{ value: "DISABLED" as UserStatus, label: TEXT.enums.userStatus.DISABLED },
|
||||
];
|
||||
|
||||
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 statusType = (s: UserStatus) => {
|
||||
switch (s) {
|
||||
case "ACTIVE":
|
||||
return "success";
|
||||
case "PENDING":
|
||||
return "warning";
|
||||
case "REJECTED":
|
||||
return "danger";
|
||||
default:
|
||||
return "info";
|
||||
case "ACTIVE": return "success";
|
||||
case "PENDING": return "warning";
|
||||
case "REJECTED": return "danger";
|
||||
default: return "info";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -88,6 +132,9 @@ const loadUsers = async () => {
|
||||
try {
|
||||
const { data } = await listPendingUsers(status.value);
|
||||
users.value = data.items || [];
|
||||
if (status.value === "PENDING") {
|
||||
pendingCount.value = users.value.length;
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.response?.data?.message || TEXT.modules.adminUserApproval.loadFailed);
|
||||
} finally {
|
||||
@@ -95,11 +142,21 @@ const loadUsers = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadPendingCount = async () => {
|
||||
try {
|
||||
const { data } = await listPendingUsers("PENDING");
|
||||
pendingCount.value = (data.items || []).length;
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
};
|
||||
|
||||
const onApprove = async (id: string) => {
|
||||
try {
|
||||
await approveUser(id);
|
||||
ElMessage.success(TEXT.modules.adminUserApproval.approveSuccess);
|
||||
loadUsers();
|
||||
loadPendingCount();
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.response?.data?.message || TEXT.modules.adminUserApproval.approveFailed);
|
||||
}
|
||||
@@ -110,6 +167,7 @@ const onReject = async (id: string) => {
|
||||
await rejectUser(id);
|
||||
ElMessage.success(TEXT.modules.adminUserApproval.rejectSuccess);
|
||||
loadUsers();
|
||||
loadPendingCount();
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.response?.data?.message || TEXT.modules.adminUserApproval.rejectFailed);
|
||||
}
|
||||
@@ -117,53 +175,231 @@ const onReject = async (id: string) => {
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers();
|
||||
if (status.value !== "PENDING") {
|
||||
loadPendingCount();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
.approval-header {
|
||||
border-bottom: 1px solid var(--unified-shell-divider);
|
||||
}
|
||||
|
||||
.status-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ctms-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: var(--ctms-transition);
|
||||
}
|
||||
|
||||
.status-tab:hover {
|
||||
background: var(--ctms-neutral-100);
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.status-tab.active {
|
||||
background: var(--ctms-primary-light);
|
||||
color: var(--ctms-primary);
|
||||
}
|
||||
|
||||
.tab-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.tab-dot.dot--pending { color: var(--ctms-warning); }
|
||||
.tab-dot.dot--active { color: var(--ctms-success); }
|
||||
.tab-dot.dot--rejected { color: var(--ctms-danger); }
|
||||
.tab-dot.dot--disabled { color: var(--ctms-text-disabled); }
|
||||
|
||||
.tab-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9px;
|
||||
background: var(--ctms-danger);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.approval-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.main-content-flat {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.main-content-flat :deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.filter-label-tag {
|
||||
border: none;
|
||||
background: transparent;
|
||||
.empty-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 16px;
|
||||
background: #e6f4ed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-icon svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--ctms-success);
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--ctms-text-main);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
padding: 0;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
/* 用户卡片 */
|
||||
.user-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
background: #fff;
|
||||
transition: var(--ctms-transition);
|
||||
}
|
||||
|
||||
.user-card:hover {
|
||||
border-color: var(--ctms-border-color-hover);
|
||||
box-shadow: var(--ctms-shadow-sm);
|
||||
}
|
||||
|
||||
.user-card.card--pending {
|
||||
border-left: 3px solid var(--ctms-warning);
|
||||
}
|
||||
|
||||
.card-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.approval-table-section {
|
||||
padding: 0;
|
||||
.card-avatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
background: var(--ctms-primary);
|
||||
}
|
||||
|
||||
.approval-table :deep(.el-table__inner-wrapper::before) {
|
||||
display: none;
|
||||
.card-avatar.avatar--active { background: linear-gradient(135deg, #3f8f6b, #2d7a5a); }
|
||||
.card-avatar.avatar--pending { background: linear-gradient(135deg, #c58b2a, #a87420); }
|
||||
.card-avatar.avatar--disabled { background: linear-gradient(135deg, #94a3b8, #64748b); }
|
||||
.card-avatar.avatar--rejected { background: linear-gradient(135deg, #c24b4b, #a83a3a); }
|
||||
|
||||
.card-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.card-email {
|
||||
font-size: 12px;
|
||||
color: var(--ctms-text-secondary);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.meta-item svg {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.user-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.card-actions {
|
||||
margin-left: 56px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,28 +1,56 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page page--flush">
|
||||
<div class="main-content-flat unified-shell">
|
||||
<div class="audit-toolbar">
|
||||
<!-- 统计概览 -->
|
||||
<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>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-value">{{ total }}</span>
|
||||
<span class="stat-label">日志总数</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card--success">
|
||||
<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">{{ successCount }}</span>
|
||||
<span class="stat-label">{{ TEXT.audit.resultSuccess }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card--fail">
|
||||
<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="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-value">{{ failCount }}</span>
|
||||
<span class="stat-label">{{ TEXT.audit.resultFail }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card--operators">
|
||||
<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">{{ operatorCount }}</span>
|
||||
<span class="stat-label">操作人数</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="audit-toolbar unified-action-bar">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<div class="filter-item-form">
|
||||
<el-select
|
||||
v-model="filters.eventType"
|
||||
clearable
|
||||
:placeholder="TEXT.modules.adminAuditLogs.filterEvent"
|
||||
@change="onServerFilterChange"
|
||||
class="filter-select-comp"
|
||||
>
|
||||
<el-select v-model="filters.eventType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEvent" @change="onServerFilterChange" class="filter-select-comp">
|
||||
<el-option v-for="opt in eventTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-item-form">
|
||||
<el-select
|
||||
v-model="filters.operatorId"
|
||||
clearable
|
||||
:placeholder="TEXT.modules.adminAuditLogs.filterOperator"
|
||||
filterable
|
||||
@change="onServerFilterChange"
|
||||
class="filter-select-comp"
|
||||
>
|
||||
<el-select v-model="filters.operatorId" clearable :placeholder="TEXT.modules.adminAuditLogs.filterOperator" filterable @change="onServerFilterChange" class="filter-select-comp">
|
||||
<el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
@@ -48,6 +76,7 @@
|
||||
<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>
|
||||
@@ -59,46 +88,56 @@
|
||||
</el-dropdown>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 日志表格 -->
|
||||
<div class="unified-section table-section">
|
||||
<el-table :data="logs" v-loading="loading" class="audit-table" style="width: 100%" stripe table-layout="fixed">
|
||||
<el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" align="center" show-overflow-tooltip>
|
||||
<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="155">
|
||||
<template #default="scope">
|
||||
<span class="text-gray-500">{{ displayDateTime(scope.row.timestamp) }}</span>
|
||||
<span class="time-text">{{ displayDateTime(scope.row.timestamp) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" show-overflow-tooltip />
|
||||
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" show-overflow-tooltip>
|
||||
<el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="130">
|
||||
<template #default="scope">
|
||||
<el-tag effect="plain">{{ scope.row.eventLabel }}</el-tag>
|
||||
<div class="actor-cell">
|
||||
<div class="actor-avatar">{{ getInitials(scope.row.actorName) }}</div>
|
||||
<span class="actor-name">{{ scope.row.actorName }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" width="140">
|
||||
<template #default="scope">
|
||||
<span class="event-tag">{{ scope.row.eventLabel }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="actionText" :label="TEXT.modules.adminAuditLogs.columns.action" show-overflow-tooltip />
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" show-overflow-tooltip>
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.targetTypeLabel">
|
||||
<strong>{{ scope.row.targetTypeLabel }}</strong>
|
||||
<span class="text-gray-500" v-if="scope.row.targetName">:{{ scope.row.targetName }}</span>
|
||||
</span>
|
||||
<span v-else>{{ TEXT.common.fallback }}</span>
|
||||
<div class="target-cell">
|
||||
<span v-if="scope.row.targetTypeLabel" class="target-text">
|
||||
<strong>{{ scope.row.targetTypeLabel }}</strong><span v-if="scope.row.targetName" class="text-muted">:{{ scope.row.targetName }}</span>
|
||||
</span>
|
||||
<span v-else class="text-muted">{{ TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff" show-overflow-tooltip>
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff">
|
||||
<template #default="scope">
|
||||
<div v-if="scope.row.diffText?.length" class="diff-container">
|
||||
<div v-for="(line, idx) in getDiffPreview(scope.row)" :key="idx" class="diff-line">{{ line }}</div>
|
||||
<div v-for="(line, idx) in getDiffPreview(scope.row)" :key="idx" class="diff-line">{{ formatDiffLine(line) }}</div>
|
||||
<div class="diff-more-row" v-if="scope.row.diffText.length > DIFF_PREVIEW_LIMIT">
|
||||
<span class="diff-more-text">其余 {{ scope.row.diffText.length - DIFF_PREVIEW_LIMIT }} 项</span>
|
||||
<el-button link type="primary" @click="openDetail(scope.row)">{{ TEXT.common.actions.view }}</el-button>
|
||||
<el-button link type="primary" size="small" @click="openDetail(scope.row)">{{ TEXT.common.actions.view }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">{{ TEXT.audit.emptyValue }}</span>
|
||||
<span v-else class="text-muted">{{ TEXT.audit.emptyValue }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="110" align="center">
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="80" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.result === 'SUCCESS' ? 'success' : 'danger'" effect="dark" size="small">
|
||||
<span class="result-badge" :class="scope.row.result === 'SUCCESS' ? 'result--success' : 'result--fail'">
|
||||
{{ scope.row.resultLabel }}
|
||||
</el-tag>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -117,43 +156,88 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<el-drawer
|
||||
v-if="detailVisible"
|
||||
v-model="detailVisible"
|
||||
direction="rtl"
|
||||
size="560px"
|
||||
size="580px"
|
||||
:close-on-click-modal="false"
|
||||
:show-close="false"
|
||||
class="audit-detail-drawer"
|
||||
>
|
||||
<template #header>
|
||||
<div class="drawer-header">
|
||||
<div class="drawer-title">审计日志详情</div>
|
||||
<el-button @click="detailVisible = false">{{ TEXT.common.actions.close }}</el-button>
|
||||
<div class="drawer-title-row">
|
||||
<div class="drawer-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="18" height="18"><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"/></svg>
|
||||
</div>
|
||||
<span class="drawer-title">审计详情</span>
|
||||
</div>
|
||||
<el-button @click="detailVisible = false" size="small">{{ TEXT.common.actions.close }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="selectedLog">
|
||||
<!-- 基本信息 -->
|
||||
<div class="detail-meta-grid">
|
||||
<div class="meta-item"><span class="meta-label">时间</span><span>{{ displayDateTime(selectedLog.timestamp) }}</span></div>
|
||||
<div class="meta-item"><span class="meta-label">操作人</span><span>{{ selectedLog.actorName || TEXT.common.fallback }}</span></div>
|
||||
<div class="meta-item"><span class="meta-label">事件</span><span>{{ selectedLog.eventLabel || TEXT.common.fallback }}</span></div>
|
||||
<div class="meta-item"><span class="meta-label">结果</span><span>{{ selectedLog.resultLabel || TEXT.common.fallback }}</span></div>
|
||||
<div class="meta-item meta-item-full">
|
||||
<span class="meta-label">对象</span>
|
||||
<span>{{ selectedLog.targetTypeLabel || TEXT.common.fallback }}<span v-if="selectedLog.targetName">:{{ selectedLog.targetName }}</span></span>
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">时间</span>
|
||||
<span class="meta-value">{{ displayDateTime(selectedLog.timestamp) }}</span>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">操作人</span>
|
||||
<span class="meta-value">{{ selectedLog.actorName || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">事件</span>
|
||||
<span class="meta-value">{{ selectedLog.eventLabel || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">结果</span>
|
||||
<span class="meta-value">
|
||||
<span class="result-badge" :class="selectedLog.result === 'SUCCESS' ? 'result--success' : 'result--fail'">{{ selectedLog.resultLabel }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="meta-item meta-item-full"><span class="meta-label">动作</span><span>{{ selectedLog.actionText || TEXT.common.fallback }}</span></div>
|
||||
</div>
|
||||
<div class="detail-meta-grid detail-meta-full">
|
||||
<div class="meta-card meta-card-full">
|
||||
<span class="meta-label">对象</span>
|
||||
<span class="meta-value">{{ selectedLog.targetTypeLabel || TEXT.common.fallback }}<span v-if="selectedLog.targetName">:{{ selectedLog.targetName }}</span></span>
|
||||
</div>
|
||||
<div class="meta-card meta-card-full">
|
||||
<span class="meta-label">动作</span>
|
||||
<span class="meta-value">{{ selectedLog.actionText || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 变更明细 -->
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title">变更明细</div>
|
||||
<el-scrollbar max-height="58vh">
|
||||
<div class="detail-section-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
变更明细
|
||||
<span class="diff-count" v-if="selectedLog.diffText?.length">{{ selectedLog.diffText.length }} 条</span>
|
||||
</div>
|
||||
<el-scrollbar max-height="55vh">
|
||||
<div v-if="selectedLog.diffText?.length" class="detail-lines">
|
||||
<div v-for="(line, idx) in selectedLog.diffText" :key="idx" class="detail-line-row">
|
||||
<span class="detail-line-index">{{ Number(idx) + 1 }}.</span>
|
||||
<span class="detail-line-text">{{ line }}</span>
|
||||
<span class="detail-line-index">{{ Number(idx) + 1 }}</span>
|
||||
<div class="detail-line-content">
|
||||
<template v-if="parseDiffLine(formatDiffLine(line))">
|
||||
<span class="diff-field">{{ parseDiffLine(formatDiffLine(line))!.field }}</span>
|
||||
<div class="diff-values">
|
||||
<span class="diff-old">{{ parseDiffLine(formatDiffLine(line))!.oldVal }}</span>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12" class="diff-arrow"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
|
||||
<span class="diff-new">{{ parseDiffLine(formatDiffLine(line))!.newVal }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else class="detail-line-text">{{ formatDiffLine(line) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-gray-400">{{ TEXT.audit.emptyValue }}</div>
|
||||
<div v-else class="empty-diff">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32" style="color:var(--ctms-text-disabled)"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<span class="text-muted">无变更记录</span>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
@@ -204,6 +288,33 @@ const filters = ref({
|
||||
range: [],
|
||||
});
|
||||
|
||||
const successCount = computed(() => allLogs.value.filter(l => (l.result || 'SUCCESS') === 'SUCCESS').length);
|
||||
const failCount = computed(() => allLogs.value.filter(l => l.result === 'FAIL').length);
|
||||
const operatorCount = computed(() => new Set(allLogs.value.map(l => l.actorId || l.actorName)).size);
|
||||
|
||||
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 formatDiffLine = (line: any): string => {
|
||||
if (typeof line === 'string') return line;
|
||||
if (line === null || line === undefined) return '-';
|
||||
try {
|
||||
return JSON.stringify(line, null, 0);
|
||||
} catch {
|
||||
return String(line);
|
||||
}
|
||||
};
|
||||
|
||||
const parseDiffLine = (line: string): { field: string; oldVal: string; newVal: string } | null => {
|
||||
const match = line.match(/^(.+?):(.+?)\s*->\s*(.+)$/);
|
||||
if (!match) return null;
|
||||
return { field: match[1], oldVal: match[2], newVal: match[3] };
|
||||
};
|
||||
|
||||
const eventTypeOptions = Object.entries(auditDict).map(([value, cfg]) => ({
|
||||
value,
|
||||
label: cfg.label,
|
||||
@@ -242,7 +353,6 @@ const loadUsers = async () => {
|
||||
const { data } = await fetchUsers({ limit: 500 });
|
||||
users.value = Array.isArray(data) ? data : (data as any).items || [];
|
||||
} else if (study.currentStudy) {
|
||||
// 非管理员,尝试加载项目成员作为备选
|
||||
const { data } = await listMembers(study.currentStudy.id, { limit: 500 });
|
||||
const items = Array.isArray(data) ? data : (data as any).items || [];
|
||||
users.value = items.map((m: any) => ({
|
||||
@@ -421,11 +531,7 @@ const confirmExport = async (scope: "system" | "project") => {
|
||||
entity_type: "audit_log",
|
||||
entity_id: null,
|
||||
detail: JSON.stringify(
|
||||
{
|
||||
targetName: currentStudy.name,
|
||||
scope,
|
||||
exportedCount: events.length,
|
||||
},
|
||||
{ targetName: currentStudy.name, scope, exportedCount: events.length },
|
||||
null,
|
||||
0
|
||||
),
|
||||
@@ -444,31 +550,52 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
/* 统计卡片 */
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--unified-shell-divider);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
background: var(--ctms-neutral-100);
|
||||
transition: var(--ctms-transition);
|
||||
}
|
||||
|
||||
.main-content-flat {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
.stat-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--ctms-shadow-sm);
|
||||
}
|
||||
|
||||
.main-content-flat :deep(.el-card__body) {
|
||||
padding: 0;
|
||||
.stat-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
padding: 0;
|
||||
}
|
||||
.stat-icon svg { width: 20px; height: 20px; }
|
||||
.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; }
|
||||
|
||||
/* 筛选栏 */
|
||||
.audit-toolbar {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--unified-shell-divider);
|
||||
}
|
||||
|
||||
@@ -486,112 +613,195 @@ onMounted(async () => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filter-select-comp {
|
||||
width: 132px;
|
||||
}
|
||||
|
||||
.filter-select-result {
|
||||
width: 96px;
|
||||
}
|
||||
|
||||
.date-range-picker-comp {
|
||||
width: 248px !important;
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.audit-export-dropdown {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.filter-select-comp { width: 132px; }
|
||||
.filter-select-result { width: 96px; }
|
||||
.date-range-picker-comp { width: 248px !important; }
|
||||
.filter-spacer { flex: 1; }
|
||||
|
||||
.audit-export-dropdown { flex-shrink: 0; }
|
||||
.audit-export-btn {
|
||||
border-radius: 8px !important;
|
||||
font-weight: 600;
|
||||
height: 32px;
|
||||
color: #3b6cb5 !important;
|
||||
border-color: #b3cce6 !important;
|
||||
color: var(--ctms-primary) !important;
|
||||
border-color: #c0cdd7 !important;
|
||||
background: #f0f6ff !important;
|
||||
}
|
||||
|
||||
.audit-export-btn:hover,
|
||||
.audit-export-btn:focus {
|
||||
color: #2a5699 !important;
|
||||
border-color: #8cb4de !important;
|
||||
.audit-export-btn:hover {
|
||||
color: var(--ctms-primary-hover) !important;
|
||||
border-color: #9bb1c2 !important;
|
||||
background: #e2edfa !important;
|
||||
}
|
||||
|
||||
.diff-container {
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
line-height: 1.5;
|
||||
/* 表格 */
|
||||
.table-section { padding: 0; }
|
||||
.audit-table :deep(.el-table__inner-wrapper::before) { display: none; }
|
||||
|
||||
.time-text {
|
||||
font-size: 12px;
|
||||
color: var(--ctms-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
margin-bottom: 2px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.diff-more-row {
|
||||
.actor-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.diff-more-text {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
.actor-avatar {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 7px;
|
||||
background: linear-gradient(135deg, var(--ctms-primary), var(--ctms-primary-active));
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.actor-name {
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.event-tag {
|
||||
display: inline-flex;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: #f0f4f8;
|
||||
color: var(--ctms-text-regular);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.target-cell {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.text-muted { color: var(--ctms-text-secondary); font-size: 12px; }
|
||||
|
||||
.result-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.result--success { background: #e6f4ed; color: #166534; }
|
||||
.result--fail { background: #fde8e8; color: #991b1b; }
|
||||
|
||||
.diff-container {
|
||||
font-size: 12px;
|
||||
color: var(--ctms-text-regular);
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.diff-more-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.diff-more-text { font-size: 11px; color: var(--ctms-text-disabled); }
|
||||
|
||||
.pagination-wrap {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0 0 6px;
|
||||
}
|
||||
|
||||
.audit-table :deep(.el-table__inner-wrapper::before) {
|
||||
display: none;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
/* 详情抽屉 */
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.drawer-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.drawer-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 9px;
|
||||
background: var(--ctms-primary-light);
|
||||
color: var(--ctms-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
font-size: 20px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #11264d;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.detail-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px 14px;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
.detail-meta-full {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.meta-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
border: 1px solid #e6edf7;
|
||||
border-radius: 8px;
|
||||
background: #fbfdff;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
border-radius: 10px;
|
||||
background: var(--ctms-neutral-100);
|
||||
}
|
||||
|
||||
.meta-item-full {
|
||||
.meta-card-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
color: var(--ctms-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-main);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
@@ -599,34 +809,131 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.detail-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1e3a5f;
|
||||
margin-bottom: 8px;
|
||||
color: var(--ctms-text-main);
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--ctms-border-color);
|
||||
}
|
||||
|
||||
.detail-section-title svg {
|
||||
color: var(--ctms-primary);
|
||||
}
|
||||
|
||||
.diff-count {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--ctms-text-secondary);
|
||||
background: var(--ctms-neutral-100);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.detail-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.detail-line-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
background: #fff;
|
||||
transition: var(--ctms-transition);
|
||||
}
|
||||
|
||||
.detail-line-row:hover {
|
||||
border-color: var(--ctms-border-color-hover);
|
||||
box-shadow: var(--ctms-shadow-sm);
|
||||
}
|
||||
|
||||
.detail-line-index {
|
||||
color: var(--ctms-text-disabled);
|
||||
min-width: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
padding-top: 2px;
|
||||
background: var(--ctms-neutral-100);
|
||||
border-radius: 4px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.detail-line-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.diff-field {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ctms-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.diff-values {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.diff-old {
|
||||
font-size: 12px;
|
||||
color: var(--ctms-danger);
|
||||
background: #fef2f2;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
word-break: break-word;
|
||||
max-width: 45%;
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: rgba(194, 75, 75, 0.3);
|
||||
}
|
||||
|
||||
.diff-arrow {
|
||||
color: var(--ctms-text-disabled);
|
||||
flex-shrink: 0;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.diff-new {
|
||||
font-size: 12px;
|
||||
color: var(--ctms-success);
|
||||
background: #f0fdf4;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
word-break: break-word;
|
||||
max-width: 45%;
|
||||
}
|
||||
|
||||
.detail-line-text {
|
||||
color: var(--ctms-text-regular);
|
||||
word-break: break-word;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.detail-line-index {
|
||||
color: #94a3b8;
|
||||
min-width: 20px;
|
||||
.empty-diff {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
.detail-line-text {
|
||||
color: #475569;
|
||||
word-break: break-word;
|
||||
@media (max-width: 768px) {
|
||||
.stats-row { grid-template-columns: repeat(2, 1fr); }
|
||||
.filter-form { flex-wrap: wrap; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -310,13 +310,16 @@
|
||||
<!-- 右侧权限编辑区 -->
|
||||
<div class="role-editor-content">
|
||||
<template v-if="editingRole">
|
||||
<div v-if="editingRole === 'PM'" class="role-editor-notice">
|
||||
<el-alert title="项目负责人拥有全部权限,不可修改" type="info" :closable="false" show-icon />
|
||||
</div>
|
||||
<div class="role-editor-toolbar">
|
||||
<el-input v-model="roleEditorSearch" placeholder="搜索权限..." clearable style="width: 220px">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<div class="filter-spacer" />
|
||||
<el-button size="small" @click="roleEditorSelectAll(true)">全选</el-button>
|
||||
<el-button size="small" @click="roleEditorSelectAll(false)">全不选</el-button>
|
||||
<el-button size="small" :disabled="editingRole === 'PM'" @click="roleEditorSelectAll(true)">全选</el-button>
|
||||
<el-button size="small" :disabled="editingRole === 'PM'" @click="roleEditorSelectAll(false)">全不选</el-button>
|
||||
</div>
|
||||
<div class="role-editor-modules">
|
||||
<div v-for="(ops, mod) in roleEditorGrouped" :key="mod" class="role-editor-module">
|
||||
@@ -328,6 +331,7 @@
|
||||
<div v-for="op in ops" :key="op.operation_key" class="role-editor-op-row">
|
||||
<el-checkbox
|
||||
:model-value="roleEditorDraft[op.operation_key] ?? false"
|
||||
:disabled="editingRole === 'PM'"
|
||||
@change="(v: boolean) => roleEditorDraft[op.operation_key] = v"
|
||||
/>
|
||||
<el-tag :type="opTagType(op)" size="small" class="op-action-tag">{{ opActionLabel(op) }}</el-tag>
|
||||
@@ -337,7 +341,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="drawer-footer">
|
||||
<el-button type="primary" :loading="roleEditorSaving" @click="saveRoleEditor">
|
||||
<el-button type="primary" :loading="roleEditorSaving" :disabled="editingRole === 'PM'" @click="saveRoleEditor">
|
||||
保存 {{ ROLE_LABELS[editingRole] }} 权限
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -585,9 +589,9 @@ const addMemberRules: FormRules = {
|
||||
};
|
||||
|
||||
const canEditMember = (row: { user_id: string; role_in_study: string; user?: any }) => {
|
||||
if (auth.user?.role !== "ADMIN") return false;
|
||||
if (!auth.user?.is_admin) return false;
|
||||
if (row.user_id === auth.user?.id) return false;
|
||||
if (row.user?.role === "ADMIN") return false;
|
||||
if (row.user?.is_admin) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,29 +1,96 @@
|
||||
<template>
|
||||
<el-dialog append-to=".layout-main .content-wrapper" :title="project ? TEXT.modules.adminProjects.editTitle : TEXT.modules.adminProjects.newTitle" width="560px" v-model="visibleProxy" :close-on-click-modal="false">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||||
<el-dialog
|
||||
append-to=".layout-main .content-wrapper"
|
||||
: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" />
|
||||
<el-input v-model="form.name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectName">
|
||||
<template #prefix>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="opacity:0.5">
|
||||
<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>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.projectCode" prop="code">
|
||||
<el-input v-model="form.code" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectCode" />
|
||||
<el-input v-model="form.code" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectCode">
|
||||
<template #prefix>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="opacity:0.5">
|
||||
<polyline points="16 18 22 12 16 6"/>
|
||||
<polyline points="8 6 2 12 8 18"/>
|
||||
</svg>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.protocolNo" prop="protocol_no">
|
||||
<el-input v-model="form.protocol_no" :placeholder="TEXT.modules.adminProjects.protocolPlaceholder" />
|
||||
<el-input v-model="form.protocol_no" :placeholder="TEXT.modules.adminProjects.protocolPlaceholder">
|
||||
<template #prefix>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="opacity:0.5">
|
||||
<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>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.phase" prop="phase">
|
||||
<el-input v-model="form.phase" :placeholder="TEXT.modules.adminProjects.phasePlaceholder" />
|
||||
<el-input v-model="form.phase" :placeholder="TEXT.modules.adminProjects.phasePlaceholder">
|
||||
<template #prefix>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="opacity:0.5">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
</svg>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.status" prop="status">
|
||||
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select">
|
||||
<el-option :label="TEXT.enums.projectStatus.DRAFT" value="DRAFT" />
|
||||
<el-option :label="TEXT.enums.projectStatus.ACTIVE" value="ACTIVE" />
|
||||
<el-option :label="TEXT.enums.projectStatus.CLOSED" value="CLOSED" />
|
||||
</el-select>
|
||||
<div class="status-cards">
|
||||
<div
|
||||
v-for="opt in statusOptions"
|
||||
:key="opt.value"
|
||||
class="status-card"
|
||||
:class="{ active: form.status === opt.value, ['card--' + opt.value.toLowerCase()]: true }"
|
||||
@click="form.status = opt.value"
|
||||
>
|
||||
<span class="status-card-dot"></span>
|
||||
<span class="status-card-label">{{ opt.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visibleProxy = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="visibleProxy = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="onSubmit">
|
||||
{{ project ? TEXT.common.actions.save : '创建项目' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -60,6 +127,12 @@ const form = reactive({
|
||||
status: "DRAFT",
|
||||
});
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "DRAFT", label: TEXT.enums.projectStatus.DRAFT },
|
||||
{ value: "ACTIVE", label: TEXT.enums.projectStatus.ACTIVE },
|
||||
{ value: "CLOSED", label: TEXT.enums.projectStatus.CLOSED },
|
||||
];
|
||||
|
||||
const rules = reactive<FormRules>({
|
||||
name: [{ required: true, message: requiredMessage(TEXT.common.fields.projectName), trigger: "blur" }],
|
||||
code: [{ required: true, message: requiredMessage(TEXT.common.fields.projectCode), trigger: "blur" }],
|
||||
@@ -123,3 +196,117 @@ 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;
|
||||
}
|
||||
|
||||
.project-form-body :deep(.el-input__wrapper) {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.status-cards {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid var(--ctms-border-color);
|
||||
cursor: pointer;
|
||||
transition: var(--ctms-transition);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.status-card:hover {
|
||||
border-color: var(--ctms-border-color-hover);
|
||||
background: var(--ctms-neutral-100);
|
||||
}
|
||||
|
||||
.status-card.active {
|
||||
border-color: var(--ctms-primary);
|
||||
background: var(--ctms-primary-light);
|
||||
color: var(--ctms-primary);
|
||||
}
|
||||
|
||||
.status-card.active.card--active {
|
||||
border-color: var(--ctms-success);
|
||||
background: #e6f4ed;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.status-card.active.card--closed {
|
||||
border-color: var(--ctms-warning);
|
||||
background: #fef3e2;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.status-card-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -139,13 +139,13 @@ const roleRank: Record<string, number> = {
|
||||
CRA: 40,
|
||||
IMP: 40,
|
||||
};
|
||||
const currentRoleRank = computed(() => auth.user?.role === "ADMIN" ? Number.POSITIVE_INFINITY : roleRank[projectRole.value] || 0);
|
||||
const canAssignRole = (role: string) => (auth.user?.role === "ADMIN" ? true : (roleRank[role] || 0) <= currentRoleRank.value);
|
||||
const currentRoleRank = computed(() => auth.user?.is_admin ? Number.POSITIVE_INFINITY : roleRank[projectRole.value] || 0);
|
||||
const canAssignRole = (role: string) => (auth.user?.is_admin ? true : (roleRank[role] || 0) <= currentRoleRank.value);
|
||||
const canEditMember = (row: StudyMember) => {
|
||||
if (!canManageMembers.value) return false;
|
||||
if (auth.user?.role === "ADMIN") return true;
|
||||
if (row.user?.is_admin) return false;
|
||||
if (auth.user?.is_admin) return true;
|
||||
if (row.user_id === auth.user?.id) return false;
|
||||
if (row.user?.role === "ADMIN") return false;
|
||||
return (roleRank[row.role_in_study] || 0) <= currentRoleRank.value;
|
||||
};
|
||||
|
||||
@@ -217,7 +217,7 @@ const openAdd = () => {
|
||||
const submitAdd = async () => {
|
||||
if (!projectId.value) return;
|
||||
const decision = evaluateAction({
|
||||
actorRole: auth.user?.role || null,
|
||||
actorRole: auth.user?.is_admin ? "ADMIN" : null,
|
||||
requiredPermission: "project.members.manage",
|
||||
target: { projectId: projectId.value },
|
||||
});
|
||||
@@ -252,7 +252,7 @@ const updateRole = async (memberId: string, role: string) => {
|
||||
return;
|
||||
}
|
||||
const decision = evaluateAction({
|
||||
actorRole: auth.user?.role || null,
|
||||
actorRole: auth.user?.is_admin ? "ADMIN" : null,
|
||||
requiredPermission: "project.members.manage",
|
||||
target: { projectId: projectId.value, memberId },
|
||||
});
|
||||
@@ -277,7 +277,7 @@ const toggleActive = async (row: StudyMember) => {
|
||||
return;
|
||||
}
|
||||
const decision = evaluateAction({
|
||||
actorRole: auth.user?.role || null,
|
||||
actorRole: auth.user?.is_admin ? "ADMIN" : null,
|
||||
requiredPermission: "project.members.manage",
|
||||
target: { projectId: projectId.value, memberId: row.id },
|
||||
});
|
||||
@@ -316,7 +316,7 @@ const onDelete = async (row: StudyMember) => {
|
||||
return;
|
||||
}
|
||||
const decision = evaluateAction({
|
||||
actorRole: auth.user?.role || null,
|
||||
actorRole: auth.user?.is_admin ? "ADMIN" : null,
|
||||
requiredPermission: "project.members.manage",
|
||||
target: { projectId: projectId.value, memberId: row.id },
|
||||
});
|
||||
|
||||
@@ -1,36 +1,117 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page page--flush">
|
||||
<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="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>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-value">{{ projects.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">{{ activeProjectCount }}</span>
|
||||
<span class="stat-label">进行中</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card--draft">
|
||||
<div class="stat-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<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="stat-body">
|
||||
<span class="stat-value">{{ draftProjectCount }}</span>
|
||||
<span class="stat-label">草稿</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card--locked">
|
||||
<div class="stat-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-value">{{ lockedProjectCount }}</span>
|
||||
<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" @click="openCreate">
|
||||
<el-button v-if="isAdmin" type="primary" :icon="Plus" @click="openCreate">
|
||||
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjects.projectLabel }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 项目表格 -->
|
||||
<div class="unified-section project-table-section">
|
||||
<el-table :data="projects" v-loading="loading" stripe class="project-table" table-layout="fixed">
|
||||
<el-table-column prop="name" :label="TEXT.common.fields.projectName" show-overflow-tooltip>
|
||||
<el-table :data="projects" v-loading="loading" class="project-table" table-layout="fixed">
|
||||
<el-table-column :label="TEXT.common.fields.projectName" min-width="220">
|
||||
<template #default="scope">
|
||||
<el-link type="primary" @click="goProject(scope.row)" class="font-medium">{{ scope.row.name }}</el-link>
|
||||
<div class="project-cell">
|
||||
<div class="project-icon" :class="'icon--' + (scope.row.status || 'draft').toLowerCase()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<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>
|
||||
</div>
|
||||
<div class="project-info">
|
||||
<el-link type="primary" @click="goProject(scope.row)" class="project-name">{{ scope.row.name }}</el-link>
|
||||
<span class="project-code">{{ scope.row.code || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="code" :label="TEXT.common.fields.projectCode" show-overflow-tooltip>
|
||||
<el-table-column :label="TEXT.common.fields.protocolNo || '方案号'" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<span class="text-gray-500">{{ scope.row.code || "-" }}</span>
|
||||
<span class="text-muted">{{ scope.row.protocol_no || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status" align="center">
|
||||
<el-table-column :label="TEXT.common.fields.status" width="110" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="statusTag(scope.row.status)" effect="plain" round>{{ statusLabel(scope.row.status) }}</el-tag>
|
||||
<span class="status-badge" :class="'badge--' + (scope.row.status || 'draft').toLowerCase()">
|
||||
<span class="badge-dot"></span>
|
||||
{{ statusLabel(scope.row.status) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="is_locked" label="锁定状态" align="center">
|
||||
<el-table-column label="锁定状态" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.is_locked" type="warning" effect="plain" round>已锁定</el-tag>
|
||||
<el-tag v-else type="success" effect="plain" round>未锁定</el-tag>
|
||||
<span v-if="scope.row.is_locked" class="lock-indicator locked">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
已锁定
|
||||
</span>
|
||||
<span v-else class="lock-indicator unlocked">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 9.9-1"/>
|
||||
</svg>
|
||||
未锁定
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="176" align="center">
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="200" align="center" fixed="right">
|
||||
<template #default="scope">
|
||||
<div class="action-row">
|
||||
<el-tooltip v-if="canProject(scope.row, 'project_members', 'read')" :content="TEXT.modules.adminProjects.members" placement="top">
|
||||
@@ -49,13 +130,7 @@
|
||||
<el-button link type="success" :icon="ArrowRight" class="action-btn enter-btn" @click="enterStudy(scope.row)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip v-if="isAdmin && !scope.row.is_locked" content="锁定项目" placement="top">
|
||||
<el-button
|
||||
link
|
||||
type="info"
|
||||
:icon="Lock"
|
||||
class="action-btn"
|
||||
@click="handleLockToggle(scope.row)"
|
||||
/>
|
||||
<el-button link type="info" :icon="Lock" class="action-btn" @click="handleLockToggle(scope.row)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip v-if="isAdmin" :content="TEXT.common.actions.delete" placement="top">
|
||||
<el-button link type="danger" :icon="Delete" class="action-btn" @click="handleDelete(scope.row)" />
|
||||
@@ -74,7 +149,7 @@
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { User, OfficeBuilding, ArrowRight, Delete, Lock, Key, Document } from "@element-plus/icons-vue";
|
||||
import { User, OfficeBuilding, ArrowRight, Delete, Lock, Key, Document, Plus } from "@element-plus/icons-vue";
|
||||
import { fetchStudies, deleteStudy, lockStudy } from "../../api/studies";
|
||||
import { fetchApiEndpointPermissions } from "../../api/projectPermissions";
|
||||
import type { ApiEndpointPermissionsResponse, Study } from "../../types/api";
|
||||
@@ -95,6 +170,10 @@ const auth = useAuthStore();
|
||||
const isAdmin = computed(() => isSystemAdmin(auth.user));
|
||||
const permissionsByProject = ref<Record<string, ApiEndpointPermissionsResponse>>({});
|
||||
|
||||
const activeProjectCount = computed(() => projects.value.filter(p => p.status === 'ACTIVE').length);
|
||||
const draftProjectCount = computed(() => projects.value.filter(p => p.status === 'DRAFT').length);
|
||||
const lockedProjectCount = computed(() => projects.value.filter(p => p.is_locked).length);
|
||||
|
||||
const loadProjects = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -160,7 +239,6 @@ const goAuditLogs = async (row: Study) => {
|
||||
const handleDelete = async (study: Study) => {
|
||||
if (!isAdmin.value) return;
|
||||
try {
|
||||
// 第一次确认
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除项目\"${study.name}\"吗?该操作将永久删除项目及其所有关联数据(成员、站点等),且不可恢复!`,
|
||||
TEXT.common.actions.delete,
|
||||
@@ -171,8 +249,6 @@ const handleDelete = async (study: Study) => {
|
||||
dangerouslyUseHTMLString: false,
|
||||
}
|
||||
);
|
||||
|
||||
// 第二次确认:要求输入项目名称
|
||||
const { value } = await ElMessageBox.prompt(
|
||||
`请输入项目名称 "${study.name}" 以确认删除`,
|
||||
"删除确认",
|
||||
@@ -185,12 +261,10 @@ const handleDelete = async (study: Study) => {
|
||||
type: "error",
|
||||
}
|
||||
);
|
||||
|
||||
if (value !== study.name) {
|
||||
ElMessage.warning("项目名称不匹配,已取消删除");
|
||||
return;
|
||||
}
|
||||
|
||||
const isDeletingCurrentStudy = studyStore.currentStudy?.id === study.id;
|
||||
await deleteStudy(study.id);
|
||||
if (isDeletingCurrentStudy) {
|
||||
@@ -209,12 +283,10 @@ const handleDelete = async (study: Study) => {
|
||||
|
||||
const handleLockToggle = async (study: Study) => {
|
||||
if (!isAdmin.value) return;
|
||||
// 锁定后不可解锁
|
||||
if (study.is_locked) {
|
||||
ElMessage.warning("项目已锁定,不可解锁");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要锁定项目\"${study.name}\"吗?锁定后项目内数据将无法编辑,且不可解锁!`,
|
||||
@@ -225,7 +297,6 @@ const handleLockToggle = async (study: Study) => {
|
||||
type: "warning",
|
||||
}
|
||||
);
|
||||
|
||||
const { value } = await ElMessageBox.prompt(TEXT.modules.adminProjects.lockConfirmPrompt, TEXT.modules.adminProjects.lockConfirmTitle, {
|
||||
confirmButtonText: TEXT.common.actions.confirm,
|
||||
cancelButtonText: TEXT.common.actions.cancel,
|
||||
@@ -269,44 +340,71 @@ const goProject = (row: Study) => {
|
||||
const statusLabel = (status: string) =>
|
||||
TEXT.enums.projectStatus[status as keyof typeof TEXT.enums.projectStatus] || status;
|
||||
|
||||
const statusTag = (status: string) =>
|
||||
({
|
||||
DRAFT: "info",
|
||||
ACTIVE: "success",
|
||||
CLOSED: "warning",
|
||||
}[status] || "info");
|
||||
|
||||
onMounted(() => {
|
||||
loadProjects();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--unified-shell-divider);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
background: var(--ctms-neutral-100);
|
||||
transition: var(--ctms-transition);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--ctms-shadow-sm);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.stat-card--total .stat-icon { background: #e8edf3; color: var(--ctms-primary); }
|
||||
.stat-card--active .stat-icon { background: #e6f4ed; color: var(--ctms-success); }
|
||||
.stat-card--draft .stat-icon { background: #eef2f6; color: var(--ctms-info); }
|
||||
.stat-card--locked .stat-icon { background: #fef3e2; color: var(--ctms-warning); }
|
||||
|
||||
.stat-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.main-content-flat {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.main-content-flat :deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--ctms-text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.actions-only-bar {
|
||||
@@ -315,12 +413,93 @@ onMounted(() => {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.project-table-section {
|
||||
padding: 0;
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.project-table :deep(.el-table__inner-wrapper::before) {
|
||||
display: none;
|
||||
/* 项目单元格 */
|
||||
.project-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.project-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.project-icon.icon--active { background: #e6f4ed; color: var(--ctms-success); }
|
||||
.project-icon.icon--draft { background: #eef2f6; color: var(--ctms-info); }
|
||||
.project-icon.icon--closed { background: #fef3e2; color: var(--ctms-warning); }
|
||||
|
||||
.project-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.project-code {
|
||||
font-size: 12px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--ctms-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 状态徽章 */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.badge--active { background: #e6f4ed; color: #166534; }
|
||||
.badge--draft { background: #eef2f6; color: #475569; }
|
||||
.badge--closed { background: #fef3e2; color: #92400e; }
|
||||
|
||||
/* 锁定指示器 */
|
||||
.lock-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.lock-indicator.locked { color: var(--ctms-warning); }
|
||||
.lock-indicator.unlocked { color: var(--ctms-text-disabled); }
|
||||
|
||||
/* 操作按钮 */
|
||||
.action-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
@@ -330,8 +509,8 @@ onMounted(() => {
|
||||
font-size: 16px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s, color 0.2s, transform 0.2s;
|
||||
border-radius: 6px;
|
||||
transition: var(--ctms-transition);
|
||||
}
|
||||
|
||||
.action-row :deep(.el-button + .el-button) {
|
||||
@@ -340,11 +519,26 @@ onMounted(() => {
|
||||
|
||||
.action-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
background-color: #f3f4f6;
|
||||
background-color: var(--ctms-neutral-100);
|
||||
}
|
||||
|
||||
.enter-btn {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 表格 */
|
||||
.project-table-section {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.project-table :deep(.el-table__inner-wrapper::before) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -78,7 +78,7 @@ watch(
|
||||
const onSave = async () => {
|
||||
if (!props.site) return;
|
||||
const decision = evaluateAction({
|
||||
actorRole: auth.user?.role || null,
|
||||
actorRole: auth.user?.is_admin ? "ADMIN" : null,
|
||||
requiredPermission: "site.cra.bind",
|
||||
target: { siteId: props.site.id, studyId: props.studyId },
|
||||
});
|
||||
|
||||
@@ -133,7 +133,7 @@ watch(
|
||||
const onSubmit = async () => {
|
||||
if (!formRef.value) return;
|
||||
const decision = evaluateAction({
|
||||
actorRole: auth.user?.role || null,
|
||||
actorRole: auth.user?.is_admin ? "ADMIN" : null,
|
||||
requiredPermission: "site.manage",
|
||||
target: { siteId: props.site?.id, studyId: props.studyId },
|
||||
});
|
||||
|
||||
@@ -98,7 +98,7 @@ const members = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const formVisible = ref(false);
|
||||
const editingSite = ref<Site | null>(null);
|
||||
const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
||||
const isAdmin = computed(() => auth.user?.is_admin);
|
||||
|
||||
const loadProject = async () => {
|
||||
if (!projectId.value) return;
|
||||
@@ -206,7 +206,7 @@ const openEdit = (row: Site) => {
|
||||
const toggleSite = async (row: Site) => {
|
||||
if (!projectId.value) return;
|
||||
const decision = evaluateAction({
|
||||
actorRole: auth.user?.role || null,
|
||||
actorRole: auth.user?.is_admin ? "ADMIN" : null,
|
||||
requiredPermission: "site.manage",
|
||||
target: { siteId: row.id, studyId: projectId.value },
|
||||
});
|
||||
|
||||
@@ -1,22 +1,79 @@
|
||||
<template>
|
||||
<el-dialog append-to=".layout-main .content-wrapper" :title="user ? TEXT.modules.adminUsers.editTitle : TEXT.modules.adminUsers.newTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" autocomplete="off">
|
||||
<el-dialog
|
||||
append-to=".layout-main .content-wrapper"
|
||||
:title="user ? TEXT.modules.adminUsers.editTitle : TEXT.modules.adminUsers.newTitle"
|
||||
width="540px"
|
||||
v-model="visibleProxy"
|
||||
: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" />
|
||||
<el-input v-model="form.email" :disabled="!!user" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.email">
|
||||
<template #prefix>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="opacity:0.5">
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
|
||||
<polyline points="22,6 12,13 2,6"/>
|
||||
</svg>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.name" prop="full_name">
|
||||
<el-input v-model="form.full_name" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
|
||||
<el-input v-model="form.full_name" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name">
|
||||
<template #prefix>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="opacity:0.5">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.clinicalDepartment" prop="clinical_department">
|
||||
<el-input v-model="form.clinical_department" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.clinicalDepartment" />
|
||||
<el-input v-model="form.clinical_department" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.clinicalDepartment">
|
||||
<template #prefix>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="opacity:0.5">
|
||||
<path d="M19 21V5a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-1 4h1"/>
|
||||
</svg>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="user" :label="TEXT.modules.adminUsers.accountStatus" prop="is_active">
|
||||
<el-switch v-model="form.is_active" :active-text="TEXT.common.actions.enable" :inactive-text="TEXT.common.actions.disable" :disabled="isLastAdmin" />
|
||||
<div class="status-switch-row">
|
||||
<el-switch
|
||||
v-model="form.is_active"
|
||||
:active-text="TEXT.common.actions.enable"
|
||||
:inactive-text="TEXT.common.actions.disable"
|
||||
:disabled="isLastAdmin"
|
||||
inline-prompt
|
||||
/>
|
||||
<span class="status-hint" v-if="isLastAdmin">系统需要至少一个活跃管理员</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visibleProxy = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="visibleProxy = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="onSubmit">
|
||||
{{ user ? TEXT.common.actions.save : '创建账号' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -54,7 +111,12 @@ const form = reactive({
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const defaultRole = "PV";
|
||||
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: [
|
||||
@@ -75,7 +137,7 @@ const resetForm = () => {
|
||||
|
||||
const isLastAdmin = computed(() => {
|
||||
if (!props.user) return false;
|
||||
if (props.user.role !== "ADMIN") return false;
|
||||
if (!props.user.is_admin) return false;
|
||||
if ((props.adminCount || 0) > 1) return false;
|
||||
return form.is_active;
|
||||
});
|
||||
@@ -99,7 +161,7 @@ watch(
|
||||
const onSubmit = async () => {
|
||||
if (!formRef.value) return;
|
||||
await formRef.value.validate();
|
||||
if (props.user?.role === "ADMIN" && (props.adminCount || 0) <= 1 && !form.is_active) {
|
||||
if (props.user?.is_admin && (props.adminCount || 0) <= 1 && !form.is_active) {
|
||||
ElMessage.warning(TEXT.modules.adminUsers.keepAdminWarning);
|
||||
return;
|
||||
}
|
||||
@@ -117,7 +179,6 @@ const onSubmit = async () => {
|
||||
email: form.email,
|
||||
full_name: form.full_name,
|
||||
clinical_department: form.clinical_department,
|
||||
role: defaultRole,
|
||||
password: form.password,
|
||||
});
|
||||
ElMessage.success(TEXT.modules.adminUsers.createSuccess);
|
||||
@@ -131,3 +192,81 @@ 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;
|
||||
}
|
||||
|
||||
.user-form-body :deep(.el-input__wrapper) {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.status-switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status-hint {
|
||||
font-size: 12px;
|
||||
color: var(--ctms-warning);
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,22 +1,59 @@
|
||||
<template>
|
||||
<el-dialog append-to=".layout-main .content-wrapper" :title="TEXT.modules.adminUsers.resetTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
|
||||
<el-alert
|
||||
type="warning"
|
||||
show-icon
|
||||
:title="TEXT.modules.adminUsers.resetWarningTitle"
|
||||
:description="TEXT.modules.adminUsers.resetWarningDesc"
|
||||
class="mb-12"
|
||||
/>
|
||||
<div class="info">{{ TEXT.modules.adminUsers.resetTarget }}<strong>{{ user?.username }}</strong></div>
|
||||
<el-form ref="formRef" :model="form" label-width="140px" :rules="rules">
|
||||
<el-dialog
|
||||
append-to=".layout-main .content-wrapper"
|
||||
:title="TEXT.modules.adminUsers.resetTitle"
|
||||
width="540px"
|
||||
v-model="visibleProxy"
|
||||
:close-on-click-modal="false"
|
||||
class="reset-password-dialog"
|
||||
>
|
||||
<!-- 警告提示 -->
|
||||
<div class="reset-warning">
|
||||
<div class="warning-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="warning-body">
|
||||
<span class="warning-title">{{ TEXT.modules.adminUsers.resetWarningTitle }}</span>
|
||||
<span class="warning-desc">{{ TEXT.modules.adminUsers.resetWarningDesc }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 目标用户 -->
|
||||
<div class="target-user">
|
||||
<div class="target-avatar">
|
||||
{{ getInitials(user?.full_name || user?.username || '') }}
|
||||
</div>
|
||||
<div class="target-info">
|
||||
<span class="target-name">{{ user?.full_name || user?.username }}</span>
|
||||
<span class="target-email">{{ user?.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form ref="formRef" :model="form" label-width="100px" :rules="rules" class="reset-form">
|
||||
<el-form-item :label="TEXT.modules.adminUsers.confirmUsername" prop="confirmUsername">
|
||||
<el-input v-model="form.confirmUsername" autocomplete="off" name="ctms-reset-confirm-username" :placeholder="TEXT.modules.adminUsers.confirmUsernameHint" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.modules.adminUsers.passwordMode" prop="mode">
|
||||
<el-radio-group v-model="form.mode">
|
||||
<el-radio label="auto">{{ TEXT.modules.adminUsers.passwordAuto }}</el-radio>
|
||||
<el-radio label="manual">{{ TEXT.modules.adminUsers.passwordManual }}</el-radio>
|
||||
</el-radio-group>
|
||||
<div class="mode-cards">
|
||||
<div class="mode-card" :class="{ active: form.mode === 'auto' }" @click="form.mode = 'auto'">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 9.9-1"/>
|
||||
</svg>
|
||||
<span>{{ TEXT.modules.adminUsers.passwordAuto }}</span>
|
||||
</div>
|
||||
<div class="mode-card" :class="{ active: form.mode === 'manual' }" @click="form.mode = 'manual'">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
|
||||
<path d="M12 3H5a2 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>
|
||||
<span>{{ TEXT.modules.adminUsers.passwordManual }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
:label="form.mode === 'auto' ? TEXT.modules.adminUsers.tempPassword : TEXT.modules.adminUsers.newPassword"
|
||||
@@ -32,7 +69,13 @@
|
||||
readonly
|
||||
>
|
||||
<template #append>
|
||||
<el-button @click="regenerate">{{ TEXT.modules.adminUsers.regenerate }}</el-button>
|
||||
<el-button @click="regenerate">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:4px">
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
{{ TEXT.modules.adminUsers.regenerate }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-input
|
||||
@@ -44,12 +87,21 @@
|
||||
name="ctms-reset-manual-password"
|
||||
:placeholder="TEXT.modules.adminUsers.manualPasswordHint"
|
||||
/>
|
||||
<div class="hint">{{ TEXT.modules.adminUsers.passwordHint }}</div>
|
||||
<div class="password-hint">{{ TEXT.modules.adminUsers.passwordHint }}</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visibleProxy = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button type="danger" :disabled="!canSubmit" :loading="submitting" @click="onSubmit">{{ TEXT.modules.adminUsers.resetConfirm }}</el-button>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="visibleProxy = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button type="danger" :disabled="!canSubmit" :loading="submitting" @click="onSubmit">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:4px">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
{{ TEXT.modules.adminUsers.resetConfirm }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -85,6 +137,13 @@ const form = reactive({
|
||||
manualPassword: "",
|
||||
});
|
||||
|
||||
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>({
|
||||
confirmUsername: [{ required: true, message: requiredMessage(TEXT.modules.adminUsers.confirmUsername), trigger: "blur" }],
|
||||
mode: [{ required: true, message: requiredMessage(TEXT.modules.adminUsers.passwordMode), trigger: "change" }],
|
||||
@@ -148,9 +207,7 @@ watch(
|
||||
|
||||
watch(
|
||||
() => form.mode,
|
||||
() => {
|
||||
clearPasswordValidation();
|
||||
}
|
||||
() => { clearPasswordValidation(); }
|
||||
);
|
||||
|
||||
const canSubmit = computed(() => !!props.user && form.confirmUsername === props.user.username);
|
||||
@@ -188,15 +245,129 @@ const onSubmit = async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mb-12 {
|
||||
margin-bottom: 12px;
|
||||
.reset-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: #fef3e2;
|
||||
border: 1px solid #fde68a;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.info {
|
||||
margin-bottom: 8px;
|
||||
|
||||
.warning-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--ctms-warning);
|
||||
margin-top: 1px;
|
||||
}
|
||||
.hint {
|
||||
|
||||
.warning-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.warning-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.warning-desc {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
color: #a16207;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.target-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: var(--ctms-neutral-100);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.target-avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, var(--ctms-primary), var(--ctms-primary-active));
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.target-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.target-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.target-email {
|
||||
font-size: 12px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.reset-form :deep(.el-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mode-cards {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mode-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid var(--ctms-border-color);
|
||||
cursor: pointer;
|
||||
transition: var(--ctms-transition);
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.mode-card:hover {
|
||||
border-color: var(--ctms-border-color-hover);
|
||||
background: var(--ctms-neutral-100);
|
||||
}
|
||||
|
||||
.mode-card.active {
|
||||
border-color: var(--ctms-primary);
|
||||
background: var(--ctms-primary-light);
|
||||
color: var(--ctms-primary);
|
||||
}
|
||||
|
||||
.mode-card span {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.password-hint {
|
||||
font-size: 12px;
|
||||
color: var(--ctms-text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,71 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page page--flush">
|
||||
<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>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-value">{{ total }}</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--pending">
|
||||
<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"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-value">{{ pendingCount }}</span>
|
||||
<span class="stat-label">{{ TEXT.enums.userStatus.PENDING }}</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>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="filter-container unified-action-bar">
|
||||
<div class="filter-form">
|
||||
<div class="filter-item-form">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
:placeholder="TEXT.common.placeholders.keyword"
|
||||
clearable
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
:placeholder="TEXT.common.placeholders.keyword"
|
||||
clearable
|
||||
class="filter-input-comp"
|
||||
:prefix-icon="Search"
|
||||
@keyup.enter="loadUsers"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-item-form">
|
||||
@@ -20,29 +77,42 @@
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-item-form">
|
||||
<el-button @click="loadUsers">{{ TEXT.common.actions.search }}</el-button>
|
||||
<el-button @click="loadUsers" :icon="Refresh">{{ TEXT.common.actions.search }}</el-button>
|
||||
</div>
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminUsers.userLabel }}</el-button>
|
||||
<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" stripe v-loading="loading" class="user-table" style="width: 100%" table-layout="fixed">
|
||||
<el-table-column prop="email" :label="TEXT.common.fields.email" show-overflow-tooltip />
|
||||
<el-table-column prop="full_name" :label="TEXT.common.fields.name" show-overflow-tooltip />
|
||||
<el-table-column prop="clinical_department" :label="TEXT.modules.adminUsers.clinicalDepartmentLabel" show-overflow-tooltip />
|
||||
<el-table-column :label="TEXT.common.fields.status">
|
||||
<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">
|
||||
<el-tag :type="statusType(scope.row.status)">
|
||||
{{ statusLabel(scope.row.status) }}
|
||||
</el-tag>
|
||||
<div class="user-cell">
|
||||
<div class="user-avatar" :class="'avatar--' + (scope.row.status || '').toLowerCase()">
|
||||
{{ getInitials(scope.row.full_name) }}
|
||||
</div>
|
||||
<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="created_at" :label="TEXT.modules.adminUsers.createdAt" show-overflow-tooltip>
|
||||
<template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template>
|
||||
<el-table-column prop="clinical_department" :label="TEXT.modules.adminUsers.clinicalDepartmentLabel" show-overflow-tooltip />
|
||||
<el-table-column :label="TEXT.common.fields.status" width="100">
|
||||
<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 :label="TEXT.common.labels.actions" width="220" align="center">
|
||||
<el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" width="160" 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">
|
||||
<template #default="scope">
|
||||
<div class="action-row">
|
||||
<el-tooltip :content="TEXT.common.actions.edit" placement="top">
|
||||
@@ -93,9 +163,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Edit, Delete, Key, Lock, Unlock } from "@element-plus/icons-vue";
|
||||
import { Edit, Delete, Key, Lock, Unlock, Search, Plus, Refresh } from "@element-plus/icons-vue";
|
||||
import { fetchUsers, updateUser, deleteUser } from "../../api/users";
|
||||
import { fetchStudies } from "../../api/studies";
|
||||
import { listMembers } from "../../api/members";
|
||||
@@ -107,6 +177,7 @@ 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);
|
||||
@@ -120,44 +191,46 @@ const editingUser = ref<UserInfo | null>(null);
|
||||
const resetUser = ref<UserInfo | null>(null);
|
||||
const auth = useAuthStore();
|
||||
|
||||
const activeCount = computed(() => allUsers.value.filter(u => u.status === 'ACTIVE').length);
|
||||
const pendingCount = computed(() => allUsers.value.filter(u => u.status === 'PENDING').length);
|
||||
const disabledCount = computed(() => allUsers.value.filter(u => u.status === 'DISABLED').length);
|
||||
|
||||
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 statusType = (status: string) => {
|
||||
switch (status) {
|
||||
case "ACTIVE":
|
||||
return "success";
|
||||
case "PENDING":
|
||||
return "warning";
|
||||
case "REJECTED":
|
||||
return "danger";
|
||||
default:
|
||||
return "info";
|
||||
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;
|
||||
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;
|
||||
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,
|
||||
fetchUsers({
|
||||
skip: (page.value - 1) * pageSize.value,
|
||||
limit: pageSize.value,
|
||||
keyword: searchKeyword.value || undefined,
|
||||
status: statusFilter.value || undefined,
|
||||
}),
|
||||
@@ -166,8 +239,9 @@ const loadUsers = async () => {
|
||||
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.role === "ADMIN" && u.status === "ACTIVE").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 {
|
||||
@@ -260,19 +334,15 @@ const findUserProjects = async (userId: string) => {
|
||||
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 { 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 {
|
||||
// Ignore errors checking specific study
|
||||
// skip
|
||||
}
|
||||
}));
|
||||
return blocking;
|
||||
@@ -281,34 +351,89 @@ const findUserProjects = async (userId: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers();
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--unified-shell-divider);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
background: var(--ctms-neutral-100);
|
||||
transition: var(--ctms-transition);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--ctms-shadow-sm);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.stat-card--total .stat-icon {
|
||||
background: #e8edf3;
|
||||
color: var(--ctms-primary);
|
||||
}
|
||||
|
||||
.stat-card--active .stat-icon {
|
||||
background: #e6f4ed;
|
||||
color: var(--ctms-success);
|
||||
}
|
||||
|
||||
.stat-card--pending .stat-icon {
|
||||
background: #fef3e2;
|
||||
color: var(--ctms-warning);
|
||||
}
|
||||
|
||||
.stat-card--disabled .stat-icon {
|
||||
background: #fde8e8;
|
||||
color: var(--ctms-danger);
|
||||
}
|
||||
|
||||
.stat-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.main-content-flat {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.main-content-flat :deep(.el-card__body) {
|
||||
padding: 0;
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--ctms-text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* 筛选栏 */
|
||||
.filter-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
@@ -322,28 +447,121 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.filter-input-comp {
|
||||
width: 190px;
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 用户单元格 */
|
||||
.user-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
background: var(--ctms-primary);
|
||||
}
|
||||
|
||||
.user-avatar.avatar--active {
|
||||
background: linear-gradient(135deg, #3f8f6b, #2d7a5a);
|
||||
}
|
||||
|
||||
.user-avatar.avatar--pending {
|
||||
background: linear-gradient(135deg, #c58b2a, #a87420);
|
||||
}
|
||||
|
||||
.user-avatar.avatar--disabled {
|
||||
background: linear-gradient(135deg, #94a3b8, #64748b);
|
||||
}
|
||||
|
||||
.user-avatar.avatar--rejected {
|
||||
background: linear-gradient(135deg, #c24b4b, #a83a3a);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-main);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
font-size: 12px;
|
||||
color: var(--ctms-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 状态点 */
|
||||
.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--pending {
|
||||
background: var(--ctms-warning);
|
||||
box-shadow: 0 0 0 3px rgba(197, 139, 42, 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--rejected {
|
||||
background: var(--ctms-danger);
|
||||
box-shadow: 0 0 0 3px rgba(194, 75, 75, 0.15);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--ctms-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.action-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 表格区域 */
|
||||
.user-table-section {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.user-table {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.user-table :deep(.el-table__inner-wrapper::before) {
|
||||
display: none;
|
||||
}
|
||||
@@ -354,14 +572,20 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.user-table :deep(td.el-table__cell) {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0 0 6px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -281,7 +281,7 @@ const siteActiveMap = computed(() => {
|
||||
return map;
|
||||
});
|
||||
const isReadOnly = computed(() => !!detail.site_id && siteActiveMap.value[detail.site_id] === false);
|
||||
const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
||||
const isAdmin = computed(() => auth.user?.is_admin);
|
||||
|
||||
const memberUserMap = computed(() =>
|
||||
members.value.reduce<Record<string, string>>((acc, member) => {
|
||||
|
||||
@@ -171,8 +171,7 @@ const siteActiveMap = computed(() => {
|
||||
return map;
|
||||
});
|
||||
const isAdmin = computed(() => {
|
||||
const role = auth.user?.role;
|
||||
return role === "ADMIN";
|
||||
return !!auth.user?.is_admin;
|
||||
});
|
||||
const isInactiveSite = (siteId?: string | null) => !!siteId && siteActiveMap.value[siteId] === false;
|
||||
const documentRowClass = ({ row }: { row: DocumentSummary }) =>
|
||||
|
||||
@@ -55,7 +55,7 @@ const sites = ref<any[]>([]);
|
||||
const users = ref<any[]>([]);
|
||||
const members = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
||||
const isAdmin = computed(() => auth.user?.is_admin);
|
||||
|
||||
const memberNameMap = computed(() => {
|
||||
const map: Record<string, string> = {};
|
||||
|
||||
@@ -177,7 +177,7 @@ const studyId = study.currentStudy?.id || "";
|
||||
const siteInfo = reactive<{ name: string; contact?: string | null; is_active?: boolean }>({ name: "" });
|
||||
const users = ref<any[]>([]);
|
||||
const members = ref<any[]>([]);
|
||||
const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
||||
const isAdmin = computed(() => auth.user?.is_admin);
|
||||
const kickoffAttachmentGroups = [
|
||||
{ entityType: "startup_kickoff_minutes", title: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel },
|
||||
{ entityType: "startup_kickoff_signin", title: TEXT.modules.startupMeetingAuth.kickoffSignInLabel },
|
||||
|
||||
@@ -78,7 +78,7 @@ const siteInfo = reactive<{ name: string; contact?: string | null; is_active?: b
|
||||
const siteId = ref("");
|
||||
const users = ref<any[]>([]);
|
||||
const members = ref<any[]>([]);
|
||||
const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
||||
const isAdmin = computed(() => auth.user?.is_admin);
|
||||
|
||||
const memberNameMap = computed(() => {
|
||||
const map: Record<string, string> = {};
|
||||
|
||||
Reference in New Issue
Block a user