移除用户系统级角色,权限完全迁移至项目级管理

- 用户注册/创建不再需要选择角色,账号管理只管资料和状态
- 移除前后端所有系统级 role 字段的暴露,改用 is_admin 标识管理员
- PM(项目负责人)角色自动拥有全部项目权限且不可修改
- 系统管理员在项目中的成员身份不可被删除
- 管理界面全面美化

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Cheng Zhou
2026-05-20 15:52:17 +08:00
parent 6cefa620e4
commit 5b97ea32ab
35 changed files with 1937 additions and 481 deletions
+256 -62
View File
@@ -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>