「权限 × 状态 × 审计」联合约束机制

This commit is contained in:
Cheng Zhou
2025-12-17 21:48:29 +08:00
parent 13efb0a3a9
commit 4202ed7922
36 changed files with 714 additions and 85 deletions
+47
View File
@@ -65,6 +65,8 @@ import { usePermission } from "../utils/permission";
import { aeMachine, getAvailableActions } from "../state-machine";
import type { ActionConfig } from "../state-machine";
import { statusDict, getDictColor, getDictLabel } from "../dictionaries";
import { evaluateAction } from "../guards/actionGuard";
import { logAudit } from "../audit";
const route = useRoute();
const study = useStudyStore();
@@ -102,6 +104,36 @@ const load = async () => {
const onAction = async (action: ActionConfig) => {
if (!study.currentStudy || !ae.value) return;
const prevStatus = ae.value.status;
if (prevStatus === action.to) {
ElMessage.warning("当前状态不允许该操作");
logAudit("INVALID_STATE_TRANSITION_ATTEMPT", {
targetId: ae.value.id,
targetName: ae.value.term,
before: { status: prevStatus },
after: { status: action.to },
severity: "warning",
});
return;
}
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: "ae.close",
stateMachine: aeMachine,
currentState: ae.value.status,
actionKey: action.key,
target: { id: ae.value.id },
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || "当前不可执行该操作");
logAudit(decision.auditType, {
targetId: ae.value.id,
targetName: ae.value.term,
reason: decision.reason,
severity: decision.severity,
});
return;
}
if (action.confirm) {
const ok = await ElMessageBox.confirm(action.confirmText || "确认执行该操作?", "提示").catch(() => null);
if (!ok) return;
@@ -110,9 +142,24 @@ const onAction = async (action: ActionConfig) => {
const resp = await updateAe(study.currentStudy.id, ae.value.id, { status: action.to });
ElMessage.success("状态已更新");
ae.value = enrichOverdue(resp.data);
logAudit("AE_CLOSED", {
targetId: ae.value.id,
targetName: ae.value.term,
before: { status: prevStatus },
after: { status: action.to },
severity: "normal",
});
await load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "更新失败");
logAudit("AE_CLOSED", {
targetId: ae.value.id,
targetName: ae.value.term,
before: { status: prevStatus },
after: { status: action.to },
severity: "warning",
reason: e?.response?.data?.message,
});
}
};
+35
View File
@@ -43,6 +43,8 @@ import VisitTable from "../components/VisitTable.vue";
import { getAvailableActions, getStateColor, getStateLabel, subjectMachine } from "../state-machine";
import type { ActionConfig } from "../state-machine";
import { statusDict, getDictLabel, getDictColor } from "../dictionaries";
import { evaluateAction } from "../guards/actionGuard";
import { logAudit } from "../audit";
const route = useRoute();
const study = useStudyStore();
@@ -86,6 +88,24 @@ const loadVisits = async () => {
const onAction = async (action: ActionConfig) => {
if (!study.currentStudy || !subject.value) return;
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: permissionMap[action.key],
stateMachine: subjectMachine,
currentState: subject.value.status,
actionKey: action.key,
target: { id: subject.value.id },
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || "当前不可执行该操作");
logAudit(decision.auditType, {
targetId: subject.value.id,
action: action.key,
reason: decision.reason,
severity: decision.severity,
});
return;
}
if (action.confirm) {
const ok = await ElMessageBox.confirm(action.confirmText || "确认执行该操作?", "提示").catch(() => null);
if (!ok) return;
@@ -97,10 +117,25 @@ const onAction = async (action: ActionConfig) => {
try {
await updateSubject(study.currentStudy.id, subject.value.id, payload);
ElMessage.success("状态已更新");
logAudit("SUBJECT_STATUS_CHANGED", {
targetId: subject.value.id,
targetName: subject.value.subject_no,
before: { status: subject.value.status },
after: { status: action.to },
severity: "normal",
});
await loadSubject();
await loadVisits();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "更新失败");
logAudit("SUBJECT_STATUS_CHANGED", {
targetId: subject.value.id,
targetName: subject.value.subject_no,
before: { status: subject.value.status },
after: { status: action.to },
severity: "warning",
reason: e?.response?.data?.message,
});
}
};
+19 -3
View File
@@ -133,8 +133,10 @@ const loadLogs = async () => {
};
const { data } = await fetchAuditLogs(study.currentStudy.id, params);
const items = Array.isArray(data) ? data : (data as any).items || [];
rawLogs.value = items;
total.value = (data as any).total || items.length;
const local = loadLocalLogs();
const merged = [...items, ...local];
rawLogs.value = merged;
total.value = (data as any).total || merged.length;
enrichLogs();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "审计日志加载失败");
@@ -143,6 +145,18 @@ const loadLogs = async () => {
}
};
const loadLocalLogs = () => {
try {
const key = `audit_local_${study.currentStudy?.id || "global"}`;
const raw = localStorage.getItem(key);
if (!raw) return [];
const list = JSON.parse(raw);
return Array.isArray(list) ? list : [];
} catch {
return [];
}
};
const enrichLogs = () => {
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
acc[cur.id] = cur.username;
@@ -158,7 +172,9 @@ const enrichLogs = () => {
}
return true;
});
logs.value = filtered.map((log) => normalizeAuditEvent(log, userMap));
logs.value = filtered
.map((log) => normalizeAuditEvent(log, userMap))
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
if (users.value.length === 0) {
const byActor = new Map<string, string>();
logs.value.forEach((log) => {
+105
View File
@@ -83,9 +83,13 @@ import { fetchUsers } from "../../api/users";
import { addMember, listMembers, removeMember, updateMember } from "../../api/members";
import { fetchStudyDetail } from "../../api/studies";
import type { Study, StudyMember, UserInfo } from "../../types/api";
import { useAuthStore } from "../../store/auth";
import { evaluateAction } from "../../guards/actionGuard";
import { logAudit } from "../../audit";
const route = useRoute();
const projectId = computed(() => route.params.projectId as string);
const auth = useAuthStore();
const project = ref<Study | null>(null);
const members = ref<StudyMember[]>([]);
@@ -154,6 +158,21 @@ const openAdd = () => {
const submitAdd = async () => {
if (!projectId.value) return;
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: "project.members.manage",
target: { projectId: projectId.value },
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || "无权限执行该操作");
logAudit(decision.auditType, {
targetId: projectId.value,
targetName: project.value?.name,
reason: decision.reason,
severity: decision.severity,
});
return;
}
await addFormRef.value?.validate();
adding.value = true;
try {
@@ -161,8 +180,21 @@ const submitAdd = async () => {
ElMessage.success("成员已添加");
addVisible.value = false;
loadMembers();
logAudit("PROJECT_MEMBER_UPDATED", {
targetId: projectId.value,
targetName: project.value?.name,
after: { user_id: newMember.user_id, role_in_study: newMember.role_in_study },
severity: "normal",
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "添加失败");
logAudit("PROJECT_MEMBER_UPDATED", {
targetId: projectId.value,
targetName: project.value?.name,
after: { user_id: newMember.user_id, role_in_study: newMember.role_in_study },
severity: "warning",
reason: e?.response?.data?.message,
});
} finally {
adding.value = false;
}
@@ -170,18 +202,61 @@ const submitAdd = async () => {
const updateRole = async (memberId: string, role: string) => {
if (!projectId.value) return;
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: "project.members.manage",
target: { projectId: projectId.value, memberId },
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || "无权限执行该操作");
logAudit(decision.auditType, {
targetId: projectId.value,
targetName: project.value?.name,
reason: decision.reason,
severity: decision.severity,
});
return;
}
try {
await updateMember(projectId.value, memberId, { role_in_study: role });
ElMessage.success("角色已更新");
loadMembers();
logAudit("PROJECT_MEMBER_UPDATED", {
targetId: projectId.value,
targetName: project.value?.name,
after: { member_id: memberId, role_in_study: role },
severity: "normal",
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "更新失败");
loadMembers();
logAudit("PROJECT_MEMBER_UPDATED", {
targetId: projectId.value,
targetName: project.value?.name,
after: { member_id: memberId, role_in_study: role },
severity: "warning",
reason: e?.response?.data?.message,
});
}
};
const toggleActive = async (row: StudyMember) => {
if (!projectId.value) return;
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: "project.members.manage",
target: { projectId: projectId.value, memberId: row.id },
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || "无权限执行该操作");
logAudit(decision.auditType, {
targetId: projectId.value,
targetName: project.value?.name,
reason: decision.reason,
severity: decision.severity,
});
return;
}
if (row.is_active) {
const ok = await ElMessageBox.confirm("该操作将影响项目成员,请确认是否继续", "停用成员", {
type: "warning",
@@ -192,16 +267,46 @@ const toggleActive = async (row: StudyMember) => {
await removeMember(projectId.value, row.id);
ElMessage.success("成员已停用");
loadMembers();
logAudit("PROJECT_MEMBER_UPDATED", {
targetId: projectId.value,
targetName: project.value?.name,
before: { is_active: row.is_active },
after: { is_active: false },
severity: "normal",
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败");
logAudit("PROJECT_MEMBER_UPDATED", {
targetId: projectId.value,
targetName: project.value?.name,
before: { is_active: row.is_active },
after: { is_active: false },
severity: "warning",
reason: e?.response?.data?.message,
});
}
} else {
try {
await updateMember(projectId.value, row.id, { is_active: true });
ElMessage.success("成员已启用");
loadMembers();
logAudit("PROJECT_MEMBER_UPDATED", {
targetId: projectId.value,
targetName: project.value?.name,
before: { is_active: row.is_active },
after: { is_active: true },
severity: "normal",
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败");
logAudit("PROJECT_MEMBER_UPDATED", {
targetId: projectId.value,
targetName: project.value?.name,
before: { is_active: row.is_active },
after: { is_active: true },
severity: "warning",
reason: e?.response?.data?.message,
});
}
}
};
@@ -21,6 +21,9 @@ import { computed, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { updateSite } from "../../api/sites";
import type { Site, UserInfo } from "../../types/api";
import { useAuthStore } from "../../store/auth";
import { evaluateAction } from "../../guards/actionGuard";
import { logAudit } from "../../audit";
const props = defineProps<{
visible: boolean;
@@ -34,6 +37,7 @@ const emit = defineEmits<{
(e: "saved"): void;
}>();
const auth = useAuthStore();
const visibleProxy = computed({
get: () => props.visible,
set: (val: boolean) => emit("update:visible", val),
@@ -73,6 +77,21 @@ watch(
const onSave = async () => {
if (!props.site) return;
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: "site.cra.bind",
target: { siteId: props.site.id, studyId: props.studyId },
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || "无权限执行该操作");
logAudit(decision.auditType, {
targetId: props.site.id,
targetName: props.site.name,
reason: decision.reason,
severity: decision.severity,
});
return;
}
saving.value = true;
try {
const names = selectedCras.value
@@ -82,8 +101,23 @@ const onSave = async () => {
ElMessage.success("绑定已保存");
emit("saved");
visibleProxy.value = false;
logAudit("SITE_CRA_BOUND", {
targetId: props.site.id,
targetName: props.site.name,
before: { contact: props.site.contact },
after: { contact: names.join(",") },
severity: "normal",
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败");
logAudit("SITE_CRA_BOUND", {
targetId: props.site.id,
targetName: props.site.name,
before: { contact: props.site.contact },
after: { contact: selectedCras.value.join(",") },
severity: "warning",
reason: e?.response?.data?.message,
});
} finally {
saving.value = false;
}
+38
View File
@@ -29,6 +29,9 @@ import { computed, reactive, ref, watch } from "vue";
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
import { createSite, updateSite } from "../../api/sites";
import type { Site } from "../../types/api";
import { useAuthStore } from "../../store/auth";
import { evaluateAction } from "../../guards/actionGuard";
import { logAudit } from "../../audit";
const props = defineProps<{
visible: boolean;
@@ -46,6 +49,7 @@ const visibleProxy = computed({
set: (val: boolean) => emit("update:visible", val),
});
const auth = useAuthStore();
const formRef = ref<FormInstance>();
const submitting = ref(false);
const form = reactive({
@@ -86,6 +90,21 @@ watch(
const onSubmit = async () => {
if (!formRef.value) return;
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: "site.manage",
target: { siteId: props.site?.id, studyId: props.studyId },
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || "无权限执行该操作");
logAudit(decision.auditType, {
targetId: props.site?.id || props.studyId,
targetName: props.site?.name,
reason: decision.reason,
severity: decision.severity,
});
return;
}
await formRef.value.validate();
submitting.value = true;
try {
@@ -98,6 +117,12 @@ const onSubmit = async () => {
is_active: form.is_active,
});
ElMessage.success("中心已更新");
logAudit("SITE_STATUS_CHANGED", {
targetId: props.site.id,
targetName: props.site.name,
after: { name: form.name, is_active: form.is_active },
severity: "normal",
});
} else {
await createSite(props.studyId, {
name: form.name,
@@ -106,11 +131,24 @@ const onSubmit = async () => {
contact: form.contact,
});
ElMessage.success("中心已创建");
logAudit("SITE_STATUS_CHANGED", {
targetId: props.studyId,
targetName: form.name,
after: { name: form.name },
severity: "normal",
});
}
emit("saved");
visibleProxy.value = false;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败");
logAudit("SITE_STATUS_CHANGED", {
targetId: props.site?.id || props.studyId,
targetName: props.site?.name || form.name,
after: { name: form.name },
severity: "warning",
reason: e?.response?.data?.message,
});
} finally {
submitting.value = false;
}
+34
View File
@@ -63,9 +63,13 @@ import { fetchUsers } from "../../api/users";
import SiteForm from "./SiteForm.vue";
import SiteCraBinding from "./SiteCraBinding.vue";
import type { Site, Study, UserInfo } from "../../types/api";
import { useAuthStore } from "../../store/auth";
import { evaluateAction } from "../../guards/actionGuard";
import { logAudit } from "../../audit";
const route = useRoute();
const projectId = computed(() => route.params.projectId as string);
const auth = useAuthStore();
const project = ref<Study | null>(null);
const sites = ref<Site[]>([]);
@@ -127,6 +131,21 @@ const openBind = (row: Site) => {
const toggleSite = async (row: Site) => {
if (!projectId.value) return;
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: "site.manage",
target: { siteId: row.id, studyId: projectId.value },
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || "无权限执行该操作");
logAudit(decision.auditType, {
targetId: row.id,
targetName: row.name,
reason: decision.reason,
severity: decision.severity,
});
return;
}
if (row.is_active) {
const ok = await ElMessageBox.confirm("该操作将影响中心数据,请确认是否继续", "停用中心", {
type: "warning",
@@ -138,8 +157,23 @@ const toggleSite = async (row: Site) => {
await updateSite(projectId.value, row.id, { is_active: !row.is_active });
ElMessage.success(row.is_active ? "已停用" : "已启用");
loadSites();
logAudit("SITE_STATUS_CHANGED", {
targetId: row.id,
targetName: row.name,
before: { is_active: row.is_active },
after: { is_active: !row.is_active },
severity: "normal",
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败");
logAudit("SITE_STATUS_CHANGED", {
targetId: row.id,
targetName: row.name,
before: { is_active: row.is_active },
after: { is_active: !row.is_active },
severity: "warning",
reason: e?.response?.data?.message,
});
}
};