中文:收口权限与中心/立项配置改造

This commit is contained in:
Cheng Zhou
2026-05-12 10:16:52 +08:00
parent 6e90370a5f
commit 77e842637d
67 changed files with 706 additions and 669 deletions
@@ -20,7 +20,7 @@
<el-table-column prop="role" :label="TEXT.common.labels.role">
<template #default="scope">{{ displayEnum(TEXT.enums.userRole, scope.row.role) }}</template>
</el-table-column>
<el-table-column prop="department" :label="TEXT.common.fields.department" show-overflow-tooltip />
<el-table-column prop="clinical_department" :label="TEXT.common.fields.clinicalDepartment" show-overflow-tooltip />
<el-table-column prop="created_at" :label="TEXT.modules.adminUserApproval.createdAt" show-overflow-tooltip>
<template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template>
</el-table-column>
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readAuditLogsView = () => readFileSync(resolve(__dirname, "./AuditLogs.vue"), "utf8");
describe("audit logs access", () => {
it("allows current project PM access without relying on global PM role", () => {
const source = readAuditLogsView();
expect(source).toContain("const canAccessAuditLogs = computed");
expect(source).toContain("isSystemAdmin");
expect(source).toContain("getProjectRole");
expect(source).toContain("study.currentStudyRole");
expect(source).toContain('projectRole.value === "PM"');
expect(source).not.toContain('const role = auth.user?.role');
expect(source).not.toContain('role !== "ADMIN"');
});
});
+8 -6
View File
@@ -175,6 +175,7 @@ import { useAuthStore } from "../../store/auth";
import { roleDict, getDictLabel } from "../../dictionaries";
import { exportAuditCsv } from "../../audit/export/auditExportService";
import { displayDateTime } from "../../utils/display";
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
import { TEXT } from "../../locales";
const study = useStudyStore();
@@ -209,19 +210,20 @@ const resolveUserDisplayName = (u: any): string => {
};
const userOptions = computed(() => users.value.map((u: any) => ({ label: resolveUserDisplayName(u), value: u.id })));
const isAdmin = computed(() => auth.user?.role === "ADMIN");
const canProjectExport = computed(() => !!study.currentStudy && auth.user?.role === "ADMIN");
const isAdmin = computed(() => isSystemAdmin(auth.user));
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
const canProjectExport = computed(() => !!study.currentStudy && isAdmin.value);
const canAccessAuditLogs = computed(() => isAdmin.value || projectRole.value === "PM");
const ensureAccess = () => {
const role = auth.user?.role;
if (role !== "ADMIN") {
router.replace("/admin/users");
if (!canAccessAuditLogs.value) {
router.replace(study.currentStudy ? "/project/overview" : "/workbench");
}
};
const loadUsers = async () => {
try {
if (auth.user?.role === "ADMIN") {
if (isAdmin.value) {
const { data } = await fetchUsers({ limit: 500 });
users.value = Array.isArray(data) ? data : (data as any).items || [];
} else if (study.currentStudy) {
@@ -81,3 +81,13 @@ describe("ProjectDetail setup draft publish workflow", () => {
expect(source).toContain("提前终止不作为常规计划访视配置");
});
});
describe("project detail management role", () => {
it("allows only current project administrators and PMs to manage setup content", () => {
const source = readProjectDetail();
expect(source).toContain("project.value?.role_in_study");
expect(source).toContain('["ADMIN", "PM"].includes(setupRole.value)');
expect(source).not.toContain("project.value?.role_in_study || authStore.user?.role");
});
});
+10 -296
View File
@@ -186,25 +186,6 @@
</el-dropdown>
<el-button class="step-action-btn" type="primary" :disabled="!canEditSetup" @click="handleAddSiteMilestoneAction">新增</el-button>
</template>
<template v-else-if="stepActionMode === 'monitoring-strategy'">
<el-dropdown trigger="click" @command="onMonitoringStrategyTemplateSelect">
<el-button class="step-action-btn" type="primary" :disabled="!canEditSetup">
<span>选择模板</span>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="item in monitoringStrategyTemplates"
:key="item.key"
:command="item.key"
>
{{ item.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button class="step-action-btn" type="primary" :disabled="!canEditSetup" @click="addMonitoringStrategy">新增</el-button>
</template>
</div>
</div>
<div v-if="drawerVisible" class="setup-drawer-header">
@@ -1210,83 +1191,6 @@
</section>
<section v-show="activeStep === 5" class="setup-section setup-section--table">
<el-table :data="currentSetupDraft.monitoringStrategies" class="ctms-table">
<el-table-column label="监查类型" min-width="180">
<template #default="scope">
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ scope.row.strategyType || "-" }}</span>
<el-tag
v-if="isMonitoringStrategyFieldUpdated(scope.row, scope.$index, 'strategyType')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="策略详情" min-width="430">
<template #default="scope">
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ scope.row.detail || "-" }}</span>
<el-tag
v-if="isMonitoringStrategyFieldUpdated(scope.row, scope.$index, 'detail')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="监查次数" width="140">
<template #default="scope">
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ scope.row.frequency || "-" }}</span>
<el-tag
v-if="isMonitoringStrategyFieldUpdated(scope.row, scope.$index, 'frequency')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="更新时间" width="180">
<template #default="scope">
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ scope.row.updatedAt || "-" }}</span>
<el-tag
v-if="isMonitoringStrategyFieldUpdated(scope.row, scope.$index, 'updatedAt')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column v-if="!isPublishedView" label="操作" width="130">
<template #default="scope">
<div class="milestone-actions-inline">
<el-button link type="primary" :disabled="!canEditSetup" @click="openMonitoringStrategyEditor(scope.$index)">编辑</el-button>
<el-button link type="danger" :disabled="!canEditSetup" @click="removeMonitoringStrategy(scope.$index)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
</section>
<section v-show="activeStep === 6" class="setup-section setup-section--table">
<el-table :data="centerConfirmDisplayRows" class="ctms-table">
<el-table-column label="中心" min-width="180">
<template #default="scope">
@@ -1997,49 +1901,6 @@
</template>
</el-drawer>
<el-drawer
v-if="monitoringStrategyEditorVisible"
v-model="monitoringStrategyEditorVisible"
direction="rtl"
size="480px"
:close-on-click-modal="false"
:show-close="false"
class="setup-milestone-editor-drawer"
>
<template #header>
<div class="sme-header">
<div class="sme-header-title">编辑监查策略</div>
<div class="sme-header-subtitle">配置监查类型触发规则与执行频次</div>
</div>
</template>
<el-form label-position="top" class="sme-form">
<div class="sme-group">
<div class="sme-group-title"><span class="sme-dot sme-dot-blue"></span>策略配置</div>
<el-form-item label="监查类型">
<el-select v-model="monitoringStrategyEditorForm.strategyType" class="w-full" placeholder="选择类型">
<el-option
v-for="item in monitoringStrategyTypeOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
<el-form-item label="策略详情">
<el-input v-model="monitoringStrategyEditorForm.detail" type="textarea" :rows="3" placeholder="填写监查触发规则与执行策略" />
</el-form-item>
<el-form-item label="监查次数">
<el-input v-model="monitoringStrategyEditorForm.frequency" placeholder="如:不限 / 2次" />
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="sme-footer">
<el-button @click="monitoringStrategyEditorVisible = false">取消</el-button>
<el-button type="primary" @click="saveMonitoringStrategyEditor">保存</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
@@ -2057,7 +1918,6 @@ import { fetchUsers } from "../../api/users";
import type { Site, Study } from "../../types/api";
import type {
CenterConfirmDraft,
MonitoringStrategyDraft,
ProjectPublishSnapshot,
ProjectMilestoneDraft,
SetupConfigDraft,
@@ -2095,7 +1955,6 @@ type SetupStepKey =
| "project-enrollment"
| "site-milestone"
| "site-enrollment"
| "monitoring-strategy"
| "site-confirm";
type Step1EditSection = "all" | "project" | "research" | "execution" | "summary";
type StepActionMode =
@@ -2103,7 +1962,7 @@ type StepActionMode =
| "edit"
| "project-milestone"
| "site-milestone"
| "monitoring-strategy";
;
type RollbackAxisLane = { branch: string; index: number; x: number };
type RollbackAxisRow = StudySetupConfigVersionItem & {
displayLabel: string;
@@ -2138,7 +1997,6 @@ const steps: Array<{ key: SetupStepKey; label: string }> = [
{ key: "project-enrollment", label: "项目入组计划" },
{ key: "site-milestone", label: "中心里程碑" },
{ key: "site-enrollment", label: "中心入组计划" },
{ key: "monitoring-strategy", label: "监查计划策略" },
{ key: "site-confirm", label: TEXT.modules.adminProjects.setupStepCenterConfirm || "中心确认" },
];
const projectMilestoneTemplates: Array<{ key: string; label: string; milestones: string[] }> = [
@@ -2178,29 +2036,7 @@ const siteMilestoneTemplates: Array<{ key: string; label: string; milestones: st
],
},
];
const monitoringStrategyTypeOptions = [
"启动访视",
"筛选访视",
"监查访视",
"协同访视",
"风险访视",
"末次访视",
];
const monitoringStrategyTemplates: Array<{
key: string;
label: string;
rows: Array<{ strategyType: string; detail: string; frequency: string }>;
}> = [
{
key: "by-times-default",
label: "基础模板",
rows: monitoringStrategyTypeOptions.map((type) => ({
strategyType: type,
detail: `${type}按方案执行`,
frequency: "1次",
})),
},
];
const route = useRoute();
const router = useRouter();
@@ -2276,8 +2112,6 @@ const siteMilestoneEditorVisible = ref(false);
const siteMilestoneEditingIndex = ref<number>(-1);
const siteEnrollmentEditorVisible = ref(false);
const siteEnrollmentEditingIndex = ref<number>(-1);
const monitoringStrategyEditorVisible = ref(false);
const monitoringStrategyEditingIndex = ref<number>(-1);
type IndexedEditorController = {
visible: Ref<boolean>;
@@ -2295,10 +2129,6 @@ const siteEnrollmentEditor: IndexedEditorController = {
visible: siteEnrollmentEditorVisible,
index: siteEnrollmentEditingIndex,
};
const monitoringStrategyEditor: IndexedEditorController = {
visible: monitoringStrategyEditorVisible,
index: monitoringStrategyEditingIndex,
};
const projectMilestoneEditorForm = reactive<ProjectMilestoneDraft>({
id: "",
@@ -2329,14 +2159,6 @@ const siteEnrollmentEditorForm = reactive<SiteEnrollmentPlanDraft>({
note: "",
stageBreakdown: "",
});
const monitoringStrategyEditorForm = reactive<MonitoringStrategyDraft>({
id: "",
strategyType: "",
detail: "",
frequency: "",
updatedAt: "",
enabled: true,
});
type EnrollmentCycle = "month" | "quarter";
@@ -2575,13 +2397,12 @@ const setupDraft = reactive<SetupConfigDraft>({
},
siteMilestones: [],
siteEnrollmentPlans: [],
monitoringStrategies: [],
centerConfirm: [],
});
const storageKey = computed(() => `ctms_setup_config_draft_${String(route.params.projectId || "")}`);
const projectDraftStorageKey = computed(() => `ctms_setup_project_draft_${String(route.params.projectId || "")}`);
const setupRole = computed(() => String(project.value?.role_in_study || authStore.user?.role || "").toUpperCase());
const setupRole = computed(() => String(project.value?.role_in_study || "").toUpperCase());
const canManageSetup = computed(() => ["ADMIN", "PM"].includes(setupRole.value));
const isPreviewView = computed(() => setupViewMode.value === "preview");
const isPublishedVersionView = computed(() => setupViewMode.value === "published");
@@ -2618,8 +2439,6 @@ const stepActionMode = computed<StepActionMode>(() => {
return "site-milestone";
case 4:
return "edit";
case 5:
return "monitoring-strategy";
default:
return "none";
}
@@ -2676,7 +2495,6 @@ const isSetupDraftEffectivelyEmpty = (draft: SetupConfigDraft): boolean => {
if (draft.projectMilestones.length > 0) return false;
if (draft.siteMilestones.length > 0) return false;
if (draft.siteEnrollmentPlans.length > 0) return false;
if (draft.monitoringStrategies.length > 0) return false;
if (draft.centerConfirm.length > 0) return false;
const plan = draft.enrollmentPlan;
const normalizedTotal = Number(plan.totalTarget || 0);
@@ -2827,8 +2645,7 @@ const setupSectionStepMap: Record<string, number> = {
enrollmentPlan: 2,
siteMilestones: 3,
siteEnrollmentPlans: 4,
monitoringStrategies: 5,
centerConfirm: 6,
centerConfirm: 5,
};
const currentStepErrors = computed(() => {
const grouped = groupErrorsBySection(
@@ -2933,20 +2750,6 @@ const isSiteMilestoneFieldUpdated = (
);
return isPreviewValueUpdated(row[field], baseRow?.[field]);
};
const isMonitoringStrategyFieldUpdated = (
row: MonitoringStrategyDraft,
index: number,
field: "strategyType" | "detail" | "frequency" | "updatedAt"
): boolean => {
const baseRow = resolveRowCompareTarget(
publishCompareBase.value.monitoringStrategies,
row,
index,
(item) =>
`${normalizeIdentityText(item.strategyType)}|${normalizeIdentityText(item.detail)}|${normalizeIdentityText(item.frequency)}`
);
return isPreviewValueUpdated(row[field], baseRow?.[field]);
};
const publishedSiteEnrollmentPlanMap = computed(() => {
const map = new Map<string, SiteEnrollmentPlanDraft>();
publishCompareBase.value.siteEnrollmentPlans.forEach((row) => {
@@ -3685,8 +3488,7 @@ const stepHasErrors = (stepIndex: number): boolean => {
if (path.startsWith("projectMilestones")) return stepIndex === 1;
if (path.startsWith("siteMilestones")) return stepIndex === 3;
if (path.startsWith("siteEnrollmentPlans")) return stepIndex === 4;
if (path.startsWith("monitoringStrategies")) return stepIndex === 5;
if (path.startsWith("centerConfirm")) return stepIndex === 6;
if (path.startsWith("centerConfirm")) return stepIndex === 5;
return false;
});
};
@@ -3753,14 +3555,6 @@ const isStepCompleted = (stepIndex: number): boolean => {
);
}
if (stepIndex === 5) {
return (
setupDraft.monitoringStrategies.length > 0 &&
setupDraft.monitoringStrategies.every(
(item) => (item.strategyType || "").trim().length > 0 && (item.detail || "").trim().length > 0 && (item.frequency || "").trim().length > 0
)
);
}
if (stepIndex === 6) {
return true;
}
return false;
@@ -3976,8 +3770,7 @@ const createDefaultSetupDraft = (_sites: Site[]): SetupConfigDraft => {
},
siteMilestones: [],
siteEnrollmentPlans: [],
monitoringStrategies: [],
centerConfirm: [],
centerConfirm: [],
};
};
@@ -3996,7 +3789,6 @@ const isSetupDraftShape = (value: unknown): value is SetupConfigDraft => {
!!data.enrollmentPlan &&
Array.isArray(data.siteMilestones) &&
Array.isArray(data.siteEnrollmentPlans) &&
Array.isArray(data.monitoringStrategies) &&
Array.isArray(data.centerConfirm)
);
};
@@ -4049,7 +3841,6 @@ const applySetupDraft = (draft: SetupConfigDraft) => {
setupDraft.enrollmentPlan = clone(draft.enrollmentPlan);
setupDraft.siteMilestones = clone(draft.siteMilestones);
setupDraft.siteEnrollmentPlans = normalizeSiteEnrollmentPlans(clone(draft.siteEnrollmentPlans));
setupDraft.monitoringStrategies = clone(draft.monitoringStrategies);
setupDraft.centerConfirm = clone(draft.centerConfirm);
applyProjectInfoDraft(projectInfo);
formBaselineSnapshot.value = serializeFormForCompare();
@@ -4190,7 +3981,6 @@ const setupModuleLabels: Array<{ key: keyof SetupConfigDraft; label: string }> =
{ key: "enrollmentPlan", label: "项目入组计划" },
{ key: "siteMilestones", label: "中心里程碑" },
{ key: "siteEnrollmentPlans", label: "中心入组计划" },
{ key: "monitoringStrategies", label: "监查计划策略" },
{ key: "centerConfirm", label: "中心确认" },
];
const emptySetupDraft: SetupConfigDraft = {
@@ -4205,7 +3995,6 @@ const emptySetupDraft: SetupConfigDraft = {
},
siteMilestones: [],
siteEnrollmentPlans: [],
monitoringStrategies: [],
centerConfirm: [],
};
const publishCompareBase = computed<SetupConfigDraft>(() => clone(publishedSetupDraft.value || emptySetupDraft));
@@ -5465,17 +5254,8 @@ const downloadVersionRaw = (versionItem: StudySetupConfigVersionItem) => {
];
});
const step6Headers = ["策略类型", "策略详情", "监查次数", "是否启用", "更新时间"];
const step6Rows = configDraft.monitoringStrategies.map((item) => [
item.strategyType || "-",
item.detail || "-",
item.frequency || "-",
item.enabled ? "是" : "否",
item.updatedAt || "-",
]);
const step7Headers = ["中心ID", "中心名称", "确认人", "确认状态", "确认日期", "备注"];
const step7Rows = configDraft.centerConfirm.map((item) => [
const step6Headers = ["中心ID", "中心名称", "确认人", "确认状态", "确认日期", "备注"];
const step6Rows = configDraft.centerConfirm.map((item) => [
item.siteId || "-",
item.siteName || "-",
item.confirmer || "-",
@@ -5528,8 +5308,7 @@ ${buildExcelWorksheetXml("第2步-项目里程碑", metaRows, step2Headers, step
${buildExcelWorksheetXml("第3步-项目入组计划", metaRows, step3Headers, step3CycleRows)}
${buildExcelWorksheetXml("第4步-中心里程碑", metaRows, step4Headers, step4Rows)}
${buildExcelWorksheetXml("第5步-中心入组计划", metaRows, step5Headers, step5Rows)}
${buildExcelWorksheetXml("第6步-监查计划策略", metaRows, step6Headers, step6Rows)}
${buildExcelWorksheetXml("第7步-中心确认", metaRows, step7Headers, step7Rows)}
${buildExcelWorksheetXml("第6步-中心确认", metaRows, step6Headers, step6Rows)}
</Workbook>`;
const blob = new Blob([`\uFEFF${workbookXml}`], { type: "application/vnd.ms-excel;charset=utf-8;" });
@@ -5782,7 +5561,7 @@ const confirmPublishConfig = async () => {
const enterProject = () => {
if (!project.value) return;
studyStore.setCurrentStudy({ ...project.value, role_in_study: project.value.role_in_study || "PM" } as Study);
studyStore.setCurrentStudy(project.value);
router.push("/project/overview");
};
const goMembers = () => {
@@ -6093,71 +5872,6 @@ const saveSiteEnrollmentEditor = () => {
closeIndexedEditor(siteEnrollmentEditor, "中心入组计划已更新");
};
const addMonitoringStrategy = () => {
if (!canMutateDraft()) return;
setupDraft.monitoringStrategies.push({ id: makeId(), strategyType: "", detail: "", frequency: "", updatedAt: nowString(), enabled: true });
};
const applyMonitoringStrategyTemplate = (templateKey: string) => {
if (!canMutateDraft()) return;
const template = monitoringStrategyTemplates.find((item) => item.key === templateKey);
if (!template) {
ElMessage.warning("未找到对应模板");
return;
}
setupDraft.monitoringStrategies = template.rows.map((item) => ({
id: makeId(),
strategyType: item.strategyType,
detail: item.detail,
frequency: item.frequency,
updatedAt: nowString(),
enabled: true,
}));
ElMessage.success(`已应用模板:${template.label}`);
};
const onMonitoringStrategyTemplateSelect = (command: string | number | object) => {
applyMonitoringStrategyTemplate(String(command));
};
const removeMonitoringStrategy = (index: number) => {
if (!canMutateDraft()) return;
setupDraft.monitoringStrategies.splice(index, 1);
};
const openMonitoringStrategyEditor = (index: number) => {
openIndexedEditor(setupDraft.monitoringStrategies, index, monitoringStrategyEditor, (row) => {
monitoringStrategyEditorForm.id = row.id || "";
monitoringStrategyEditorForm.strategyType = row.strategyType || "";
monitoringStrategyEditorForm.detail = row.detail || "";
monitoringStrategyEditorForm.frequency = row.frequency || "";
monitoringStrategyEditorForm.updatedAt = row.updatedAt || "";
monitoringStrategyEditorForm.enabled = row.enabled ?? true;
});
};
const saveMonitoringStrategyEditor = () => {
if (!canMutateDraft()) return;
const index = getEditingIndex(monitoringStrategyEditor, setupDraft.monitoringStrategies.length);
if (index < 0) return;
if (!(monitoringStrategyEditorForm.strategyType || "").trim()) {
ElMessage.warning("请选择监查类型");
return;
}
if (!(monitoringStrategyEditorForm.detail || "").trim()) {
ElMessage.warning("请填写策略详情");
return;
}
if (!(monitoringStrategyEditorForm.frequency || "").trim()) {
ElMessage.warning("请填写监查次数");
return;
}
setupDraft.monitoringStrategies[index] = {
id: monitoringStrategyEditorForm.id || makeId(),
strategyType: monitoringStrategyEditorForm.strategyType.trim(),
detail: monitoringStrategyEditorForm.detail.trim(),
frequency: monitoringStrategyEditorForm.frequency.trim(),
updatedAt: nowString(),
enabled: monitoringStrategyEditorForm.enabled ?? true,
};
closeIndexedEditor(monitoringStrategyEditor, "监查策略已更新");
};
const addCenterConfirm = () => {
if (!canMutateDraft()) return;
setupDraft.centerConfirm.push({ id: makeId(), siteId: "", siteName: "", confirmer: "", confirmStatus: "待确认", confirmDate: "", note: "" });
+1 -1
View File
@@ -198,7 +198,7 @@ const handleLockToggle = async (study: Study) => {
};
const enterStudy = (row: Study) => {
studyStore.setCurrentStudy({ ...row, role_in_study: "PM" } as Study);
studyStore.setCurrentStudy(row);
router.push("/project/overview");
};
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSiteForm = () => readFileSync(resolve(__dirname, "./SiteForm.vue"), "utf8");
const readSiteTypes = () => readFileSync(resolve(__dirname, "../../types/api.ts"), "utf8");
describe("Admin site form phone field", () => {
it("hydrates and submits the phone field", () => {
const formSource = readSiteForm();
const typeSource = readSiteTypes();
expect(formSource).toContain("form.phone = props.site.phone || (props.site as any)?.contact_phone || \"\"");
expect(formSource).toContain("phone: form.phone");
expect(formSource).toContain('v-model="form.phone"');
expect(typeSource).toContain("phone?: string | null;");
expect(typeSource).toContain("contact_phone?: string | null;");
});
});
+27 -22
View File
@@ -101,30 +101,33 @@ const resetForm = () => {
form.is_active = true;
};
const hydrateForm = () => {
if (!props.site) return;
form.name = props.site.name;
form.city = props.site.city || "";
form.pi_name = props.site.pi_name || "";
form.phone = props.site.phone || (props.site as any)?.contact_phone || "";
form.contact = props.site.contact || "";
const rawSelections = (props.site.contact || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const optionByLabel: Record<string, string> = {};
craOptions.value.forEach((opt) => {
optionByLabel[opt.label] = opt.value;
});
form.craSelections = rawSelections.map((s) => optionByLabel[s] || s);
form.is_active = props.site.is_active;
};
watch(
() => props.visible,
(val) => {
if (val) {
resetForm();
if (props.site) {
form.name = props.site.name;
form.city = props.site.city || "";
form.pi_name = props.site.pi_name || "";
form.phone = (props.site as any)?.contact_phone || (props.site as any)?.phone || "";
form.contact = props.site.contact || "";
const rawSelections = (props.site.contact || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const optionByLabel: Record<string, string> = {};
craOptions.value.forEach((opt) => {
optionByLabel[opt.label] = opt.value;
});
form.craSelections = rawSelections.map((s) => optionByLabel[s] || s);
form.is_active = props.site.is_active;
}
}
}
(visible) => {
if (!visible) return;
resetForm();
hydrateForm();
},
{ immediate: true }
);
const onSubmit = async () => {
@@ -147,6 +150,7 @@ const onSubmit = async () => {
name: form.name,
city: form.city,
pi_name: form.pi_name,
phone: form.phone,
contact,
is_active: form.is_active,
});
@@ -156,6 +160,7 @@ const onSubmit = async () => {
name: form.name,
city: form.city,
pi_name: form.pi_name,
phone: form.phone,
contact,
});
ElMessage.success(TEXT.modules.adminSites.createSuccess);
+1 -1
View File
@@ -164,7 +164,7 @@ const contactLabel = (row: any) => {
.join("、") || TEXT.common.fallback;
};
const phoneText = (row: any) => row?.phone || row?.contact_phone || TEXT.common.fallback;
const phoneText = (row: any) => row?.phone || TEXT.common.fallback;
const normalizeSiteName = (value?: string | null) => String(value || "").trim().replace(/\s+/g, "").toLowerCase();
const isLeadUnitSite = (row: Site): boolean => {
+13 -28
View File
@@ -1,17 +1,14 @@
<template>
<el-dialog append-to=".layout-main .content-wrapper" :title="user ? TEXT.modules.adminUsers.editTitle : TEXT.modules.adminUsers.newTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" autocomplete="off">
<el-form-item :label="TEXT.common.fields.email" prop="email">
<el-input v-model="form.email" :disabled="!!user" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.email" />
<el-input v-model="form.email" :disabled="!!user" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.email" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.name" prop="full_name">
<el-input v-model="form.full_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
<el-input v-model="form.full_name" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.department" prop="department">
<el-input v-model="form.department" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.department" />
</el-form-item>
<el-form-item :label="user ? TEXT.modules.adminUsers.passwordOptional : TEXT.modules.adminUsers.passwordInitial" prop="password">
<el-input v-model="form.password" type="password" show-password :placeholder="user ? TEXT.modules.adminUsers.passwordOptionalHint : TEXT.modules.adminUsers.passwordInitialHint" />
<el-form-item :label="TEXT.common.fields.clinicalDepartment" prop="clinical_department">
<el-input v-model="form.clinical_department" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.clinicalDepartment" />
</el-form-item>
<el-form-item v-if="user" :label="TEXT.modules.adminUsers.accountStatus" prop="is_active">
<el-switch v-model="form.is_active" :active-text="TEXT.common.actions.enable" :inactive-text="TEXT.common.actions.disable" :disabled="isLastAdmin" />
@@ -52,7 +49,7 @@ const submitting = ref(false);
const form = reactive({
email: "",
full_name: "",
department: "",
clinical_department: "",
password: "",
is_active: true,
});
@@ -65,25 +62,13 @@ const rules = reactive<FormRules>({
{ type: "email", message: TEXT.common.validation.invalidEmail, trigger: "blur" },
],
full_name: [{ required: true, message: requiredMessage(TEXT.common.fields.name), trigger: "blur" }],
department: [{ required: true, message: requiredMessage(TEXT.common.fields.department), trigger: "blur" }],
password: [
{
validator: (_rule, value, callback) => {
if (!props.user && !value) {
callback(new Error(TEXT.modules.adminUsers.passwordInitialRequired));
return;
}
callback();
},
trigger: "blur",
},
],
clinical_department: [{ required: true, message: requiredMessage(TEXT.common.fields.clinicalDepartment), trigger: "blur" }],
});
const resetForm = () => {
form.email = "";
form.full_name = "";
form.department = "";
form.clinical_department = "";
form.password = "";
form.is_active = true;
};
@@ -103,11 +88,12 @@ watch(
if (props.user) {
form.email = props.user.email;
form.full_name = props.user.full_name;
form.department = props.user.department;
form.clinical_department = props.user.clinical_department;
form.is_active = props.user.is_active ?? true;
}
}
}
},
{ immediate: true }
);
const onSubmit = async () => {
@@ -123,15 +109,14 @@ const onSubmit = async () => {
await updateUser(props.user.id, {
is_active: form.is_active,
full_name: form.full_name,
department: form.department,
password: form.password || undefined,
clinical_department: form.clinical_department,
});
ElMessage.success(TEXT.modules.adminUsers.updateSuccess);
} else {
await createUser({
email: form.email,
full_name: form.full_name,
department: form.department,
clinical_department: form.clinical_department,
role: defaultRole,
password: form.password,
});
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readResetForm = () => readFileSync(resolve(__dirname, "./UserResetPassword.vue"), "utf8");
describe("Admin reset password autofill", () => {
it("disables browser autofill for confirm username and manual password", () => {
const source = readResetForm();
expect(source).toContain('autocomplete="off" name="ctms-reset-confirm-username"');
expect(source).toContain('autocomplete="new-password"');
expect(source).toContain('name="ctms-reset-temp-password"');
expect(source).toContain('name="ctms-reset-manual-password"');
expect(source).toContain(":prop=\"form.mode === 'auto' ? 'tempPassword' : 'manualPassword'\"");
});
});
+34 -3
View File
@@ -10,7 +10,7 @@
<div class="info">{{ TEXT.modules.adminUsers.resetTarget }}<strong>{{ user?.username }}</strong></div>
<el-form ref="formRef" :model="form" label-width="140px" :rules="rules">
<el-form-item :label="TEXT.modules.adminUsers.confirmUsername" prop="confirmUsername">
<el-input v-model="form.confirmUsername" :placeholder="TEXT.modules.adminUsers.confirmUsernameHint" />
<el-input v-model="form.confirmUsername" autocomplete="off" name="ctms-reset-confirm-username" :placeholder="TEXT.modules.adminUsers.confirmUsernameHint" />
</el-form-item>
<el-form-item :label="TEXT.modules.adminUsers.passwordMode" prop="mode">
<el-radio-group v-model="form.mode">
@@ -18,12 +18,17 @@
<el-radio label="manual">{{ TEXT.modules.adminUsers.passwordManual }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item :label="form.mode === 'auto' ? TEXT.modules.adminUsers.tempPassword : TEXT.modules.adminUsers.newPassword" prop="passwordValue">
<el-form-item
:label="form.mode === 'auto' ? TEXT.modules.adminUsers.tempPassword : TEXT.modules.adminUsers.newPassword"
:prop="form.mode === 'auto' ? 'tempPassword' : 'manualPassword'"
>
<el-input
v-if="form.mode === 'auto'"
v-model="form.tempPassword"
type="password"
show-password
autocomplete="new-password"
name="ctms-reset-temp-password"
readonly
>
<template #append>
@@ -35,6 +40,8 @@
v-model="form.manualPassword"
type="password"
show-password
autocomplete="new-password"
name="ctms-reset-manual-password"
:placeholder="TEXT.modules.adminUsers.manualPasswordHint"
/>
<div class="hint">{{ TEXT.modules.adminUsers.passwordHint }}</div>
@@ -81,7 +88,7 @@ const form = reactive({
const rules = reactive<FormRules>({
confirmUsername: [{ required: true, message: requiredMessage(TEXT.modules.adminUsers.confirmUsername), trigger: "blur" }],
mode: [{ required: true, message: requiredMessage(TEXT.modules.adminUsers.passwordMode), trigger: "change" }],
passwordValue: [
tempPassword: [
{
validator: (_rule, _value, callback) => {
if (form.mode === "auto" && !form.tempPassword) {
@@ -97,6 +104,18 @@ const rules = reactive<FormRules>({
trigger: "blur",
},
],
manualPassword: [
{
validator: (_rule, _value, callback) => {
if (form.mode === "manual" && !form.manualPassword) {
callback(new Error(TEXT.modules.adminUsers.manualPasswordRequired));
return;
}
callback();
},
trigger: "blur",
},
],
});
const generatePassword = () => {
@@ -110,6 +129,10 @@ const generatePassword = () => {
const regenerate = () => generatePassword();
const clearPasswordValidation = () => {
formRef.value?.clearValidate(["tempPassword", "manualPassword"]);
};
watch(
() => props.visible,
(val) => {
@@ -118,10 +141,18 @@ watch(
form.mode = "auto";
generatePassword();
form.manualPassword = "";
clearPasswordValidation();
}
}
);
watch(
() => form.mode,
() => {
clearPasswordValidation();
}
);
const canSubmit = computed(() => !!props.user && form.confirmUsername === props.user.username);
const onSubmit = async () => {
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readUsersView = () => readFileSync(resolve(__dirname, "./Users.vue"), "utf8");
const readUserForm = () => readFileSync(resolve(__dirname, "./UserForm.vue"), "utf8");
const readLocale = () => readFileSync(resolve(__dirname, "../../locales/zh-CN.ts"), "utf8");
describe("Admin users table labels", () => {
it("shows clinical_department as 科室 in the account management table", () => {
const viewSource = readUsersView();
const localeSource = readLocale();
expect(viewSource).toContain(':label="TEXT.modules.adminUsers.clinicalDepartmentLabel"');
expect(viewSource).not.toContain(':label="TEXT.common.fields.clinicalDepartment"');
expect(localeSource).toContain('clinicalDepartmentLabel: "科室"');
});
});
describe("Admin user form autocomplete", () => {
it("prevents browser credential autofill when creating a user", () => {
const formSource = readUserForm();
expect(formSource).toContain('autocomplete="off"');
expect(formSource).not.toContain("current_password");
expect(formSource).not.toContain("confirmPassword");
expect(formSource).not.toContain("TEXT.modules.profile.currentPassword");
expect(formSource).not.toContain("TEXT.modules.profile.confirmPassword");
});
});
describe("Admin user edit form defaults", () => {
it("hydrates the selected user immediately when the dialog is mounted open", () => {
const formSource = readUserForm();
expect(formSource).toContain("immediate: true");
expect(formSource).not.toContain("current_password");
expect(formSource).not.toContain("confirmPassword");
expect(formSource).not.toContain("TEXT.modules.profile.currentPassword");
expect(formSource).not.toContain("TEXT.modules.profile.confirmPassword");
});
});
+1 -1
View File
@@ -31,7 +31,7 @@
<el-table :data="users" stripe v-loading="loading" class="user-table" style="width: 100%" table-layout="fixed">
<el-table-column prop="email" :label="TEXT.common.fields.email" show-overflow-tooltip />
<el-table-column prop="full_name" :label="TEXT.common.fields.name" show-overflow-tooltip />
<el-table-column prop="department" :label="TEXT.common.fields.department" show-overflow-tooltip />
<el-table-column prop="clinical_department" :label="TEXT.modules.adminUsers.clinicalDepartmentLabel" show-overflow-tooltip />
<el-table-column :label="TEXT.common.fields.status">
<template #default="scope">
<el-tag :type="statusType(scope.row.status)">