「权限 × 状态 × 审计」联合约束机制
This commit is contained in:
-70
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+70
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CTMS</title>
|
||||
<script type="module" crossorigin src="/assets/index-CeU-aW8t.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CUEjPO3h.css">
|
||||
<script type="module" crossorigin src="/assets/index-DZqfrNxW.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CrvflH-M.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
+131
@@ -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;
|
||||
}
|
||||
@@ -32,4 +32,29 @@ export const auditDict: Record<
|
||||
actionText: "更新了风险/问题状态",
|
||||
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: "中心",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { auditDict } from "./auditDict";
|
||||
import { roleDict, getDictLabel, statusDict } from "../dictionaries";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { useStudyStore } from "../store/study";
|
||||
|
||||
export interface AuditEvent {
|
||||
eventType: string;
|
||||
@@ -77,5 +79,47 @@ export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>):
|
||||
|
||||
export { auditDict };
|
||||
|
||||
// 前端占位的 logAudit:不修改后端逻辑,失败不影响主流程
|
||||
export const logAudit = async (_eventType: string, _payload: Record<string, any>) => Promise.resolve();
|
||||
const LOCAL_KEY_PREFIX = "audit_local_";
|
||||
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 { changeFinanceStatus } from "../api/finance";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import PermissionAction from "./PermissionAction.vue";
|
||||
import { financeMachine, getAvailableActions } from "../state-machine";
|
||||
import type { ActionConfig } from "../state-machine";
|
||||
import { evaluateAction } from "../guards/actionGuard";
|
||||
import { logAudit } from "../audit";
|
||||
|
||||
const props = defineProps<{ item: any }>();
|
||||
const emit = defineEmits(["success"]);
|
||||
|
||||
const study = useStudyStore();
|
||||
const auth = useAuthStore();
|
||||
const loading = ref(false);
|
||||
const permissionMap: Record<string, string> = {
|
||||
submit: "finance.create",
|
||||
@@ -46,20 +50,69 @@ const buttonType = (action: ActionConfig) => {
|
||||
};
|
||||
|
||||
const change = async (status: string, reject_reason?: string) => {
|
||||
if (!study.currentStudy) return;
|
||||
if (!study.currentStudy) return false;
|
||||
loading.value = true;
|
||||
try {
|
||||
await changeFinanceStatus(study.currentStudy.id, props.item.id, { status, reject_reason });
|
||||
ElMessage.success("状态已更新");
|
||||
emit("success");
|
||||
return true;
|
||||
} 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 {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
const ok = await ElMessageBox.confirm(action.confirmText || "确认执行该操作?", "提示", {
|
||||
type: action.danger ? "warning" : "info",
|
||||
@@ -72,10 +125,24 @@ const onAction = async (action: ActionConfig) => {
|
||||
cancelButtonText: "取消",
|
||||
}).catch(() => null);
|
||||
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;
|
||||
}
|
||||
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>
|
||||
|
||||
|
||||
@@ -38,9 +38,7 @@
|
||||
</el-sub-menu>
|
||||
<el-menu-item index="/study/finance">费用</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>
|
||||
<el-menu-item v-if="isAdmin || isPm" index="/study/audit-logs">审计日志</el-menu-item>
|
||||
</template>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
};
|
||||
@@ -20,6 +20,9 @@ const PERMISSIONS: Record<string, string[]> = {
|
||||
"imp.transaction": ["ADMIN", "PM", "IMP"],
|
||||
"faq.edit": ["ADMIN", "PM"],
|
||||
"dataquery.edit": ["ADMIN", "PM", "CRA"],
|
||||
"project.members.manage": ["ADMIN", "PM"],
|
||||
"site.manage": ["ADMIN"],
|
||||
"site.cra.bind": ["ADMIN"],
|
||||
};
|
||||
|
||||
const REASONS: Record<string, string> = {
|
||||
@@ -35,6 +38,9 @@ const REASONS: Record<string, string> = {
|
||||
"imp.transaction": "仅 IMP/PM/ADMIN 可操作台账",
|
||||
"faq.edit": "仅 PM/ADMIN 可维护 FAQ",
|
||||
"dataquery.edit": "仅 PM/CRA/ADMIN 可编辑数据问题",
|
||||
"project.members.manage": "仅 ADMIN / PM 可调整项目成员",
|
||||
"site.manage": "仅 ADMIN 可管理中心",
|
||||
"site.cra.bind": "仅 ADMIN 可绑定 CRA",
|
||||
};
|
||||
|
||||
export const usePermission = () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
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.
Reference in New Issue
Block a user