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

- 用户注册/创建不再需要选择角色,账号管理只管资料和状态
- 移除前后端所有系统级 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
-2
View File
@@ -89,8 +89,6 @@ async def register(
payload: UserRegisterRequest,
db: AsyncSession = Depends(get_db_session),
):
if payload.role == UserRole.ADMIN.value:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不允许注册管理员账号")
existing = await user_crud.get_by_email(db, payload.email)
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="邮箱已注册")
+3
View File
@@ -274,6 +274,9 @@ async def remove_member(
member = await member_crud.get_member_by_id(db, member_id)
if not member or member.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="成员不存在")
target_user = await user_crud.get_by_id(db, member.user_id)
if target_user and _role_value(target_user) == "ADMIN":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="系统管理员不可从项目中移除")
await _ensure_member_mutation_allowed(db, study_id, current_user, target_member=member)
before_data = {"role_in_study": member.role_in_study, "is_active": member.is_active}
removed = await member_crud.remove_member(db, member)
+1 -5
View File
@@ -54,21 +54,17 @@ async def update_user(
if user_crud.is_protected_admin_user(db_user):
if user_in.email is not None and user_in.email != db_user.email:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="系统管理员邮箱不允许修改")
if user_in.role is not None and user_in.role != "ADMIN":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="系统管理员角色不允许修改")
requested_status = user_in.status
if user_in.is_active is not None:
requested_status = "ACTIVE" if user_in.is_active else "DISABLED"
if requested_status is not None and requested_status != "ACTIVE":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="系统管理员不允许停用")
if db_user.role.value == "ADMIN":
requested_role = user_in.role
requested_status = user_in.status
if user_in.is_active is not None:
requested_status = "ACTIVE" if user_in.is_active else "DISABLED"
will_leave_admin = requested_role is not None and requested_role != "ADMIN"
will_disable = requested_status is not None and requested_status != "ACTIVE"
if (will_leave_admin or will_disable) and db_user.status.value == "ACTIVE":
if will_disable and db_user.status.value == "ACTIVE":
active_admins = await user_crud.count_active_admins(db)
if active_admins <= 1:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="至少保留一个管理员账号")
+8 -5
View File
@@ -48,7 +48,7 @@ async def role_has_api_permission(
check_prerequisites: bool = True,
) -> bool:
"""检查角色是否有权访问特定接口"""
if role == "ADMIN":
if role == "ADMIN" or role == "PM":
return True
permissions = await _get_project_permission_overrides(db, study_id)
@@ -78,7 +78,7 @@ async def get_missing_prerequisites(
endpoint_key: str,
) -> list[str]:
"""获取缺失的前置权限列表"""
if role == "ADMIN":
if role == "ADMIN" or role == "PM":
return []
missing = []
@@ -111,8 +111,11 @@ async def get_api_endpoint_permissions(
for role in roles:
matrix[role] = {}
for endpoint_key, config in API_ENDPOINT_PERMISSIONS.items():
default_allowed = role in config.get("default_roles", [])
matrix[role][endpoint_key] = {"allowed": default_allowed}
if role == "PM":
matrix[role][endpoint_key] = {"allowed": True}
else:
default_allowed = role in config.get("default_roles", [])
matrix[role][endpoint_key] = {"allowed": default_allowed}
for role, endpoints in overrides.items():
if role not in matrix:
@@ -136,7 +139,7 @@ async def replace_api_endpoint_permissions(
)
for role, endpoints in payload.items():
if role == "ADMIN":
if role in ("ADMIN", "PM"):
continue
for endpoint_key, allowed in endpoints.items():
if endpoint_key not in API_ENDPOINT_PERMISSIONS:
+1 -3
View File
@@ -42,7 +42,7 @@ async def create_user(
email=user_in.email,
password_hash=hash_password(user_in.password),
full_name=user_in.full_name,
role=UserRole(user_in.role),
role=UserRole.PV,
clinical_department=user_in.clinical_department,
status=status_value,
)
@@ -58,8 +58,6 @@ async def create_pending_user(db: AsyncSession, user_in: UserRegisterRequest) ->
async def update_user(db: AsyncSession, user: User, user_in: UserUpdate) -> User:
update_data = {}
if user_in.role is not None:
update_data["role"] = UserRole(user_in.role)
if user_in.status is not None:
update_data["status"] = UserStatus(user_in.status)
if user_in.is_active is not None:
+4
View File
@@ -67,6 +67,10 @@ class User(Base):
def is_active(self) -> bool: # compatibility helper for existing checks
return self.status == UserStatus.ACTIVE
@property
def is_admin(self) -> bool:
return self.role == UserRole.ADMIN
@property
def username(self) -> str: # backward compatibility for existing UI copy
return self.email
+1 -6
View File
@@ -6,7 +6,6 @@ from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
UserRole = Literal["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA", "ADMIN"]
RegisterRole = Literal["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"]
UserStatus = Literal["PENDING", "ACTIVE", "REJECTED", "DISABLED"]
PASSWORD_REGEX = re.compile(r"^(?=.*[A-Za-z])(?=.*\d).{8,}$")
@@ -16,7 +15,6 @@ class UserDisplay(BaseModel):
id: uuid.UUID
email: EmailStr
full_name: str
role: UserRole
avatar_url: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
@@ -39,7 +37,6 @@ class UserRegisterRequest(_PasswordValidator):
password: str = Field(min_length=8, max_length=72)
email: EmailStr
full_name: str = Field(min_length=1)
role: RegisterRole
clinical_department: str = Field(min_length=1)
@@ -47,7 +44,6 @@ class UserCreate(_PasswordValidator):
password: str = Field(min_length=8, max_length=72)
email: EmailStr
full_name: str = Field(min_length=1)
role: UserRole
clinical_department: str = Field(min_length=1)
status: UserStatus = "ACTIVE"
@@ -57,10 +53,10 @@ class UserRead(BaseModel):
email: EmailStr
username: str
full_name: str
role: UserRole
clinical_department: str
status: UserStatus
is_active: bool
is_admin: bool = False
created_at: datetime
approved_at: Optional[datetime] = None
approved_by: Optional[uuid.UUID] = None
@@ -72,7 +68,6 @@ class UserRead(BaseModel):
class UserUpdate(_PasswordValidator):
email: Optional[EmailStr] = None
full_name: Optional[str] = None
role: Optional[UserRole] = None
clinical_department: Optional[str] = None
status: Optional[UserStatus] = None
password: Optional[str] = None
+1 -2
View File
@@ -9,14 +9,13 @@ export const createUser = (payload: {
email: string;
password: string;
full_name: string;
role: string;
clinical_department: string;
}) =>
apiPost<UserInfo>("/api/v1/users/", payload);
export const updateUser = (
userId: string,
payload: Partial<{ role: string; status: string; password: string; full_name: string; clinical_department: string; is_active: boolean }>
payload: Partial<{ status: string; password: string; full_name: string; clinical_department: string; is_active: boolean }>
) =>
apiPatch<UserInfo>(`/api/v1/users/${userId}`, payload);
+4 -1
View File
@@ -185,7 +185,10 @@ const compactObjectValue = (value: Record<string, any>): string => {
if (entries.length === 0) return "未填写";
const rendered = entries
.slice(0, 3)
.map(([k, v]) => `${toReadableFieldLabel(k)}=${String(v)}`)
.map(([k, v]) => {
const valStr = typeof v === "object" ? JSON.stringify(v) : String(v);
return `${toReadableFieldLabel(k)}=${valStr}`;
})
.join("");
return entries.length > 3 ? `${rendered}${entries.length}` : rendered;
};
+1 -1
View File
@@ -51,7 +51,7 @@ const showForm = ref(false);
const editing = ref<FaqCategory | null>(null);
const { can } = usePermission();
const isAdmin = computed(() => auth.user?.role === "ADMIN");
const isAdmin = computed(() => !!auth.user?.is_admin);
const canMaintain = computed(() => can("faq.edit"));
const canDelete = computed(() => isAdmin.value);
+1 -1
View File
@@ -232,7 +232,7 @@ const auth = useAuthStore();
const study = useStudyStore();
const router = useRouter();
const route = useRoute();
const isAdmin = computed(() => auth.user?.role === "ADMIN");
const isAdmin = computed(() => !!auth.user?.is_admin);
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const canAccessProjectPath = (path: string) =>
+1 -1
View File
@@ -36,7 +36,7 @@ export const useAuthStore = defineStore("auth", () => {
const me = await fetchMeAction();
const studyStore = useStudyStore();
const userKey = me?.email || email;
await studyStore.restoreStudyForUser(userKey, { preferActive: me?.role === "ADMIN" });
await studyStore.restoreStudyForUser(userKey, { preferActive: me?.is_admin });
await studyStore.loadCurrentStudyPermissions().catch(() => {});
forceLogin.value = false;
} finally {
+1 -2
View File
@@ -41,9 +41,9 @@ export interface UserInfo {
username?: string;
full_name: string;
clinical_department: string;
role: UserRole;
status: UserStatus;
is_active?: boolean;
is_admin?: boolean;
created_at?: string;
approved_at?: string | null;
approved_by?: string | null;
@@ -56,7 +56,6 @@ export interface RegisterRequest {
email: string;
password: string;
full_name: string;
role: Exclude<UserRole, "ADMIN">;
clinical_department: string;
}
+4 -3
View File
@@ -1,10 +1,11 @@
import { describe, expect, it } from "vitest";
import { getProjectRole, getSystemRole, isSystemAdmin } from "./roles";
import { getProjectRole, isSystemAdmin } from "./roles";
describe("role helpers", () => {
it("keeps system admin separate from project admin", () => {
expect(isSystemAdmin({ role: "ADMIN" } as any)).toBe(true);
expect(getSystemRole({ role: "ADMIN" } as any)).toBe("ADMIN");
expect(isSystemAdmin({ is_admin: true } as any)).toBe(true);
expect(isSystemAdmin({ is_admin: false } as any)).toBe(false);
expect(isSystemAdmin(null)).toBe(false);
expect(getProjectRole({ role_in_study: null } as any, null)).toBeNull();
});
+1 -3
View File
@@ -1,8 +1,6 @@
import type { Study, UserInfo } from "../types/api";
export const getSystemRole = (user?: Pick<UserInfo, "role"> | null) => user?.role || null;
export const isSystemAdmin = (user?: Pick<UserInfo, "role"> | null) => getSystemRole(user) === "ADMIN";
export const isSystemAdmin = (user?: Pick<UserInfo, "is_admin"> | null) => !!user?.is_admin;
export const getProjectRole = (study?: Pick<Study, "role_in_study"> | null, currentStudyRole?: string | null) =>
currentStudyRole || study?.role_in_study || null;
+3 -3
View File
@@ -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");
});
+2 -2
View File
@@ -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");
-2
View File
@@ -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;
+325 -89
View File
@@ -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>
+448 -141
View File
@@ -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;
};
+200 -13
View File
@@ -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>
+8 -8
View File
@@ -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 },
});
+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>
+1 -1
View File
@@ -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 },
});
+1 -1
View File
@@ -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 },
});
+2 -2
View File
@@ -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 },
});
+151 -12
View File
@@ -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>
+198 -27
View File
@@ -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>
+296 -72
View File
@@ -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 }) =>
+1 -1
View File
@@ -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> = {};
+1 -1
View File
@@ -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 },
+1 -1
View File
@@ -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> = {};