移除用户系统级角色,权限完全迁移至项目级管理
- 用户注册/创建不再需要选择角色,账号管理只管资料和状态 - 移除前后端所有系统级 role 字段的暴露,改用 is_admin 标识管理员 - PM(项目负责人)角色自动拥有全部项目权限且不可修改 - 系统管理员在项目中的成员身份不可被删除 - 管理界面全面美化 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user