立项配置数据入库、审计日志数据入库、编辑页UI界面美化、设备管理内容补充、审计日志显示优化
This commit is contained in:
@@ -148,7 +148,7 @@ import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { ArrowDown } from "@element-plus/icons-vue";
|
||||
import { fetchAuditLogs } from "../../api/auditLogs";
|
||||
import { createAuditEvent, fetchAuditLogs } from "../../api/auditLogs";
|
||||
import { fetchUsers } from "../../api/users";
|
||||
import { listMembers } from "../../api/members";
|
||||
import { auditDict, normalizeAuditEvent } from "../../audit";
|
||||
@@ -156,27 +156,9 @@ import { useStudyStore } from "../../store/study";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { roleDict, getDictLabel } from "../../dictionaries";
|
||||
import { exportAuditCsv } from "../../audit/export/auditExportService";
|
||||
import { logAudit } from "../../audit";
|
||||
import { displayDateTime } from "../../utils/display";
|
||||
import { TEXT } from "../../locales";
|
||||
|
||||
const buildLogKey = (log: any) => {
|
||||
if (log.id) return `id:${log.id}`;
|
||||
return `${log.action || log.eventType || "event"}-${log.entity_id || log.entityId || ""}-${log.created_at || log.timestamp || ""}`;
|
||||
};
|
||||
|
||||
const dedupeLogs = (list: any[]) => {
|
||||
const seen = new Set<string>();
|
||||
const result: any[] = [];
|
||||
list.forEach((log) => {
|
||||
const key = buildLogKey(log);
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
result.push(log);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const study = useStudyStore();
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
@@ -250,10 +232,8 @@ const loadLogs = async () => {
|
||||
};
|
||||
const { data } = await fetchAuditLogs(study.currentStudy.id, params);
|
||||
const items = Array.isArray(data) ? data : (data as any).items || [];
|
||||
const local = loadLocalLogs();
|
||||
const merged = dedupeLogs([...items, ...local]);
|
||||
rawLogs.value = merged;
|
||||
total.value = (data as any).total || merged.length;
|
||||
rawLogs.value = items;
|
||||
total.value = (data as any).total || items.length;
|
||||
enrichLogs();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminAuditLogs.loadFailed);
|
||||
@@ -262,18 +242,6 @@ const loadLogs = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadLocalLogs = () => {
|
||||
try {
|
||||
const key = `audit_local_${study.currentStudy?.id || "global"}`;
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return [];
|
||||
const list = JSON.parse(raw);
|
||||
return Array.isArray(list) ? list : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const enrichLogs = () => {
|
||||
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
|
||||
acc[cur.id] = resolveUserDisplayName(cur);
|
||||
@@ -333,13 +301,11 @@ const fetchAllForExport = async () => {
|
||||
};
|
||||
const { data } = await fetchAuditLogs(study.currentStudy.id, params);
|
||||
const items = Array.isArray(data) ? data : (data as any).items || [];
|
||||
const local = loadLocalLogs();
|
||||
const merged = dedupeLogs([...items, ...local]);
|
||||
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
|
||||
acc[cur.id] = resolveUserDisplayName(cur);
|
||||
return acc;
|
||||
}, {});
|
||||
const filtered = merged.filter((log) => {
|
||||
const filtered = items.filter((log: any) => {
|
||||
if (filters.value.range?.length === 2) {
|
||||
const ts = new Date(log.created_at);
|
||||
const start = new Date(filters.value.range[0]);
|
||||
@@ -348,7 +314,7 @@ const fetchAllForExport = async () => {
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return filtered.map((log) => normalizeAuditEvent(log, userMap));
|
||||
return filtered.map((log: any) => normalizeAuditEvent(log, userMap));
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminAuditLogs.exportLoadFailed);
|
||||
return [];
|
||||
@@ -362,6 +328,8 @@ const handleExportCommand = (command: string) => {
|
||||
};
|
||||
|
||||
const confirmExport = async (scope: "system" | "project") => {
|
||||
const currentStudy = study.currentStudy;
|
||||
if (!currentStudy) return;
|
||||
const ok = await ElMessageBox.confirm(
|
||||
TEXT.modules.adminAuditLogs.exportConfirm,
|
||||
TEXT.modules.adminAuditLogs.exportConfirmTitle,
|
||||
@@ -376,13 +344,22 @@ const confirmExport = async (scope: "system" | "project") => {
|
||||
const fileName =
|
||||
scope === "system"
|
||||
? `${TEXT.modules.adminAuditLogs.exportSystemName}_${new Date().toISOString().slice(0, 10)}`
|
||||
: `${TEXT.modules.adminAuditLogs.exportProjectName}_${study.currentStudy?.code || ""}_${new Date().toISOString().slice(0, 10)}`;
|
||||
: `${TEXT.modules.adminAuditLogs.exportProjectName}_${currentStudy.code || ""}_${new Date().toISOString().slice(0, 10)}`;
|
||||
exportAuditCsv(events, { fileName });
|
||||
logAudit(scope === "system" ? "AUDIT_EXPORT_SYSTEM" : "AUDIT_EXPORT_PROJECT", {
|
||||
targetId: study.currentStudy?.id,
|
||||
targetName: study.currentStudy?.name,
|
||||
severity: "normal",
|
||||
});
|
||||
await createAuditEvent(currentStudy.id, {
|
||||
action: scope === "system" ? "AUDIT_EXPORT_SYSTEM" : "AUDIT_EXPORT_PROJECT",
|
||||
entity_type: "audit_log",
|
||||
entity_id: null,
|
||||
detail: JSON.stringify(
|
||||
{
|
||||
targetName: currentStudy.name,
|
||||
scope,
|
||||
exportedCount: events.length,
|
||||
},
|
||||
null,
|
||||
0
|
||||
),
|
||||
}).catch(() => null);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -105,7 +105,6 @@ import { fetchStudyDetail } from "../../api/studies";
|
||||
import type { Study, StudyMember, UserInfo } from "../../types/api";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { evaluateAction } from "../../guards/actionGuard";
|
||||
import { logAudit } from "../../audit";
|
||||
import { displayDateTime, displayEnum } from "../../utils/display";
|
||||
import { TEXT, requiredMessage } from "../../locales";
|
||||
|
||||
@@ -189,12 +188,6 @@ const submitAdd = async () => {
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
||||
logAudit(decision.auditType, {
|
||||
targetId: projectId.value,
|
||||
targetName: project.value?.name,
|
||||
reason: decision.reason,
|
||||
severity: decision.severity,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await addFormRef.value?.validate();
|
||||
@@ -204,21 +197,8 @@ const submitAdd = async () => {
|
||||
ElMessage.success(TEXT.modules.adminProjectMembers.addSuccess);
|
||||
addVisible.value = false;
|
||||
loadMembers();
|
||||
logAudit("PROJECT_MEMBER_UPDATED", {
|
||||
targetId: projectId.value,
|
||||
targetName: project.value?.name,
|
||||
after: { user_id: newMember.user_id, role_in_study: newMember.role_in_study },
|
||||
severity: "normal",
|
||||
});
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjectMembers.addFailed);
|
||||
logAudit("PROJECT_MEMBER_UPDATED", {
|
||||
targetId: projectId.value,
|
||||
targetName: project.value?.name,
|
||||
after: { user_id: newMember.user_id, role_in_study: newMember.role_in_study },
|
||||
severity: "warning",
|
||||
reason: e?.response?.data?.message,
|
||||
});
|
||||
} finally {
|
||||
adding.value = false;
|
||||
}
|
||||
@@ -233,34 +213,15 @@ const updateRole = async (memberId: string, role: string) => {
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
||||
logAudit(decision.auditType, {
|
||||
targetId: projectId.value,
|
||||
targetName: project.value?.name,
|
||||
reason: decision.reason,
|
||||
severity: decision.severity,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateMember(projectId.value, memberId, { role_in_study: role });
|
||||
ElMessage.success(TEXT.modules.adminProjectMembers.roleUpdated);
|
||||
loadMembers();
|
||||
logAudit("PROJECT_MEMBER_UPDATED", {
|
||||
targetId: projectId.value,
|
||||
targetName: project.value?.name,
|
||||
after: { member_id: memberId, role_in_study: role },
|
||||
severity: "normal",
|
||||
});
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.updateFailed);
|
||||
loadMembers();
|
||||
logAudit("PROJECT_MEMBER_UPDATED", {
|
||||
targetId: projectId.value,
|
||||
targetName: project.value?.name,
|
||||
after: { member_id: memberId, role_in_study: role },
|
||||
severity: "warning",
|
||||
reason: e?.response?.data?.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -273,12 +234,6 @@ const toggleActive = async (row: StudyMember) => {
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
||||
logAudit(decision.auditType, {
|
||||
targetId: projectId.value,
|
||||
targetName: project.value?.name,
|
||||
reason: decision.reason,
|
||||
severity: decision.severity,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (row.is_active) {
|
||||
@@ -291,46 +246,16 @@ const toggleActive = async (row: StudyMember) => {
|
||||
await updateMember(projectId.value, row.id, { is_active: false });
|
||||
ElMessage.success(TEXT.modules.adminProjectMembers.disableSuccess);
|
||||
loadMembers();
|
||||
logAudit("PROJECT_MEMBER_UPDATED", {
|
||||
targetId: projectId.value,
|
||||
targetName: project.value?.name,
|
||||
before: { is_active: row.is_active },
|
||||
after: { is_active: false },
|
||||
severity: "normal",
|
||||
});
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.actionFailed);
|
||||
logAudit("PROJECT_MEMBER_UPDATED", {
|
||||
targetId: projectId.value,
|
||||
targetName: project.value?.name,
|
||||
before: { is_active: row.is_active },
|
||||
after: { is_active: false },
|
||||
severity: "warning",
|
||||
reason: e?.response?.data?.message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await updateMember(projectId.value, row.id, { is_active: true });
|
||||
ElMessage.success(TEXT.modules.adminProjectMembers.enableSuccess);
|
||||
loadMembers();
|
||||
logAudit("PROJECT_MEMBER_UPDATED", {
|
||||
targetId: projectId.value,
|
||||
targetName: project.value?.name,
|
||||
before: { is_active: row.is_active },
|
||||
after: { is_active: true },
|
||||
severity: "normal",
|
||||
});
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.actionFailed);
|
||||
logAudit("PROJECT_MEMBER_UPDATED", {
|
||||
targetId: projectId.value,
|
||||
targetName: project.value?.name,
|
||||
before: { is_active: row.is_active },
|
||||
after: { is_active: true },
|
||||
severity: "warning",
|
||||
reason: e?.response?.data?.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -344,12 +269,6 @@ const onDelete = async (row: StudyMember) => {
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
||||
logAudit(decision.auditType, {
|
||||
targetId: projectId.value,
|
||||
targetName: project.value?.name,
|
||||
reason: decision.reason,
|
||||
severity: decision.severity,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const ok = await ElMessageBox.confirm(TEXT.modules.adminProjectMembers.removeConfirm, TEXT.modules.adminProjectMembers.removeTitle, {
|
||||
@@ -362,23 +281,8 @@ const onDelete = async (row: StudyMember) => {
|
||||
await removeMember(projectId.value, row.id);
|
||||
members.value = members.value.filter((m) => m.id !== row.id);
|
||||
ElMessage.success(TEXT.modules.adminProjectMembers.removeSuccess);
|
||||
logAudit("PROJECT_MEMBER_UPDATED", {
|
||||
targetId: projectId.value,
|
||||
targetName: project.value?.name,
|
||||
before: { is_active: row.is_active },
|
||||
after: { removed_member_id: row.id },
|
||||
severity: "normal",
|
||||
});
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
|
||||
logAudit("PROJECT_MEMBER_UPDATED", {
|
||||
targetId: projectId.value,
|
||||
targetName: project.value?.name,
|
||||
before: { is_active: row.is_active },
|
||||
after: { removed_member_id: row.id },
|
||||
severity: "warning",
|
||||
reason: e?.response?.data?.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ import { updateSite } from "../../api/sites";
|
||||
import type { Site, UserInfo } from "../../types/api";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { evaluateAction } from "../../guards/actionGuard";
|
||||
import { logAudit } from "../../audit";
|
||||
import { TEXT } from "../../locales";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -85,12 +84,6 @@ const onSave = async () => {
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
||||
logAudit(decision.auditType, {
|
||||
targetId: props.site.id,
|
||||
targetName: props.site.name,
|
||||
reason: decision.reason,
|
||||
severity: decision.severity,
|
||||
});
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
@@ -102,23 +95,8 @@ const onSave = async () => {
|
||||
ElMessage.success(TEXT.modules.adminSites.craSaveSuccess);
|
||||
emit("saved");
|
||||
visibleProxy.value = false;
|
||||
logAudit("SITE_CRA_BOUND", {
|
||||
targetId: props.site.id,
|
||||
targetName: props.site.name,
|
||||
before: { contact: props.site.contact },
|
||||
after: { contact: names.join(",") },
|
||||
severity: "normal",
|
||||
});
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||
logAudit("SITE_CRA_BOUND", {
|
||||
targetId: props.site.id,
|
||||
targetName: props.site.name,
|
||||
before: { contact: props.site.contact },
|
||||
after: { contact: selectedCras.value.join(",") },
|
||||
severity: "warning",
|
||||
reason: e?.response?.data?.message,
|
||||
});
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,6 @@ import { createSite, updateSite } from "../../api/sites";
|
||||
import type { Site } from "../../types/api";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { evaluateAction } from "../../guards/actionGuard";
|
||||
import { logAudit } from "../../audit";
|
||||
import { TEXT, requiredMessage } from "../../locales";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -137,12 +136,6 @@ const onSubmit = async () => {
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
||||
logAudit(decision.auditType, {
|
||||
targetId: props.site?.id || props.studyId,
|
||||
targetName: props.site?.name,
|
||||
reason: decision.reason,
|
||||
severity: decision.severity,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await formRef.value.validate();
|
||||
@@ -158,12 +151,6 @@ const onSubmit = async () => {
|
||||
is_active: form.is_active,
|
||||
});
|
||||
ElMessage.success(TEXT.modules.adminSites.updateSuccess);
|
||||
logAudit("SITE_STATUS_CHANGED", {
|
||||
targetId: props.site.id,
|
||||
targetName: props.site.name,
|
||||
after: { name: form.name, is_active: form.is_active },
|
||||
severity: "normal",
|
||||
});
|
||||
} else {
|
||||
await createSite(props.studyId, {
|
||||
name: form.name,
|
||||
@@ -172,25 +159,12 @@ const onSubmit = async () => {
|
||||
contact,
|
||||
});
|
||||
ElMessage.success(TEXT.modules.adminSites.createSuccess);
|
||||
logAudit("SITE_STATUS_CHANGED", {
|
||||
targetId: props.studyId,
|
||||
targetName: form.name,
|
||||
after: { name: form.name },
|
||||
severity: "normal",
|
||||
});
|
||||
}
|
||||
|
||||
emit("saved");
|
||||
visibleProxy.value = false;
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||
logAudit("SITE_STATUS_CHANGED", {
|
||||
targetId: props.site?.id || props.studyId,
|
||||
targetName: props.site?.name || form.name,
|
||||
after: { name: form.name },
|
||||
severity: "warning",
|
||||
reason: e?.response?.data?.message,
|
||||
});
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
|
||||
@@ -86,7 +86,6 @@ import SiteForm from "./SiteForm.vue";
|
||||
import type { Site, Study, UserInfo } from "../../types/api";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { evaluateAction } from "../../guards/actionGuard";
|
||||
import { logAudit } from "../../audit";
|
||||
import { TEXT } from "../../locales";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -217,12 +216,6 @@ const toggleSite = async (row: Site) => {
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
|
||||
logAudit(decision.auditType, {
|
||||
targetId: row.id,
|
||||
targetName: row.name,
|
||||
reason: decision.reason,
|
||||
severity: decision.severity,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (row.is_active) {
|
||||
@@ -237,23 +230,8 @@ const toggleSite = async (row: Site) => {
|
||||
await updateSite(projectId.value, row.id, { is_active: !row.is_active });
|
||||
ElMessage.success(row.is_active ? "已锁定" : "已解锁");
|
||||
loadSites();
|
||||
logAudit("SITE_STATUS_CHANGED", {
|
||||
targetId: row.id,
|
||||
targetName: row.name,
|
||||
before: { is_active: row.is_active },
|
||||
after: { is_active: !row.is_active },
|
||||
severity: "normal",
|
||||
});
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.actionFailed);
|
||||
logAudit("SITE_STATUS_CHANGED", {
|
||||
targetId: row.id,
|
||||
targetName: row.name,
|
||||
before: { is_active: row.is_active },
|
||||
after: { is_active: !row.is_active },
|
||||
severity: "warning",
|
||||
reason: e?.response?.data?.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -277,19 +255,8 @@ const confirmDelete = async (row: Site) => {
|
||||
await deleteSite(projectId.value, row.id);
|
||||
ElMessage.success(TEXT.common.messages.deleteSuccess);
|
||||
loadSites();
|
||||
logAudit("SITE_DELETED", {
|
||||
targetId: row.id,
|
||||
targetName: row.name,
|
||||
severity: "high",
|
||||
});
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
|
||||
logAudit("SITE_DELETED", {
|
||||
targetId: row.id,
|
||||
targetName: row.name,
|
||||
severity: "warning",
|
||||
reason: e?.response?.data?.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,13 +1,601 @@
|
||||
<template>
|
||||
<ModulePlaceholder
|
||||
:title="TEXT.modules.materialEquipment.title"
|
||||
:subtitle="TEXT.modules.materialEquipment.subtitle"
|
||||
:list-title="TEXT.modules.materialEquipment.listTitle"
|
||||
:empty-description="TEXT.modules.materialEquipment.emptyDescription"
|
||||
/>
|
||||
<div class="page">
|
||||
<div v-if="study.currentStudy" class="unified-shell">
|
||||
<section class="unified-section equipment-section">
|
||||
<div class="filter-row">
|
||||
<el-form :inline="true" :model="filters">
|
||||
<el-form-item label="设备名称">
|
||||
<el-input v-model="filters.name" clearable placeholder="请输入" class="name-filter" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-button type="primary" @click="openCreate">新建</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="rows" class="ctms-table" style="width: 100%" table-layout="fixed">
|
||||
<el-table-column prop="name" label="设备名称" />
|
||||
<el-table-column prop="specModel" label="规格型号" />
|
||||
<el-table-column prop="unit" label="单位" />
|
||||
<el-table-column prop="brand" label="品牌" />
|
||||
<el-table-column label="是否需要校准">
|
||||
<template #default="{ row }">
|
||||
{{ row.needCalibration ? "是" : "否" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="removeRow(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<StateEmpty v-if="!loading && rows.length === 0" description="暂无设备数据" />
|
||||
</section>
|
||||
</div>
|
||||
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
|
||||
|
||||
<el-drawer
|
||||
v-model="drawerVisible"
|
||||
direction="rtl"
|
||||
size="620px"
|
||||
:close-on-click-modal="false"
|
||||
:show-close="false"
|
||||
class="equipment-editor-drawer"
|
||||
>
|
||||
<template #header>
|
||||
<div class="editor-header">
|
||||
<div class="editor-title">{{ editingId ? "编辑设备" : "新建设备" }}</div>
|
||||
<div class="editor-subtitle">{{ editingId ? "修改设备基本信息与校准配置" : "添加新的设备记录到设备台账" }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="equipment-form">
|
||||
<!-- 基本信息分组 -->
|
||||
<div class="form-group">
|
||||
<div class="form-group-title">
|
||||
<span class="group-dot group-dot-basic"></span>
|
||||
基本信息
|
||||
</div>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="设备名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="请输入设备名称" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="规格型号" prop="specModel">
|
||||
<el-input v-model="form.specModel" placeholder="请输入规格型号" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="单位" prop="unit">
|
||||
<el-input v-model="form.unit" placeholder="请输入" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="品牌" prop="brand">
|
||||
<el-input v-model="form.brand" placeholder="请输入品牌" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="产地" prop="origin">
|
||||
<el-input v-model="form.origin" placeholder="请输入产地" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 资质文件分组 -->
|
||||
<div class="form-group">
|
||||
<div class="form-group-title">
|
||||
<span class="group-dot group-dot-file"></span>
|
||||
资质文件
|
||||
</div>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<div class="upload-card">
|
||||
<div class="upload-card-label">生产许可证</div>
|
||||
<el-upload :auto-upload="false" :show-file-list="false" :on-change="onProductionPermitChange">
|
||||
<div v-if="!form.productionPermitFileName" class="upload-trigger">
|
||||
<span class="upload-icon">📄</span>
|
||||
<span class="upload-text">点击上传文件</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div v-if="form.productionPermitFileName" class="upload-result">
|
||||
<span class="upload-result-icon">✅</span>
|
||||
<span class="upload-result-name">{{ form.productionPermitFileName }}</span>
|
||||
<el-button link type="danger" size="small" @click="form.productionPermitFileName = ''">移除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<div class="upload-card">
|
||||
<div class="upload-card-label">技术指标</div>
|
||||
<el-upload :auto-upload="false" :show-file-list="false" :on-change="onTechIndexChange">
|
||||
<div v-if="!form.techIndexFileName" class="upload-trigger">
|
||||
<span class="upload-icon">📄</span>
|
||||
<span class="upload-text">点击上传文件</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div v-if="form.techIndexFileName" class="upload-result">
|
||||
<span class="upload-result-icon">✅</span>
|
||||
<span class="upload-result-name">{{ form.techIndexFileName }}</span>
|
||||
<el-button link type="danger" size="small" @click="form.techIndexFileName = ''">移除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 校准设置分组 -->
|
||||
<div class="form-group">
|
||||
<div class="form-group-title">
|
||||
<span class="group-dot group-dot-calibration"></span>
|
||||
校准设置
|
||||
</div>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="是否需要校准" prop="needCalibration">
|
||||
<el-select v-model="form.needCalibration" placeholder="请选择" class="full-width">
|
||||
<el-option label="是" :value="true" />
|
||||
<el-option label="否" :value="false" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="form.needCalibration" :span="12">
|
||||
<el-form-item label="校准周期(天)" prop="calibrationCycleDays">
|
||||
<el-input-number v-model="form.calibrationCycleDays" :min="1" :precision="0" class="full-width" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div v-if="!form.needCalibration" class="calibration-hint">
|
||||
<span class="hint-icon">ℹ️</span>
|
||||
<span>该设备无需定期校准</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveForm">保存</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
|
||||
import { reactive, ref, watch } from "vue";
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules, type UploadFile } from "element-plus";
|
||||
import {
|
||||
createMaterialEquipment,
|
||||
deleteMaterialEquipment,
|
||||
listMaterialEquipments,
|
||||
updateMaterialEquipment,
|
||||
} from "../../api/materialEquipments";
|
||||
import StateEmpty from "../../components/StateEmpty.vue";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { TEXT } from "../../locales";
|
||||
|
||||
interface EquipmentRow {
|
||||
id: string;
|
||||
name: string;
|
||||
specModel: string;
|
||||
unit: string;
|
||||
brand: string;
|
||||
origin: string;
|
||||
productionPermitFileName: string;
|
||||
techIndexFileName: string;
|
||||
needCalibration: boolean;
|
||||
calibrationCycleDays: number | null;
|
||||
}
|
||||
|
||||
type FormModel = Omit<EquipmentRow, "id">;
|
||||
|
||||
const study = useStudyStore();
|
||||
const filters = reactive({ name: "" });
|
||||
const rows = ref<EquipmentRow[]>([]);
|
||||
const loading = ref(false);
|
||||
const drawerVisible = ref(false);
|
||||
const editingId = ref("");
|
||||
const formRef = ref<FormInstance>();
|
||||
|
||||
const defaultForm: FormModel = {
|
||||
name: "",
|
||||
specModel: "",
|
||||
unit: "",
|
||||
brand: "",
|
||||
origin: "",
|
||||
productionPermitFileName: "",
|
||||
techIndexFileName: "",
|
||||
needCalibration: true,
|
||||
calibrationCycleDays: 30,
|
||||
};
|
||||
|
||||
const form = reactive<FormModel>({ ...defaultForm });
|
||||
|
||||
const rules: FormRules<FormModel> = {
|
||||
name: [{ required: true, message: "请输入设备名称", trigger: "blur" }],
|
||||
specModel: [{ required: true, message: "请输入规格型号", trigger: "blur" }],
|
||||
brand: [{ required: true, message: "请输入品牌", trigger: "blur" }],
|
||||
needCalibration: [{ required: true, message: "请选择是否需要校准", trigger: "change" }],
|
||||
calibrationCycleDays: [
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (form.needCalibration && (!value || value < 1)) {
|
||||
callback(new Error("请填写校准周期"));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
},
|
||||
trigger: "change",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const loadRows = async () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) {
|
||||
rows.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = (await listMaterialEquipments(studyId, {
|
||||
name: filters.name.trim() || undefined,
|
||||
limit: 500,
|
||||
})) as any;
|
||||
const list = Array.isArray(data) ? data : data?.items || [];
|
||||
rows.value = list.map((item: any) => ({
|
||||
id: item.id,
|
||||
name: item.name || "",
|
||||
specModel: item.spec_model || "",
|
||||
unit: item.unit || "",
|
||||
brand: item.brand || "",
|
||||
origin: item.origin || "",
|
||||
productionPermitFileName: item.production_permit_file_name || "",
|
||||
techIndexFileName: item.tech_index_file_name || "",
|
||||
needCalibration: !!item.need_calibration,
|
||||
calibrationCycleDays: item.calibration_cycle_days ?? null,
|
||||
}));
|
||||
} catch (e: any) {
|
||||
rows.value = [];
|
||||
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.loadFailed);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(form, defaultForm);
|
||||
formRef.value?.clearValidate();
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
editingId.value = "";
|
||||
resetForm();
|
||||
drawerVisible.value = true;
|
||||
};
|
||||
|
||||
const openEdit = (row: EquipmentRow) => {
|
||||
editingId.value = row.id;
|
||||
Object.assign(form, {
|
||||
name: row.name,
|
||||
specModel: row.specModel,
|
||||
unit: row.unit,
|
||||
brand: row.brand,
|
||||
origin: row.origin,
|
||||
productionPermitFileName: row.productionPermitFileName,
|
||||
techIndexFileName: row.techIndexFileName,
|
||||
needCalibration: row.needCalibration,
|
||||
calibrationCycleDays: row.calibrationCycleDays,
|
||||
});
|
||||
formRef.value?.clearValidate();
|
||||
drawerVisible.value = true;
|
||||
};
|
||||
|
||||
const saveForm = async () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
const ok = await formRef.value?.validate().catch(() => false);
|
||||
if (!ok) return;
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
spec_model: form.specModel.trim(),
|
||||
unit: form.unit.trim() || null,
|
||||
brand: form.brand.trim(),
|
||||
origin: form.origin.trim() || null,
|
||||
production_permit_file_name: form.productionPermitFileName || null,
|
||||
tech_index_file_name: form.techIndexFileName || null,
|
||||
need_calibration: !!form.needCalibration,
|
||||
calibration_cycle_days: form.needCalibration ? form.calibrationCycleDays : null,
|
||||
};
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateMaterialEquipment(studyId, editingId.value, payload);
|
||||
} else {
|
||||
await createMaterialEquipment(studyId, payload);
|
||||
}
|
||||
await loadRows();
|
||||
drawerVisible.value = false;
|
||||
ElMessage.success("保存成功");
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.saveFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const removeRow = async (row: EquipmentRow) => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
const ok = await ElMessageBox.confirm("确认删除该条设备记录吗?", "提示", { type: "warning" }).catch(() => null);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteMaterialEquipment(studyId, row.id);
|
||||
await loadRows();
|
||||
ElMessage.success("删除成功");
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.deleteFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadChange = (file: UploadFile, field: "productionPermitFileName" | "techIndexFileName") => {
|
||||
form[field] = file.name;
|
||||
};
|
||||
|
||||
const onProductionPermitChange = (file: UploadFile) => {
|
||||
handleUploadChange(file, "productionPermitFileName");
|
||||
};
|
||||
|
||||
const onTechIndexChange = (file: UploadFile) => {
|
||||
handleUploadChange(file, "techIndexFileName");
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
loadRows();
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.name = "";
|
||||
loadRows();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => study.currentStudy?.id,
|
||||
() => {
|
||||
filters.name = "";
|
||||
loadRows();
|
||||
drawerVisible.value = false;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => form.needCalibration,
|
||||
(need) => {
|
||||
if (!need) form.calibrationCycleDays = null;
|
||||
if (need && !form.calibrationCycleDays) form.calibrationCycleDays = 30;
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.equipment-section {
|
||||
padding-top: 14px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.name-filter {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
/* ========== 抽屉头部 ========== */
|
||||
.editor-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.editor-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--ctms-text-main);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.editor-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ========== 表单整体 ========== */
|
||||
.equipment-form {
|
||||
padding: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
/* ========== 分组卡片 ========== */
|
||||
.form-group {
|
||||
border: 1px solid #e8eef6;
|
||||
border-radius: 10px;
|
||||
padding: 16px 18px 8px;
|
||||
background: #fbfcfe;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form-group:hover {
|
||||
border-color: #d0dced;
|
||||
}
|
||||
|
||||
.form-group + .form-group {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.form-group-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 14px;
|
||||
color: #1a3560;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.group-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 基本信息 - 蓝色 */
|
||||
.group-dot-basic {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
/* 资质文件 - 琥珀色 */
|
||||
.group-dot-file {
|
||||
background: #f0ad2c;
|
||||
}
|
||||
|
||||
/* 校准设置 - 绿色 */
|
||||
.group-dot-calibration {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
/* ========== 上传卡片 ========== */
|
||||
.upload-card {
|
||||
border: 1px dashed #d0dced;
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
background: #ffffff;
|
||||
transition: all 0.2s ease;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.upload-card:hover {
|
||||
border-color: var(--ctms-primary);
|
||||
background: #f8faff;
|
||||
}
|
||||
|
||||
.upload-card-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #4a6283;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upload-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.upload-trigger:hover .upload-text {
|
||||
color: var(--ctms-primary);
|
||||
}
|
||||
|
||||
.upload-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.upload-result-icon {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.upload-result-name {
|
||||
font-size: 12px;
|
||||
color: var(--ctms-text-regular);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ========== 校准提示 ========== */
|
||||
.calibration-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #166534;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.hint-icon {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ========== 表单元素细节 ========== */
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ========== 底部按钮 ========== */
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ========== 表单元素微调 ========== */
|
||||
.equipment-form :deep(.el-form-item) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.equipment-form :deep(.el-form-item__label) {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #4a6283;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ========== 表格居中 ========== */
|
||||
:deep(.ctms-table th.el-table__cell .cell),
|
||||
:deep(.ctms-table td.el-table__cell .cell) {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<template>
|
||||
<ModulePlaceholder
|
||||
:title="TEXT.modules.materialOthers.title"
|
||||
:subtitle="TEXT.modules.materialOthers.subtitle"
|
||||
:list-title="TEXT.modules.materialOthers.listTitle"
|
||||
:empty-description="TEXT.modules.materialOthers.emptyDescription"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
</script>
|
||||
@@ -196,11 +196,10 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { fetchStudySetupConfig } from "../../api/studies";
|
||||
import { listProjectMilestones, updateProjectMilestone } from "../../api/projectMilestones";
|
||||
import { TEXT } from "../../locales";
|
||||
import StateEmpty from "../../components/StateEmpty.vue";
|
||||
import { displayDateTime } from "../../utils/display";
|
||||
import type { ProjectMilestoneDraft, StudySetupConfigResponse } from "../../types/setupConfig";
|
||||
|
||||
interface MilestoneRow {
|
||||
id: string;
|
||||
@@ -234,11 +233,10 @@ type MilestoneLocalEdit = {
|
||||
const study = useStudyStore();
|
||||
const rows = ref<MilestoneRow[]>([]);
|
||||
const loading = ref(false);
|
||||
const dataSourceLabel = ref<string>(TEXT.modules.projectMilestones.sourceDraft);
|
||||
const dataSourceLabel = ref<string>(TEXT.modules.projectMilestones.sourcePublished);
|
||||
const updatedAtLabel = ref<string>(TEXT.common.fallback);
|
||||
const editorVisible = ref(false);
|
||||
const editingRowId = ref("");
|
||||
const localEdits = ref<Record<string, MilestoneLocalEdit>>({});
|
||||
const editorForm = reactive<MilestoneLocalEdit>({
|
||||
adjustedStartDate: "",
|
||||
adjustedEndDate: "",
|
||||
@@ -249,8 +247,6 @@ const editorForm = reactive<MilestoneLocalEdit>({
|
||||
|
||||
const doneCount = computed(() => rows.value.filter((item) => getStatusView(item).completed).length);
|
||||
|
||||
const getLocalEditKey = (studyId: string) => `ctms_project_milestones_edits_${studyId}`;
|
||||
|
||||
const toDateOnly = (value?: string): Date | null => {
|
||||
if (!value) return null;
|
||||
const d = new Date(`${value}T00:00:00`);
|
||||
@@ -267,22 +263,24 @@ const calcDurationDays = (start?: string, end?: string): number | null => {
|
||||
};
|
||||
|
||||
const toDurationText = (days: number | null) => (days && days > 0 ? `${days}天` : TEXT.common.fallback);
|
||||
const toPositiveInt = (value: unknown): number | null => {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
return Math.floor(n);
|
||||
};
|
||||
|
||||
const applyEditToRow = (row: MilestoneRow): MilestoneRow => {
|
||||
const patch = localEdits.value[row.id] || {};
|
||||
const adjustedStartDate = String(patch.adjustedStartDate ?? row.adjustedStartDate ?? "").trim();
|
||||
const adjustedEndDate = String(patch.adjustedEndDate ?? row.adjustedEndDate ?? "").trim();
|
||||
const actualStartDate = String(patch.actualStartDate ?? row.actualStartDate ?? "").trim();
|
||||
const actualEndDate = String(patch.actualEndDate ?? row.actualEndDate ?? "").trim();
|
||||
const normalizeRow = (row: any): MilestoneRow => {
|
||||
const startDate = String(row?.planned_date || "").trim();
|
||||
const endDate = String(row?.planned_date || "").trim();
|
||||
const adjustedStartDate = String(row?.adjusted_start_date || "").trim();
|
||||
const adjustedEndDate = String(row?.adjusted_end_date || "").trim();
|
||||
const actualStartDate = String(row?.actual_start_date || "").trim();
|
||||
const actualEndDate = String(row?.actual_end_date || "").trim();
|
||||
const adjustedDurationDays = calcDurationDays(adjustedStartDate, adjustedEndDate);
|
||||
const actualDurationDays = calcDurationDays(actualStartDate, actualEndDate);
|
||||
return {
|
||||
...row,
|
||||
id: row?.id || "",
|
||||
name: String(row?.name || "").trim(),
|
||||
planDate: startDate,
|
||||
startDate,
|
||||
endDate,
|
||||
durationDays: startDate ? 1 : null,
|
||||
durationText: startDate ? "1天" : TEXT.common.fallback,
|
||||
adjustedStartDate,
|
||||
adjustedEndDate,
|
||||
adjustedDurationDays,
|
||||
@@ -291,85 +289,26 @@ const applyEditToRow = (row: MilestoneRow): MilestoneRow => {
|
||||
actualEndDate,
|
||||
actualDurationDays,
|
||||
actualDurationText: toDurationText(actualDurationDays),
|
||||
remark: String(patch.remark ?? row.remark ?? "").trim(),
|
||||
owner: String(row?.owner_name || "").trim(),
|
||||
status: String(row?.status || "NOT_STARTED").trim(),
|
||||
remark: String(row?.notes || "").trim(),
|
||||
};
|
||||
};
|
||||
|
||||
const loadLocalEdits = () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
try {
|
||||
const raw = localStorage.getItem(getLocalEditKey(studyId));
|
||||
localEdits.value = raw ? (JSON.parse(raw) as Record<string, MilestoneLocalEdit>) : {};
|
||||
} catch {
|
||||
localEdits.value = {};
|
||||
}
|
||||
};
|
||||
|
||||
const persistLocalEdits = () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
localStorage.setItem(getLocalEditKey(studyId), JSON.stringify(localEdits.value));
|
||||
};
|
||||
|
||||
const normalizeRows = (items: ProjectMilestoneDraft[]) =>
|
||||
items
|
||||
.map((item, index) => {
|
||||
const planDate = String(item.planDate || "").trim();
|
||||
const startDate = String(item.startDate || planDate).trim();
|
||||
const endDate = String(item.endDate || planDate).trim();
|
||||
const adjustedStartDate = String((item as any).adjustedStartDate || "").trim();
|
||||
const adjustedEndDate = String((item as any).adjustedEndDate || "").trim();
|
||||
const actualStartDate = String((item as any).actualStartDate || "").trim();
|
||||
const actualEndDate = String((item as any).actualEndDate || "").trim();
|
||||
const rawDays = toPositiveInt(item.durationDays);
|
||||
const adjustedRawDays = toPositiveInt((item as any).adjustedDurationDays) ?? calcDurationDays(adjustedStartDate, adjustedEndDate);
|
||||
const actualRawDays = toPositiveInt((item as any).actualDurationDays) ?? calcDurationDays(actualStartDate, actualEndDate);
|
||||
const durationDays = rawDays ?? (startDate || endDate ? 1 : null);
|
||||
const adjustedDurationDays = adjustedRawDays ?? (adjustedStartDate || adjustedEndDate ? 1 : null);
|
||||
const actualDurationDays = actualRawDays ?? (actualStartDate || actualEndDate ? 1 : null);
|
||||
return {
|
||||
id: item.id || `setup-project-milestone-${index + 1}`,
|
||||
name: String(item.name || "").trim(),
|
||||
planDate,
|
||||
startDate,
|
||||
endDate,
|
||||
durationDays,
|
||||
durationText: durationDays ? `${durationDays}天` : TEXT.common.fallback,
|
||||
adjustedStartDate,
|
||||
adjustedEndDate,
|
||||
adjustedDurationDays,
|
||||
adjustedDurationText: adjustedDurationDays ? `${adjustedDurationDays}天` : TEXT.common.fallback,
|
||||
actualStartDate,
|
||||
actualEndDate,
|
||||
actualDurationDays,
|
||||
actualDurationText: actualDurationDays ? `${actualDurationDays}天` : TEXT.common.fallback,
|
||||
owner: String(item.owner || "").trim(),
|
||||
status: String(item.status || "未开始").trim(),
|
||||
remark: String(item.remark || "").trim(),
|
||||
};
|
||||
})
|
||||
.filter((item) => item.name || item.startDate || item.endDate || item.owner || item.remark);
|
||||
|
||||
const pickProjectMilestones = (setup: StudySetupConfigResponse): ProjectMilestoneDraft[] => {
|
||||
const publishedRows = normalizeRows(setup.published_data?.projectMilestones || []);
|
||||
if (publishedRows.length > 0) {
|
||||
dataSourceLabel.value = TEXT.modules.projectMilestones.sourcePublished;
|
||||
return setup.published_data?.projectMilestones || [];
|
||||
}
|
||||
dataSourceLabel.value = setup.published_data ? TEXT.modules.projectMilestones.sourceDraftFallback : TEXT.modules.projectMilestones.sourceDraft;
|
||||
return setup.data?.projectMilestones || [];
|
||||
};
|
||||
|
||||
const loadMilestones = async () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await fetchStudySetupConfig(studyId);
|
||||
loadLocalEdits();
|
||||
rows.value = normalizeRows(pickProjectMilestones(data)).map(applyEditToRow);
|
||||
updatedAtLabel.value = displayDateTime(data.published_at || data.updated_at);
|
||||
const { data } = (await listProjectMilestones(studyId)) as any;
|
||||
const list = Array.isArray(data) ? data : data?.items || [];
|
||||
rows.value = list.map(normalizeRow);
|
||||
const updatedAt = list
|
||||
.map((item: any) => String(item?.updated_at || ""))
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.pop();
|
||||
updatedAtLabel.value = updatedAt ? displayDateTime(updatedAt) : TEXT.common.fallback;
|
||||
} catch (error: any) {
|
||||
rows.value = [];
|
||||
updatedAtLabel.value = TEXT.common.fallback;
|
||||
@@ -468,25 +407,26 @@ const openEditor = (row: MilestoneRow) => {
|
||||
editorVisible.value = true;
|
||||
};
|
||||
|
||||
const saveEditor = () => {
|
||||
if (!editingRowId.value) return;
|
||||
const saveEditor = async () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId || !editingRowId.value) return;
|
||||
if (!validateDateRange(editorForm.adjustedStartDate, editorForm.adjustedEndDate, "调整计划时间")) return;
|
||||
if (!validateDateRange(editorForm.actualStartDate, editorForm.actualEndDate, "实际时间")) return;
|
||||
const patch: MilestoneLocalEdit = {
|
||||
adjustedStartDate: editorForm.adjustedStartDate || "",
|
||||
adjustedEndDate: editorForm.adjustedEndDate || "",
|
||||
actualStartDate: editorForm.actualStartDate || "",
|
||||
actualEndDate: editorForm.actualEndDate || "",
|
||||
remark: (editorForm.remark || "").trim(),
|
||||
const patch = {
|
||||
adjusted_start_date: editorForm.adjustedStartDate || null,
|
||||
adjusted_end_date: editorForm.adjustedEndDate || null,
|
||||
actual_start_date: editorForm.actualStartDate || null,
|
||||
actual_end_date: editorForm.actualEndDate || null,
|
||||
notes: (editorForm.remark || "").trim() || null,
|
||||
};
|
||||
localEdits.value = {
|
||||
...localEdits.value,
|
||||
[editingRowId.value]: patch,
|
||||
};
|
||||
persistLocalEdits();
|
||||
rows.value = rows.value.map((item) => (item.id === editingRowId.value ? applyEditToRow(item) : item));
|
||||
editorVisible.value = false;
|
||||
ElMessage.success("里程碑时间已更新");
|
||||
try {
|
||||
await updateProjectMilestone(studyId, editingRowId.value, patch);
|
||||
await loadMilestones();
|
||||
editorVisible.value = false;
|
||||
ElMessage.success("里程碑时间已更新");
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.response?.data?.detail || TEXT.common.messages.saveFailed);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
@@ -497,7 +437,6 @@ watch(
|
||||
() => study.currentStudy?.id,
|
||||
() => {
|
||||
rows.value = [];
|
||||
localEdits.value = {};
|
||||
updatedAtLabel.value = TEXT.common.fallback;
|
||||
loadMilestones();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user