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

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 name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CTMS</title>
<script type="module" crossorigin src="/assets/index-DMpbNHFr.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C22MsXA0.css">
<script type="module" crossorigin src="/assets/index-BA1yIlA_.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DMvYZYMw.css">
</head>
<body>
<div id="app"></div>
@@ -1,64 +1,49 @@
<template>
<div class="actions">
<PermissionAction action="finance.create">
<el-button
v-if="item.status === 'DRAFT'"
type="primary"
size="small"
:loading="loading"
@click="change('SUBMITTED')"
>
提交
</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>
</PermissionAction>
<template v-for="action in availableActions" :key="action.key">
<PermissionAction :action="permissionMap[action.key]">
<el-button
size="small"
:type="buttonType(action)"
:loading="loading"
@click="onAction(action)"
>
{{ action.label }}
</el-button>
</PermissionAction>
</template>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { ref } from "vue";
import { computed, ref } from "vue";
import { changeFinanceStatus } from "../api/finance";
import { useStudyStore } from "../store/study";
import PermissionAction from "./PermissionAction.vue";
import { financeMachine, getAvailableActions } from "../state-machine";
import type { ActionConfig } from "../state-machine";
const props = defineProps<{ item: any }>();
const emit = defineEmits(["success"]);
const study = useStudyStore();
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) => {
if (!study.currentStudy) return;
@@ -74,13 +59,23 @@ const change = async (status: string, reject_reason?: string) => {
}
};
const reject = async () => {
const reason = await ElMessageBox.prompt("请输入驳回原因", "驳回审批", {
confirmButtonText: "确定",
cancelButtonText: "取消",
}).catch(() => null);
if (!reason || !reason.value) return;
change("REJECTED", reason.value);
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("请输入驳回原因", "驳回审批", {
confirmButtonText: "确定",
cancelButtonText: "取消",
}).catch(() => null);
if (!reason || !reason.value) return;
await change(action.to, reason.value);
return;
}
await change(action.to);
};
</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>;
}
+28 -21
View File
@@ -6,18 +6,28 @@
<h3>AE不良事件</h3>
<p>{{ ae.term }}</p>
</div>
<PermissionAction action="ae.close">
<el-button :type="ae.status === 'CLOSED' ? 'primary' : 'danger'" size="small" @click="toggleClose">
{{ ae.status === "CLOSED" ? "重新打开" : "关闭" }}
</el-button>
</PermissionAction>
<div class="action-buttons">
<template v-for="action in availableActions" :key="action.key">
<PermissionAction action="ae.close">
<el-button
size="small"
:type="action.danger ? 'danger' : 'primary'"
@click="onAction(action)"
>
{{ action.label }}
</el-button>
</PermissionAction>
</template>
</div>
</div>
<el-descriptions :column="2" border>
<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.severity }}</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>
</el-card>
@@ -52,6 +62,8 @@ import CommentList from "../components/CommentList.vue";
import AttachmentList from "../components/attachments/AttachmentList.vue";
import PermissionAction from "../components/PermissionAction.vue";
import { usePermission } from "../utils/permission";
import { aeMachine, getAvailableActions, getStateColor, getStateLabel } from "../state-machine";
import type { ActionConfig } from "../state-machine";
const route = useRoute();
const study = useStudyStore();
@@ -61,7 +73,10 @@ const ae = ref<any | null>(null);
const studyId = computed(() => study.currentStudy?.id || "");
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) => {
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;
const targetStatus = ae.value.status === "CLOSED" ? "FOLLOW_UP" : "CLOSED";
await ElMessageBox.confirm(
`确定将该 AE 状态设置为 ${targetStatus === "CLOSED" ? "已关闭" : "随访中"}?该操作不可撤销,请谨慎确认。`,
"提示"
);
if (action.confirm) {
const ok = await ElMessageBox.confirm(action.confirmText || "确认执行该操作?", "提示").catch(() => null);
if (!ok) return;
}
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("状态已更新");
ae.value = enrichOverdue(resp.data);
await load();
@@ -101,13 +115,6 @@ const toggleClose = async () => {
}
};
const statusLabel = (v: string) =>
({
NEW: "新增",
FOLLOW_UP: "随访中",
CLOSED: "已关闭",
}[v] || v);
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
+9 -26
View File
@@ -4,7 +4,7 @@
<div class="header">
<div>
<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 class="header-actions" v-if="item">
<PermissionAction action="finance.create">
@@ -53,6 +53,7 @@ import { fetchFinanceItem } from "../api/finance";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import { usePermission } from "../utils/permission";
import { financeMachine, getAvailableActions, getStateColor, getStateLabel } from "../state-machine";
const study = useStudyStore();
const auth = useAuthStore();
@@ -62,7 +63,11 @@ const item = ref<any>(null);
const loading = ref(false);
const showEdit = ref(false);
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 () => {
if (!study.currentStudy) return;
@@ -78,30 +83,8 @@ const loadItem = async () => {
}
};
const statusTag = (status?: string) => {
switch (status) {
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 stateLabel = (v?: string | null) => getStateLabel(financeMachine, v);
const stateColor = (v?: string | null) => getStateColor(financeMachine, v) || "info";
const formatAmount = (num?: number | string) => {
const n = Number(num);
+34 -16
View File
@@ -3,17 +3,22 @@
<el-card class="mb-12">
<h3>受试者 {{ subject.subject_no }}</h3>
<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">
<PermissionAction action="subject.enroll">
<el-button size="small" type="primary" @click="setStatus('ENROLLED')">标记入组</el-button>
</PermissionAction>
<PermissionAction action="subject.complete">
<el-button size="small" type="success" @click="setStatus('COMPLETED')">标记完成</el-button>
</PermissionAction>
<PermissionAction action="subject.drop">
<el-button size="small" type="danger" @click="setStatus('DROPPED')">标记脱落</el-button>
</PermissionAction>
<template v-for="action in availableActions" :key="action.key">
<PermissionAction :action="permissionMap[action.key]">
<el-button
size="small"
:type="action.danger ? 'danger' : 'primary'"
@click="onAction(action)"
>
{{ action.label }}
</el-button>
</PermissionAction>
</template>
</div>
</el-card>
@@ -35,6 +40,8 @@ import { useAuthStore } from "../store/auth";
import PermissionAction from "../components/PermissionAction.vue";
import { usePermission } from "../utils/permission";
import VisitTable from "../components/VisitTable.vue";
import { getAvailableActions, getStateColor, getStateLabel, subjectMachine } from "../state-machine";
import type { ActionConfig } from "../state-machine";
const route = useRoute();
const study = useStudyStore();
@@ -45,6 +52,14 @@ const visits = ref<any[]>([]);
const { can } = usePermission();
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 () => {
if (!study.currentStudy) return;
@@ -68,13 +83,16 @@ const loadVisits = async () => {
}
};
const setStatus = async (status: string) => {
await ElMessageBox.confirm(`确认将受试者状态设置为 ${status}`, "提示");
const onAction = async (action: ActionConfig) => {
if (!study.currentStudy || !subject.value) return;
const payload: Record<string, any> = { status };
if (status == "ENROLLED") payload.enrollment_date = new Date().toISOString().slice(0, 10);
if (status == "COMPLETED") payload.completion_date = new Date().toISOString().slice(0, 10);
if (status == "DROPPED") payload.drop_reason = "Dropped";
if (action.confirm) {
const ok = await ElMessageBox.confirm(action.confirmText || "确认执行该操作?", "提示").catch(() => null);
if (!ok) return;
}
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 {
await updateSubject(study.currentStudy.id, subject.value.id, payload);
ElMessage.success("状态已更新");