完善后台管理与测试支撑

1、为后台用户列表增加关键词和状态筛选,后端同步支持过滤与总数统计。

2、创建用户时要求填写初始密码,并补充前端校验和自动填充隔离。

3、优化项目管理列表角色展示、项目详情抽屉脏数据保护和审计详情抽屉关闭体验。

4、清理旧费用附件关联、统一测试模型注册,并更新迁移端点和注册筛选测试。
This commit is contained in:
Cheng Zhou
2026-06-04 11:12:40 +08:00
parent 90ca061db8
commit 41968384b5
17 changed files with 337 additions and 145 deletions
+38 -28
View File
@@ -34,18 +34,18 @@
</el-form>
</section>
<section class="unified-section">
<div v-if="isEdit && meetingId" class="attachment-grid">
<AttachmentList
v-for="group in kickoffAttachmentGroups"
:key="group.entityType"
:study-id="studyId"
:entity-type="group.entityType"
:entity-id="meetingId"
:title="group.title"
:readonly="isReadOnly"
/>
<div class="ctms-section-header">
<div class="ctms-section-title">{{ TEXT.common.labels.attachments }}</div>
</div>
<StateEmpty v-else :description="TEXT.modules.startupMeetingAuth.kickoffUploadHint" />
<AttachmentList
ref="attachmentPanelRef"
:study-id="studyId"
entity-type="startup_kickoff"
:entity-id="meetingId || ''"
:entity-groups="kickoffAttachmentGroups"
:readonly="isReadOnly"
:mode="'upload'"
/>
</section>
</div>
</div>
@@ -62,14 +62,16 @@ import { fetchSites } from "../../api/sites";
import { listMembers } from "../../api/members";
import { fetchUsers } from "../../api/users";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
import { usePermission } from "../../utils/permission";
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const study = useStudyStore();
const { can } = usePermission();
const saving = ref(false);
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
const meetingId = computed(() => route.params.meetingId as string | undefined);
const isEdit = computed(() => !!meetingId.value);
@@ -79,6 +81,10 @@ const siteId = ref("");
const users = ref<any[]>([]);
const members = ref<any[]>([]);
const isAdmin = computed(() => auth.user?.is_admin);
const canReadMembers = computed(() => can("project.members.list"));
const canCreateAuth = computed(() => can("startup.auth.create"));
const canUpdateAuth = computed(() => can("startup.auth.update"));
const canSaveAuth = computed(() => (isEdit.value ? canUpdateAuth.value : canCreateAuth.value));
const memberNameMap = computed(() => {
const map: Record<string, string> = {};
@@ -103,12 +109,13 @@ const ownerLabel = computed(() => {
.map((c) => memberNameMap.value[c] || c)
.join("、") || TEXT.common.fallback;
});
const isReadOnly = computed(() => siteInfo.is_active === false);
const isInactiveSite = computed(() => siteInfo.is_active === false);
const isReadOnly = computed(() => !canSaveAuth.value || isInactiveSite.value);
const kickoffAttachmentGroups = [
{ entityType: "startup_kickoff_minutes", title: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel },
{ entityType: "startup_kickoff_signin", title: TEXT.modules.startupMeetingAuth.kickoffSignInLabel },
{ entityType: "startup_kickoff_ppt", title: TEXT.modules.startupMeetingAuth.kickoffPptLabel },
{ entityType: "startup_kickoff", title: TEXT.modules.startupMeetingAuth.kickoffOtherLabel },
{ entityType: "startup_kickoff_minutes", label: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel },
{ entityType: "startup_kickoff_signin", label: TEXT.modules.startupMeetingAuth.kickoffSignInLabel },
{ entityType: "startup_kickoff_ppt", label: TEXT.modules.startupMeetingAuth.kickoffPptLabel },
{ entityType: "startup_kickoff", label: TEXT.modules.startupMeetingAuth.kickoffOtherLabel },
];
const form = reactive({
@@ -149,7 +156,11 @@ const load = async () => {
const submit = async () => {
if (!studyId.value) return;
if (isReadOnly.value) {
if (!canSaveAuth.value) {
ElMessage.warning("权限不足");
return;
}
if (isInactiveSite.value) {
ElMessage.warning("中心已停用");
return;
}
@@ -159,15 +170,16 @@ const submit = async () => {
kickoff_date: form.kickoff_date || null,
attendees: parseAttendees(form.attendees),
};
let savedId = meetingId.value || "";
if (isEdit.value && meetingId.value) {
await updateKickoff(studyId.value, meetingId.value, payload);
ElMessage.success(TEXT.common.messages.saveSuccess);
router.push(`/startup/kickoff/${meetingId.value}`);
} else {
const { data } = await createKickoff(studyId.value, { ...payload, site_id: siteId.value });
ElMessage.success(TEXT.common.messages.createSuccess);
router.push(`/startup/kickoff/${data.id}`);
savedId = data.id;
}
await attachmentPanelRef.value?.uploadPending(savedId);
ElMessage.success(isEdit.value ? TEXT.common.messages.saveSuccess : TEXT.common.messages.createSuccess);
router.push(`/startup/kickoff/${savedId}`);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
@@ -179,8 +191,11 @@ const goBack = () => router.push("/startup/meeting-auth");
onMounted(async () => {
if (studyId.value) {
const membersReq = listMembers(studyId.value, { limit: 500 });
const usersReq = isAdmin.value ? fetchUsers({ limit: 500 }) : Promise.resolve(null);
let membersReq: Promise<any> = Promise.resolve(null);
if (canReadMembers.value) {
membersReq = listMembers(studyId.value, { limit: 500 });
}
const [membersResp, usersResp] = await Promise.all([membersReq, usersReq]).catch(() => [null, null] as const);
if (membersResp?.data) {
members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || [];
@@ -200,9 +215,4 @@ onMounted(async () => {
gap: 0;
}
.attachment-grid {
display: flex;
flex-direction: column;
gap: 16px;
}
</style>
+24 -10
View File
@@ -40,14 +40,17 @@
</el-form>
</section>
<section class="unified-section">
<div class="ctms-section-header">
<div class="ctms-section-title">{{ TEXT.common.labels.attachments }}</div>
</div>
<AttachmentList
v-if="isEdit && recordId"
ref="attachmentPanelRef"
:study-id="studyId"
entity-type="training_authorization"
:entity-id="recordId"
:entity-id="recordId || ''"
:readonly="isReadOnly"
:mode="'upload'"
/>
<StateEmpty v-else :description="TEXT.modules.startupMeetingAuth.trainingUploadHint" />
</section>
</div>
</div>
@@ -61,13 +64,15 @@ import { useStudyStore } from "../../store/study";
import { createTrainingAuthorization, getTrainingAuthorization, updateTrainingAuthorization } from "../../api/startup";
import { fetchSites } from "../../api/sites";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
import { usePermission } from "../../utils/permission";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const { can } = usePermission();
const saving = ref(false);
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
const sites = ref<any[]>([]);
const recordId = computed(() => route.params.recordId as string | undefined);
@@ -92,7 +97,11 @@ const siteActiveMap = computed(() => {
});
return map;
});
const isReadOnly = computed(() => isEdit.value && !!form.site_name && siteActiveMap.value[form.site_name] === false);
const canCreateAuth = computed(() => can("startup.auth.create"));
const canUpdateAuth = computed(() => can("startup.auth.update"));
const canSaveAuth = computed(() => (isEdit.value ? canUpdateAuth.value : canCreateAuth.value));
const isInactiveSite = computed(() => isEdit.value && !!form.site_name && siteActiveMap.value[form.site_name] === false);
const isReadOnly = computed(() => !canSaveAuth.value || isInactiveSite.value);
const loadSites = async () => {
if (!studyId.value) return;
@@ -125,7 +134,11 @@ const load = async () => {
const submit = async () => {
if (!studyId.value) return;
if (isReadOnly.value) {
if (!canSaveAuth.value) {
ElMessage.warning("权限不足");
return;
}
if (isInactiveSite.value) {
ElMessage.warning("中心已停用");
return;
}
@@ -145,15 +158,16 @@ const submit = async () => {
authorized_date: form.authorized_date || null,
remark: form.remark || null,
};
let savedId = recordId.value || "";
if (isEdit.value && recordId.value) {
await updateTrainingAuthorization(studyId.value, recordId.value, payload);
ElMessage.success(TEXT.common.messages.saveSuccess);
router.push(`/startup/training/${recordId.value}`);
} else {
const { data } = await createTrainingAuthorization(studyId.value, payload);
ElMessage.success(TEXT.common.messages.createSuccess);
router.push(`/startup/training/${data.id}`);
savedId = data.id;
}
await attachmentPanelRef.value?.uploadPending(savedId);
ElMessage.success(isEdit.value ? TEXT.common.messages.saveSuccess : TEXT.common.messages.createSuccess);
router.push(`/startup/training/${savedId}`);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {