统一状态流转为前端状态机

This commit is contained in:
Cheng Zhou
2025-12-17 20:52:55 +08:00
parent c074c2b5bc
commit c2328594b0
15 changed files with 361 additions and 188 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-DMpbNHFr.js"></script> <script type="module" crossorigin src="/assets/index-BA1yIlA_.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C22MsXA0.css"> <link rel="stylesheet" crossorigin href="/assets/index-DMvYZYMw.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
@@ -1,64 +1,49 @@
<template> <template>
<div class="actions"> <div class="actions">
<PermissionAction action="finance.create"> <template v-for="action in availableActions" :key="action.key">
<PermissionAction :action="permissionMap[action.key]">
<el-button <el-button
v-if="item.status === 'DRAFT'"
type="primary"
size="small" size="small"
:type="buttonType(action)"
:loading="loading" :loading="loading"
@click="change('SUBMITTED')" @click="onAction(action)"
> >
提交 {{ action.label }}
</el-button>
</PermissionAction>
<PermissionAction action="finance.approve">
<el-button
v-if="item.status === 'SUBMITTED'"
type="success"
size="small"
:loading="loading"
@click="change('APPROVED')"
>
审批通过
</el-button>
</PermissionAction>
<PermissionAction action="finance.approve">
<el-button
v-if="item.status === 'SUBMITTED'"
type="danger"
size="small"
:loading="loading"
@click="reject"
>
驳回
</el-button>
</PermissionAction>
<PermissionAction action="finance.pay">
<el-button
v-if="item.status === 'APPROVED'"
type="warning"
size="small"
:loading="loading"
@click="change('PAID')"
>
确认支付
</el-button> </el-button>
</PermissionAction> </PermissionAction>
</template>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { 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 PermissionAction from "./PermissionAction.vue"; import PermissionAction from "./PermissionAction.vue";
import { financeMachine, getAvailableActions } from "../state-machine";
import type { ActionConfig } from "../state-machine";
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 loading = ref(false); const loading = ref(false);
const permissionMap: Record<string, string> = {
submit: "finance.create",
approve: "finance.approve",
reject: "finance.approve",
pay: "finance.pay",
};
const availableActions = computed(() => getAvailableActions(financeMachine, props.item?.status));
const buttonType = (action: ActionConfig) => {
if (action.danger) return "danger";
if (action.key === "approve") return "success";
if (action.key === "pay") return "warning";
return "primary";
};
const change = async (status: string, reject_reason?: string) => { const change = async (status: string, reject_reason?: string) => {
if (!study.currentStudy) return; if (!study.currentStudy) return;
@@ -74,13 +59,23 @@ const change = async (status: string, reject_reason?: string) => {
} }
}; };
const reject = async () => { const onAction = async (action: ActionConfig) => {
if (action.confirm) {
const ok = await ElMessageBox.confirm(action.confirmText || "确认执行该操作?", "提示", {
type: action.danger ? "warning" : "info",
}).catch(() => null);
if (!ok) return;
}
if (action.key === "reject") {
const reason = await ElMessageBox.prompt("请输入驳回原因", "驳回审批", { const reason = await ElMessageBox.prompt("请输入驳回原因", "驳回审批", {
confirmButtonText: "确定", confirmButtonText: "确定",
cancelButtonText: "取消", cancelButtonText: "取消",
}).catch(() => null); }).catch(() => null);
if (!reason || !reason.value) return; if (!reason || !reason.value) return;
change("REJECTED", reason.value); await change(action.to, reason.value);
return;
}
await change(action.to);
}; };
</script> </script>
+19
View File
@@ -0,0 +1,19 @@
import type { StateMachine } from "./types";
export const aeMachine: StateMachine = {
states: {
NEW: { value: "NEW", label: "新增", color: "info" },
FOLLOW_UP: { value: "FOLLOW_UP", label: "随访中", color: "warning" },
CLOSED: { value: "CLOSED", label: "已关闭", color: "success", isTerminal: true },
},
actions: {
close: {
key: "close",
label: "关闭",
from: ["NEW", "FOLLOW_UP"],
to: "CLOSED",
confirm: true,
confirmText: "确定关闭该 AE?该操作不可撤销。",
},
},
};
@@ -0,0 +1,43 @@
import type { StateMachine } from "./types";
export const financeMachine: StateMachine = {
states: {
DRAFT: { value: "DRAFT", label: "草稿", color: "info" },
SUBMITTED: { value: "SUBMITTED", label: "已提交", color: "warning" },
APPROVED: { value: "APPROVED", label: "已审批", color: "success" },
REJECTED: { value: "REJECTED", label: "已驳回", color: "danger", isTerminal: true },
PAID: { value: "PAID", label: "已支付", color: "success", isTerminal: true },
},
actions: {
submit: {
key: "submit",
label: "提交",
from: ["DRAFT"],
to: "SUBMITTED",
},
approve: {
key: "approve",
label: "审批通过",
from: ["SUBMITTED"],
to: "APPROVED",
},
reject: {
key: "reject",
label: "驳回",
from: ["SUBMITTED"],
to: "REJECTED",
confirm: true,
confirmText: "确认驳回该费用吗?",
danger: true,
},
pay: {
key: "pay",
label: "确认支付",
from: ["APPROVED"],
to: "PAID",
confirm: true,
confirmText: "确认支付该费用吗?",
danger: true,
},
},
};
@@ -0,0 +1,8 @@
import type { StateMachine } from "./types";
export const impMachine: StateMachine = {
states: {
CREATED: { value: "CREATED", label: "已创建", color: "info" },
},
actions: {},
};
+30
View File
@@ -0,0 +1,30 @@
import type { StateMachine } from "./types";
export const getStateLabel = (machine: StateMachine, stateValue?: string | null) => {
if (!stateValue) return "";
return machine.states[stateValue]?.label || stateValue;
};
export const getStateColor = (machine: StateMachine, stateValue?: string | null) => {
if (!stateValue) return undefined;
return machine.states[stateValue]?.color;
};
export const getAvailableActions = (machine: StateMachine, currentState?: string | null) => {
if (!currentState) return [];
return Object.values(machine.actions).filter((a) => a.from.includes(currentState));
};
export const canDoAction = (machine: StateMachine, actionKey: string, currentState?: string | null) => {
if (!currentState) return false;
const action = machine.actions[actionKey];
if (!action) return false;
return action.from.includes(currentState);
};
export * from "./types";
export * from "./task.machine";
export * from "./subject.machine";
export * from "./ae.machine";
export * from "./finance.machine";
export * from "./imp.machine";
@@ -0,0 +1,33 @@
import type { StateMachine } from "./types";
export const subjectMachine: StateMachine = {
states: {
SCREENING: { value: "SCREENING", label: "筛选中", color: "info" },
ENROLLED: { value: "ENROLLED", label: "已入组", color: "success" },
COMPLETED: { value: "COMPLETED", label: "已完成", color: "success", isTerminal: true },
DROPPED: { value: "DROPPED", label: "已脱落", color: "danger", isTerminal: true },
},
actions: {
enroll: {
key: "enroll",
label: "标记入组",
from: ["SCREENING"],
to: "ENROLLED",
},
complete: {
key: "complete",
label: "标记完成",
from: ["ENROLLED"],
to: "COMPLETED",
},
drop: {
key: "drop",
label: "标记脱落",
from: ["SCREENING", "ENROLLED"],
to: "DROPPED",
danger: true,
confirm: true,
confirmText: "确定将该受试者标记为脱落?",
},
},
};
@@ -0,0 +1,16 @@
import type { StateMachine } from "./types";
export const taskMachine: StateMachine = {
states: {
TODO: { value: "TODO", label: "待处理", color: "info" },
DONE: { value: "DONE", label: "已完成", color: "success", isTerminal: true },
},
actions: {
complete: {
key: "complete",
label: "完成",
from: ["TODO"],
to: "DONE",
},
},
};
+21
View File
@@ -0,0 +1,21 @@
export interface StateConfig {
value: string;
label: string;
color?: string;
isTerminal?: boolean;
}
export interface ActionConfig {
key: string;
label: string;
from: string[];
to: string;
confirm?: boolean;
confirmText?: string;
danger?: boolean;
}
export interface StateMachine {
states: Record<string, StateConfig>;
actions: Record<string, ActionConfig>;
}
+25 -18
View File
@@ -6,18 +6,28 @@
<h3>AE不良事件</h3> <h3>AE不良事件</h3>
<p>{{ ae.term }}</p> <p>{{ ae.term }}</p>
</div> </div>
<div class="action-buttons">
<template v-for="action in availableActions" :key="action.key">
<PermissionAction action="ae.close"> <PermissionAction action="ae.close">
<el-button :type="ae.status === 'CLOSED' ? 'primary' : 'danger'" size="small" @click="toggleClose"> <el-button
{{ ae.status === "CLOSED" ? "重新打开" : "关闭" }} size="small"
:type="action.danger ? 'danger' : 'primary'"
@click="onAction(action)"
>
{{ action.label }}
</el-button> </el-button>
</PermissionAction> </PermissionAction>
</template>
</div>
</div> </div>
<el-descriptions :column="2" border> <el-descriptions :column="2" border>
<el-descriptions-item label="受试者ID">{{ ae.subject_id }}</el-descriptions-item> <el-descriptions-item label="受试者ID">{{ ae.subject_id }}</el-descriptions-item>
<el-descriptions-item label="严重性">{{ ae.seriousness }}</el-descriptions-item> <el-descriptions-item label="严重性">{{ ae.seriousness }}</el-descriptions-item>
<el-descriptions-item label="严重程度">{{ ae.severity }}</el-descriptions-item> <el-descriptions-item label="严重程度">{{ ae.severity }}</el-descriptions-item>
<el-descriptions-item label="发生日期">{{ ae.onset_date }}</el-descriptions-item> <el-descriptions-item label="发生日期">{{ ae.onset_date }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ statusLabel(ae.status) }}</el-descriptions-item> <el-descriptions-item label="状态">
<el-tag :type="stateColor(aeState)">{{ stateLabel(aeState) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="描述">{{ ae.description }}</el-descriptions-item> <el-descriptions-item label="描述">{{ ae.description }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-card> </el-card>
@@ -52,6 +62,8 @@ import CommentList from "../components/CommentList.vue";
import AttachmentList from "../components/attachments/AttachmentList.vue"; import AttachmentList from "../components/attachments/AttachmentList.vue";
import PermissionAction from "../components/PermissionAction.vue"; import PermissionAction from "../components/PermissionAction.vue";
import { usePermission } from "../utils/permission"; import { usePermission } from "../utils/permission";
import { aeMachine, getAvailableActions, getStateColor, getStateLabel } from "../state-machine";
import type { ActionConfig } from "../state-machine";
const route = useRoute(); const route = useRoute();
const study = useStudyStore(); const study = useStudyStore();
@@ -61,7 +73,10 @@ const ae = ref<any | null>(null);
const studyId = computed(() => study.currentStudy?.id || ""); const studyId = computed(() => study.currentStudy?.id || "");
const { can } = usePermission(); const { can } = usePermission();
const canClose = computed(() => can("ae.close")); const aeState = computed(() => (ae.value?.status as string) || "NEW");
const availableActions = computed(() => getAvailableActions(aeMachine, aeState.value));
const stateLabel = (v?: string | null) => getStateLabel(aeMachine, v);
const stateColor = (v?: string | null) => getStateColor(aeMachine, v) || "info";
const enrichOverdue = (item: any) => { const enrichOverdue = (item: any) => {
if (!item) return item; if (!item) return item;
@@ -84,15 +99,14 @@ const load = async () => {
} }
}; };
const toggleClose = async () => { const onAction = async (action: ActionConfig) => {
if (!study.currentStudy || !ae.value) return; if (!study.currentStudy || !ae.value) return;
const targetStatus = ae.value.status === "CLOSED" ? "FOLLOW_UP" : "CLOSED"; if (action.confirm) {
await ElMessageBox.confirm( const ok = await ElMessageBox.confirm(action.confirmText || "确认执行该操作?", "提示").catch(() => null);
`确定将该 AE 状态设置为 ${targetStatus === "CLOSED" ? "已关闭" : "随访中"}?该操作不可撤销,请谨慎确认。`, if (!ok) return;
"提示" }
);
try { try {
const resp = await updateAe(study.currentStudy.id, ae.value.id, { status: targetStatus }); 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);
await load(); await load();
@@ -101,13 +115,6 @@ const toggleClose = async () => {
} }
}; };
const statusLabel = (v: string) =>
({
NEW: "新增",
FOLLOW_UP: "随访中",
CLOSED: "已关闭",
}[v] || v);
onMounted(async () => { onMounted(async () => {
if (!auth.user && auth.token) { if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {}); await auth.fetchMe().catch(() => {});
+9 -26
View File
@@ -4,7 +4,7 @@
<div class="header"> <div class="header">
<div> <div>
<h3>{{ item?.title }}</h3> <h3>{{ item?.title }}</h3>
<el-tag :type="statusTag(item?.status)">{{ statusLabel(item?.status) }}</el-tag> <el-tag :type="stateColor(item?.status)">{{ stateLabel(item?.status) }}</el-tag>
</div> </div>
<div class="header-actions" v-if="item"> <div class="header-actions" v-if="item">
<PermissionAction action="finance.create"> <PermissionAction action="finance.create">
@@ -53,6 +53,7 @@ import { fetchFinanceItem } from "../api/finance";
import { useStudyStore } from "../store/study"; import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
import { usePermission } from "../utils/permission"; import { usePermission } from "../utils/permission";
import { financeMachine, getAvailableActions, getStateColor, getStateLabel } from "../state-machine";
const study = useStudyStore(); const study = useStudyStore();
const auth = useAuthStore(); const auth = useAuthStore();
@@ -62,7 +63,11 @@ const item = ref<any>(null);
const loading = ref(false); const loading = ref(false);
const showEdit = ref(false); const showEdit = ref(false);
const { can } = usePermission(); const { can } = usePermission();
const canEditDraft = computed(() => item.value?.status === "DRAFT" && can("finance.create")); const canEditDraft = computed(() => {
const actions = getAvailableActions(financeMachine, item.value?.status);
const submitAllowed = actions.some((a) => a.key === "submit");
return submitAllowed && can("finance.create");
});
const loadItem = async () => { const loadItem = async () => {
if (!study.currentStudy) return; if (!study.currentStudy) return;
@@ -78,30 +83,8 @@ const loadItem = async () => {
} }
}; };
const statusTag = (status?: string) => { const stateLabel = (v?: string | null) => getStateLabel(financeMachine, v);
switch (status) { const stateColor = (v?: string | null) => getStateColor(financeMachine, v) || "info";
case "DRAFT":
return "info";
case "SUBMITTED":
return "warning";
case "APPROVED":
return "success";
case "REJECTED":
return "danger";
case "PAID":
return "success";
default:
return "info";
}
};
const statusLabel = (status?: string) =>
({
DRAFT: "草稿",
SUBMITTED: "已提交",
APPROVED: "已审批",
REJECTED: "已驳回",
PAID: "已支付",
}[status || ""] || status || "");
const formatAmount = (num?: number | string) => { const formatAmount = (num?: number | string) => {
const n = Number(num); const n = Number(num);
+33 -15
View File
@@ -3,17 +3,22 @@
<el-card class="mb-12"> <el-card class="mb-12">
<h3>受试者 {{ subject.subject_no }}</h3> <h3>受试者 {{ subject.subject_no }}</h3>
<p>中心{{ subject.site_id }}</p> <p>中心{{ subject.site_id }}</p>
<p>状态{{ subject.status }}</p> <p>
状态
<el-tag :type="stateColor(subject.status)">{{ stateLabel(subject.status) }}</el-tag>
</p>
<div class="actions"> <div class="actions">
<PermissionAction action="subject.enroll"> <template v-for="action in availableActions" :key="action.key">
<el-button size="small" type="primary" @click="setStatus('ENROLLED')">标记入组</el-button> <PermissionAction :action="permissionMap[action.key]">
</PermissionAction> <el-button
<PermissionAction action="subject.complete"> size="small"
<el-button size="small" type="success" @click="setStatus('COMPLETED')">标记完成</el-button> :type="action.danger ? 'danger' : 'primary'"
</PermissionAction> @click="onAction(action)"
<PermissionAction action="subject.drop"> >
<el-button size="small" type="danger" @click="setStatus('DROPPED')">标记脱落</el-button> {{ action.label }}
</el-button>
</PermissionAction> </PermissionAction>
</template>
</div> </div>
</el-card> </el-card>
@@ -35,6 +40,8 @@ import { useAuthStore } from "../store/auth";
import PermissionAction from "../components/PermissionAction.vue"; import PermissionAction from "../components/PermissionAction.vue";
import { usePermission } from "../utils/permission"; import { usePermission } from "../utils/permission";
import VisitTable from "../components/VisitTable.vue"; import VisitTable from "../components/VisitTable.vue";
import { getAvailableActions, getStateColor, getStateLabel, subjectMachine } from "../state-machine";
import type { ActionConfig } from "../state-machine";
const route = useRoute(); const route = useRoute();
const study = useStudyStore(); const study = useStudyStore();
@@ -45,6 +52,14 @@ const visits = ref<any[]>([]);
const { can } = usePermission(); const { can } = usePermission();
const canVisitEdit = computed(() => can("subject.enroll")); const canVisitEdit = computed(() => can("subject.enroll"));
const permissionMap: Record<string, string> = {
enroll: "subject.enroll",
complete: "subject.complete",
drop: "subject.drop",
};
const availableActions = computed(() => getAvailableActions(subjectMachine, subject.value?.status));
const stateLabel = (v?: string | null) => getStateLabel(subjectMachine, v);
const stateColor = (v?: string | null) => getStateColor(subjectMachine, v) || "info";
const loadSubject = async () => { const loadSubject = async () => {
if (!study.currentStudy) return; if (!study.currentStudy) return;
@@ -68,13 +83,16 @@ const loadVisits = async () => {
} }
}; };
const setStatus = async (status: string) => { const onAction = async (action: ActionConfig) => {
await ElMessageBox.confirm(`确认将受试者状态设置为 ${status}`, "提示");
if (!study.currentStudy || !subject.value) return; if (!study.currentStudy || !subject.value) return;
const payload: Record<string, any> = { status }; if (action.confirm) {
if (status == "ENROLLED") payload.enrollment_date = new Date().toISOString().slice(0, 10); const ok = await ElMessageBox.confirm(action.confirmText || "确认执行该操作?", "提示").catch(() => null);
if (status == "COMPLETED") payload.completion_date = new Date().toISOString().slice(0, 10); if (!ok) return;
if (status == "DROPPED") payload.drop_reason = "Dropped"; }
const payload: Record<string, any> = { status: action.to };
if (action.to === "ENROLLED") payload.enrollment_date = new Date().toISOString().slice(0, 10);
if (action.to === "COMPLETED") payload.completion_date = new Date().toISOString().slice(0, 10);
if (action.to === "DROPPED") payload.drop_reason = "Dropped";
try { try {
await updateSubject(study.currentStudy.id, subject.value.id, payload); await updateSubject(study.currentStudy.id, subject.value.id, payload);
ElMessage.success("状态已更新"); ElMessage.success("状态已更新");