细节优化——1
This commit is contained in:
+358
-84
@@ -5,17 +5,10 @@
|
||||
<h1 class="ctms-page-title">不良事件</h1>
|
||||
</div>
|
||||
<div class="ctms-page-actions">
|
||||
<template v-for="action in availableActions" :key="action.key">
|
||||
<PermissionAction action="ae.close">
|
||||
<el-button
|
||||
size="small"
|
||||
:type="action.danger ? 'danger' : 'primary'"
|
||||
:link="action.danger"
|
||||
@click="onAction(action)"
|
||||
>
|
||||
{{ action.label }}
|
||||
</el-button>
|
||||
</PermissionAction>
|
||||
<el-button v-if="canEdit && !editing" type="primary" size="small" @click="startEdit">编辑</el-button>
|
||||
<template v-else-if="canEdit && editing">
|
||||
<el-button size="small" @click="cancelEdit">取消</el-button>
|
||||
<el-button type="primary" size="small" :loading="saving" @click="saveEdit">保存</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -27,14 +20,56 @@
|
||||
</div>
|
||||
</template>
|
||||
<el-descriptions :column="2" border class="detail-descriptions">
|
||||
<el-descriptions-item label="受试者">{{ subjectName(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="状态">
|
||||
<el-tag :type="stateColor(aeState)" effect="plain">{{ stateLabel(aeState) }}</el-tag>
|
||||
<el-descriptions-item label="受试者">
|
||||
<el-select v-if="editing" v-model="form.subject_id" placeholder="可留空" filterable class="inline-input">
|
||||
<el-option v-for="s in subjectOptions" :key="s.value" :label="s.label" :value="s.value" />
|
||||
</el-select>
|
||||
<span v-else>{{ subjectName(ae.subject_id) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="事件描述">
|
||||
<el-input v-if="editing" v-model="form.term" class="inline-input" />
|
||||
<span v-else>{{ ae.term || "—" }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="SAE">
|
||||
<el-select v-if="editing" v-model="form.seriousness" placeholder="请选择" class="inline-input">
|
||||
<el-option label="是" value="SERIOUS" />
|
||||
<el-option label="否" value="NON_SERIOUS" />
|
||||
</el-select>
|
||||
<span v-else>{{ seriousnessLabel(ae.seriousness) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="严重程度">
|
||||
<el-select v-if="editing" v-model="form.severity" placeholder="请选择" class="inline-input">
|
||||
<el-option v-for="s in severities" :key="s" :label="severityLabel(s)" :value="s" />
|
||||
</el-select>
|
||||
<span v-else>{{ severityLabel(ae.severity) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="发生日期">
|
||||
<el-date-picker
|
||||
v-if="editing"
|
||||
v-model="form.onset_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
class="inline-input"
|
||||
:disabled-date="disableFutureDate"
|
||||
/>
|
||||
<span v-else>{{ ae.onset_date || "—" }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-select v-if="editing" v-model="form.status" placeholder="请选择" class="inline-input">
|
||||
<el-option v-for="s in statusOptions" :key="s" :label="stateLabel(s)" :value="s" />
|
||||
</el-select>
|
||||
<el-tag v-else :type="stateColor(aeState)" effect="plain">{{ stateLabel(aeState) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="描述" :span="2">
|
||||
<el-input v-if="editing" v-model="form.description" type="textarea" class="inline-input" />
|
||||
<span v-else>{{ ae.description || "—" }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="转归结局" :span="2">
|
||||
<el-select v-if="editing" v-model="form.outcome" placeholder="可留空" clearable class="inline-input">
|
||||
<el-option v-for="o in outcomes" :key="o" :label="o" :value="o" />
|
||||
</el-select>
|
||||
<span v-else>{{ ae.outcome || "—" }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="描述">{{ ae.description }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
@@ -56,6 +91,57 @@
|
||||
:show-uploader="true"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="变更记录">
|
||||
<el-timeline v-if="auditLogs.length">
|
||||
<el-timeline-item v-for="log in auditLogs" :key="log.id" :timestamp="displayDateTime(log.created_at)">
|
||||
<div class="audit-item">
|
||||
<div class="audit-meta">
|
||||
<strong>{{ displayUser(log.operator_id, { members: memberMap }) }}</strong>
|
||||
<span class="audit-action">{{ log.action }}</span>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="canDeleteAudit"
|
||||
type="danger"
|
||||
text
|
||||
size="small"
|
||||
@click="onDeleteAuditLog(log.id)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="log._detail" class="audit-detail-grid">
|
||||
<div class="audit-detail-row">
|
||||
<span class="audit-detail-label">事件</span>
|
||||
<span class="audit-detail-value">{{ formatAuditField(log._detail, "event") }}</span>
|
||||
</div>
|
||||
<div class="audit-detail-row">
|
||||
<span class="audit-detail-label">发生日期</span>
|
||||
<span class="audit-detail-value">
|
||||
{{ formatAuditField(log._detail, "onset_date") }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="audit-detail-row">
|
||||
<span class="audit-detail-label">严重程度</span>
|
||||
<span class="audit-detail-value">
|
||||
{{ formatAuditField(log._detail, "severity", severityLabel) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="audit-detail-row">
|
||||
<span class="audit-detail-label">状态</span>
|
||||
<span class="audit-detail-value">
|
||||
{{ formatAuditField(log._detail, "status", stateLabel) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="audit-detail-row">
|
||||
<span class="audit-detail-label">描述</span>
|
||||
<span class="audit-detail-value">{{ formatAuditField(log._detail, "description") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="audit-detail">{{ log.detail || "—" }}</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<div v-else class="empty">暂无变更记录</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
</div>
|
||||
@@ -65,23 +151,21 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { fetchAes, updateAe } from "../api/aes";
|
||||
import { fetchSubjects } from "../api/subjects";
|
||||
import { deleteAuditLog, fetchAuditLogs } from "../api/auditLogs";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import CommentList from "../components/CommentList.vue";
|
||||
import AttachmentList from "../components/attachments/AttachmentList.vue";
|
||||
import PermissionAction from "../components/PermissionAction.vue";
|
||||
import StateLoading from "../components/StateLoading.vue";
|
||||
import { usePermission } from "../utils/permission";
|
||||
import { aeMachine, getAvailableActions } from "../state-machine";
|
||||
import type { ActionConfig } from "../state-machine";
|
||||
import { statusDict, getDictColor, getDictLabel } from "../dictionaries";
|
||||
import { evaluateAction } from "../guards/actionGuard";
|
||||
import { logAudit } from "../audit";
|
||||
import { listMembers } from "../api/members";
|
||||
import { displayDateTime, displayUser, getMemberDisplayName } from "../utils/display";
|
||||
|
||||
const route = useRoute();
|
||||
const study = useStudyStore();
|
||||
@@ -90,12 +174,76 @@ const auth = useAuthStore();
|
||||
const ae = ref<any | null>(null);
|
||||
const studyId = computed(() => study.currentStudy?.id || "");
|
||||
const subjects = ref<any[]>([]);
|
||||
const members = ref<any[]>([]);
|
||||
const auditLogs = ref<any[]>([]);
|
||||
const editing = ref(false);
|
||||
const saving = ref(false);
|
||||
const form = reactive({
|
||||
subject_id: "",
|
||||
term: "",
|
||||
onset_date: "",
|
||||
seriousness: "NON_SERIOUS",
|
||||
severity: "G1",
|
||||
status: "NEW",
|
||||
description: "",
|
||||
outcome: "",
|
||||
});
|
||||
const severities = ["G1", "G2", "G3", "G4", "G5"];
|
||||
const statusOptions = ["FOLLOW_UP", "CLOSED"];
|
||||
const outcomes = ["痊愈", "好转", "持续", "恶化", "死亡", "其他"];
|
||||
const severityLabel = (v?: string | null) =>
|
||||
({
|
||||
G1: "I级",
|
||||
G2: "II级",
|
||||
G3: "III级",
|
||||
G4: "IV级",
|
||||
G5: "V级",
|
||||
}[v || ""] || v || "—");
|
||||
const seriousnessLabel = (v?: string | null) =>
|
||||
({ SERIOUS: "是", NON_SERIOUS: "否" }[v || ""] || v || "—");
|
||||
|
||||
const { can } = usePermission();
|
||||
const canEdit = computed(() => can("ae.create"));
|
||||
const canDeleteAudit = computed(() => auth.user?.role === "ADMIN");
|
||||
const aeState = computed(() => (ae.value?.status as string) || "NEW");
|
||||
const availableActions = computed(() => getAvailableActions(aeMachine, aeState.value));
|
||||
const stateLabel = (v?: string | null) => getDictLabel(statusDict, v || "");
|
||||
const stateLabel = (v?: string | null) =>
|
||||
({ FOLLOW_UP: "随访中", CLOSED: "结束", NEW: "随访中" }[v || ""] || getDictLabel(statusDict, v || ""));
|
||||
const stateColor = (v?: string | null) => getDictColor(statusDict, v || "") || "info";
|
||||
const disableFutureDate = (time: Date) => time.getTime() > Date.now();
|
||||
const parseAuditDetail = (detail: any) => {
|
||||
if (!detail) return null;
|
||||
if (typeof detail === "object") return detail;
|
||||
if (typeof detail !== "string") return null;
|
||||
try {
|
||||
const parsed = JSON.parse(detail);
|
||||
if (parsed && typeof parsed === "object") return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const formatAuditValue = (value: any, formatter?: (val?: string | null) => string) => {
|
||||
if (value === null || value === undefined || value === "") return "—";
|
||||
return formatter ? formatter(value) : String(value);
|
||||
};
|
||||
const formatAuditField = (
|
||||
detail: any,
|
||||
key: string,
|
||||
formatter?: (val?: string | null) => string
|
||||
) => {
|
||||
const before = detail?.before?.[key];
|
||||
const after = detail?.after?.[key];
|
||||
if (before !== undefined || after !== undefined) {
|
||||
const beforeLabel = formatAuditValue(before, formatter);
|
||||
const afterLabel = formatAuditValue(after, formatter);
|
||||
if (beforeLabel === "—" && afterLabel === "—") return "—";
|
||||
if (beforeLabel === afterLabel) return afterLabel;
|
||||
if (beforeLabel === "—") return afterLabel;
|
||||
if (afterLabel === "—") return beforeLabel;
|
||||
return `${beforeLabel} → ${afterLabel}`;
|
||||
}
|
||||
return formatAuditValue(detail?.[key], formatter);
|
||||
};
|
||||
|
||||
const enrichOverdue = (item: any) => {
|
||||
if (!item) return item;
|
||||
@@ -135,79 +283,156 @@ const subjectLabel = computed(() => {
|
||||
}, {});
|
||||
return map;
|
||||
});
|
||||
const subjectOptions = computed(() =>
|
||||
subjects.value.map((s: any) => ({ value: s.id, label: s.subject_no || s.id }))
|
||||
);
|
||||
|
||||
const subjectName = (id?: string | null) => {
|
||||
if (!id) return "—";
|
||||
return subjectLabel.value[id] || id;
|
||||
};
|
||||
|
||||
const memberMap = computed(() =>
|
||||
members.value.reduce<Record<string, string>>((acc, cur) => {
|
||||
const username = getMemberDisplayName(cur);
|
||||
if (cur?.user_id && username) acc[cur.user_id] = username;
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
|
||||
const onAction = async (action: ActionConfig) => {
|
||||
if (!study.currentStudy || !ae.value) return;
|
||||
const prevStatus = ae.value.status;
|
||||
if (prevStatus === action.to) {
|
||||
ElMessage.warning("当前状态不允许该操作");
|
||||
logAudit("INVALID_STATE_TRANSITION_ATTEMPT", {
|
||||
targetId: ae.value.id,
|
||||
targetName: ae.value.term,
|
||||
before: { status: prevStatus },
|
||||
after: { status: action.to },
|
||||
severity: "warning",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const decision = evaluateAction({
|
||||
actorRole: auth.user?.role || null,
|
||||
requiredPermission: "ae.close",
|
||||
stateMachine: aeMachine,
|
||||
currentState: ae.value.status,
|
||||
actionKey: action.key,
|
||||
target: { id: ae.value.id },
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
ElMessage.warning(decision.reason || "当前不可执行该操作");
|
||||
logAudit(decision.auditType, {
|
||||
targetId: ae.value.id,
|
||||
targetName: ae.value.term,
|
||||
reason: decision.reason,
|
||||
severity: decision.severity,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (action.confirm) {
|
||||
const ok = await ElMessageBox.confirm(action.confirmText || "确认执行该操作?", "提示").catch(() => null);
|
||||
if (!ok) return;
|
||||
}
|
||||
const loadMembers = async () => {
|
||||
if (!study.currentStudy) return;
|
||||
try {
|
||||
const resp = await updateAe(study.currentStudy.id, ae.value.id, { status: action.to });
|
||||
ElMessage.success("状态已更新");
|
||||
ae.value = enrichOverdue(resp.data);
|
||||
logAudit("AE_CLOSED", {
|
||||
targetId: ae.value.id,
|
||||
targetName: ae.value.term,
|
||||
before: { status: prevStatus },
|
||||
after: { status: action.to },
|
||||
severity: "normal",
|
||||
});
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "更新失败");
|
||||
logAudit("AE_CLOSED", {
|
||||
targetId: ae.value.id,
|
||||
targetName: ae.value.term,
|
||||
before: { status: prevStatus },
|
||||
after: { status: action.to },
|
||||
severity: "warning",
|
||||
reason: e?.response?.data?.message,
|
||||
});
|
||||
const { data } = await listMembers(study.currentStudy.id, { limit: 500 });
|
||||
members.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch {
|
||||
members.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const loadAuditLogs = async () => {
|
||||
if (!study.currentStudy || !ae.value) return;
|
||||
try {
|
||||
const { data } = await fetchAuditLogs(study.currentStudy.id, {
|
||||
entity_type: "ae",
|
||||
entity_id: ae.value.id,
|
||||
limit: 200,
|
||||
});
|
||||
const logs = Array.isArray(data) ? data : data.items || [];
|
||||
auditLogs.value = logs.map((log: any) => ({
|
||||
...log,
|
||||
_detail: parseAuditDetail(log.detail),
|
||||
}));
|
||||
} catch {
|
||||
auditLogs.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteAuditLog = async (logId: string) => {
|
||||
if (!study.currentStudy) return;
|
||||
try {
|
||||
await ElMessageBox.confirm("确认删除该变更记录?", "提示", { type: "warning" });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteAuditLog(study.currentStudy.id, logId);
|
||||
auditLogs.value = auditLogs.value.filter((log) => log.id !== logId);
|
||||
ElMessage.success("已删除");
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
const syncForm = () => {
|
||||
if (!ae.value) return;
|
||||
form.subject_id = ae.value.subject_id || "";
|
||||
form.term = ae.value.term || "";
|
||||
form.onset_date = ae.value.onset_date || "";
|
||||
form.seriousness = ae.value.seriousness || "NON_SERIOUS";
|
||||
form.severity = ae.value.severity || "G1";
|
||||
form.status = statusOptions.includes(ae.value.status) ? ae.value.status : "FOLLOW_UP";
|
||||
form.description = ae.value.description || "";
|
||||
form.outcome = ae.value.outcome || "";
|
||||
};
|
||||
|
||||
const startEdit = () => {
|
||||
syncForm();
|
||||
editing.value = true;
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
editing.value = false;
|
||||
syncForm();
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!study.currentStudy || !ae.value) return;
|
||||
if (!form.term.trim()) {
|
||||
ElMessage.warning("请输入事件描述");
|
||||
return;
|
||||
}
|
||||
if (!form.onset_date) {
|
||||
ElMessage.warning("请选择发生日期");
|
||||
return;
|
||||
}
|
||||
if (form.onset_date && new Date(form.onset_date) > new Date()) {
|
||||
ElMessage.warning("发生日期不得晚于当前日期");
|
||||
return;
|
||||
}
|
||||
if (!form.seriousness) {
|
||||
ElMessage.warning("请选择 SAE");
|
||||
return;
|
||||
}
|
||||
if (!form.severity) {
|
||||
ElMessage.warning("请选择严重程度");
|
||||
return;
|
||||
}
|
||||
if (!form.status) {
|
||||
ElMessage.warning("请选择状态");
|
||||
return;
|
||||
}
|
||||
if (form.status === "CLOSED" && !form.outcome) {
|
||||
ElMessage.warning("状态为结束时,请选择转归结局");
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload: Record<string, any> = {};
|
||||
Object.entries(form).forEach(([k, v]) => {
|
||||
if (v !== "" && v !== null && v !== undefined) {
|
||||
payload[k] = v;
|
||||
}
|
||||
});
|
||||
if (form.outcome === "" || form.outcome === null || form.outcome === undefined) {
|
||||
payload.outcome = null;
|
||||
}
|
||||
const resp = await updateAe(study.currentStudy.id, ae.value.id, payload);
|
||||
ae.value = enrichOverdue(resp.data);
|
||||
ElMessage.success("已保存");
|
||||
editing.value = false;
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "保存失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => ae.value,
|
||||
() => {
|
||||
if (!editing.value) {
|
||||
syncForm();
|
||||
}
|
||||
loadAuditLogs();
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.user && auth.token) {
|
||||
await auth.fetchMe().catch(() => {});
|
||||
}
|
||||
await loadSubjects();
|
||||
await Promise.all([loadSubjects(), loadMembers()]);
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
@@ -220,4 +445,53 @@ onMounted(async () => {
|
||||
.detail-tabs {
|
||||
margin-top: -8px;
|
||||
}
|
||||
.audit-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.audit-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.audit-action {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
background: #f1f5f9;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.audit-detail {
|
||||
margin-top: 4px;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
}
|
||||
.audit-detail-grid {
|
||||
margin-top: 6px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.audit-detail-row {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
.audit-detail-label {
|
||||
color: #94a3b8;
|
||||
}
|
||||
.audit-detail-value {
|
||||
color: #475569;
|
||||
word-break: break-word;
|
||||
}
|
||||
.empty {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
.inline-input {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
+51
-94
@@ -18,7 +18,7 @@
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="ctms-filter-item">
|
||||
<span class="ctms-filter-label">严重性</span>
|
||||
<span class="ctms-filter-label">SAE</span>
|
||||
<el-select v-model="filters.seriousness" placeholder="全部" clearable @change="loadAes">
|
||||
<el-option v-for="s in seriousnessOptions" :key="s" :label="seriousnessLabel(s)" :value="s" />
|
||||
</el-select>
|
||||
@@ -39,31 +39,27 @@
|
||||
{{ subjectMap[scope.row.subject_id] || scope.row.subject_id || "-" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="seriousness" label="严重性" width="140">
|
||||
<el-table-column label="中心" width="200">
|
||||
<template #default="scope">
|
||||
{{ siteName(subjectSiteMap[scope.row.subject_id]) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="seriousness" label="SAE" width="120">
|
||||
<template #default="scope">
|
||||
{{ seriousnessLabel(scope.row.seriousness) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="severity" label="严重程度" width="140">
|
||||
<template #default="scope">
|
||||
{{ severityLabel(scope.row.severity) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="onset_date" label="发生日期" width="140" />
|
||||
<el-table-column prop="report_due_date" label="报告截止" width="140" />
|
||||
<el-table-column prop="status" label="状态" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.is_overdue ? 'danger' : 'info'">{{ statusLabel(scope.row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" align="right">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
v-if="showClose(scope.row)"
|
||||
type="danger"
|
||||
size="small"
|
||||
link
|
||||
@click.stop="onClose(scope.row)"
|
||||
>
|
||||
关闭 AE
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
class="ctms-pagination"
|
||||
@@ -82,15 +78,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { fetchAes, updateAe } from "../api/aes";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { fetchAes } from "../api/aes";
|
||||
import { fetchSubjects } from "../api/subjects";
|
||||
import { fetchSites } from "../api/sites";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import AeForm from "../components/AeForm.vue";
|
||||
import { aeMachine, getAvailableActions } from "../state-machine";
|
||||
import { evaluateAction } from "../guards/actionGuard";
|
||||
import { logAudit } from "../audit";
|
||||
import { aeSeriousnessDict, aeSeverityDict, getDictLabel } from "../dictionaries";
|
||||
|
||||
const study = useStudyStore();
|
||||
const auth = useAuthStore();
|
||||
@@ -102,14 +97,27 @@ const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = 10;
|
||||
const subjects = ref<any[]>([]);
|
||||
const sites = ref<any[]>([]);
|
||||
const subjectMap = computed(() =>
|
||||
subjects.value.reduce<Record<string, string>>((acc, cur) => {
|
||||
acc[cur.id] = cur.subject_no || cur.id;
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
const subjectSiteMap = computed(() =>
|
||||
subjects.value.reduce<Record<string, string>>((acc, cur) => {
|
||||
if (cur?.id) acc[cur.id] = cur.site_id || "";
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
const siteMap = computed(() =>
|
||||
sites.value.reduce<Record<string, string>>((acc, cur) => {
|
||||
if (cur?.id) acc[cur.id] = cur.name || cur.id;
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
|
||||
const statuses = ["NEW", "FOLLOW_UP", "CLOSED"];
|
||||
const statuses = ["FOLLOW_UP", "CLOSED"];
|
||||
const seriousnessOptions = ["SERIOUS", "NON_SERIOUS"];
|
||||
|
||||
const filters = ref({
|
||||
@@ -130,7 +138,7 @@ const loadAes = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params: Record<string, any> = { skip: (page.value - 1) * pageSize, limit: pageSize };
|
||||
if (filters.value.status) params.status = filters.value.status;
|
||||
if (filters.value.status) params.status_filter = filters.value.status;
|
||||
if (filters.value.seriousness) params.seriousness = filters.value.seriousness;
|
||||
if (filters.value.overdue) params.overdue = true;
|
||||
const { data } = await fetchAes(study.currentStudy.id, params);
|
||||
@@ -171,92 +179,41 @@ const loadSubjects = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadSites = async () => {
|
||||
if (!study.currentStudy) return;
|
||||
try {
|
||||
const { data } = await fetchSites(study.currentStudy.id, { skip: 0, limit: 500 });
|
||||
sites.value = data.items || data || [];
|
||||
} catch {
|
||||
sites.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const goDetail = (row: any) => {
|
||||
router.push(`/study/aes/${row.id}`);
|
||||
};
|
||||
|
||||
const siteName = (id?: string) => {
|
||||
if (!id) return "-";
|
||||
return siteMap.value[id] || id;
|
||||
};
|
||||
|
||||
const statusLabel = (v: string) =>
|
||||
({
|
||||
NEW: "新增",
|
||||
FOLLOW_UP: "随访中",
|
||||
CLOSED: "已关闭",
|
||||
CLOSED: "结束",
|
||||
NEW: "随访中",
|
||||
}[v] || v);
|
||||
|
||||
const seriousnessLabel = (v: string) =>
|
||||
({
|
||||
SERIOUS: "严重",
|
||||
NON_SERIOUS: "非严重",
|
||||
}[v] || v);
|
||||
const seriousnessLabel = (v: string) => getDictLabel(aeSeriousnessDict, v) || v;
|
||||
const severityLabel = (v: string) => getDictLabel(aeSeverityDict, v) || v;
|
||||
|
||||
const showClose = (row: any) => {
|
||||
const actions = getAvailableActions(aeMachine, row.status);
|
||||
const has = actions.some((a) => a.key === "close");
|
||||
if (!has) return false;
|
||||
const decision = evaluateAction({
|
||||
actorRole: auth.user?.role || null,
|
||||
requiredPermission: "ae.close",
|
||||
stateMachine: aeMachine,
|
||||
currentState: row.status,
|
||||
actionKey: "close",
|
||||
});
|
||||
return decision.allowed;
|
||||
};
|
||||
|
||||
const onClose = async (row: any) => {
|
||||
if (!study.currentStudy) return;
|
||||
const action = getAvailableActions(aeMachine, row.status).find((a) => a.key === "close");
|
||||
const decision = evaluateAction({
|
||||
actorRole: auth.user?.role || null,
|
||||
requiredPermission: "ae.close",
|
||||
stateMachine: aeMachine,
|
||||
currentState: row.status,
|
||||
actionKey: "close",
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
ElMessage.warning(decision.reason || "当前不可执行该操作");
|
||||
logAudit(decision.auditType, {
|
||||
targetId: row.id,
|
||||
targetName: row.term,
|
||||
reason: decision.reason,
|
||||
severity: decision.severity,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (action?.confirm) {
|
||||
const ok = await ElMessageBox.confirm(action.confirmText || "确认关闭?", "提示", {
|
||||
type: action.danger ? "warning" : "info",
|
||||
}).catch(() => null);
|
||||
if (!ok) return;
|
||||
}
|
||||
try {
|
||||
await updateAe(study.currentStudy.id, row.id, { status: action?.to || "CLOSED" });
|
||||
ElMessage.success("已关闭");
|
||||
logAudit("AE_CLOSED", {
|
||||
targetId: row.id,
|
||||
targetName: row.term,
|
||||
before: { status: row.status },
|
||||
after: { status: action?.to || "CLOSED" },
|
||||
severity: "normal",
|
||||
});
|
||||
loadAes();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "操作失败");
|
||||
logAudit("AE_CLOSED", {
|
||||
targetId: row.id,
|
||||
targetName: row.term,
|
||||
before: { status: row.status },
|
||||
after: { status: action?.to || "CLOSED" },
|
||||
severity: "warning",
|
||||
reason: e?.response?.data?.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.user && auth.token) {
|
||||
await auth.fetchMe().catch(() => {});
|
||||
}
|
||||
await loadSubjects();
|
||||
await Promise.all([loadSubjects(), loadSites()]);
|
||||
loadAes();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
</div>
|
||||
<div class="ctms-filter-item">
|
||||
<span class="ctms-filter-label">负责人</span>
|
||||
<el-input v-model="filters.assigned_to" placeholder="输入邮箱或姓名" @change="loadQueries" />
|
||||
<el-select v-model="filters.assigned_to" placeholder="负责人" clearable filterable @change="loadQueries">
|
||||
<el-option v-for="m in memberOptions" :key="m.value" :label="m.label" :value="m.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="ctms-filter-item">
|
||||
<span class="ctms-filter-label">仅逾期</span>
|
||||
@@ -86,12 +88,14 @@ import { useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { fetchDataQueries, updateDataQuery } from "../api/dataQueries";
|
||||
import { fetchSubjects } from "../api/subjects";
|
||||
import { listMembers } from "../api/members";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import DataQueryForm from "../components/DataQueryForm.vue";
|
||||
import { evaluateAction } from "../guards/actionGuard";
|
||||
import { logAudit } from "../audit";
|
||||
import { getAvailableActions, type StateMachine } from "../state-machine";
|
||||
import { getMemberDisplayName } from "../utils/display";
|
||||
|
||||
const study = useStudyStore();
|
||||
const auth = useAuthStore();
|
||||
@@ -103,12 +107,21 @@ const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = 10;
|
||||
const subjects = ref<any[]>([]);
|
||||
const members = ref<any[]>([]);
|
||||
const subjectMap = computed(() =>
|
||||
subjects.value.reduce<Record<string, string>>((acc, cur) => {
|
||||
acc[cur.id] = cur.subject_no || cur.id;
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
const memberOptions = computed(() =>
|
||||
members.value
|
||||
.map((m: any) => ({
|
||||
value: m.user_id,
|
||||
label: getMemberDisplayName(m) || m.user_id,
|
||||
}))
|
||||
.filter((m: any) => m.value)
|
||||
);
|
||||
|
||||
const statuses = ["OPEN", "IN_PROGRESS", "ANSWERED", "CLOSED"];
|
||||
const categories = ["MISSING", "INCONSISTENT", "OUT_OF_RANGE", "OTHER"];
|
||||
@@ -150,7 +163,7 @@ const loadQueries = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params: Record<string, any> = { skip: (page.value - 1) * pageSize, limit: pageSize };
|
||||
if (filters.value.status) params.status = filters.value.status;
|
||||
if (filters.value.status) params.status_filter = filters.value.status;
|
||||
if (filters.value.assigned_to) params.assigned_to = filters.value.assigned_to;
|
||||
if (filters.value.overdue) params.overdue = true;
|
||||
const { data } = await fetchDataQueries(study.currentStudy.id, params);
|
||||
@@ -175,6 +188,26 @@ const loadQueries = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadSubjects = async () => {
|
||||
if (!study.currentStudy) return;
|
||||
try {
|
||||
const { data } = await fetchSubjects(study.currentStudy.id, { skip: 0, limit: 500 });
|
||||
subjects.value = data.items || data || [];
|
||||
} catch {
|
||||
subjects.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const loadMembers = async () => {
|
||||
if (!study.currentStudy) return;
|
||||
try {
|
||||
const { data } = await listMembers(study.currentStudy.id, { limit: 500 });
|
||||
members.value = Array.isArray(data) ? data : (data as any).items || [];
|
||||
} catch {
|
||||
members.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const onPageChange = (p: number) => {
|
||||
page.value = p;
|
||||
loadQueries();
|
||||
@@ -272,10 +305,7 @@ onMounted(async () => {
|
||||
if (!auth.user && auth.token) {
|
||||
await auth.fetchMe().catch(() => {});
|
||||
}
|
||||
if (study.currentStudy) {
|
||||
const { data } = await fetchSubjects(study.currentStudy.id, { skip: 0, limit: 500 });
|
||||
subjects.value = data.items || data || [];
|
||||
}
|
||||
await Promise.all([loadSubjects(), loadMembers()]);
|
||||
loadQueries();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -80,9 +80,9 @@ const canEdit = computed(() => can("faq.edit"));
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
if (!study.currentStudy) return;
|
||||
const params: Record<string, any> = {};
|
||||
if (study.currentStudy) params.study_id = study.currentStudy.id;
|
||||
params.include_global = false;
|
||||
const { data } = await fetchFaqCategories(params);
|
||||
categories.value = data.items || data || [];
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -44,53 +44,39 @@
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="canReply" class="reply-form">
|
||||
<div v-if="quoteReply" class="quote-box">
|
||||
<div class="quote-meta">
|
||||
引用 {{ displayUser(quoteReply.created_by, { members: memberMap, users: userMap }) }}
|
||||
· {{ displayDateTime(quoteReply.created_at) }}
|
||||
<el-button type="text" size="small" @click="clearQuote">取消引用</el-button>
|
||||
</div>
|
||||
<div class="quote-content">{{ quoteReply.content }}</div>
|
||||
</div>
|
||||
<el-input v-model="replyContent" type="textarea" :rows="4" placeholder="请输入回复内容" />
|
||||
<div class="reply-actions">
|
||||
<el-button type="primary" :loading="submitting" @click="submitReply">提交回复</el-button>
|
||||
</div>
|
||||
<ThreadComposer
|
||||
v-model="replyContent"
|
||||
v-model:file-list="replyFiles"
|
||||
:submitting="submitting"
|
||||
:quote-item="quoteReply"
|
||||
:member-map="memberMap"
|
||||
:user-map="userMap"
|
||||
:allow-attachments="true"
|
||||
@submit="submitReply"
|
||||
@clear-quote="clearQuote"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="reply-disabled">当前角色无权限回复</div>
|
||||
<el-divider v-if="replies.length" />
|
||||
<el-timeline v-if="replies.length">
|
||||
<el-timeline-item v-for="r in replies" :key="r.id" :timestamp="displayDateTime(r.created_at)">
|
||||
<div class="reply-item">
|
||||
<div class="reply-meta">
|
||||
<strong>{{ displayUser(r.created_by, { members: memberMap, users: userMap }) }}</strong>
|
||||
<div class="reply-actions-inline">
|
||||
<el-tag v-if="item?.best_reply_id === r.id" type="success" size="small">最佳答案</el-tag>
|
||||
<el-button v-if="canReply" type="text" size="small" @click="setQuote(r)">引用</el-button>
|
||||
<el-button
|
||||
v-if="canSelectBest && !r.is_deleted"
|
||||
type="text"
|
||||
size="small"
|
||||
@click="toggleBest(r)"
|
||||
>
|
||||
{{ item?.best_reply_id === r.id ? "取消最佳" : "设为最佳" }}
|
||||
</el-button>
|
||||
<el-button v-if="canDeleteReply(r)" type="text" size="small" class="danger" @click="removeReply(r)">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="r.quote" class="quote-block">
|
||||
<div class="quote-meta">
|
||||
{{ displayUser(r.quote.created_by, { members: memberMap, users: userMap }) }}
|
||||
· {{ displayDateTime(r.quote.created_at) }}
|
||||
</div>
|
||||
<div class="quote-content">{{ r.quote.content }}</div>
|
||||
</div>
|
||||
<div class="reply-content">{{ r.is_deleted ? "回复已删除" : r.content }}</div>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<ThreadList
|
||||
v-if="replies.length"
|
||||
:items="replies"
|
||||
:attachments-map="replyAttachmentsMap"
|
||||
:member-map="memberMap"
|
||||
:user-map="userMap"
|
||||
:can-quote="canReply"
|
||||
:can-delete="canDeleteReply"
|
||||
:highlight-ids="bestReplyId ? [bestReplyId] : []"
|
||||
@quote="setQuote"
|
||||
@delete="removeReply"
|
||||
>
|
||||
<template #actions="{ item: r }">
|
||||
<el-tag v-if="item?.best_reply_id === r.id" type="success" size="small">最佳答案</el-tag>
|
||||
<el-button v-if="canSelectBest && !r.is_deleted" type="text" size="small" @click="toggleBest(r)">
|
||||
{{ item?.best_reply_id === r.id ? "取消最佳" : "设为最佳" }}
|
||||
</el-button>
|
||||
</template>
|
||||
</ThreadList>
|
||||
<div v-else class="reply-empty">暂无回复</div>
|
||||
</el-card>
|
||||
|
||||
@@ -107,6 +93,7 @@
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import type { UploadUserFile } from "element-plus";
|
||||
import {
|
||||
createFaqReply,
|
||||
deleteFaqReply,
|
||||
@@ -117,10 +104,13 @@ import {
|
||||
setFaqStatus,
|
||||
} from "../api/faqs";
|
||||
import { listMembers } from "../api/members";
|
||||
import { fetchAttachments, uploadAttachment } from "../api/attachments";
|
||||
import { displayDateTime, displayUser, getMemberDisplayName, getUserDisplayName } from "../utils/display";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { usePermission } from "../utils/permission";
|
||||
import FaqItemForm from "../components/FaqItemForm.vue";
|
||||
import ThreadComposer from "../components/ThreadComposer.vue";
|
||||
import ThreadList from "../components/ThreadList.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const auth = useAuthStore();
|
||||
@@ -135,12 +125,13 @@ const quoteReply = ref<any | null>(null);
|
||||
const members = ref<any[]>([]);
|
||||
const showForm = ref(false);
|
||||
const editing = ref<any | null>(null);
|
||||
const replyFiles = ref<UploadUserFile[]>([]);
|
||||
const replyAttachmentsMap = ref<Record<string, any[]>>({});
|
||||
|
||||
const { can } = usePermission();
|
||||
|
||||
const canReply = computed(() => {
|
||||
if (!item.value) return false;
|
||||
if (!item.value.study_id) return auth.user?.role === "ADMIN";
|
||||
return can("faq.reply");
|
||||
});
|
||||
|
||||
@@ -153,7 +144,6 @@ const canDeleteReply = (reply: any) => {
|
||||
|
||||
const canSelectBest = computed(() => {
|
||||
if (!item.value) return false;
|
||||
if (!item.value.study_id) return auth.user?.role === "ADMIN";
|
||||
return can("faq.reply");
|
||||
});
|
||||
|
||||
@@ -161,7 +151,7 @@ const canEditQuestion = computed(() => {
|
||||
if (!item.value) return false;
|
||||
if (auth.user?.role === "ADMIN") return true;
|
||||
if (item.value.created_by === auth.user?.id) return true;
|
||||
return item.value?.study_id ? can("faq.edit") : false;
|
||||
return can("faq.edit");
|
||||
});
|
||||
|
||||
const canConfirmResolved = computed(() => {
|
||||
@@ -169,7 +159,7 @@ const canConfirmResolved = computed(() => {
|
||||
if (item.value.status === "RESOLVED") return false;
|
||||
if (auth.user?.role === "ADMIN") return true;
|
||||
if (item.value.created_by === auth.user?.id) return true;
|
||||
return item.value?.study_id ? can("faq.edit") : false;
|
||||
return can("faq.edit");
|
||||
});
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
@@ -182,6 +172,7 @@ const bestReply = computed(() => {
|
||||
if (!item.value?.best_reply_id) return null;
|
||||
return replies.value.find((r) => r.id === item.value.best_reply_id) || null;
|
||||
});
|
||||
const bestReplyId = computed(() => item.value?.best_reply_id || null);
|
||||
|
||||
const memberMap = computed(() =>
|
||||
members.value.reduce<Record<string, string>>((acc, cur) => {
|
||||
@@ -214,6 +205,7 @@ const categoryName = computed(() => {
|
||||
return cat ? cat.name : item.value?.category_id;
|
||||
});
|
||||
|
||||
|
||||
const loadMembers = async (studyId?: string | null) => {
|
||||
if (!studyId) {
|
||||
members.value = [];
|
||||
@@ -233,6 +225,7 @@ const loadReplies = async () => {
|
||||
try {
|
||||
const { data } = await fetchFaqReplies(id);
|
||||
replies.value = Array.isArray(data) ? data : data.items || [];
|
||||
await loadReplyAttachments();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "回复加载失败");
|
||||
} finally {
|
||||
@@ -249,7 +242,6 @@ const loadData = async () => {
|
||||
const categoryParams: Record<string, any> = {};
|
||||
if (faqData.study_id) {
|
||||
categoryParams.study_id = faqData.study_id;
|
||||
categoryParams.include_global = false;
|
||||
}
|
||||
const { data: catData } = await fetchFaqCategories(categoryParams);
|
||||
categories.value = catData.items || catData || [];
|
||||
@@ -277,13 +269,25 @@ const submitReply = async () => {
|
||||
if (!item.value) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
await createFaqReply(item.value.id, {
|
||||
const { data } = await createFaqReply(item.value.id, {
|
||||
content: replyContent.value,
|
||||
quote_reply_id: quoteReply.value?.id || null,
|
||||
});
|
||||
const created = data as any;
|
||||
if (replyFiles.value.length && created?.id) {
|
||||
try {
|
||||
for (const file of replyFiles.value) {
|
||||
if (!file.raw) continue;
|
||||
await uploadAttachment(item.value.study_id, "faq_replies", created.id, file.raw as File);
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "附件上传失败");
|
||||
}
|
||||
}
|
||||
ElMessage.success("回复已提交");
|
||||
replyContent.value = "";
|
||||
quoteReply.value = null;
|
||||
replyFiles.value = [];
|
||||
loadReplies();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "回复失败");
|
||||
@@ -342,6 +346,29 @@ const onEditSuccess = () => {
|
||||
loadData();
|
||||
};
|
||||
|
||||
const loadReplyAttachments = async () => {
|
||||
if (!item.value?.study_id || replies.value.length === 0) {
|
||||
replyAttachmentsMap.value = {};
|
||||
return;
|
||||
}
|
||||
const entries = await Promise.all(
|
||||
replies.value.map(async (r) => {
|
||||
try {
|
||||
const { data } = await fetchAttachments(item.value.study_id, "faq_replies", r.id);
|
||||
const items = (data as any).items || data || [];
|
||||
return [r.id, items] as const;
|
||||
} catch {
|
||||
return [r.id, []] as const;
|
||||
}
|
||||
})
|
||||
);
|
||||
const next: Record<string, any[]> = {};
|
||||
entries.forEach(([id, items]) => {
|
||||
next[id] = items;
|
||||
});
|
||||
replyAttachmentsMap.value = next;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
|
||||
@@ -301,7 +301,7 @@ const loadList = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params: Record<string, any> = { skip: (page.value - 1) * pageSize, limit: pageSize };
|
||||
if (filters.value.status) params.status = filters.value.status;
|
||||
if (filters.value.status) params.status_filter = filters.value.status;
|
||||
if (filters.value.category) params.category = filters.value.category;
|
||||
if (filters.value.date_from) params.date_from = filters.value.date_from;
|
||||
if (filters.value.date_to) params.date_to = filters.value.date_to;
|
||||
|
||||
@@ -96,7 +96,7 @@ const load = async () => {
|
||||
try {
|
||||
const params: Record<string, any> = { skip: (page.value - 1) * pageSize, limit: pageSize };
|
||||
if (filters.value.product_id) params.product_id = filters.value.product_id;
|
||||
if (filters.value.status) params.status = filters.value.status;
|
||||
if (filters.value.status) params.status_filter = filters.value.status;
|
||||
const { data } = await fetchImpBatches(study.currentStudy.id, params);
|
||||
if (Array.isArray(data)) {
|
||||
batches.value = data;
|
||||
|
||||
@@ -113,7 +113,7 @@ const loadIssues = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params: Record<string, any> = { skip: (page.value - 1) * pageSize, limit: pageSize };
|
||||
if (filters.value.status) params.status = filters.value.status;
|
||||
if (filters.value.status) params.status_filter = filters.value.status;
|
||||
if (filters.value.level) params.level = filters.value.level;
|
||||
if (filters.value.category) params.category = filters.value.category;
|
||||
if (filters.value.overdue) params.overdue = true;
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
<div>
|
||||
<h1 class="ctms-page-title">节点详情</h1>
|
||||
</div>
|
||||
<div class="ctms-page-actions" v-if="canEdit">
|
||||
<el-button v-if="!editing" type="primary" size="small" @click="startEdit">编辑</el-button>
|
||||
<template v-else>
|
||||
<el-button size="small" @click="cancelEdit">取消</el-button>
|
||||
<el-button type="primary" size="small" :loading="saving" @click="saveEdit">保存</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="ctms-section-card">
|
||||
@@ -13,16 +20,62 @@
|
||||
</div>
|
||||
</template>
|
||||
<el-descriptions :column="2" border class="detail-descriptions">
|
||||
<el-descriptions-item label="名称">{{ milestone.name || "—" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="类型">{{ typeLabel(milestone.type) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="中心">{{ siteName(milestone) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="负责人">{{ ownerName(milestone.owner_id) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="计划日期">{{ displayDate(milestone.planned_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="实际日期">{{ displayDate(milestone.actual_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前状态">
|
||||
<el-tag :type="statusColor(milestone.status)" effect="plain">{{ statusLabel(milestone.status) }}</el-tag>
|
||||
<el-descriptions-item label="名称">
|
||||
<el-input v-if="editing" v-model="form.name" class="inline-input" />
|
||||
<span v-else>{{ milestone.name || "—" }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="类型">
|
||||
<el-select v-if="editing" v-model="form.type" placeholder="请选择" class="inline-input">
|
||||
<el-option v-for="item in types" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<span v-else>{{ typeLabel(milestone.type) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="中心">
|
||||
<el-select v-if="editing" v-model="form.site_id" placeholder="请选择中心" filterable class="inline-input">
|
||||
<el-option v-for="s in siteOptions" :key="s.value" :label="s.label" :value="s.value" />
|
||||
</el-select>
|
||||
<span v-else>{{ siteName(milestone) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="负责人">
|
||||
<el-select v-if="editing" v-model="form.owner_id" placeholder="请选择负责人" filterable class="inline-input">
|
||||
<el-option v-for="m in memberOptions" :key="m.value" :label="m.label" :value="m.value" />
|
||||
</el-select>
|
||||
<span v-else>{{ ownerName(milestone.owner_id) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="计划日期">
|
||||
<el-date-picker
|
||||
v-if="editing"
|
||||
v-model="form.planned_date"
|
||||
type="date"
|
||||
placeholder="选择日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
:disabled-date="disablePastDates"
|
||||
class="inline-input"
|
||||
/>
|
||||
<span v-else>{{ displayDate(milestone.planned_date) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="实际日期">
|
||||
<el-date-picker
|
||||
v-if="editing"
|
||||
v-model="form.actual_date"
|
||||
type="date"
|
||||
placeholder="选择日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
:disabled-date="disablePastDates"
|
||||
class="inline-input"
|
||||
/>
|
||||
<span v-else>{{ displayDate(milestone.actual_date) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="当前状态">
|
||||
<el-select v-if="editing" v-model="form.status" placeholder="请选择" class="inline-input">
|
||||
<el-option v-for="item in statuses" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<el-tag v-else :type="statusColor(milestone.status)" effect="plain">{{ statusLabel(milestone.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">
|
||||
<el-input v-if="editing" v-model="form.notes" type="textarea" class="inline-input" />
|
||||
<span v-else>{{ milestone.notes || "—" }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ milestone.notes || "—" }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
@@ -63,10 +116,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { fetchMilestones } from "../api/milestones";
|
||||
import { fetchMilestones, updateMilestone } from "../api/milestones";
|
||||
import { fetchAttachments } from "../api/attachments";
|
||||
import { fetchSites } from "../api/sites";
|
||||
import { listMembers } from "../api/members";
|
||||
import { useStudyStore } from "../store/study";
|
||||
@@ -78,17 +132,38 @@ import {
|
||||
getMilestoneStatusColor,
|
||||
getMilestoneStatusLabel,
|
||||
getMilestoneTypeLabel,
|
||||
milestoneStatusOptions,
|
||||
milestoneTypeOptions,
|
||||
} from "../dictionaries/milestone.dict";
|
||||
import { displayDate, displayUser, getMemberDisplayName, getUserDisplayName } from "../utils/display";
|
||||
import { displayDate, getMemberDisplayName, getUserDisplayName } from "../utils/display";
|
||||
import { usePermission } from "../utils/permission";
|
||||
|
||||
const route = useRoute();
|
||||
const study = useStudyStore();
|
||||
const auth = useAuthStore();
|
||||
const { can } = usePermission();
|
||||
|
||||
const milestone = ref<any | null>(null);
|
||||
const studyId = computed(() => study.currentStudy?.id || "");
|
||||
const sites = ref<any[]>([]);
|
||||
const members = ref<any[]>([]);
|
||||
const editing = ref(false);
|
||||
const saving = ref(false);
|
||||
const form = reactive({
|
||||
name: "",
|
||||
type: "",
|
||||
site_id: "",
|
||||
owner_id: "",
|
||||
planned_date: "",
|
||||
actual_date: "",
|
||||
status: "NOT_STARTED",
|
||||
notes: "",
|
||||
});
|
||||
const types = milestoneTypeOptions;
|
||||
const statuses = milestoneStatusOptions;
|
||||
const todayStart = new Date();
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
const disablePastDates = (date: Date) => date.getTime() < todayStart.getTime();
|
||||
|
||||
const loadMilestone = async () => {
|
||||
if (!study.currentStudy) return;
|
||||
@@ -124,6 +199,7 @@ const loadMembers = async () => {
|
||||
const typeLabel = (v: string) => getMilestoneTypeLabel(v);
|
||||
const statusLabel = (v: string) => getMilestoneStatusLabel(v);
|
||||
const statusColor = (v: string) => getMilestoneStatusColor(v);
|
||||
const canEdit = computed(() => can("milestone.create"));
|
||||
|
||||
const siteName = (row: any) => {
|
||||
if (row?.site?.name) return row.site.name;
|
||||
@@ -147,6 +223,129 @@ const ownerName = (ownerId: string) => {
|
||||
return memberMap[ownerId] || "—";
|
||||
};
|
||||
|
||||
const memberOptions = computed(() => {
|
||||
const memberList = members.value.filter((m: any) => m.is_active !== false);
|
||||
const seen = new Set<string>();
|
||||
return memberList
|
||||
.map((m: any) => {
|
||||
const user = m.user || {};
|
||||
const label = user.full_name || user.display_name || user.username || user.email || m.username || m.email || "—";
|
||||
return { value: m.user_id, label };
|
||||
})
|
||||
.filter((opt) => {
|
||||
if (seen.has(opt.value)) return false;
|
||||
seen.add(opt.value);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
const siteOptions = computed(() =>
|
||||
sites.value.map((s: any) => ({
|
||||
value: s.id,
|
||||
label: s.name || "—",
|
||||
}))
|
||||
);
|
||||
|
||||
const syncForm = () => {
|
||||
if (!milestone.value) return;
|
||||
form.name = milestone.value.name || "";
|
||||
form.type = milestone.value.type || "";
|
||||
form.site_id = milestone.value.site_id || "";
|
||||
form.owner_id = milestone.value.owner_id || "";
|
||||
form.planned_date = milestone.value.planned_date || "";
|
||||
form.actual_date = milestone.value.actual_date || "";
|
||||
form.status = milestone.value.status || "NOT_STARTED";
|
||||
form.notes = milestone.value.notes || "";
|
||||
};
|
||||
|
||||
const startEdit = () => {
|
||||
syncForm();
|
||||
editing.value = true;
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
editing.value = false;
|
||||
syncForm();
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
if (!form.name?.trim()) {
|
||||
ElMessage.warning("请输入名称");
|
||||
return false;
|
||||
}
|
||||
if (!form.type) {
|
||||
ElMessage.warning("请选择类型");
|
||||
return false;
|
||||
}
|
||||
if (!form.site_id) {
|
||||
ElMessage.warning("请选择中心");
|
||||
return false;
|
||||
}
|
||||
if (!form.owner_id) {
|
||||
ElMessage.warning("请选择负责人");
|
||||
return false;
|
||||
}
|
||||
if (!form.planned_date) {
|
||||
ElMessage.warning("请选择计划日期");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!study.currentStudy || !milestone.value) return;
|
||||
if (!validateForm()) return;
|
||||
const parseDate = (value: string) => {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return null;
|
||||
parsed.setHours(0, 0, 0, 0);
|
||||
return parsed;
|
||||
};
|
||||
const planned = form.planned_date ? parseDate(form.planned_date) : null;
|
||||
const actual = form.actual_date ? parseDate(form.actual_date) : null;
|
||||
if (planned && planned.getTime() < todayStart.getTime()) {
|
||||
ElMessage.warning("计划日期不得早于今天");
|
||||
return;
|
||||
}
|
||||
if (actual && actual.getTime() < todayStart.getTime()) {
|
||||
ElMessage.warning("实际日期不得早于今天");
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
actual_date: form.actual_date || null,
|
||||
};
|
||||
if (payload.actual_date) {
|
||||
const { data } = await fetchAttachments(study.currentStudy.id, "ethics_node", milestone.value.id);
|
||||
const items = (data as any).items || data || [];
|
||||
if (!items.length) {
|
||||
ElMessage.warning("请先上传伦理批件后再更新实际日期");
|
||||
return;
|
||||
}
|
||||
payload.status = "DONE";
|
||||
}
|
||||
await updateMilestone(study.currentStudy.id, milestone.value.id, payload);
|
||||
ElMessage.success("已保存");
|
||||
await loadMilestone();
|
||||
editing.value = false;
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "保存失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => milestone.value,
|
||||
() => {
|
||||
if (!editing.value) {
|
||||
syncForm();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.user && auth.token) {
|
||||
await auth.fetchMe().catch(() => {});
|
||||
@@ -164,6 +363,10 @@ onMounted(async () => {
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
.inline-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tip {
|
||||
margin-bottom: 8px;
|
||||
color: var(--ctms-text-regular);
|
||||
|
||||
@@ -50,9 +50,15 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="scope">
|
||||
<PermissionAction action="milestone.create">
|
||||
<el-button type="primary" link size="small" @click.stop="openEdit(scope.row)">编辑</el-button>
|
||||
</PermissionAction>
|
||||
<el-button
|
||||
v-if="canDelete"
|
||||
type="danger"
|
||||
link
|
||||
size="small"
|
||||
@click.stop="onDelete(scope.row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -71,11 +77,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { fetchMilestones } from "../api/milestones";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { fetchMilestones, deleteMilestone } from "../api/milestones";
|
||||
import { fetchSites } from "../api/sites";
|
||||
import { listMembers } from "../api/members";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import PermissionAction from "../components/PermissionAction.vue";
|
||||
import MilestoneForm from "../components/MilestoneForm.vue";
|
||||
import {
|
||||
@@ -86,6 +93,7 @@ import {
|
||||
import { displayDate, displayUser, getMemberDisplayName } from "../utils/display";
|
||||
|
||||
const study = useStudyStore();
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
const milestones = ref<any[]>([]);
|
||||
@@ -94,6 +102,7 @@ const members = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const showForm = ref(false);
|
||||
const editingMilestone = ref<any | null>(null);
|
||||
const canDelete = computed(() => auth.user?.role === "ADMIN" || !!study.currentStudyRole);
|
||||
|
||||
const loadMilestones = async () => {
|
||||
if (!study.currentStudy) return;
|
||||
@@ -133,9 +142,21 @@ const openCreate = () => {
|
||||
showForm.value = true;
|
||||
};
|
||||
|
||||
const openEdit = (row: any) => {
|
||||
editingMilestone.value = row;
|
||||
showForm.value = true;
|
||||
const onDelete = async (row: any) => {
|
||||
if (!study.currentStudy || !row?.id) return;
|
||||
const ok = await ElMessageBox.confirm("确认删除该节点?删除后不可恢复。", "提示", {
|
||||
type: "warning",
|
||||
confirmButtonText: "确认删除",
|
||||
cancelButtonText: "取消",
|
||||
}).catch(() => null);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteMilestone(study.currentStudy.id, row.id);
|
||||
ElMessage.success("已删除");
|
||||
loadMilestones();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -147,7 +147,7 @@ const loadSubjects = async () => {
|
||||
limit: pageSize,
|
||||
};
|
||||
if (filters.value.site_id) params.site_id = filters.value.site_id;
|
||||
if (filters.value.status) params.status = filters.value.status;
|
||||
if (filters.value.status) params.status_filter = filters.value.status;
|
||||
const { data } = await fetchSubjects(study.currentStudy.id, params);
|
||||
if (Array.isArray(data)) {
|
||||
subjects.value = data;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<el-input v-model="form.password" type="password" show-password :placeholder="user ? '留空则不修改密码' : '请输入初始密码'" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="user" label="账号状态" prop="is_active">
|
||||
<el-switch v-model="form.is_active" active-text="启用" inactive-text="禁用" />
|
||||
<el-switch v-model="form.is_active" active-text="启用" inactive-text="禁用" :disabled="isLastAdmin" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -33,6 +33,7 @@ import type { UserInfo } from "../../types/api";
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
user?: UserInfo | null;
|
||||
adminCount?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -86,6 +87,13 @@ const resetForm = () => {
|
||||
form.is_active = true;
|
||||
};
|
||||
|
||||
const isLastAdmin = computed(() => {
|
||||
if (!props.user) return false;
|
||||
if (props.user.role !== "ADMIN") return false;
|
||||
if ((props.adminCount || 0) > 1) return false;
|
||||
return form.is_active;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
@@ -104,6 +112,10 @@ watch(
|
||||
const onSubmit = async () => {
|
||||
if (!formRef.value) return;
|
||||
await formRef.value.validate();
|
||||
if (props.user?.role === "ADMIN" && (props.adminCount || 0) <= 1 && !form.is_active) {
|
||||
ElMessage.warning("至少保留一个管理员账号,不能禁用该账号");
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
if (props.user) {
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
link
|
||||
:type="scope.row.status === 'ACTIVE' ? 'danger' : 'primary'"
|
||||
size="small"
|
||||
:disabled="isLastAdmin(scope.row)"
|
||||
@click="toggleStatus(scope.row)"
|
||||
>
|
||||
{{ scope.row.status === 'ACTIVE' ? "禁用" : "启用" }}
|
||||
@@ -57,7 +58,7 @@
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
<UserForm v-model:visible="formVisible" :user="editingUser" @saved="loadUsers" />
|
||||
<UserForm v-model:visible="formVisible" :user="editingUser" :admin-count="activeAdminCount" @saved="loadUsers" />
|
||||
<UserResetPassword v-model:visible="resetVisible" :user="resetUser" @reset="loadUsers" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -77,6 +78,7 @@ const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(10);
|
||||
const loading = ref(false);
|
||||
const activeAdminCount = ref(0);
|
||||
const formVisible = ref(false);
|
||||
const resetVisible = ref(false);
|
||||
const editingUser = ref<UserInfo | null>(null);
|
||||
@@ -86,10 +88,15 @@ const auth = useAuthStore();
|
||||
const loadUsers = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await fetchUsers({ skip: (page.value - 1) * pageSize.value, limit: pageSize.value });
|
||||
const [{ data }, { data: adminData }] = await Promise.all([
|
||||
fetchUsers({ skip: (page.value - 1) * pageSize.value, limit: pageSize.value }),
|
||||
fetchUsers({ skip: 0, limit: 10000 }),
|
||||
]);
|
||||
const items = (data as any).items || [];
|
||||
const allItems = (adminData as any).items || [];
|
||||
users.value = items;
|
||||
total.value = (data as any).total || items.length;
|
||||
activeAdminCount.value = allItems.filter((u: UserInfo) => u.role === "ADMIN" && u.status === "ACTIVE").length;
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "用户列表加载失败");
|
||||
} finally {
|
||||
@@ -108,6 +115,10 @@ const openEdit = (row: UserInfo) => {
|
||||
};
|
||||
|
||||
const toggleStatus = async (row: UserInfo) => {
|
||||
if (isLastAdmin(row)) {
|
||||
ElMessage.warning("至少保留一个管理员账号,不能禁用该账号");
|
||||
return;
|
||||
}
|
||||
const disable = row.status === "ACTIVE";
|
||||
const ok = await ElMessageBox.confirm(
|
||||
disable ? "该操作将影响用户账号,请确认是否继续" : "确认启用该用户账号?",
|
||||
@@ -192,6 +203,9 @@ const statusLabel = (status: string) => {
|
||||
return status || "未知";
|
||||
}
|
||||
};
|
||||
|
||||
const isLastAdmin = (row: UserInfo) =>
|
||||
row.role === "ADMIN" && row.status === "ACTIVE" && activeAdminCount.value <= 1;
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user