「权限 × 状态 × 审计」联合约束机制
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user