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

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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CTMS</title> <title>CTMS</title>
<script type="module" crossorigin src="/assets/index-CeU-aW8t.js"></script> <script type="module" crossorigin src="/assets/index-DZqfrNxW.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CUEjPO3h.css"> <link rel="stylesheet" crossorigin href="/assets/index-CrvflH-M.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+131
View File
@@ -0,0 +1,131 @@
// @ts-nocheck
export {};
declare global {
var __VLS_PROPS_FALLBACK: Record<string, unknown>;
const __VLS_directiveBindingRestFields: { instance: null, oldValue: null, modifiers: any, dir: any };
const __VLS_placeholder: any;
const __VLS_intrinsics: import('vue/jsx-runtime').JSX.IntrinsicElements;
type __VLS_Elements = __VLS_SpreadMerge<SVGElementTagNameMap, HTMLElementTagNameMap>;
type __VLS_GlobalComponents = import('vue').GlobalComponents;
type __VLS_GlobalDirectives = import('vue').GlobalDirectives;
type __VLS_IsAny<T> = 0 extends 1 & T ? true : false;
type __VLS_PickNotAny<A, B> = __VLS_IsAny<A> extends true ? B : A;
type __VLS_SpreadMerge<A, B> = Omit<A, keyof B> & B;
type __VLS_WithComponent<N0 extends string, LocalComponents, Self, N1 extends string, N2 extends string = N1, N3 extends string = N1> =
N1 extends keyof LocalComponents ? { [K in N0]: LocalComponents[N1] } :
N2 extends keyof LocalComponents ? { [K in N0]: LocalComponents[N2] } :
N3 extends keyof LocalComponents ? { [K in N0]: LocalComponents[N3] } :
Self extends object ? { [K in N0]: Self } :
N1 extends keyof __VLS_GlobalComponents ? { [K in N0]: __VLS_GlobalComponents[N1] } :
N2 extends keyof __VLS_GlobalComponents ? { [K in N0]: __VLS_GlobalComponents[N2] } :
N3 extends keyof __VLS_GlobalComponents ? { [K in N0]: __VLS_GlobalComponents[N3] } :
{};
type __VLS_FunctionalComponentCtx<T, K> = __VLS_PickNotAny<'__ctx' extends keyof __VLS_PickNotAny<K, {}>
? K extends { __ctx?: infer Ctx } ? NonNullable<Ctx> : never : any
, T extends (props: any, ctx: infer Ctx) => any ? Ctx : any
>;
type __VLS_FunctionalComponentProps<T, K> = '__ctx' extends keyof __VLS_PickNotAny<K, {}>
? K extends { __ctx?: { props?: infer P } } ? NonNullable<P> : never
: T extends (props: infer P, ...args: any) => any ? P
: {};
type __VLS_FunctionalComponent<T> = (props: (T extends { $props: infer Props } ? Props : {}) & Record<string, unknown>, ctx?: any) => import('vue/jsx-runtime').JSX.Element & {
__ctx?: {
attrs?: any;
slots?: T extends { $slots: infer Slots } ? Slots : Record<string, any>;
emit?: T extends { $emit: infer Emit } ? Emit : {};
props?: typeof props;
expose?: (exposed: T) => void;
};
};
type __VLS_IsFunction<T, K> = K extends keyof T
? __VLS_IsAny<T[K]> extends false
? unknown extends T[K]
? false
: true
: false
: false;
type __VLS_NormalizeComponentEvent<
Props,
Emits,
onEvent extends keyof Props,
Event extends keyof Emits,
CamelizedEvent extends keyof Emits,
> = __VLS_IsFunction<Props, onEvent> extends true
? Props
: __VLS_IsFunction<Emits, Event> extends true
? { [K in onEvent]?: Emits[Event] }
: __VLS_IsFunction<Emits, CamelizedEvent> extends true
? { [K in onEvent]?: Emits[CamelizedEvent] }
: Props;
// fix https://github.com/vuejs/language-tools/issues/926
type __VLS_UnionToIntersection<U> = (U extends unknown ? (arg: U) => unknown : never) extends ((arg: infer P) => unknown) ? P : never;
type __VLS_OverloadUnionInner<T, U = unknown> = U & T extends (...args: infer A) => infer R
? U extends T
? never
: __VLS_OverloadUnionInner<T, Pick<T, keyof T> & U & ((...args: A) => R)> | ((...args: A) => R)
: never;
type __VLS_OverloadUnion<T> = Exclude<
__VLS_OverloadUnionInner<(() => never) & T>,
T extends () => never ? never : () => never
>;
type __VLS_ConstructorOverloads<T> = __VLS_OverloadUnion<T> extends infer F
? F extends (event: infer E, ...args: infer A) => any
? { [K in E & string]: (...args: A) => void; }
: never
: never;
type __VLS_NormalizeEmits<T> = __VLS_PrettifyGlobal<
__VLS_UnionToIntersection<
__VLS_ConstructorOverloads<T> & {
[K in keyof T]: T[K] extends any[] ? { (...args: T[K]): void } : never
}
>
>;
type __VLS_EmitsToProps<T> = __VLS_PrettifyGlobal<{
[K in string & keyof T as `on${Capitalize<K>}`]?:
(...args: T[K] extends (...args: infer P) => any ? P : T[K] extends null ? any[] : never) => any;
}>;
type __VLS_ResolveEmits<
Comp,
Emits,
TypeEmits = Comp extends { __typeEmits?: infer T } ? unknown extends T ? {} : import('vue').ShortEmitsToObject<T> : {},
NormalizedEmits = __VLS_NormalizeEmits<Emits> extends infer E ? string extends keyof E ? {} : E : never,
> = __VLS_SpreadMerge<NormalizedEmits, TypeEmits>;
type __VLS_ResolveDirectives<T> = {
[K in keyof T & string as `v${Capitalize<K>}`]: T[K];
};
type __VLS_PrettifyGlobal<T> = (T extends any ? { [K in keyof T]: T[K]; } : { [K in keyof T as K]: T[K]; }) & {};
type __VLS_UseTemplateRef<T> = Readonly<import('vue').ShallowRef<T | null>>;
type __VLS_ProxyRefs<T> = import('vue').ShallowUnwrapRef<T>;
function __VLS_getVForSourceType<T extends number | string | any[] | Iterable<any>>(source: T): [
item: T extends number ? number
: T extends string ? string
: T extends any[] ? T[number]
: T extends Iterable<infer T1> ? T1
: any,
index: number,
][];
function __VLS_getVForSourceType<T>(source: T): [
item: T[keyof T],
key: keyof T,
index: number,
][];
function __VLS_getSlotParameters<S, D extends S>(slot: S, decl?: D):
D extends (...args: infer P) => any ? P : any[];
function __VLS_asFunctionalDirective<T>(dir: T): T extends import('vue').ObjectDirective
? NonNullable<T['created' | 'beforeMount' | 'mounted' | 'beforeUpdate' | 'updated' | 'beforeUnmount' | 'unmounted']>
: T extends (...args: any) => any
? T
: (arg1: unknown, arg2: unknown, arg3: unknown, arg4: unknown) => void;
function __VLS_asFunctionalComponent<T, K = T extends new (...args: any) => any ? InstanceType<T> : unknown>(t: T, instance?: K):
T extends new (...args: any) => any ? __VLS_FunctionalComponent<K>
: T extends () => any ? (props: {}, ctx?: any) => ReturnType<T>
: T extends (...args: any) => any ? T
: __VLS_FunctionalComponent<{}>;
function __VLS_functionalComponentArgsRest<T extends (...args: any) => any>(t: T): 2 extends Parameters<T>['length'] ? [any] : [];
function __VLS_asFunctionalElement<T>(tag: T, endTag?: T): (attrs: T & Record<string, unknown>) => void;
function __VLS_asFunctionalSlot<S>(slot: S): S extends () => infer R ? (props: {}) => R : NonNullable<S>;
function __VLS_tryAsConstant<const T>(t: T): T;
}
+25
View File
@@ -32,4 +32,29 @@ export const auditDict: Record<
actionText: "更新了风险/问题状态", actionText: "更新了风险/问题状态",
targetLabel: "风险/问题", targetLabel: "风险/问题",
}, },
UNAUTHORIZED_ACTION_ATTEMPT: {
label: "越权操作尝试",
actionText: "尝试执行无权限操作",
targetLabel: "系统资源",
},
INVALID_STATE_TRANSITION_ATTEMPT: {
label: "非法状态流转尝试",
actionText: "尝试执行非法状态流转",
targetLabel: "业务对象",
},
PROJECT_MEMBER_UPDATED: {
label: "项目成员变更",
actionText: "调整了项目成员",
targetLabel: "项目成员",
},
SITE_CRA_BOUND: {
label: "中心 CRA 绑定",
actionText: "调整了中心与 CRA 的绑定",
targetLabel: "中心",
},
SITE_STATUS_CHANGED: {
label: "中心状态变更",
actionText: "更新了中心信息",
targetLabel: "中心",
},
}; };
+46 -2
View File
@@ -1,5 +1,7 @@
import { auditDict } from "./auditDict"; import { auditDict } from "./auditDict";
import { roleDict, getDictLabel, statusDict } from "../dictionaries"; import { roleDict, getDictLabel, statusDict } from "../dictionaries";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
export interface AuditEvent { export interface AuditEvent {
eventType: string; eventType: string;
@@ -77,5 +79,47 @@ export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>):
export { auditDict }; export { auditDict };
// 前端占位的 logAudit:不修改后端逻辑,失败不影响主流程 const LOCAL_KEY_PREFIX = "audit_local_";
export const logAudit = async (_eventType: string, _payload: Record<string, any>) => Promise.resolve(); const getLocalKey = (studyId?: string | null) => `${LOCAL_KEY_PREFIX}${studyId || "global"}`;
const appendLocalAudit = (studyId: string | null, log: any) => {
try {
const key = getLocalKey(studyId);
const existingRaw = localStorage.getItem(key);
const existing = existingRaw ? JSON.parse(existingRaw) : [];
existing.unshift(log);
localStorage.setItem(key, JSON.stringify(existing.slice(0, 200)));
} catch {
/* ignore */
}
};
// 前端占位的 logAudit:不修改后端接口,落地到本地存储,失败不影响主流程
export const logAudit = async (eventType: string, payload: Record<string, any>) => {
try {
const auth = useAuthStore();
const study = useStudyStore();
const user = auth.user;
const studyId = study.currentStudy?.id || null;
const log = {
action: eventType,
operator_id: user?.id || user?.username || "current_user",
operator_role: user?.role || "",
entity_type: payload.targetType || "client",
entity_id: payload.targetId || "",
created_at: new Date().toISOString(),
detail: JSON.stringify({
targetName: payload.targetName,
before: payload.before,
after: payload.after,
reason: payload.reason,
severity: payload.severity,
result: payload.severity === "normal" ? "SUCCESS" : "FAIL",
}),
};
appendLocalAudit(studyId, log);
} catch {
/* ignore */
}
return Promise.resolve();
};
@@ -20,14 +20,18 @@ import { ElMessage, ElMessageBox } from "element-plus";
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { changeFinanceStatus } from "../api/finance"; import { changeFinanceStatus } from "../api/finance";
import { useStudyStore } from "../store/study"; import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import PermissionAction from "./PermissionAction.vue"; import PermissionAction from "./PermissionAction.vue";
import { financeMachine, getAvailableActions } from "../state-machine"; import { financeMachine, getAvailableActions } from "../state-machine";
import type { ActionConfig } from "../state-machine"; import type { ActionConfig } from "../state-machine";
import { evaluateAction } from "../guards/actionGuard";
import { logAudit } from "../audit";
const props = defineProps<{ item: any }>(); const props = defineProps<{ item: any }>();
const emit = defineEmits(["success"]); const emit = defineEmits(["success"]);
const study = useStudyStore(); const study = useStudyStore();
const auth = useAuthStore();
const loading = ref(false); const loading = ref(false);
const permissionMap: Record<string, string> = { const permissionMap: Record<string, string> = {
submit: "finance.create", submit: "finance.create",
@@ -46,20 +50,69 @@ const buttonType = (action: ActionConfig) => {
}; };
const change = async (status: string, reject_reason?: string) => { const change = async (status: string, reject_reason?: string) => {
if (!study.currentStudy) return; if (!study.currentStudy) return false;
loading.value = true; loading.value = true;
try { try {
await changeFinanceStatus(study.currentStudy.id, props.item.id, { status, reject_reason }); await changeFinanceStatus(study.currentStudy.id, props.item.id, { status, reject_reason });
ElMessage.success("状态已更新"); ElMessage.success("状态已更新");
emit("success"); emit("success");
return true;
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "更新失败"); const msg = e?.response?.data?.detail || e?.response?.data?.message || "更新失败";
ElMessage.error(msg);
if (e?.response?.status === 403) {
logAudit("UNAUTHORIZED_ACTION_ATTEMPT", {
targetId: props.item.id,
targetName: props.item.title || props.item.id,
reason: msg,
severity: "violation",
});
} else {
logAudit("FINANCE_STATUS_CHANGED", {
targetId: props.item.id,
targetName: props.item.title || props.item.id,
reason: msg,
severity: "warning",
});
}
return false;
} finally { } finally {
loading.value = false; loading.value = false;
} }
}; };
const onAction = async (action: ActionConfig) => { const onAction = async (action: ActionConfig) => {
if (!props.item) return;
const prevStatus = props.item.status;
if (prevStatus === action.to) {
ElMessage.warning("当前状态不允许该操作");
logAudit("INVALID_STATE_TRANSITION_ATTEMPT", {
targetId: props.item.id,
targetName: props.item.title || props.item.id,
before: { status: prevStatus },
after: { status: action.to },
severity: "warning",
});
return;
}
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: permissionMap[action.key],
stateMachine: financeMachine,
currentState: props.item.status,
actionKey: action.key,
target: { id: props.item.id },
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || "当前不可执行该操作");
logAudit(decision.auditType, {
targetId: props.item.id,
targetName: props.item.title || props.item.id,
reason: decision.reason,
severity: decision.severity,
});
return;
}
if (action.confirm) { if (action.confirm) {
const ok = await ElMessageBox.confirm(action.confirmText || "确认执行该操作?", "提示", { const ok = await ElMessageBox.confirm(action.confirmText || "确认执行该操作?", "提示", {
type: action.danger ? "warning" : "info", type: action.danger ? "warning" : "info",
@@ -72,10 +125,24 @@ const onAction = async (action: ActionConfig) => {
cancelButtonText: "取消", cancelButtonText: "取消",
}).catch(() => null); }).catch(() => null);
if (!reason || !reason.value) return; if (!reason || !reason.value) return;
await change(action.to, reason.value); const ok = await change(action.to, reason.value);
logAudit("FINANCE_STATUS_CHANGED", {
targetId: props.item.id,
targetName: props.item.title || props.item.id,
before: { status: prevStatus },
after: { status: action.to },
severity: ok ? "normal" : "warning",
});
return; return;
} }
await change(action.to); const ok = await change(action.to);
logAudit("FINANCE_STATUS_CHANGED", {
targetId: props.item.id,
targetName: props.item.title || props.item.id,
before: { status: prevStatus },
after: { status: action.to },
severity: ok ? "normal" : "warning",
});
}; };
</script> </script>
+1 -3
View File
@@ -38,9 +38,7 @@
</el-sub-menu> </el-sub-menu>
<el-menu-item index="/study/finance">费用</el-menu-item> <el-menu-item index="/study/finance">费用</el-menu-item>
<el-menu-item index="/study/faq">知识库</el-menu-item> <el-menu-item index="/study/faq">知识库</el-menu-item>
<el-menu-item v-if="isAdmin || isPm" index="/study/audit-logs"> <el-menu-item v-if="isAdmin || isPm" index="/study/audit-logs">审计日志</el-menu-item>
审计日志
</el-menu-item>
</template> </template>
</el-menu> </el-menu>
</el-aside> </el-aside>
+49
View File
@@ -0,0 +1,49 @@
import { usePermission } from "../utils/permission";
import { canDoAction, type StateMachine } from "../state-machine";
export interface ActionContext {
actorRole?: string | null;
requiredPermission?: string | null;
stateMachine?: StateMachine;
currentState?: string | null;
actionKey?: string | null;
target?: Record<string, any>;
}
export interface ActionDecision {
allowed: boolean;
reason?: string;
auditType: string;
severity: "normal" | "warning" | "violation";
}
export const evaluateAction = (context: ActionContext): ActionDecision => {
const permission = usePermission();
const { requiredPermission, stateMachine, currentState, actionKey } = context;
const isAdmin = context.actorRole === "ADMIN";
if (requiredPermission && !isAdmin && !permission.can(requiredPermission)) {
return {
allowed: false,
severity: "violation",
reason: "无权限执行该操作",
auditType: "UNAUTHORIZED_ACTION_ATTEMPT",
};
}
if (stateMachine && actionKey && currentState && !canDoAction(stateMachine, actionKey, currentState)) {
return {
allowed: false,
severity: "warning",
reason: "当前状态不允许该操作",
auditType: "INVALID_STATE_TRANSITION_ATTEMPT",
};
}
return {
allowed: true,
severity: "normal",
auditType: actionKey || requiredPermission || "ACTION_EXECUTED",
};
};
+6
View File
@@ -20,6 +20,9 @@ const PERMISSIONS: Record<string, string[]> = {
"imp.transaction": ["ADMIN", "PM", "IMP"], "imp.transaction": ["ADMIN", "PM", "IMP"],
"faq.edit": ["ADMIN", "PM"], "faq.edit": ["ADMIN", "PM"],
"dataquery.edit": ["ADMIN", "PM", "CRA"], "dataquery.edit": ["ADMIN", "PM", "CRA"],
"project.members.manage": ["ADMIN", "PM"],
"site.manage": ["ADMIN"],
"site.cra.bind": ["ADMIN"],
}; };
const REASONS: Record<string, string> = { const REASONS: Record<string, string> = {
@@ -35,6 +38,9 @@ const REASONS: Record<string, string> = {
"imp.transaction": "仅 IMP/PM/ADMIN 可操作台账", "imp.transaction": "仅 IMP/PM/ADMIN 可操作台账",
"faq.edit": "仅 PM/ADMIN 可维护 FAQ", "faq.edit": "仅 PM/ADMIN 可维护 FAQ",
"dataquery.edit": "仅 PM/CRA/ADMIN 可编辑数据问题", "dataquery.edit": "仅 PM/CRA/ADMIN 可编辑数据问题",
"project.members.manage": "仅 ADMIN / PM 可调整项目成员",
"site.manage": "仅 ADMIN 可管理中心",
"site.cra.bind": "仅 ADMIN 可绑定 CRA",
}; };
export const usePermission = () => { export const usePermission = () => {
+47
View File
@@ -65,6 +65,8 @@ import { usePermission } from "../utils/permission";
import { aeMachine, getAvailableActions } from "../state-machine"; import { aeMachine, getAvailableActions } from "../state-machine";
import type { ActionConfig } from "../state-machine"; import type { ActionConfig } from "../state-machine";
import { statusDict, getDictColor, getDictLabel } from "../dictionaries"; import { statusDict, getDictColor, getDictLabel } from "../dictionaries";
import { evaluateAction } from "../guards/actionGuard";
import { logAudit } from "../audit";
const route = useRoute(); const route = useRoute();
const study = useStudyStore(); const study = useStudyStore();
@@ -102,6 +104,36 @@ const load = async () => {
const onAction = async (action: ActionConfig) => { const onAction = async (action: ActionConfig) => {
if (!study.currentStudy || !ae.value) return; 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) { if (action.confirm) {
const ok = await ElMessageBox.confirm(action.confirmText || "确认执行该操作?", "提示").catch(() => null); const ok = await ElMessageBox.confirm(action.confirmText || "确认执行该操作?", "提示").catch(() => null);
if (!ok) return; 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 }); const resp = await updateAe(study.currentStudy.id, ae.value.id, { status: action.to });
ElMessage.success("状态已更新"); ElMessage.success("状态已更新");
ae.value = enrichOverdue(resp.data); 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(); await load();
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "更新失败"); 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 { getAvailableActions, getStateColor, getStateLabel, subjectMachine } from "../state-machine";
import type { ActionConfig } from "../state-machine"; import type { ActionConfig } from "../state-machine";
import { statusDict, getDictLabel, getDictColor } from "../dictionaries"; import { statusDict, getDictLabel, getDictColor } from "../dictionaries";
import { evaluateAction } from "../guards/actionGuard";
import { logAudit } from "../audit";
const route = useRoute(); const route = useRoute();
const study = useStudyStore(); const study = useStudyStore();
@@ -86,6 +88,24 @@ const loadVisits = async () => {
const onAction = async (action: ActionConfig) => { const onAction = async (action: ActionConfig) => {
if (!study.currentStudy || !subject.value) return; 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) { if (action.confirm) {
const ok = await ElMessageBox.confirm(action.confirmText || "确认执行该操作?", "提示").catch(() => null); const ok = await ElMessageBox.confirm(action.confirmText || "确认执行该操作?", "提示").catch(() => null);
if (!ok) return; if (!ok) return;
@@ -97,10 +117,25 @@ const onAction = async (action: ActionConfig) => {
try { try {
await updateSubject(study.currentStudy.id, subject.value.id, payload); await updateSubject(study.currentStudy.id, subject.value.id, payload);
ElMessage.success("状态已更新"); 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 loadSubject();
await loadVisits(); await loadVisits();
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "更新失败"); 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 { data } = await fetchAuditLogs(study.currentStudy.id, params);
const items = Array.isArray(data) ? data : (data as any).items || []; const items = Array.isArray(data) ? data : (data as any).items || [];
rawLogs.value = items; const local = loadLocalLogs();
total.value = (data as any).total || items.length; const merged = [...items, ...local];
rawLogs.value = merged;
total.value = (data as any).total || merged.length;
enrichLogs(); enrichLogs();
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "审计日志加载失败"); 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 enrichLogs = () => {
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => { const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
acc[cur.id] = cur.username; acc[cur.id] = cur.username;
@@ -158,7 +172,9 @@ const enrichLogs = () => {
} }
return true; 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) { if (users.value.length === 0) {
const byActor = new Map<string, string>(); const byActor = new Map<string, string>();
logs.value.forEach((log) => { 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 { addMember, listMembers, removeMember, updateMember } from "../../api/members";
import { fetchStudyDetail } from "../../api/studies"; import { fetchStudyDetail } from "../../api/studies";
import type { Study, StudyMember, UserInfo } from "../../types/api"; 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 route = useRoute();
const projectId = computed(() => route.params.projectId as string); const projectId = computed(() => route.params.projectId as string);
const auth = useAuthStore();
const project = ref<Study | null>(null); const project = ref<Study | null>(null);
const members = ref<StudyMember[]>([]); const members = ref<StudyMember[]>([]);
@@ -154,6 +158,21 @@ const openAdd = () => {
const submitAdd = async () => { const submitAdd = async () => {
if (!projectId.value) return; 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(); await addFormRef.value?.validate();
adding.value = true; adding.value = true;
try { try {
@@ -161,8 +180,21 @@ const submitAdd = async () => {
ElMessage.success("成员已添加"); ElMessage.success("成员已添加");
addVisible.value = false; addVisible.value = false;
loadMembers(); 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) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "添加失败"); 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 { } finally {
adding.value = false; adding.value = false;
} }
@@ -170,18 +202,61 @@ const submitAdd = async () => {
const updateRole = async (memberId: string, role: string) => { const updateRole = async (memberId: string, role: string) => {
if (!projectId.value) return; 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 { try {
await updateMember(projectId.value, memberId, { role_in_study: role }); await updateMember(projectId.value, memberId, { role_in_study: role });
ElMessage.success("角色已更新"); ElMessage.success("角色已更新");
loadMembers(); 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) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "更新失败"); ElMessage.error(e?.response?.data?.message || "更新失败");
loadMembers(); 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) => { const toggleActive = async (row: StudyMember) => {
if (!projectId.value) return; 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) { if (row.is_active) {
const ok = await ElMessageBox.confirm("该操作将影响项目成员,请确认是否继续", "停用成员", { const ok = await ElMessageBox.confirm("该操作将影响项目成员,请确认是否继续", "停用成员", {
type: "warning", type: "warning",
@@ -192,16 +267,46 @@ const toggleActive = async (row: StudyMember) => {
await removeMember(projectId.value, row.id); await removeMember(projectId.value, row.id);
ElMessage.success("成员已停用"); ElMessage.success("成员已停用");
loadMembers(); 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) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败"); 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 { } else {
try { try {
await updateMember(projectId.value, row.id, { is_active: true }); await updateMember(projectId.value, row.id, { is_active: true });
ElMessage.success("成员已启用"); ElMessage.success("成员已启用");
loadMembers(); 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) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败"); 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 { ElMessage } from "element-plus";
import { updateSite } from "../../api/sites"; import { updateSite } from "../../api/sites";
import type { Site, UserInfo } from "../../types/api"; import type { Site, UserInfo } from "../../types/api";
import { useAuthStore } from "../../store/auth";
import { evaluateAction } from "../../guards/actionGuard";
import { logAudit } from "../../audit";
const props = defineProps<{ const props = defineProps<{
visible: boolean; visible: boolean;
@@ -34,6 +37,7 @@ const emit = defineEmits<{
(e: "saved"): void; (e: "saved"): void;
}>(); }>();
const auth = useAuthStore();
const visibleProxy = computed({ const visibleProxy = computed({
get: () => props.visible, get: () => props.visible,
set: (val: boolean) => emit("update:visible", val), set: (val: boolean) => emit("update:visible", val),
@@ -73,6 +77,21 @@ watch(
const onSave = async () => { const onSave = async () => {
if (!props.site) return; 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; saving.value = true;
try { try {
const names = selectedCras.value const names = selectedCras.value
@@ -82,8 +101,23 @@ const onSave = async () => {
ElMessage.success("绑定已保存"); ElMessage.success("绑定已保存");
emit("saved"); emit("saved");
visibleProxy.value = false; 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) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败"); 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 { } finally {
saving.value = false; 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 { ElMessage, type FormInstance, type FormRules } from "element-plus";
import { createSite, updateSite } from "../../api/sites"; import { createSite, updateSite } from "../../api/sites";
import type { Site } from "../../types/api"; import type { Site } from "../../types/api";
import { useAuthStore } from "../../store/auth";
import { evaluateAction } from "../../guards/actionGuard";
import { logAudit } from "../../audit";
const props = defineProps<{ const props = defineProps<{
visible: boolean; visible: boolean;
@@ -46,6 +49,7 @@ const visibleProxy = computed({
set: (val: boolean) => emit("update:visible", val), set: (val: boolean) => emit("update:visible", val),
}); });
const auth = useAuthStore();
const formRef = ref<FormInstance>(); const formRef = ref<FormInstance>();
const submitting = ref(false); const submitting = ref(false);
const form = reactive({ const form = reactive({
@@ -86,6 +90,21 @@ watch(
const onSubmit = async () => { const onSubmit = async () => {
if (!formRef.value) return; 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(); await formRef.value.validate();
submitting.value = true; submitting.value = true;
try { try {
@@ -98,6 +117,12 @@ const onSubmit = async () => {
is_active: form.is_active, is_active: form.is_active,
}); });
ElMessage.success("中心已更新"); 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 { } else {
await createSite(props.studyId, { await createSite(props.studyId, {
name: form.name, name: form.name,
@@ -106,11 +131,24 @@ const onSubmit = async () => {
contact: form.contact, contact: form.contact,
}); });
ElMessage.success("中心已创建"); ElMessage.success("中心已创建");
logAudit("SITE_STATUS_CHANGED", {
targetId: props.studyId,
targetName: form.name,
after: { name: form.name },
severity: "normal",
});
} }
emit("saved"); emit("saved");
visibleProxy.value = false; visibleProxy.value = false;
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败"); 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 { } finally {
submitting.value = false; submitting.value = false;
} }
+34
View File
@@ -63,9 +63,13 @@ import { fetchUsers } from "../../api/users";
import SiteForm from "./SiteForm.vue"; import SiteForm from "./SiteForm.vue";
import SiteCraBinding from "./SiteCraBinding.vue"; import SiteCraBinding from "./SiteCraBinding.vue";
import type { Site, Study, UserInfo } from "../../types/api"; 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 route = useRoute();
const projectId = computed(() => route.params.projectId as string); const projectId = computed(() => route.params.projectId as string);
const auth = useAuthStore();
const project = ref<Study | null>(null); const project = ref<Study | null>(null);
const sites = ref<Site[]>([]); const sites = ref<Site[]>([]);
@@ -127,6 +131,21 @@ const openBind = (row: Site) => {
const toggleSite = async (row: Site) => { const toggleSite = async (row: Site) => {
if (!projectId.value) return; 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) { if (row.is_active) {
const ok = await ElMessageBox.confirm("该操作将影响中心数据,请确认是否继续", "停用中心", { const ok = await ElMessageBox.confirm("该操作将影响中心数据,请确认是否继续", "停用中心", {
type: "warning", type: "warning",
@@ -138,8 +157,23 @@ const toggleSite = async (row: Site) => {
await updateSite(projectId.value, row.id, { is_active: !row.is_active }); await updateSite(projectId.value, row.id, { is_active: !row.is_active });
ElMessage.success(row.is_active ? "已停用" : "已启用"); ElMessage.success(row.is_active ? "已停用" : "已启用");
loadSites(); 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) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败"); 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,
});
} }
}; };
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.