统一状态流转为前端状态机
This commit is contained in:
+70
File diff suppressed because one or more lines are too long
-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
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-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>
|
||||
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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("状态已更新");
|
||||
|
||||
Reference in New Issue
Block a user