立项配置数据入库、审计日志数据入库、编辑页UI界面美化、设备管理内容补充、审计日志显示优化

This commit is contained in:
Cheng Zhou
2026-02-27 16:16:26 +08:00
parent fd7e3fc948
commit db2d38edbc
48 changed files with 2936 additions and 909 deletions
+4 -1
View File
@@ -1,4 +1,4 @@
import { apiDelete, apiGet } from "./axios";
import { apiDelete, apiGet, apiPost } from "./axios";
import type { ApiListResponse } from "../types/api";
export const fetchAuditLogs = (studyId: string, params?: Record<string, any>) =>
@@ -6,3 +6,6 @@ export const fetchAuditLogs = (studyId: string, params?: Record<string, any>) =>
export const deleteAuditLog = (studyId: string, logId: string) =>
apiDelete(`/api/v1/studies/${studyId}/audit-logs/${logId}`);
export const createAuditEvent = (studyId: string, payload: Record<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/audit-logs/events`, payload);
+16
View File
@@ -0,0 +1,16 @@
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
export const listMaterialEquipments = (studyId: string, params?: Record<string, any>) =>
apiGet(`/api/v1/studies/${studyId}/materials/equipment`, { params });
export const getMaterialEquipment = (studyId: string, equipmentId: string) =>
apiGet(`/api/v1/studies/${studyId}/materials/equipment/${equipmentId}`);
export const createMaterialEquipment = (studyId: string, payload: Record<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/materials/equipment`, payload);
export const updateMaterialEquipment = (studyId: string, equipmentId: string, payload: Record<string, any>) =>
apiPatch(`/api/v1/studies/${studyId}/materials/equipment/${equipmentId}`, payload);
export const deleteMaterialEquipment = (studyId: string, equipmentId: string) =>
apiDelete(`/api/v1/studies/${studyId}/materials/equipment/${equipmentId}`);
+10
View File
@@ -0,0 +1,10 @@
import { apiGet, apiPatch } from "./axios";
export const listProjectMilestones = (studyId: string) =>
apiGet(`/api/v1/studies/${studyId}/project/milestones`);
export const updateProjectMilestone = (
studyId: string,
milestoneId: string,
payload: Record<string, any>
) => apiPatch(`/api/v1/studies/${studyId}/project/milestones/${milestoneId}`, payload);
+3 -43
View File
@@ -1,7 +1,5 @@
import { auditDict } from "./auditDict";
import { roleDict, getDictLabel, statusDict } from "../dictionaries";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { TEXT } from "../locales";
export interface AuditEvent {
@@ -250,47 +248,9 @@ export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>):
export { auditDict };
const LOCAL_KEY_PREFIX = "audit_local_";
const getLocalKey = (studyId?: string | null) => `${LOCAL_KEY_PREFIX}${studyId || "global"}`;
const appendLocalAudit = (studyId: string | null, log: any) => {
try {
const key = getLocalKey(studyId);
const existingRaw = localStorage.getItem(key);
const existing = existingRaw ? JSON.parse(existingRaw) : [];
existing.unshift(log);
localStorage.setItem(key, JSON.stringify(existing.slice(0, 200)));
} catch {
/* ignore */
}
};
// 前端占位的 logAudit:不修改后端接口,落地到本地存储,失败不影响主流程
// Deprecated: keep API-compatible no-op to avoid accidental reliance on browser local storage.
export const logAudit = async (eventType: string, payload: Record<string, any>) => {
try {
const auth = useAuthStore();
const study = useStudyStore();
const user = auth.user;
const studyId = study.currentStudy?.id || null;
const log = {
action: eventType,
operator_id: user?.id || user?.username || TEXT.audit.currentUser,
operator_role: user?.role || "",
entity_type: payload.targetType || TEXT.audit.localClient,
entity_id: payload.targetId || "",
created_at: new Date().toISOString(),
detail: JSON.stringify({
targetName: payload.targetName,
before: payload.before,
after: payload.after,
reason: payload.reason,
severity: payload.severity,
result: payload.severity === "normal" ? "SUCCESS" : "FAIL",
}),
};
appendLocalAudit(studyId, log);
} catch {
/* ignore */
}
void eventType;
void payload;
return Promise.resolve();
};
+14 -4
View File
@@ -58,7 +58,6 @@
</template>
<el-menu-item index="/drug/shipments">{{ TEXT.menu.drugShipments }}</el-menu-item>
<el-menu-item index="/materials/equipment">{{ TEXT.menu.materialEquipment }}</el-menu-item>
<el-menu-item index="/materials/others">{{ TEXT.menu.materialOthers }}</el-menu-item>
</el-sub-menu>
<el-menu-item index="/file-versions">
<el-icon><Files /></el-icon>
@@ -206,7 +205,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { computed, onMounted, ref, watch, watchEffect } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
@@ -226,6 +225,19 @@ const route = useRoute();
const isAdmin = computed(() => auth.user?.role === "ADMIN");
const isPm = computed(() => auth.user?.role === "PM" || study.currentStudyRole === "PM");
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
/* 动态设置 body 上的侧边栏宽度 CSS 变量,用于全局弹窗定位偏移 */
const hasSidebar = computed(() => isAdmin.value || !!study.currentStudy);
watchEffect(() => {
const width = !hasSidebar.value ? '0px' : (isCollapsed.value ? '68px' : '200px');
document.body.style.setProperty('--ctms-sidebar-width', width);
// 在 body 上添加/移除标记类,以便全局 CSS :has() 降级使用
if (hasSidebar.value) {
document.body.classList.add('has-sidebar');
} else {
document.body.classList.remove('has-sidebar');
}
});
const userDisplayName = computed(
() => auth.user?.full_name || auth.user?.username || auth.user?.email || TEXT.common.labels.userFallback
);
@@ -243,7 +255,6 @@ const activeMenu = computed(() => {
if (path.startsWith("/finance/special")) return "/fees/special";
if (path.startsWith("/drug/shipments")) return "/drug/shipments";
if (path.startsWith("/materials/equipment")) return "/materials/equipment";
if (path.startsWith("/materials/others")) return "/materials/others";
if (path.startsWith("/file-versions") || path.startsWith("/trial/") || path.startsWith("/documents/")) return "/file-versions";
if (path.startsWith("/startup/feasibility") || path.startsWith("/startup/ethics")) return "/startup/feasibility-ethics";
if (path.startsWith("/startup/meeting-auth") || path.startsWith("/startup/kickoff") || path.startsWith("/startup/training")) {
@@ -319,7 +330,6 @@ const breadcrumbs = computed(() => {
"/project/milestones": { label: TEXT.menu.projectMilestones, path: "/project/milestones" },
"/drug/shipments": { label: TEXT.menu.drugShipments, path: "/drug/shipments" },
"/materials/equipment": { label: TEXT.menu.materialEquipment, path: "/materials/equipment" },
"/materials/others": { label: TEXT.menu.materialOthers, path: "/materials/others" },
"/subjects": { label: TEXT.menu.subjects, path: "/subjects" },
"/risk-issues/sae": { label: TEXT.menu.riskIssueSae, path: "/risk-issues/sae" },
"/risk-issues/pd": { label: TEXT.menu.riskIssuePd, path: "/risk-issues/pd" },
-7
View File
@@ -263,7 +263,6 @@ export const TEXT = {
materialManagement: "物资管理",
drugShipments: "药品流向管理",
materialEquipment: "设备管理",
materialOthers: "其他",
fileVersionManagement: "文件版本管理",
startupFeasibilityEthics: "立项与伦理",
startupMeetingAuth: "启动与授权",
@@ -488,12 +487,6 @@ export const TEXT = {
listTitle: "设备列表",
emptyDescription: "设备管理模块正在建设中,敬请期待。",
},
materialOthers: {
title: "其他物资",
subtitle: "其他物资登记与管理",
listTitle: "物资列表",
emptyDescription: "其他物资模块正在建设中,敬请期待。",
},
fileVersionManagement: {
title: "文件版本管理",
subtitle: "管理试验文档与版本审批",
-7
View File
@@ -28,7 +28,6 @@ import FeeSpecialForm from "../views/fees/SpecialExpenseForm.vue";
import FeeSpecialDetail from "../views/fees/SpecialExpenseDetail.vue";
import DrugShipments from "../views/ia/DrugShipments.vue";
import MaterialEquipment from "../views/ia/MaterialEquipment.vue";
import MaterialOthers from "../views/ia/MaterialOthers.vue";
import FileVersionManagement from "../views/ia/FileVersionManagement.vue";
import DocumentList from "../views/documents/DocumentList.vue";
import DocumentDetail from "../views/documents/DocumentDetail.vue";
@@ -220,12 +219,6 @@ const routes: RouteRecordRaw[] = [
component: MaterialEquipment,
meta: { title: TEXT.menu.materialEquipment, requiresStudy: true },
},
{
path: "materials/others",
name: "MaterialOthers",
component: MaterialOthers,
meta: { title: TEXT.menu.materialOthers, requiresStudy: true },
},
{
path: "drug/shipments/new",
name: "DrugShipmentNew",
+18 -1
View File
@@ -463,4 +463,21 @@ body {
.text-sm {
font-size: 13px;
}
}
/* ============================================================
* 全局弹窗定位修复:弹窗仅显示在主内容区域,不覆盖左侧菜单栏
* --ctms-sidebar-width 由 Layout.vue 在 body 上动态设置
* ============================================================ */
/* ElMessage 提示消息:按“主内容区域”居中(而非全窗口/左上角) */
.el-message {
left: calc((100% + var(--ctms-sidebar-width, 0px)) / 2) !important;
margin-left: 0 !important;
transform: translateX(-50%) !important;
}
/* ElMessageBox / ElDialog 遮罩层:左侧偏移侧边栏宽度,只覆盖主内容区域 */
.el-overlay {
left: var(--ctms-sidebar-width, 0px) !important;
}
+30
View File
@@ -66,6 +66,34 @@ export interface SetupConfigDraft {
centerConfirm: CenterConfirmDraft[];
}
export interface ProjectPublishSnapshot {
code: string;
name: string;
project_full_name: string;
sponsor: string;
protocol_no: string;
lead_unit: string;
principal_investigator: string;
main_pm: string;
research_analysis: string;
research_product: string;
control_product: string;
indication: string;
research_population: string;
research_design: string;
plan_start_date: string;
plan_end_date: string;
planned_site_count: number | null;
planned_enrollment_count: number | null;
summary_note: string;
objective_note: string;
status: string;
visit_interval_days: number | null;
visit_total: number | null;
visit_window_start_offset: number | null;
visit_window_end_offset: number | null;
}
export interface StudySetupConfigResponse {
id: string;
study_id: string;
@@ -73,6 +101,7 @@ export interface StudySetupConfigResponse {
data: SetupConfigDraft;
publish_status: "DRAFT" | "PUBLISHED" | string;
published_data?: SetupConfigDraft | null;
published_project_snapshot?: ProjectPublishSnapshot | null;
published_by?: string | null;
published_by_name?: string | null;
published_at?: string | null;
@@ -93,6 +122,7 @@ export interface StudySetupConfigResponse {
export interface StudySetupConfigUpsertPayload {
expected_version?: number | null;
data: SetupConfigDraft;
force_draft?: boolean;
}
export interface StudySetupConfigPublishPayload {
+148
View File
@@ -0,0 +1,148 @@
import type { ProjectPublishSnapshot, SetupConfigDraft } from "../types/setupConfig";
import type { SetupDiffRow } from "./setupDiffRows";
export type DraftSyncStatus = "SYNCED" | "LOCAL_ONLY" | "DIRTY_UNSAVED";
export type SetupWorkflowStatus = "UNSAVED_DRAFT" | "SAVED_DRAFT" | "PUBLISHED";
export type SetupWorkflowTagMeta = { label: string; type: "success" | "warning" | "info" };
export const SETUP_WORKFLOW_TAG_META: Record<SetupWorkflowStatus, SetupWorkflowTagMeta> = {
UNSAVED_DRAFT: { label: "草稿未保存", type: "warning" },
SAVED_DRAFT: { label: "草稿已保存(未发布)", type: "info" },
PUBLISHED: { label: "已发布", type: "success" },
};
export const resolveSetupWorkflowStatus = (
draftSyncStatus: DraftSyncStatus,
options: {
canStrictlyDetectNoDiff: boolean;
hasPublishableDiff: boolean;
}
): SetupWorkflowStatus => {
const { canStrictlyDetectNoDiff, hasPublishableDiff } = options;
if (draftSyncStatus !== "SYNCED") return "UNSAVED_DRAFT";
if (canStrictlyDetectNoDiff && !hasPublishableDiff) return "PUBLISHED";
return "SAVED_DRAFT";
};
export const buildSetupModuleDiffLines = (
localDraft: SetupConfigDraft | null,
compareBase: SetupConfigDraft | null,
moduleLabels: Array<{ key: keyof SetupConfigDraft; label: string }>
): string[] => {
if (!localDraft || !compareBase) return [];
const lines: string[] = [];
moduleLabels.forEach((item) => {
const localChunk = JSON.stringify(localDraft[item.key] ?? null);
const baseChunk = JSON.stringify(compareBase[item.key] ?? null);
if (localChunk !== baseChunk) {
lines.push(`${item.label} 存在差异`);
}
});
return lines;
};
export const resolveProjectPublishCompareBase = (
publishedSnapshot: ProjectPublishSnapshot | null,
fallbackSnapshot: ProjectPublishSnapshot | null
): ProjectPublishSnapshot | null => {
return publishedSnapshot || fallbackSnapshot || null;
};
export const hasProjectSnapshotDiff = (
currentSnapshot: ProjectPublishSnapshot,
baseSnapshot: ProjectPublishSnapshot | null
): boolean => {
if (!baseSnapshot) return false;
return JSON.stringify(currentSnapshot) !== JSON.stringify(baseSnapshot);
};
type ResolvePublishedProjectSnapshotInput = {
publishStatus: string;
apiProjectSnapshot: ProjectPublishSnapshot | null;
previousSnapshot: ProjectPublishSnapshot | null;
projectSnapshotAtLoad: ProjectPublishSnapshot | null;
currentProjectSnapshot: ProjectPublishSnapshot;
};
type ResolvePublishedProjectSnapshotResult = {
snapshot: ProjectPublishSnapshot | null;
clearFallback: boolean;
};
export const resolvePublishedProjectSnapshot = (
input: ResolvePublishedProjectSnapshotInput
): ResolvePublishedProjectSnapshotResult => {
if (input.apiProjectSnapshot) {
return { snapshot: input.apiProjectSnapshot, clearFallback: true };
}
if (input.publishStatus === "PUBLISHED") {
return {
snapshot: input.previousSnapshot || input.projectSnapshotAtLoad || input.currentProjectSnapshot,
clearFallback: true,
};
}
return {
// Draft state keeps the previous published baseline when available.
snapshot: input.previousSnapshot,
clearFallback: false,
};
};
export const shouldCaptureProjectCompareFallback = (params: {
hasProjectPendingChanges: boolean;
hasPublishedProjectSnapshot: boolean;
isFirstPublish: boolean;
}): boolean => {
if (!params.hasProjectPendingChanges) return false;
if (params.hasPublishedProjectSnapshot) return false;
if (params.isFirstPublish) return false;
return true;
};
const PROJECT_FIELD_LABEL_MAP: Record<keyof ProjectPublishSnapshot, string> = {
code: "项目编号",
name: "项目名称",
project_full_name: "项目全称",
sponsor: "申办方",
protocol_no: "方案号",
lead_unit: "组长单位",
principal_investigator: "主要研究者",
main_pm: "主PM",
research_analysis: "研究分期",
research_product: "研究产品",
control_product: "对照产品",
indication: "适应症",
research_population: "研究人群",
research_design: "研究设计",
plan_start_date: "计划开始日期",
plan_end_date: "计划结束日期",
planned_site_count: "计划中心数",
planned_enrollment_count: "计划入组数",
summary_note: "方案摘要说明",
objective_note: "研究目标摘要",
status: "项目状态",
visit_interval_days: "访视间隔(天)",
visit_total: "访视总数",
visit_window_start_offset: "窗口期起始偏移",
visit_window_end_offset: "窗口期结束偏移",
};
export const buildProjectDiffRows = (
currentSnapshot: ProjectPublishSnapshot,
baseSnapshot: ProjectPublishSnapshot | null,
serializeValue: (value: unknown) => string
): SetupDiffRow[] => {
if (!baseSnapshot) return [];
const rows: SetupDiffRow[] = [];
(Object.keys(PROJECT_FIELD_LABEL_MAP) as Array<keyof ProjectPublishSnapshot>).forEach((key) => {
if (currentSnapshot[key] === baseSnapshot[key]) return;
rows.push({
moduleLabel: "项目信息",
path: PROJECT_FIELD_LABEL_MAP[key],
changeType: "修改",
localValue: serializeValue(currentSnapshot[key]),
serverValue: serializeValue(baseSnapshot[key]),
});
});
return rows;
};
+22 -45
View File
@@ -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;
}
-26
View File
@@ -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;
}
-33
View File
@@ -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,
});
}
};
+595 -7
View File
@@ -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>
-13
View File
@@ -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>
+45 -106
View File
@@ -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();
}