统一状态流转为前端状态机
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 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
<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(() => {});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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("状态已更新");
|
||||||
|
|||||||
Reference in New Issue
Block a user