文件版本管理-初步优化

This commit is contained in:
Cheng Zhou
2026-01-14 11:35:37 +08:00
parent ef1e67218c
commit f9ef5c109f
46 changed files with 4603 additions and 30 deletions
@@ -0,0 +1,890 @@
<template>
<div class="ctms-page">
<div class="ctms-page-header">
<div>
<h1 class="ctms-page-title">
{{ detail.doc_no || TEXT.common.fallback }}
<span class="text-secondary mx-2">/</span>
{{ detail.title || TEXT.modules.fileVersionManagement.title }}
</h1>
<p class="ctms-page-subtitle">
<el-tag size="small" effect="plain" type="info" class="mr-2">{{ displayText(detail.doc_type, TEXT.enums.documentType) }}</el-tag>
<el-tag size="small" effect="plain" type="warning" class="mr-2">{{ displaySite(detail.site_id) }}</el-tag>
<span class="text-secondary text-sm">
{{ TEXT.modules.fileVersionManagement.labels.currentEffective }}
<span class="font-medium text-main">{{ detail.current_effective_version?.version_no || TEXT.common.fallback }}</span>
</span>
</p>
</div>
<div class="ctms-page-actions">
<el-button @click="goBack">
<el-icon class="el-icon--left"><ArrowLeft /></el-icon>
{{ TEXT.common.actions.back }}
</el-button>
</div>
</div>
<el-card class="ctms-section-card" v-loading="loading">
<el-descriptions :column="3" border class="ctms-descriptions">
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.columns.docNo">{{ detail.doc_no || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.fields.docType">{{ displayText(detail.doc_type, TEXT.enums.documentType) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.fields.site">{{ displaySite(detail.site_id) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.columns.status">
<el-tag :type="getStatusType(detail.status)" effect="light" size="small">{{ displayEnum(TEXT.enums.documentStatus, detail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.labels.currentEffective">{{ detail.current_effective_version?.version_no || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.columns.effectiveAt">{{ formatDate(detail.current_effective_version?.effective_at) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.columns.updatedAt">{{ formatDate(detail.updated_at) }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-tabs v-model="activeTab" @tab-change="handleTabChange" class="ctms-tabs">
<el-tab-pane :label="TEXT.modules.fileVersionManagement.tabs.versions" name="versions">
<div class="ctms-section-header">
<div class="ctms-section-title">{{ TEXT.modules.fileVersionManagement.labels.versionTimeline }}</div>
<div class="ctms-section-actions">
<el-button type="primary" @click="openUpload" v-if="canAction('create_version')">
<el-icon class="el-icon--left"><Upload /></el-icon>
{{ TEXT.modules.fileVersionManagement.actions.uploadVersion }}
</el-button>
</div>
</div>
<el-card class="ctms-table-card">
<el-table :data="detail.version_timeline" v-loading="versionLoading" class="ctms-table">
<el-table-column prop="version_no" :label="TEXT.modules.fileVersionManagement.fields.versionNo" width="120">
<template #default="{ row }">
<span class="font-medium">V{{ row.version_no }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.status" width="120">
<template #default="{ row }">
<el-tag :type="getVersionStatusType(row.status)" effect="light" size="small">{{ displayEnum(TEXT.enums.documentVersionStatus, row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="change_summary" :label="TEXT.modules.fileVersionManagement.fields.changeSummary" min-width="200" show-overflow-tooltip />
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.createdBy" width="160">
<template #default="{ row }">
<span>{{ displayCreator(row.created_by) }}</span>
</template>
</el-table-column>
<el-table-column prop="created_at" :label="TEXT.modules.fileVersionManagement.labels.createdAt" width="180">
<template #default="{ row }">
<span class="text-secondary">{{ formatDate(row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.approvalSummary" min-width="220">
<template #default="{ row }">
<div class="text-xs text-secondary lh-sm">
<div v-if="row.submitted_at">{{ TEXT.modules.fileVersionManagement.labels.submitAt }}{{ formatDate(row.submitted_at) }}</div>
<div v-if="row.approved_at" class="mt-1">
{{ TEXT.modules.fileVersionManagement.labels.approveAt }}{{ formatDate(row.approved_at) }}
</div>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="280" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="downloadVersion(row)" v-if="canAction('view')">
{{ TEXT.modules.fileVersionManagement.actions.download }}
</el-button>
<el-button link type="primary" size="small" @click="openSubmit(row)" v-if="canAction('submit') && row.status === 'DRAFT'">
{{ TEXT.modules.fileVersionManagement.actions.submit }}
</el-button>
<el-button link type="success" size="small" @click="approve(row)" v-if="canAction('approve') && row.status === 'SUBMITTED'">
{{ TEXT.modules.fileVersionManagement.actions.approve }}
</el-button>
<el-button link type="danger" size="small" @click="reject(row)" v-if="canAction('approve') && row.status === 'SUBMITTED'">
{{ TEXT.modules.fileVersionManagement.actions.reject }}
</el-button>
<el-button link type="warning" size="small" @click="makeEffective(row)" v-if="canAction('approve') && row.status === 'APPROVED'">
{{ TEXT.modules.fileVersionManagement.actions.makeEffective }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.fileVersionManagement.tabs.distributions" name="distributions">
<div class="ctms-section-header">
<div class="ctms-section-title">{{ TEXT.modules.fileVersionManagement.labels.distributionList }}</div>
<div class="ctms-section-actions">
<el-button type="primary" @click="openDistribute" v-if="canAction('distribute')">
<el-icon class="el-icon--left"><Share /></el-icon>
{{ TEXT.modules.fileVersionManagement.actions.distribute }}
</el-button>
</div>
</div>
<el-card class="ctms-table-card">
<el-table :data="distributions" v-loading="distributionLoading" class="ctms-table">
<el-table-column :label="TEXT.modules.fileVersionManagement.fields.targetType" width="120">
<template #default="{ row }">
<el-tag effect="plain" type="info">{{ displayEnum(TEXT.enums.distributionTargetType, row.target_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.fields.target" min-width="200">
<template #default="{ row }">
<span class="font-medium">{{ displayTarget(row) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.ackStats" min-width="200">
<template #default="{ row }">
<span>{{ TEXT.modules.fileVersionManagement.actions.ackReceived }}: {{ row.stats?.received || 0 }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.overdue" width="100">
<template #default="{ row }">
<el-tag v-if="row.stats?.overdue" type="danger" effect="dark" size="small">{{ TEXT.modules.fileVersionManagement.labels.overdue }}</el-tag>
<span v-else class="text-secondary">-</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.dueAt" width="160">
<template #default="{ row }">
<span :class="{ 'text-danger': row.stats?.overdue }">{{ formatDate(row.due_at) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.ackAction" width="140" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="ack(row, 'RECEIVED')" v-if="canAction('ack')">
{{ TEXT.modules.fileVersionManagement.actions.ackReceived }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.fileVersionManagement.tabs.audit" name="audit">
<el-card class="ctms-table-card">
<el-table :data="auditLogs" v-loading="auditLoading" class="ctms-table">
<el-table-column prop="action" :label="TEXT.modules.fileVersionManagement.labels.auditAction" width="200">
<template #default="{ row }">
<span class="font-medium">{{ row.action }}</span>
</template>
</el-table-column>
<el-table-column prop="detail" :label="TEXT.modules.fileVersionManagement.labels.auditDetail" min-width="300" show-overflow-tooltip />
<el-table-column prop="operator_role" :label="TEXT.common.labels.role" width="120">
<template #default="{ row }">
<el-tag effect="plain" size="small">{{ row.operator_role }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" :label="TEXT.modules.fileVersionManagement.labels.auditTime" width="180">
<template #default="{ row }">
<span class="text-secondary">{{ formatDate(row.created_at) }}</span>
</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
</el-tabs>
<el-dialog v-model="uploadVisible" :title="TEXT.modules.fileVersionManagement.dialog.uploadTitle" width="520px" destroy-on-close class="ctms-dialog">
<el-form ref="uploadFormRef" :model="uploadForm" :rules="uploadRules" label-width="110px" class="ctms-form">
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.versionNo" prop="version_no">
<el-input v-model="uploadForm.version_no" :placeholder="TEXT.modules.fileVersionManagement.placeholders.versionNo" />
</el-form-item>
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.changeSummary">
<el-input v-model="uploadForm.change_summary" type="textarea" :rows="3" />
</el-form-item>
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.parentVersion">
<el-select v-model="uploadForm.parent_version_id" clearable style="width: 100%">
<el-option
v-for="item in detail.version_timeline"
:key="item.id"
:label="item.version_no"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.file">
<input type="file" @change="onFileChange" class="ctms-file-input" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="uploadVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="uploading" @click="submitUpload">{{ TEXT.common.actions.confirm }}</el-button>
</template>
</el-dialog>
<el-dialog v-model="distributeVisible" :title="TEXT.modules.fileVersionManagement.dialog.distributeTitle" width="600px" destroy-on-close class="ctms-dialog">
<el-form :model="distributeForm" label-width="100px" class="ctms-form">
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.targetList">
<div class="target-list w-full">
<div class="target-row" v-for="(item, index) in distributeForm.targets" :key="item.uid">
<el-select
v-model="item.target_type"
:placeholder="TEXT.common.placeholders.select"
style="width: 120px"
@change="onTargetTypeChange(index)"
>
<el-option :label="TEXT.enums.distributionTargetType.ROLE" value="ROLE" />
<el-option :label="TEXT.enums.distributionTargetType.USER" value="USER" />
</el-select>
<el-select
v-if="item.target_type === 'ROLE'"
v-model="item.target_id"
filterable
clearable
:placeholder="TEXT.common.placeholders.select"
class="target-select"
>
<el-option v-for="role in roleOptions" :key="role.value" :label="role.label" :value="role.value" />
</el-select>
<el-select
v-else
v-model="item.target_id"
filterable
clearable
:placeholder="TEXT.common.placeholders.select"
class="target-select"
>
<el-option v-for="member in memberOptions" :key="member.value" :label="member.label" :value="member.value" />
</el-select>
<el-button link type="danger" @click="removeTarget(index)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<el-button type="primary" plain @click="addTarget" class="w-full dashed-button">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.fileVersionManagement.actions.addTarget }}
</el-button>
</div>
</el-form-item>
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.dueAt">
<el-date-picker v-model="distributeForm.due_at" type="datetime" :placeholder="TEXT.common.placeholders.select" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="distributeVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="distributing" @click="submitDistribute">{{ TEXT.common.actions.confirm }}</el-button>
</template>
</el-dialog>
<el-dialog v-model="submitVisible" :title="TEXT.modules.fileVersionManagement.messages.submitTitle" width="480px" destroy-on-close class="ctms-dialog">
<el-form :model="submitForm" label-width="100px" class="ctms-form">
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.workflowTemplate">
<el-select
v-model="submitForm.template_id"
filterable
clearable
:placeholder="TEXT.modules.fileVersionManagement.placeholders.workflowTemplate"
style="width: 100%"
>
<el-option v-for="item in workflowTemplates" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="submitVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="submitLoading" @click="confirmSubmit">{{ TEXT.common.actions.confirm }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, FormInstance, FormRules } from "element-plus";
import { ArrowLeft, Upload, Plus, Download, Check, Close, Link, Share, Delete } from "@element-plus/icons-vue";
import {
approveVersion,
createAcknowledgement,
createDistributions,
downloadDocumentVersion,
fetchDocumentDetail,
listDistributions,
listWorkflowTemplates,
makeVersionEffective,
rejectVersion,
submitVersion,
uploadDocumentVersion,
} from "../../api/documents";
import { fetchAuditLogs } from "../../api/auditLogs";
import { listMembers } from "../../api/members";
import { fetchSites } from "../../api/sites";
import { fetchUsers } from "../../api/users";
import type { Distribution, DocumentDetail, DocumentVersion, WorkflowTemplate } from "../../types/documents";
import type { Site, StudyMember } from "../../types/api";
import type { UserInfo } from "../../types/api";
import { TEXT } from "../../locales";
import { displayEnum, displayText, getUserDisplayName } from "../../utils/display";
const getStatusType = (status: string) => {
switch (status) {
case "ACTIVE":
return "success";
case "DRAFT":
return "info";
case "ARCHIVED":
return "info";
case "OBSOLETE":
return "danger";
default:
return "";
}
};
const getVersionStatusType = (status: string) => {
switch (status) {
case "APPROVED":
return "success";
case "DRAFT":
return "info";
case "SUBMITTED":
return "warning";
case "REJECTED":
return "danger";
default:
return "";
}
};
const route = useRoute();
const router = useRouter();
const documentId = computed(() => route.params.id as string);
const loading = ref(false);
const versionLoading = ref(false);
const distributionLoading = ref(false);
const auditLoading = ref(false);
const detail = reactive<DocumentDetail>({
id: "",
trial_id: "",
site_id: null,
doc_no: "",
doc_type: "",
title: "",
scope_type: "GLOBAL",
status: "ACTIVE",
current_effective_version_id: null,
current_effective_version: null,
created_at: "",
updated_at: "",
version_timeline: [],
distribution_stats: { total: 0, received: 0, read: 0, trained: 0, overdue: 0 },
});
const activeTab = ref("versions");
const distributions = ref<Distribution[]>([]);
const auditLogs = ref<any[]>([]);
const users = ref<UserInfo[]>([]);
const sites = ref<Site[]>([]);
const members = ref<StudyMember[]>([]);
const workflowTemplates = ref<WorkflowTemplate[]>([]);
const userMap = computed(() =>
users.value.reduce<Record<string, string>>((acc, user) => {
acc[user.id] = getUserDisplayName(user) || user.email || user.username || user.id;
return acc;
}, {})
);
const displayCreator = (userId?: string | null) => {
if (!userId) return TEXT.common.fallback;
return userMap.value[userId] || userId;
};
const displaySite = (siteId?: string | null) => {
if (!siteId) return TEXT.enums.scopeType.GLOBAL;
return sites.value.find((s) => s.id === siteId)?.name || siteId;
};
const uploadVisible = ref(false);
const uploading = ref(false);
const uploadFormRef = ref<FormInstance>();
const uploadForm = reactive({
version_no: "",
change_summary: "",
parent_version_id: "",
});
const uploadFile = ref<File | null>(null);
const distributeVisible = ref(false);
const distributing = ref(false);
const targetSeed = ref(0);
const distributeForm = reactive({
targets: [{ uid: 0, target_type: "ROLE", target_id: "" }],
due_at: "",
});
const submitVisible = ref(false);
const submitLoading = ref(false);
const submitForm = reactive({
template_id: "",
});
const submitTarget = ref<DocumentVersion | null>(null);
const uploadRules: FormRules = {
version_no: [
{ required: true, message: TEXT.common.messages.required, trigger: "blur" },
{
validator: (_rule, value, callback) => {
const valid = /^\d+\.\d+$/.test(value);
callback(valid ? undefined : new Error(TEXT.modules.fileVersionManagement.messages.versionNoInvalid));
},
trigger: "blur",
},
],
};
const roleOptions = computed(() => {
const fallbackRoles = ["CRA", "PM", "IMP", "PV"];
const roles = new Set(members.value.map((m) => m.role_in_study).filter(Boolean));
const values = roles.size ? Array.from(roles) : fallbackRoles;
return values.map((value) => ({
value,
label: displayEnum(TEXT.enums.userRole, value),
}));
});
const memberOptions = computed(() =>
members.value.map((m) => {
const name = getUserDisplayName((m as any).user) || (m as any).user?.email || m.user_id;
const roleLabel = displayEnum(TEXT.enums.userRole, m.role_in_study);
return {
value: m.user_id,
label: `${name}${roleLabel}`,
role: m.role_in_study,
};
})
);
const displayTarget = (row: Distribution) => {
if (row.target_type === "ROLE") {
return displayEnum(TEXT.enums.userRole, row.target_id);
}
if (row.target_type === "USER") {
const hit = memberOptions.value.find((m) => m.value === row.target_id);
return hit?.label || row.target_id;
}
return row.target_id;
};
const createTarget = () => ({
uid: ++targetSeed.value,
target_type: "ROLE",
target_id: "",
});
const canAction = (action: string) => {
const list = detail.can_actions || [];
if (list.length === 0) return true;
return list.includes(action);
};
const loadMembers = async () => {
if (!detail.trial_id) {
members.value = [];
return;
}
try {
const { data } = await listMembers(detail.trial_id, { limit: 500 });
members.value = Array.isArray(data) ? data : data.items || [];
} catch {
members.value = [];
}
};
const loadSites = async () => {
if (!detail.trial_id) {
sites.value = [];
return;
}
try {
const { data } = await fetchSites(detail.trial_id, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const loadUsers = async () => {
try {
const { data } = await fetchUsers({ limit: 500 });
users.value = (data as any).items || data || [];
} catch {
users.value = [];
}
};
const loadDetail = async () => {
if (!documentId.value) return;
loading.value = true;
try {
const { data } = await fetchDocumentDetail(documentId.value);
Object.assign(detail, data);
if (!users.value.length) {
await loadUsers();
}
if (!members.value.length) {
await loadMembers();
}
if (!sites.value.length) {
await loadSites();
}
if (activeTab.value === "distributions") {
await loadDistributions();
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
}
};
const loadDistributions = async () => {
if (!detail.current_effective_version_id) {
distributions.value = [];
return;
}
distributionLoading.value = true;
try {
const { data } = await listDistributions(detail.current_effective_version_id);
distributions.value = data || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
distributionLoading.value = false;
}
};
const loadAuditLogs = async () => {
if (!detail.trial_id) return;
auditLoading.value = true;
try {
const { data } = await fetchAuditLogs(detail.trial_id, {
entity_type: "DOCUMENT",
entity_id: documentId.value,
limit: 20,
});
auditLogs.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
auditLoading.value = false;
}
};
const loadWorkflowTemplates = async () => {
try {
const params = detail.trial_id ? { trial_id: detail.trial_id } : undefined;
const { data } = await listWorkflowTemplates(params);
workflowTemplates.value = data || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
}
};
const handleTabChange = async (name: string) => {
if (name === "distributions") {
await loadDistributions();
}
if (name === "audit") {
await loadAuditLogs();
}
};
const openUpload = () => {
uploadForm.version_no = "";
uploadForm.change_summary = "";
uploadForm.parent_version_id = "";
uploadFile.value = null;
uploadVisible.value = true;
};
const onFileChange = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files && target.files[0]) {
uploadFile.value = target.files[0];
}
};
const submitUpload = async () => {
if (!uploadFormRef.value) return;
await uploadFormRef.value.validate(async (valid) => {
if (!valid || !documentId.value || !uploadFile.value) {
if (!uploadFile.value) {
ElMessage.warning(TEXT.modules.fileVersionManagement.messages.missingFile);
}
return;
}
uploading.value = true;
try {
const formData = new FormData();
formData.append("version_no", uploadForm.version_no);
if (uploadForm.change_summary) {
formData.append("change_summary", uploadForm.change_summary);
}
if (uploadForm.parent_version_id) {
formData.append("parent_version_id", uploadForm.parent_version_id);
}
formData.append("file", uploadFile.value);
await uploadDocumentVersion(documentId.value, formData);
uploadVisible.value = false;
await loadDetail();
ElMessage.success(TEXT.modules.fileVersionManagement.messages.uploadSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
uploading.value = false;
}
});
};
const openSubmit = async (version: DocumentVersion) => {
submitTarget.value = version;
submitForm.template_id = "";
submitVisible.value = true;
if (!workflowTemplates.value.length) {
await loadWorkflowTemplates();
}
};
const confirmSubmit = async () => {
if (!submitTarget.value) return;
if (!submitForm.template_id) {
ElMessage.warning(TEXT.modules.fileVersionManagement.messages.workflowTemplateRequired);
return;
}
submitLoading.value = true;
try {
await submitVersion(submitTarget.value.id, { template_id: submitForm.template_id, comment: "" });
submitVisible.value = false;
await loadDetail();
ElMessage.success(TEXT.modules.fileVersionManagement.messages.submitSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
submitLoading.value = false;
}
};
const approve = async (version: DocumentVersion) => {
versionLoading.value = true;
try {
await approveVersion(version.id, { comment: "" });
await loadDetail();
ElMessage.success(TEXT.modules.fileVersionManagement.messages.approveSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
versionLoading.value = false;
}
};
const reject = async (version: DocumentVersion) => {
versionLoading.value = true;
try {
await rejectVersion(version.id, { comment: "" });
await loadDetail();
ElMessage.success(TEXT.modules.fileVersionManagement.messages.rejectSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
versionLoading.value = false;
}
};
const makeEffective = async (version: DocumentVersion) => {
versionLoading.value = true;
try {
await makeVersionEffective(version.id);
await loadDetail();
ElMessage.success(TEXT.modules.fileVersionManagement.messages.effectiveSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
versionLoading.value = false;
}
};
const getFilename = (header?: string | null) => {
if (!header) return null;
const match = /filename\*=UTF-8''([^;]+)|filename="?([^\";]+)"?/i.exec(header);
if (!match) return null;
return decodeURIComponent(match[1] || match[2] || "");
};
const downloadVersion = async (version: DocumentVersion) => {
try {
const response = await downloadDocumentVersion(version.id);
const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename =
getFilename(response.headers?.["content-disposition"]) ||
`document-${version.version_no || version.id}.bin`;
const blob = new Blob([response.data], { type: contentType });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.downloadFailed);
}
};
const openDistribute = () => {
distributeForm.targets = [createTarget()];
distributeForm.due_at = "";
distributeVisible.value = true;
if (!members.value.length) {
loadMembers();
}
};
const addTarget = () => {
distributeForm.targets.push(createTarget());
};
const removeTarget = (index: number) => {
distributeForm.targets.splice(index, 1);
};
const onTargetTypeChange = (index: number) => {
const target = distributeForm.targets[index];
if (!target) return;
target.target_id = "";
};
const submitDistribute = async () => {
if (!detail.current_effective_version_id) {
ElMessage.warning(TEXT.modules.fileVersionManagement.messages.noEffective);
return;
}
if (distributeForm.targets.some((t) => !t.target_id)) {
ElMessage.warning(TEXT.modules.fileVersionManagement.messages.missingTarget);
return;
}
distributing.value = true;
try {
const targets = distributeForm.targets.map((t) => ({
target_type: t.target_type,
target_id: t.target_id,
}));
await createDistributions(detail.current_effective_version_id, {
targets,
due_at: distributeForm.due_at || undefined,
});
distributeVisible.value = false;
await loadDistributions();
ElMessage.success(TEXT.modules.fileVersionManagement.messages.distributeSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
distributing.value = false;
}
};
const ack = async (distribution: Distribution, ackType: string) => {
try {
await createAcknowledgement(distribution.id, { ack_type: ackType });
await loadDistributions();
ElMessage.success(TEXT.modules.fileVersionManagement.messages.ackSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
}
};
const goBack = () => {
if (detail.trial_id) {
router.push(`/trial/${detail.trial_id}/documents`);
} else {
router.back();
}
};
const formatDate = (value?: string | null) => {
if (!value) return TEXT.common.fallback;
return value.replace("T", " ").replace("Z", "").split(".")[0];
};
onMounted(async () => {
await loadDetail();
await loadDistributions();
});
watch(
() => documentId.value,
async (value, oldValue) => {
if (value && value !== oldValue) {
await loadDetail();
await loadDistributions();
}
}
);
</script>
<style scoped>
.text-secondary {
color: var(--ctms-text-secondary);
}
.text-main {
color: var(--ctms-text-main);
}
.font-medium {
font-weight: 500;
}
.bg-primary-light {
background-color: var(--ctms-primary-light);
}
.text-primary {
color: var(--ctms-primary);
}
.text-xs {
font-size: 12px;
}
.text-sm {
font-size: 13px;
}
.text-danger {
color: var(--ctms-danger);
}
.lh-sm {
line-height: 1.4;
}
.dashed-button {
border-style: dashed;
}
.flex {
display: flex;
}
.items-center {
align-items: center;
}
.gap-2 {
gap: 8px;
}
.mr-2 {
margin-right: 8px;
}
.mt-1 {
margin-top: 4px;
}
.mx-2 {
margin-left: 8px;
margin-right: 8px;
}
.w-full {
width: 100%;
}
</style>