文件版本管理-初步优化
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
<template>
|
||||
<div class="ctms-page">
|
||||
<div class="ctms-page-header">
|
||||
<div>
|
||||
<h1 class="ctms-page-title">{{ TEXT.modules.fileVersionManagement.title }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.fileVersionManagement.subtitle }}</p>
|
||||
</div>
|
||||
<div class="ctms-page-actions">
|
||||
<el-button type="primary" @click="openCreate">
|
||||
<el-icon class="el-icon--left"><Plus /></el-icon>
|
||||
{{ TEXT.modules.fileVersionManagement.newDocument }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="ctms-filter-card">
|
||||
<div class="ctms-filter-row">
|
||||
<div class="ctms-filter-item">
|
||||
<span class="ctms-filter-label">{{ TEXT.modules.fileVersionManagement.filters.scope }}</span>
|
||||
<el-select v-model="filters.scope_type" :placeholder="TEXT.modules.fileVersionManagement.filters.all" clearable>
|
||||
<el-option v-for="item in scopeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="ctms-filter-item">
|
||||
<span class="ctms-filter-label">{{ TEXT.modules.fileVersionManagement.filters.site }}</span>
|
||||
<el-select v-model="filters.site_id" :placeholder="TEXT.modules.fileVersionManagement.filters.all" clearable filterable>
|
||||
<el-option v-for="item in siteOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="ctms-filter-item">
|
||||
<span class="ctms-filter-label">{{ TEXT.modules.fileVersionManagement.filters.docType }}</span>
|
||||
<el-select v-model="filters.doc_type" :placeholder="TEXT.modules.fileVersionManagement.filters.all" clearable>
|
||||
<el-option v-for="item in docTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="ctms-filter-item">
|
||||
<span class="ctms-filter-label">{{ TEXT.modules.fileVersionManagement.filters.status }}</span>
|
||||
<el-select v-model="filters.status" :placeholder="TEXT.modules.fileVersionManagement.filters.all" clearable>
|
||||
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="ctms-filter-spacer"></div>
|
||||
<div class="ctms-filter-actions">
|
||||
<el-button type="primary" @click="load">{{ TEXT.common.actions.search }}</el-button>
|
||||
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="ctms-table-card">
|
||||
<el-table :data="items" v-loading="loading" @row-click="handleRowClick" row-class-name="clickable-row" class="ctms-table">
|
||||
<el-table-column prop="doc_no" :label="TEXT.modules.fileVersionManagement.columns.docNo" width="160">
|
||||
<template #default="{ row }">
|
||||
<span class="font-mono font-medium">{{ row.doc_no }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" :label="TEXT.modules.fileVersionManagement.columns.title" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span class="font-semibold">{{ row.title }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.scope" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
effect="plain"
|
||||
:type="row.scope_type === 'SITE' ? 'warning' : 'success'"
|
||||
>
|
||||
{{ displayEnum(TEXT.enums.scopeType, row.scope_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.site" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span>{{ displaySite(row.site_id) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.docType" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag effect="plain" type="info">{{ displayText(row.doc_type, TEXT.enums.documentType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.currentVersion" width="140">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.current_effective_version">{{ row.current_effective_version.version_no }}</span>
|
||||
<span v-else class="text-secondary">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.status" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)" effect="light">
|
||||
{{ displayEnum(TEXT.enums.documentStatus, row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.effectiveAt" width="180">
|
||||
<template #default="{ row }">
|
||||
<span>{{ formatDate(row.current_effective_version?.effective_at) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" :label="TEXT.modules.fileVersionManagement.columns.updatedAt" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="text-secondary">{{ formatDate(row.updated_at) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" @click.stop="confirmDelete(row)">
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty :description="TEXT.modules.fileVersionManagement.emptyDescription" />
|
||||
</template>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="createVisible" :title="TEXT.modules.fileVersionManagement.dialog.createTitle" width="520px" destroy-on-close class="ctms-dialog">
|
||||
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="100px" class="ctms-form">
|
||||
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.docNo" prop="doc_no">
|
||||
<el-input v-model="createForm.doc_no" placeholder="e.g. SOP-001" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.title" prop="title">
|
||||
<el-input v-model="createForm.title" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.scope" prop="scope_type">
|
||||
<el-select v-model="createForm.scope_type" style="width: 100%">
|
||||
<el-option v-for="item in scopeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="createForm.scope_type === 'SITE'" :label="TEXT.modules.fileVersionManagement.fields.site" prop="site_id">
|
||||
<el-select v-model="createForm.site_id" filterable style="width: 100%">
|
||||
<el-option v-for="item in siteOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.docType" prop="doc_type">
|
||||
<el-select v-model="createForm.doc_type" style="width: 100%">
|
||||
<el-option v-for="item in docTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button type="primary" :loading="creating" @click="submitCreate">{{ TEXT.common.actions.confirm }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, computed, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
import { fetchDocuments, createDocument, deleteDocument } from "../../api/documents";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import type { DocumentSummary } from "../../types/documents";
|
||||
import type { Site } from "../../types/api";
|
||||
import { displayEnum, displayText } from "../../utils/display";
|
||||
import { TEXT } from "../../locales";
|
||||
|
||||
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 route = useRoute();
|
||||
const router = useRouter();
|
||||
const study = useStudyStore();
|
||||
const loading = ref(false);
|
||||
const items = ref<DocumentSummary[]>([]);
|
||||
const sites = ref<Site[]>([]);
|
||||
const createVisible = ref(false);
|
||||
const creating = ref(false);
|
||||
const createFormRef = ref<FormInstance>();
|
||||
|
||||
const trialId = computed(() => (route.params.trialId as string) || "");
|
||||
|
||||
const filters = reactive({
|
||||
scope_type: "",
|
||||
site_id: "",
|
||||
doc_type: "",
|
||||
status: "",
|
||||
});
|
||||
|
||||
const docTypeOptions = Object.keys(TEXT.enums.documentType).map((value) => ({
|
||||
value,
|
||||
label: TEXT.enums.documentType[value as keyof typeof TEXT.enums.documentType],
|
||||
}));
|
||||
const statusOptions = Object.keys(TEXT.enums.documentStatus).map((value) => ({
|
||||
value,
|
||||
label: TEXT.enums.documentStatus[value as keyof typeof TEXT.enums.documentStatus],
|
||||
}));
|
||||
const scopeOptions = [
|
||||
{ value: "GLOBAL", label: TEXT.enums.scopeType.GLOBAL },
|
||||
{ value: "SITE", label: TEXT.enums.scopeType.SITE },
|
||||
];
|
||||
const siteOptions = computed(() =>
|
||||
sites.value.map((site) => ({ value: site.id, label: site.name }))
|
||||
);
|
||||
|
||||
const createForm = reactive({
|
||||
doc_no: "",
|
||||
title: "",
|
||||
scope_type: "GLOBAL",
|
||||
site_id: "",
|
||||
doc_type: "",
|
||||
});
|
||||
|
||||
const createRules: FormRules = {
|
||||
doc_no: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
|
||||
title: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
|
||||
scope_type: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
|
||||
site_id: [
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (createForm.scope_type === "SITE" && !value) {
|
||||
callback(new Error(TEXT.common.messages.required));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
},
|
||||
trigger: "change",
|
||||
},
|
||||
],
|
||||
doc_type: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
|
||||
};
|
||||
|
||||
const displaySite = (siteId?: string | null) => {
|
||||
if (!siteId) return TEXT.common.fallback;
|
||||
return sites.value.find((s) => s.id === siteId)?.name || siteId;
|
||||
};
|
||||
|
||||
const ensureTrialRoute = () => {
|
||||
if (trialId.value) return;
|
||||
const currentStudy = study.currentStudy?.id;
|
||||
if (currentStudy) {
|
||||
router.replace(`/trial/${currentStudy}/documents`);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSites = async () => {
|
||||
if (!trialId.value) return;
|
||||
try {
|
||||
const { data } = await fetchSites(trialId.value, { limit: 500 });
|
||||
const items = Array.isArray(data) ? data : data.items || [];
|
||||
sites.value = items;
|
||||
} catch {
|
||||
sites.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!trialId.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await fetchDocuments({
|
||||
trial_id: trialId.value,
|
||||
scope_type: filters.scope_type || undefined,
|
||||
site_id: filters.site_id || undefined,
|
||||
doc_type: filters.doc_type || undefined,
|
||||
status: filters.status || undefined,
|
||||
});
|
||||
items.value = data.items || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.scope_type = "";
|
||||
filters.site_id = "";
|
||||
filters.doc_type = "";
|
||||
filters.status = "";
|
||||
load();
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
createForm.doc_no = "";
|
||||
createForm.title = "";
|
||||
createForm.scope_type = "GLOBAL";
|
||||
createForm.site_id = "";
|
||||
createForm.doc_type = "";
|
||||
createVisible.value = true;
|
||||
};
|
||||
|
||||
const submitCreate = async () => {
|
||||
if (!createFormRef.value) return;
|
||||
await createFormRef.value.validate(async (valid) => {
|
||||
if (!valid || !trialId.value) return;
|
||||
creating.value = true;
|
||||
try {
|
||||
await createDocument({
|
||||
trial_id: trialId.value,
|
||||
site_id: createForm.scope_type === "SITE" ? createForm.site_id : undefined,
|
||||
doc_no: createForm.doc_no,
|
||||
title: createForm.title,
|
||||
doc_type: createForm.doc_type,
|
||||
scope_type: createForm.scope_type,
|
||||
});
|
||||
createVisible.value = false;
|
||||
await load();
|
||||
ElMessage.success(TEXT.modules.fileVersionManagement.messages.createSuccess);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const goDetail = (id: string) => router.push(`/documents/${id}`);
|
||||
|
||||
const handleRowClick = (row: DocumentSummary) => {
|
||||
if (!row?.id) return;
|
||||
goDetail(row.id);
|
||||
};
|
||||
|
||||
const confirmDelete = async (row: DocumentSummary) => {
|
||||
if (!row?.id) return;
|
||||
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteDocument(row.id);
|
||||
ElMessage.success(TEXT.common.messages.deleteSuccess);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
if (!value) return TEXT.common.fallback;
|
||||
return value.replace("T", " ").replace("Z", "").split(".")[0];
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
ensureTrialRoute();
|
||||
load();
|
||||
loadSites();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => trialId.value,
|
||||
(value, oldValue) => {
|
||||
if (value && value !== oldValue) {
|
||||
load();
|
||||
loadSites();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-secondary {
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
.font-medium {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: var(--ctms-primary);
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user