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

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
@@ -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("状态已更新");