管理后台前后端逻辑、UI美化
This commit is contained in:
@@ -1,11 +1,16 @@
|
||||
import { apiGet, apiPatch, apiPost } from "./axios";
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
|
||||
import type { ApiListResponse, Site } from "../types/api";
|
||||
|
||||
export const fetchSites = (studyId: string, params?: Record<string, any>) =>
|
||||
apiGet<ApiListResponse<Site> | Site[]>(`/api/v1/studies/${studyId}/sites/`, { params });
|
||||
apiGet<ApiListResponse<Site> | Site[]>(`/api/v1/studies/${studyId}/sites/`, {
|
||||
params: { include_inactive: true, ...(params || {}) },
|
||||
});
|
||||
|
||||
export const createSite = (studyId: string, payload: Partial<Site> & { name: string }) =>
|
||||
apiPost<Site>(`/api/v1/studies/${studyId}/sites/`, payload);
|
||||
|
||||
export const updateSite = (studyId: string, siteId: string, payload: Partial<Site>) =>
|
||||
apiPatch<Site>(`/api/v1/studies/${studyId}/sites/${siteId}`, payload);
|
||||
|
||||
export const deleteSite = (studyId: string, siteId: string) =>
|
||||
apiDelete<void>(`/api/v1/studies/${studyId}/sites/${siteId}`);
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { apiGet, apiPatch, apiPost } from "./axios";
|
||||
import { apiGet, apiPatch, apiPost, apiDelete } from "./axios";
|
||||
import type { ApiListResponse, Study } from "../types/api";
|
||||
|
||||
// Backend expects trailing slash to avoid 307 redirect
|
||||
export const fetchStudies = () => apiGet<ApiListResponse<Study>>("/api/v1/studies/");
|
||||
|
||||
export const createStudy = (payload: Partial<Study> & { code: string; name: string }) =>
|
||||
export const createStudy = (payload: Partial<Study> & { name: string }) =>
|
||||
apiPost<Study>("/api/v1/studies/", payload);
|
||||
|
||||
export const updateStudy = (studyId: string, payload: Partial<Study>) =>
|
||||
apiPatch<Study>(`/api/v1/studies/${studyId}`, payload);
|
||||
|
||||
export const fetchStudyDetail = (studyId: string) => apiGet<Study>(`/api/v1/studies/${studyId}`);
|
||||
|
||||
export const deleteStudy = (studyId: string) =>
|
||||
apiDelete(`/api/v1/studies/${studyId}`);
|
||||
|
||||
export const lockStudy = (studyId: string) =>
|
||||
apiPatch<Study>(`/api/v1/studies/${studyId}/lock`, {});
|
||||
|
||||
|
||||
@@ -20,5 +20,5 @@ export const updateUser = (
|
||||
) =>
|
||||
apiPatch<UserInfo>(`/api/v1/users/${userId}`, payload);
|
||||
|
||||
export const deleteUser = (userId: string, payload: { admin_password: string }) =>
|
||||
apiDelete<void>(`/api/v1/users/${userId}`, { data: payload });
|
||||
export const deleteUser = (userId: string) =>
|
||||
apiDelete<void>(`/api/v1/users/${userId}`);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<div class="header">
|
||||
<span>{{ headerTitle }}</span>
|
||||
<AttachmentUploader
|
||||
v-if="!readonly"
|
||||
:study-id="studyId"
|
||||
:entity-type="entityType"
|
||||
:entity-id="entityId"
|
||||
@@ -75,6 +76,7 @@ const props = defineProps<{
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
title?: string;
|
||||
readonly?: boolean;
|
||||
}>();
|
||||
|
||||
const attachments = ref<any[]>([]);
|
||||
@@ -199,6 +201,7 @@ const preview = (row: any) => {
|
||||
};
|
||||
|
||||
const canDelete = (row: any) => {
|
||||
if (props.readonly) return false;
|
||||
const userId = auth.user?.id;
|
||||
const role = auth.user?.role;
|
||||
const projectRole = study.currentStudyRole || (study.currentStudy as any)?.role_in_study;
|
||||
|
||||
@@ -716,10 +716,10 @@ export const TEXT = {
|
||||
loadFailed: "用户列表加载失败",
|
||||
updateSuccess: "用户已更新",
|
||||
createSuccess: "用户已创建",
|
||||
deleteConfirm: "删除后账号将无法登录且无法恢复,需先在各项目成员配置中移除该账号。请输入当前管理员密码确认删除。",
|
||||
deleteConfirm: "删除后不可恢复,请输入“确认删除”继续。",
|
||||
deleteTitle: "危险操作:删除账号",
|
||||
deletePasswordPlaceholder: "请输入 admin 密码",
|
||||
deleteConfirmButton: "确认删除",
|
||||
deleteConfirmPlaceholder: "确认删除",
|
||||
deleteConfirmInputError: "请输入“确认删除”",
|
||||
deleteSuccess: "账号已删除",
|
||||
deleteFailed: "删除失败",
|
||||
enableConfirm: "确认启用该用户账号?",
|
||||
@@ -752,7 +752,7 @@ export const TEXT = {
|
||||
resetFailed: "重置失败",
|
||||
},
|
||||
adminProjects: {
|
||||
title: "项目治理",
|
||||
title: "项目管理",
|
||||
subtitle: "项目是独立业务实体,账号加入项目后才成为项目成员(角色在成员配置中设置)",
|
||||
projectLabel: "项目",
|
||||
members: "项目账号/成员配置",
|
||||
@@ -827,6 +827,7 @@ export const TEXT = {
|
||||
craSaveSuccess: "绑定已保存",
|
||||
},
|
||||
adminAuditLogs: {
|
||||
title: "审计日志",
|
||||
filterEvent: "操作类型",
|
||||
filterOperator: "操作人",
|
||||
filterResult: "结果",
|
||||
|
||||
@@ -65,6 +65,7 @@ export interface Study {
|
||||
protocol_no?: string | null;
|
||||
phase?: string | null;
|
||||
status: string;
|
||||
is_locked?: boolean;
|
||||
visit_interval_days?: number | null;
|
||||
visit_total?: number | null;
|
||||
visit_window_start_offset?: number | null;
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card>
|
||||
<div class="header">
|
||||
<div>
|
||||
<h3>{{ TEXT.modules.adminAuditLogs.title }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ctms-table-toolbar">
|
||||
<div class="ctms-filter-group">
|
||||
<el-select v-model="filters.eventType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEvent" @change="loadLogs" class="ctms-filter-item">
|
||||
<el-select v-model="filters.eventType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEvent" @change="loadLogs" class="ctms-filter-item w-40">
|
||||
<el-option v-for="opt in eventTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.operatorId" clearable :placeholder="TEXT.modules.adminAuditLogs.filterOperator" filterable @change="loadLogs" class="ctms-filter-item">
|
||||
<el-select v-model="filters.operatorId" clearable :placeholder="TEXT.modules.adminAuditLogs.filterOperator" filterable @change="loadLogs" class="ctms-filter-item w-40">
|
||||
<el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.result" clearable :placeholder="TEXT.modules.adminAuditLogs.filterResult" @change="loadLogs" style="width: 100px">
|
||||
<el-select v-model="filters.result" clearable :placeholder="TEXT.modules.adminAuditLogs.filterResult" @change="loadLogs" class="ctms-filter-item w-32">
|
||||
<el-option :label="TEXT.audit.resultSuccess" value="SUCCESS" />
|
||||
<el-option :label="TEXT.audit.resultFail" value="FAIL" />
|
||||
</el-select>
|
||||
@@ -21,7 +26,7 @@
|
||||
:end-placeholder="TEXT.modules.adminAuditLogs.rangeEnd"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 240px"
|
||||
class="ctms-filter-item date-range-picker"
|
||||
@change="loadLogs"
|
||||
/>
|
||||
</div>
|
||||
@@ -49,33 +54,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="logs" v-loading="loading" style="width: 100%">
|
||||
<el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" width="180">
|
||||
<template #default="scope">{{ displayDateTime(scope.row.timestamp) }}</template>
|
||||
<el-table :data="logs" v-loading="loading" style="width: 100%" stripe>
|
||||
<el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" width="180" align="center">
|
||||
<template #default="scope">
|
||||
<span class="text-gray-500">{{ displayDateTime(scope.row.timestamp) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="140" />
|
||||
<el-table-column prop="actorRoleLabel" :label="TEXT.common.labels.role" width="120" />
|
||||
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" min-width="160" />
|
||||
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" width="160">
|
||||
<template #default="scope">
|
||||
<el-tag effect="plain">{{ scope.row.eventLabel }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="actionText" :label="TEXT.modules.adminAuditLogs.columns.action" min-width="180" />
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" min-width="180">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.targetTypeLabel">
|
||||
{{ scope.row.targetTypeLabel }}({{ scope.row.targetName || TEXT.common.fallback }})
|
||||
<strong>{{ scope.row.targetTypeLabel }}</strong>
|
||||
<span class="text-gray-500" v-if="scope.row.targetName">({{ scope.row.targetName }})</span>
|
||||
</span>
|
||||
<span v-else>{{ TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff" min-width="220">
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff" min-width="260">
|
||||
<template #default="scope">
|
||||
<div v-if="scope.row.diffText?.length">
|
||||
<div v-for="(line, idx) in scope.row.diffText" :key="idx">{{ line }}</div>
|
||||
<div v-if="scope.row.diffText?.length" class="diff-container">
|
||||
<div v-for="(line, idx) in scope.row.diffText" :key="idx" class="diff-line">{{ line }}</div>
|
||||
</div>
|
||||
<span v-else>{{ TEXT.audit.emptyValue }}</span>
|
||||
<span v-else class="text-gray-400">{{ TEXT.audit.emptyValue }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="120">
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.result === 'SUCCESS' ? 'success' : 'danger'">
|
||||
<el-tag :type="scope.row.result === 'SUCCESS' ? 'success' : 'danger'" effect="dark" size="small">
|
||||
{{ scope.row.resultLabel }}
|
||||
</el-tag>
|
||||
</template>
|
||||
@@ -322,10 +334,53 @@ onMounted(async () => {
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 16px;
|
||||
padding: 24px;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.header h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
.ctms-table-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ctms-filter-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ctms-filter-item {
|
||||
width: auto;
|
||||
}
|
||||
.w-40 { width: 160px; }
|
||||
.w-32 { width: 130px; }
|
||||
.w-64 { width: 260px; }
|
||||
|
||||
.diff-container {
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.diff-line {
|
||||
margin-bottom: 2px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.date-range-picker {
|
||||
width: auto;
|
||||
min-width: 320px;
|
||||
}
|
||||
.pagination {
|
||||
margin-top: 12px;
|
||||
margin-top: 24px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,79 +1,320 @@
|
||||
<template>
|
||||
<div class="ctms-page">
|
||||
<div class="ctms-page-header">
|
||||
<div>
|
||||
<h1 class="ctms-page-title">{{ TEXT.modules.adminProjects.detailTitle }}</h1>
|
||||
<div class="page-header-section">
|
||||
<div class="header-top">
|
||||
<h1 class="page-title">{{ TEXT.modules.adminProjects.detailTitle }}</h1>
|
||||
<el-tag v-if="project?.is_locked" type="warning" size="large" effect="dark">
|
||||
<el-icon><Lock /></el-icon>
|
||||
已锁定
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="ctms-page-actions">
|
||||
<el-button type="primary" @click="enterProject" :disabled="!project">{{ TEXT.modules.adminProjects.enter }}</el-button>
|
||||
<el-button @click="goMembers" :disabled="!project">{{ TEXT.modules.adminProjects.members }}</el-button>
|
||||
<el-button @click="goSites" :disabled="!project">{{ TEXT.modules.adminProjects.sites }}</el-button>
|
||||
<div class="quick-actions">
|
||||
<div class="action-card" @click="enterProject" :class="{ disabled: !project }">
|
||||
<el-icon class="action-icon"><Promotion /></el-icon>
|
||||
<span class="action-text">{{ TEXT.modules.adminProjects.enter }}</span>
|
||||
</div>
|
||||
<div class="action-card" @click="goMembers" :class="{ disabled: !project }">
|
||||
<el-icon class="action-icon"><User /></el-icon>
|
||||
<span class="action-text">{{ TEXT.modules.adminProjects.members }}</span>
|
||||
</div>
|
||||
<div class="action-card" @click="goSites" :class="{ disabled: !project }">
|
||||
<el-icon class="action-icon"><OfficeBuilding /></el-icon>
|
||||
<span class="action-text">{{ TEXT.modules.adminProjects.sites }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="ctms-section-card">
|
||||
<template #header>
|
||||
<div>
|
||||
<div class="ctms-section-header">
|
||||
<div class="ctms-section-title">{{ TEXT.modules.adminProjects.basicInfo }}</div>
|
||||
<div class="ctms-section-actions">
|
||||
<template v-if="!isEditing">
|
||||
<el-button type="primary" @click="startEdit" :disabled="!project || project.is_locked">
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="!project?.is_locked"
|
||||
type="info"
|
||||
@click="handleLock"
|
||||
:disabled="!project"
|
||||
>
|
||||
<el-icon><Lock /></el-icon>
|
||||
锁定项目
|
||||
</el-button>
|
||||
<el-button @click="goBack">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
{{ TEXT.common.actions.back }}
|
||||
</el-button>
|
||||
<el-button type="danger" @click="handleDelete" :disabled="!project">
|
||||
<el-icon><Delete /></el-icon>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button @click="cancelEdit">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="saveEdit">
|
||||
{{ TEXT.common.actions.save }}
|
||||
</el-button>
|
||||
<el-button @click="goBack">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
{{ TEXT.common.actions.back }}
|
||||
</el-button>
|
||||
<el-button type="danger" @click="handleDelete" :disabled="!project">
|
||||
<el-icon><Delete /></el-icon>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-descriptions v-if="project" :column="2" border class="detail-descriptions">
|
||||
<el-descriptions-item :label="TEXT.common.fields.projectCode">{{ project.code }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.projectName">{{ project.name }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.sponsor">{{ project.sponsor || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.status">{{ statusLabel(project.status) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.modules.adminProjects.createdAt">{{ displayDateTime(project.created_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<StateLoading v-else :rows="4" />
|
||||
</el-card>
|
||||
<el-form v-if="project" ref="formRef" :model="form" :rules="rules" label-width="120px" class="detail-form" label-position="top">
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="16">
|
||||
<el-form-item :label="TEXT.common.fields.projectName" prop="name">
|
||||
<el-input v-model="form.name" :disabled="!isEditing" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item :label="TEXT.common.fields.projectCode" prop="code">
|
||||
<el-input v-model="form.code" :disabled="!isEditing" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card class="ctms-section-card">
|
||||
<template #header>
|
||||
<div>
|
||||
<div class="ctms-section-title">{{ TEXT.modules.adminProjects.filesTitle }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<AttachmentList
|
||||
v-if="project"
|
||||
:study-id="project.id"
|
||||
entity-type="project"
|
||||
:entity-id="project.id"
|
||||
:show-uploader="true"
|
||||
/>
|
||||
<StateLoading v-else :rows="3" />
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="8">
|
||||
<el-form-item :label="TEXT.common.fields.status" prop="status">
|
||||
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" :disabled="!isEditing" class="w-full">
|
||||
<el-option :label="TEXT.enums.projectStatus.DRAFT" value="DRAFT" />
|
||||
<el-option :label="TEXT.enums.projectStatus.ACTIVE" value="ACTIVE" />
|
||||
<el-option :label="TEXT.enums.projectStatus.CLOSED" value="CLOSED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item :label="TEXT.common.fields.protocolNo" prop="protocol_no">
|
||||
<el-input v-model="form.protocol_no" :placeholder="TEXT.modules.adminProjects.protocolPlaceholder" :disabled="!isEditing" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item :label="TEXT.common.fields.phase" prop="phase">
|
||||
<el-input v-model="form.phase" :placeholder="TEXT.modules.adminProjects.phasePlaceholder" :disabled="!isEditing" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-divider content-position="left" class="form-divider">{{ TEXT.modules.adminProjects.visitTemplate }}</el-divider>
|
||||
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="6">
|
||||
<el-form-item :label="TEXT.common.fields.visitTotal">
|
||||
<el-input-number v-model="form.visit_total" :min="1" :max="50" :step="1" :disabled="!isEditing" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item :label="TEXT.common.fields.visitIntervalDays">
|
||||
<el-input-number v-model="form.visit_interval_days" :min="1" :max="365" :step="1" :disabled="!isEditing" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item :label="TEXT.common.fields.visitWindowStartOffset">
|
||||
<el-input-number v-model="form.visit_window_start_offset" :min="-365" :max="365" :step="1" :disabled="!isEditing" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item :label="TEXT.common.fields.visitWindowEndOffset">
|
||||
<el-input-number v-model="form.visit_window_end_offset" :min="-365" :max="365" :step="1" :disabled="!isEditing" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="24" class="mt-4">
|
||||
<el-col :span="24">
|
||||
<el-form-item :label="TEXT.modules.adminProjects.createdAt">
|
||||
<span class="info-text">{{ displayDateTime(project.created_at) }}</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<StateLoading v-else :rows="4" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { fetchStudyDetail } from "../../api/studies";
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
|
||||
import { ArrowLeft, Promotion, User, OfficeBuilding, Delete, Lock } from "@element-plus/icons-vue";
|
||||
import { fetchStudyDetail, updateStudy, deleteStudy, lockStudy } from "../../api/studies";
|
||||
import type { Study } from "../../types/api";
|
||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||
import StateLoading from "../../components/StateLoading.vue";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { displayDateTime } from "../../utils/display";
|
||||
import { TEXT } from "../../locales";
|
||||
import { TEXT, requiredMessage } from "../../locales";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const studyStore = useStudyStore();
|
||||
|
||||
const project = ref<Study | null>(null);
|
||||
const isEditing = ref(false);
|
||||
const submitting = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
const form = ref({
|
||||
code: "",
|
||||
name: "",
|
||||
protocol_no: "",
|
||||
phase: "",
|
||||
status: "DRAFT",
|
||||
visit_interval_days: null as number | null,
|
||||
visit_total: null as number | null,
|
||||
visit_window_start_offset: null as number | null,
|
||||
visit_window_end_offset: null as number | null,
|
||||
});
|
||||
|
||||
const rules = ref<FormRules>({
|
||||
code: [{ required: true, message: requiredMessage(TEXT.common.fields.projectCode), trigger: "blur" }],
|
||||
name: [{ required: true, message: requiredMessage(TEXT.common.fields.projectName), trigger: "blur" }],
|
||||
status: [{ required: true, message: requiredMessage(TEXT.common.fields.status), trigger: "change" }],
|
||||
});
|
||||
|
||||
const syncForm = (data: Study | null) => {
|
||||
form.value.code = data?.code || "";
|
||||
form.value.name = data?.name || "";
|
||||
form.value.protocol_no = data?.protocol_no || "";
|
||||
form.value.phase = data?.phase || "";
|
||||
form.value.status = data?.status || "DRAFT";
|
||||
form.value.visit_interval_days = data?.visit_interval_days ?? null;
|
||||
form.value.visit_total = data?.visit_total ?? null;
|
||||
form.value.visit_window_start_offset = data?.visit_window_start_offset ?? null;
|
||||
form.value.visit_window_end_offset = data?.visit_window_end_offset ?? null;
|
||||
};
|
||||
|
||||
const loadProject = async () => {
|
||||
try {
|
||||
const { data } = await fetchStudyDetail(route.params.projectId as string);
|
||||
project.value = data as Study;
|
||||
syncForm(project.value);
|
||||
isEditing.value = false;
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjects.loadFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const statusLabel = (status: string) =>
|
||||
TEXT.enums.projectStatus[status as keyof typeof TEXT.enums.projectStatus] || status;
|
||||
const startEdit = () => {
|
||||
if (!project.value) return;
|
||||
if (project.value.is_locked) {
|
||||
ElMessage.warning("项目已锁定,无法编辑。请先解锁项目。");
|
||||
return;
|
||||
}
|
||||
isEditing.value = true;
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
syncForm(project.value);
|
||||
isEditing.value = false;
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!project.value || !formRef.value) return;
|
||||
await formRef.value.validate();
|
||||
submitting.value = true;
|
||||
try {
|
||||
const { data } = await updateStudy(project.value.id, {
|
||||
code: form.value.code,
|
||||
name: form.value.name,
|
||||
protocol_no: form.value.protocol_no,
|
||||
phase: form.value.phase,
|
||||
status: form.value.status,
|
||||
visit_interval_days: form.value.visit_interval_days,
|
||||
visit_total: form.value.visit_total,
|
||||
visit_window_start_offset: form.value.visit_window_start_offset,
|
||||
visit_window_end_offset: form.value.visit_window_end_offset,
|
||||
});
|
||||
project.value = data as Study;
|
||||
syncForm(project.value);
|
||||
isEditing.value = false;
|
||||
ElMessage.success(TEXT.modules.adminProjects.updateSuccess);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!project.value) return;
|
||||
try {
|
||||
// 第一次确认
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除项目"${project.value.name}"吗?该操作将永久删除项目及其所有关联数据(成员、站点等),且不可恢复!`,
|
||||
TEXT.common.actions.delete,
|
||||
{
|
||||
confirmButtonText: "继续",
|
||||
cancelButtonText: TEXT.common.actions.cancel,
|
||||
type: "error",
|
||||
}
|
||||
);
|
||||
|
||||
// 第二次确认:要求输入项目名称
|
||||
const { value } = await ElMessageBox.prompt(
|
||||
`请输入项目名称 "${project.value.name}" 以确认删除`,
|
||||
"删除确认",
|
||||
{
|
||||
confirmButtonText: TEXT.common.actions.confirm,
|
||||
cancelButtonText: TEXT.common.actions.cancel,
|
||||
inputPattern: new RegExp(`^${project.value.name.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}$`),
|
||||
inputErrorMessage: "项目名称不匹配",
|
||||
inputPlaceholder: "请输入项目名称",
|
||||
type: "error",
|
||||
}
|
||||
);
|
||||
|
||||
if (value !== project.value.name) {
|
||||
ElMessage.warning("项目名称不匹配,已取消删除");
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteStudy(project.value.id);
|
||||
ElMessage.success("项目已删除");
|
||||
router.push("/admin/projects");
|
||||
} catch (err) {
|
||||
if (err !== "cancel") {
|
||||
ElMessage.error("删除项目失败");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLock = async () => {
|
||||
if (!project.value) return;
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要锁定项目"${project.value.name}"吗?锁定后项目内数据将无法编辑,且不可解锁!`,
|
||||
"锁定项目",
|
||||
{
|
||||
confirmButtonText: TEXT.common.actions.confirm,
|
||||
cancelButtonText: TEXT.common.actions.cancel,
|
||||
type: "warning",
|
||||
}
|
||||
);
|
||||
|
||||
const { data } = await lockStudy(project.value.id);
|
||||
project.value = data as Study;
|
||||
syncForm(project.value);
|
||||
ElMessage.success("项目已锁定");
|
||||
} catch (err) {
|
||||
if (err !== "cancel") {
|
||||
ElMessage.error("锁定项目失败");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
const enterProject = () => {
|
||||
if (!project.value) return;
|
||||
@@ -97,7 +338,169 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail-descriptions :deep(.el-descriptions__label) {
|
||||
color: var(--ctms-text-secondary);
|
||||
.page-header-section {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 32px 40px;
|
||||
border-radius: 16px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.25);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
color: white;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 12px;
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border: 2px solid transparent;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.action-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.action-card:hover:not(.disabled) {
|
||||
transform: translateY(-4px) scale(1.02);
|
||||
box-shadow: 0 8px 24px rgba(247, 107, 28, 0.4);
|
||||
border-color: #f76b1c;
|
||||
}
|
||||
|
||||
.action-card:hover:not(.disabled)::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.action-card.primary {
|
||||
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%);
|
||||
box-shadow: 0 2px 8px rgba(252, 182, 159, 0.3);
|
||||
}
|
||||
|
||||
.action-card.primary::before {
|
||||
background: linear-gradient(135deg, rgba(255, 107, 28, 0.15) 0%, rgba(252, 107, 79, 0.15) 100%);
|
||||
}
|
||||
|
||||
.action-card.primary:hover:not(.disabled) {
|
||||
transform: translateY(-4px) scale(1.02);
|
||||
box-shadow: 0 8px 24px rgba(247, 107, 28, 0.4);
|
||||
border-color: #f76b1c;
|
||||
background: linear-gradient(135deg, #fff0e0 0%, #ffd0b8 100%);
|
||||
}
|
||||
|
||||
.action-card.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 28px;
|
||||
color: #667eea;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.action-card:hover:not(.disabled) .action-icon {
|
||||
transform: scale(1.15);
|
||||
color: #f76b1c;
|
||||
}
|
||||
|
||||
.action-card.primary .action-icon {
|
||||
color: #f76b1c;
|
||||
}
|
||||
|
||||
.action-card.primary:hover:not(.disabled) .action-icon {
|
||||
color: #e05a0a;
|
||||
transform: scale(1.15) rotate(5deg);
|
||||
}
|
||||
|
||||
.action-text {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #2d3748;
|
||||
transition: color 0.3s ease;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.action-card:hover:not(.disabled) .action-text {
|
||||
color: #1a202c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ctms-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.ctms-section-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
|
||||
.detail-form :deep(.el-form-item) {
|
||||
margin-bottom: 0; /* Let rows handle internal spacing if needed, or keep minimal */
|
||||
}
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
.form-divider {
|
||||
margin: 32px 0 24px;
|
||||
}
|
||||
.mt-4 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.info-text {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Ensure inputs are full width in grid */
|
||||
.detail-form :deep(.el-input),
|
||||
.detail-form :deep(.el-select),
|
||||
.detail-form :deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
<template>
|
||||
<el-dialog :title="project ? TEXT.modules.adminProjects.editTitle : TEXT.modules.adminProjects.newTitle" width="560px" v-model="visibleProxy" :close-on-click-modal="false">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||||
<el-form-item :label="TEXT.common.fields.projectCode" prop="code">
|
||||
<el-input v-model="form.code" :disabled="!!project" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectCode" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.projectName" prop="name">
|
||||
<el-input v-model="form.name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectName" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.sponsor" prop="sponsor">
|
||||
<el-input v-model="form.sponsor" :placeholder="TEXT.modules.adminProjects.sponsorPlaceholder" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.protocolNo" prop="protocol_no">
|
||||
<el-input v-model="form.protocol_no" :placeholder="TEXT.modules.adminProjects.protocolPlaceholder" />
|
||||
</el-form-item>
|
||||
@@ -69,9 +63,7 @@ const visibleProxy = computed({
|
||||
const formRef = ref<FormInstance>();
|
||||
const submitting = ref(false);
|
||||
const form = reactive({
|
||||
code: "",
|
||||
name: "",
|
||||
sponsor: "",
|
||||
protocol_no: "",
|
||||
phase: "",
|
||||
status: "DRAFT",
|
||||
@@ -82,15 +74,12 @@ const form = reactive({
|
||||
});
|
||||
|
||||
const rules = reactive<FormRules>({
|
||||
code: [{ required: true, message: requiredMessage(TEXT.common.fields.projectCode), trigger: "blur" }],
|
||||
name: [{ required: true, message: requiredMessage(TEXT.common.fields.projectName), trigger: "blur" }],
|
||||
status: [{ required: true, message: requiredMessage(TEXT.common.fields.status), trigger: "change" }],
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
form.code = "";
|
||||
form.name = "";
|
||||
form.sponsor = "";
|
||||
form.protocol_no = "";
|
||||
form.phase = "";
|
||||
form.status = "DRAFT";
|
||||
@@ -106,9 +95,7 @@ watch(
|
||||
if (val) {
|
||||
resetForm();
|
||||
if (props.project) {
|
||||
form.code = props.project.code;
|
||||
form.name = props.project.name;
|
||||
form.sponsor = props.project.sponsor || "";
|
||||
form.protocol_no = props.project.protocol_no || "";
|
||||
form.phase = props.project.phase || "";
|
||||
form.status = props.project.status || "DRAFT";
|
||||
@@ -129,7 +116,6 @@ const onSubmit = async () => {
|
||||
if (props.project) {
|
||||
await updateStudy(props.project.id, {
|
||||
name: form.name,
|
||||
sponsor: form.sponsor,
|
||||
protocol_no: form.protocol_no,
|
||||
phase: form.phase,
|
||||
status: form.status,
|
||||
@@ -141,9 +127,7 @@ const onSubmit = async () => {
|
||||
ElMessage.success(TEXT.modules.adminProjects.updateSuccess);
|
||||
} else {
|
||||
await createStudy({
|
||||
code: form.code,
|
||||
name: form.name,
|
||||
sponsor: form.sponsor,
|
||||
protocol_no: form.protocol_no,
|
||||
phase: form.phase,
|
||||
status: form.status,
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
<div>
|
||||
<h3>{{ TEXT.modules.adminProjectMembers.title }}</h3>
|
||||
<div class="sub" v-if="project">{{ TEXT.modules.adminProjectMembers.projectPrefix }}{{ project.code }} - {{ project.name }}</div>
|
||||
<div class="sub">{{ TEXT.modules.adminProjectMembers.subtitle }}</div>
|
||||
</div>
|
||||
<el-button type="primary" @click="openAdd">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjectMembers.memberLabel }}</el-button>
|
||||
</div>
|
||||
@@ -39,7 +38,7 @@
|
||||
v-model="scope.row.role_in_study"
|
||||
size="small"
|
||||
style="width: 120px"
|
||||
@change="(val) => updateRole(scope.row.id, val)"
|
||||
@change="(val: string) => updateRole(scope.row.id, val)"
|
||||
:disabled="!scope.row.is_active || scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
|
||||
>
|
||||
<el-option :label="TEXT.enums.userRole.PM" value="PM" />
|
||||
@@ -144,7 +143,7 @@ const loadMembers = async () => {
|
||||
if (!projectId.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await listMembers(projectId.value, { limit: 500 });
|
||||
const { data } = await listMembers(projectId.value, { limit: 500, include_inactive: true });
|
||||
members.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjectMembers.loadFailed);
|
||||
@@ -382,7 +381,10 @@ const onDelete = async (row: StudyMember) => {
|
||||
}
|
||||
};
|
||||
|
||||
const availableUsers = computed(() => users.value);
|
||||
const availableUsers = computed(() => {
|
||||
const memberUserIds = new Set(members.value.map((m) => m.user_id));
|
||||
return users.value.filter((u) => !memberUserIds.has(u.id));
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadProject(), loadUsers()]);
|
||||
|
||||
@@ -4,36 +4,54 @@
|
||||
<div class="header">
|
||||
<div>
|
||||
<h3>{{ TEXT.modules.adminProjects.title }}</h3>
|
||||
<p class="sub">{{ TEXT.modules.adminProjects.subtitle }}</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjects.projectLabel }}</el-button>
|
||||
</div>
|
||||
<el-table :data="projects" v-loading="loading" stripe>
|
||||
<el-table-column prop="code" :label="TEXT.modules.adminProjects.projectCode" width="140">
|
||||
<el-table-column prop="name" :label="TEXT.common.fields.projectName" min-width="300">
|
||||
<template #default="scope">
|
||||
<el-link type="primary" @click="goDetail(scope.row)">{{ scope.row.code }}</el-link>
|
||||
<el-link type="primary" @click="goDetail(scope.row)" class="font-medium">{{ scope.row.name }}</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" :label="TEXT.common.fields.projectName" min-width="200">
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status" min-width="120" align="center">
|
||||
<template #default="scope">
|
||||
<el-link type="primary" @click="goDetail(scope.row)">{{ scope.row.name }}</el-link>
|
||||
<el-tag :type="statusTag(scope.row.status)" effect="plain" round>{{ statusLabel(scope.row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sponsor" :label="TEXT.common.fields.sponsor" min-width="160" />
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
|
||||
<el-table-column prop="is_locked" label="锁定状态" min-width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="statusTag(scope.row.status)">{{ statusLabel(scope.row.status) }}</el-tag>
|
||||
<el-tag v-if="scope.row.is_locked" type="warning" effect="plain" round>已锁定</el-tag>
|
||||
<el-tag v-else type="success" effect="plain" round>未锁定</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" width="200">
|
||||
<template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="320" fixed="right">
|
||||
<el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" min-width="180" align="center">
|
||||
<template #default="scope">
|
||||
<el-button link type="primary" size="small" @click="openEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button link type="primary" size="small" @click="goMembers(scope.row)">{{ TEXT.modules.adminProjects.members }}</el-button>
|
||||
<el-button link type="primary" size="small" @click="goSites(scope.row)">{{ TEXT.modules.adminProjects.sites }}</el-button>
|
||||
<el-button link type="success" size="small" @click="enterStudy(scope.row)">{{ TEXT.modules.adminProjects.enter }}</el-button>
|
||||
<span class="text-gray-500">{{ displayDateTime(scope.row.created_at) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" min-width="280" align="center">
|
||||
<template #default="scope">
|
||||
<el-tooltip :content="TEXT.modules.adminProjects.members" placement="top">
|
||||
<el-button link type="primary" :icon="User" class="action-btn" @click="goMembers(scope.row)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="TEXT.modules.adminProjects.sites" placement="top">
|
||||
<el-button link type="primary" :icon="OfficeBuilding" class="action-btn" @click="goSites(scope.row)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="TEXT.modules.adminProjects.enter" placement="top">
|
||||
<el-button link type="success" :icon="ArrowRight" class="action-btn enter-btn" @click="enterStudy(scope.row)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip v-if="!scope.row.is_locked" content="锁定项目" placement="top">
|
||||
<el-button
|
||||
link
|
||||
type="info"
|
||||
:icon="Lock"
|
||||
class="action-btn"
|
||||
@click="handleLockToggle(scope.row)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="TEXT.common.actions.delete" placement="top">
|
||||
<el-button link type="danger" :icon="Delete" class="action-btn" @click="handleDelete(scope.row)" />
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -45,8 +63,9 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { fetchStudies } from "../../api/studies";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { User, OfficeBuilding, ArrowRight, Delete, Lock } from "@element-plus/icons-vue";
|
||||
import { fetchStudies, deleteStudy, lockStudy } from "../../api/studies";
|
||||
import type { Study } from "../../types/api";
|
||||
import ProjectForm from "./ProjectForm.vue";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
@@ -78,11 +97,6 @@ const openCreate = () => {
|
||||
formVisible.value = true;
|
||||
};
|
||||
|
||||
const openEdit = (row: Study) => {
|
||||
editingProject.value = row;
|
||||
formVisible.value = true;
|
||||
};
|
||||
|
||||
const goMembers = (row: Study) => {
|
||||
router.push(`/admin/projects/${row.id}/members`);
|
||||
};
|
||||
@@ -91,6 +105,79 @@ const goSites = (row: Study) => {
|
||||
router.push(`/admin/projects/${row.id}/sites`);
|
||||
};
|
||||
|
||||
const handleDelete = async (study: Study) => {
|
||||
try {
|
||||
// 第一次确认
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除项目\"${study.name}\"吗?该操作将永久删除项目及其所有关联数据(成员、站点等),且不可恢复!`,
|
||||
TEXT.common.actions.delete,
|
||||
{
|
||||
confirmButtonText: "继续",
|
||||
cancelButtonText: TEXT.common.actions.cancel,
|
||||
type: "error",
|
||||
dangerouslyUseHTMLString: false,
|
||||
}
|
||||
);
|
||||
|
||||
// 第二次确认:要求输入项目名称
|
||||
const { value } = await ElMessageBox.prompt(
|
||||
`请输入项目名称 "${study.name}" 以确认删除`,
|
||||
"删除确认",
|
||||
{
|
||||
confirmButtonText: TEXT.common.actions.confirm,
|
||||
cancelButtonText: TEXT.common.actions.cancel,
|
||||
inputPattern: new RegExp(`^${study.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`),
|
||||
inputErrorMessage: "项目名称不匹配",
|
||||
inputPlaceholder: "请输入项目名称",
|
||||
type: "error",
|
||||
}
|
||||
);
|
||||
|
||||
if (value !== study.name) {
|
||||
ElMessage.warning("项目名称不匹配,已取消删除");
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteStudy(study.id);
|
||||
ElMessage.success("项目已删除");
|
||||
await loadProjects();
|
||||
} catch (err: any) {
|
||||
if (err !== "cancel") {
|
||||
const errorMsg = err?.response?.data?.detail || err?.message || "删除项目失败";
|
||||
console.error("删除项目错误:", err);
|
||||
ElMessage.error(errorMsg);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLockToggle = async (study: Study) => {
|
||||
// 锁定后不可解锁
|
||||
if (study.is_locked) {
|
||||
ElMessage.warning("项目已锁定,不可解锁");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要锁定项目\"${study.name}\"吗?锁定后项目内数据将无法编辑,且不可解锁!`,
|
||||
"锁定项目",
|
||||
{
|
||||
confirmButtonText: TEXT.common.actions.confirm,
|
||||
cancelButtonText: TEXT.common.actions.cancel,
|
||||
type: "warning",
|
||||
}
|
||||
);
|
||||
|
||||
await lockStudy(study.id);
|
||||
ElMessage.success("项目已锁定");
|
||||
await loadProjects();
|
||||
} catch (err) {
|
||||
if (err !== "cancel") {
|
||||
ElMessage.error("锁定项目失败");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const enterStudy = (row: Study) => {
|
||||
studyStore.setCurrentStudy({ ...row, role_in_study: "PM" } as Study);
|
||||
router.push("/project/overview");
|
||||
@@ -117,16 +204,64 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 16px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sub {
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
color: #6b7280;
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 表格样式美化 */
|
||||
:deep(.el-table) {
|
||||
--el-table-header-bg-color: #f9fafb;
|
||||
--el-table-header-text-color: #374151;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.el-table th.el-table__cell) {
|
||||
background-color: #f9fafb;
|
||||
font-weight: 600;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
/* 增加单元格间距 */
|
||||
:deep(.el-table .el-table__cell) {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
/* 操作按钮美化 */
|
||||
.action-btn {
|
||||
font-size: 20px; /* 图标变更大 */
|
||||
padding: 6px;
|
||||
margin: 0 4px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
transform: scale(1.1);
|
||||
background-color: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.enter-btn {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminSites.siteLabel }}</el-button>
|
||||
</div>
|
||||
<el-table :data="sites" v-loading="loading" stripe>
|
||||
<el-table :data="sites" v-loading="loading" stripe :row-class-name="siteRowClass">
|
||||
<el-table-column prop="name" :label="TEXT.common.fields.siteName" min-width="180" />
|
||||
<el-table-column prop="city" :label="TEXT.common.fields.city" width="140" />
|
||||
<el-table-column prop="pi_name" :label="TEXT.modules.adminSites.piLabel" width="160" />
|
||||
@@ -24,11 +24,11 @@
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.status" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag type="success" v-if="scope.row.is_active">{{ TEXT.common.actions.enable }}</el-tag>
|
||||
<el-tag type="danger" v-else>{{ TEXT.common.actions.disable }}</el-tag>
|
||||
<el-tag type="success" v-if="scope.row.is_active">已解锁</el-tag>
|
||||
<el-tag type="danger" v-else>已锁定</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="220" fixed="right">
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="260" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button link type="primary" size="small" @click="openEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button
|
||||
@@ -37,7 +37,16 @@
|
||||
size="small"
|
||||
@click="toggleSite(scope.row)"
|
||||
>
|
||||
{{ scope.row.is_active ? TEXT.common.actions.disable : TEXT.common.actions.enable }}
|
||||
{{ scope.row.is_active ? "锁定" : "解锁" }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isAdmin"
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="confirmDelete(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -60,7 +69,7 @@ import { computed, onMounted, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { fetchStudyDetail } from "../../api/studies";
|
||||
import { fetchSites, updateSite } from "../../api/sites";
|
||||
import { deleteSite, fetchSites, updateSite } from "../../api/sites";
|
||||
import { fetchUsers } from "../../api/users";
|
||||
import { listMembers } from "../../api/members";
|
||||
import SiteForm from "./SiteForm.vue";
|
||||
@@ -81,6 +90,7 @@ const members = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const formVisible = ref(false);
|
||||
const editingSite = ref<Site | null>(null);
|
||||
const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
||||
|
||||
const loadProject = async () => {
|
||||
if (!projectId.value) return;
|
||||
@@ -96,7 +106,7 @@ const loadSites = async () => {
|
||||
if (!projectId.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await fetchSites(projectId.value);
|
||||
const { data } = await fetchSites(projectId.value, { include_inactive: true });
|
||||
sites.value = Array.isArray(data) ? data : (data as any).items || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminSites.loadFailed);
|
||||
@@ -147,6 +157,8 @@ const contactLabel = (row: any) => {
|
||||
|
||||
const phoneText = (row: any) => row?.phone || row?.contact_phone || TEXT.common.fallback;
|
||||
|
||||
const siteRowClass = ({ row }: { row: Site }) => (row.is_active ? "" : "row-inactive");
|
||||
|
||||
const openCreate = () => {
|
||||
loadUsers();
|
||||
loadMembers();
|
||||
@@ -179,15 +191,16 @@ const toggleSite = async (row: Site) => {
|
||||
return;
|
||||
}
|
||||
if (row.is_active) {
|
||||
const ok = await ElMessageBox.confirm(TEXT.modules.adminSites.disableConfirm, TEXT.modules.adminSites.disableTitle, {
|
||||
const ok = await ElMessageBox.confirm("锁定后该中心将不可编辑,但数据会保留,确认锁定?", "锁定中心", {
|
||||
type: "warning",
|
||||
confirmButtonText: TEXT.common.actions.confirm,
|
||||
cancelButtonText: TEXT.common.actions.cancel,
|
||||
}).catch(() => null);
|
||||
if (!ok) return;
|
||||
}
|
||||
try {
|
||||
await updateSite(projectId.value, row.id, { is_active: !row.is_active });
|
||||
ElMessage.success(row.is_active ? TEXT.modules.adminSites.disableSuccess : TEXT.modules.adminSites.enableSuccess);
|
||||
ElMessage.success(row.is_active ? "已锁定" : "已解锁");
|
||||
loadSites();
|
||||
logAudit("SITE_STATUS_CHANGED", {
|
||||
targetId: row.id,
|
||||
@@ -209,6 +222,42 @@ const toggleSite = async (row: Site) => {
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async (row: Site) => {
|
||||
if (!projectId.value) return;
|
||||
if (!isAdmin.value) {
|
||||
ElMessage.warning("仅管理员可删除中心");
|
||||
return;
|
||||
}
|
||||
const message = "删除该中心会删除中心所有数据,请谨慎操作。输入“确认删除”继续。";
|
||||
const result = await ElMessageBox.prompt(message, "危险操作", {
|
||||
type: "warning",
|
||||
confirmButtonText: TEXT.common.actions.confirm,
|
||||
cancelButtonText: TEXT.common.actions.cancel,
|
||||
inputPlaceholder: "确认删除",
|
||||
inputPattern: /^确认删除$/,
|
||||
inputErrorMessage: "请输入“确认删除”",
|
||||
}).catch(() => null);
|
||||
if (!result) return;
|
||||
try {
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadProject(), loadUsers(), loadMembers()]);
|
||||
loadSites();
|
||||
@@ -229,4 +278,16 @@ onMounted(async () => {
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.row-inactive td {
|
||||
color: var(--ctms-text-secondary);
|
||||
background-color: #fff7d6 !important;
|
||||
}
|
||||
|
||||
.row-inactive .el-tag {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<div class="header">
|
||||
<div>
|
||||
<h3>{{ TEXT.modules.adminUsers.title }}</h3>
|
||||
<p class="sub">{{ TEXT.modules.adminUsers.subtitle }}</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminUsers.userLabel }}</el-button>
|
||||
</div>
|
||||
@@ -22,28 +21,32 @@
|
||||
<el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" width="200">
|
||||
<template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="320" fixed="right">
|
||||
<el-table-column :label="TEXT.common.labels.actions" min-width="200" align="center">
|
||||
<template #default="scope">
|
||||
<el-button link type="primary" size="small" @click="openEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button
|
||||
link
|
||||
:type="scope.row.status === 'ACTIVE' ? 'danger' : 'primary'"
|
||||
size="small"
|
||||
:disabled="isLastAdmin(scope.row)"
|
||||
@click="toggleStatus(scope.row)"
|
||||
>
|
||||
{{ scope.row.status === 'ACTIVE' ? TEXT.common.actions.disable : TEXT.common.actions.enable }}
|
||||
</el-button>
|
||||
<el-button link type="danger" size="small" @click="openReset(scope.row)">{{ TEXT.modules.adminUsers.resetPassword }}</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="scope.row.id === auth.user?.id"
|
||||
@click="onDelete(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
<el-tooltip :content="TEXT.common.actions.edit" placement="top">
|
||||
<el-button link type="primary" :icon="Edit" @click="openEdit(scope.row)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="scope.row.status === 'ACTIVE' ? TEXT.common.actions.disable : TEXT.common.actions.enable" placement="top">
|
||||
<el-button
|
||||
link
|
||||
:type="scope.row.status === 'ACTIVE' ? 'warning' : 'success'"
|
||||
:icon="scope.row.status === 'ACTIVE' ? Lock : Unlock"
|
||||
:disabled="isLastAdmin(scope.row)"
|
||||
@click="toggleStatus(scope.row)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="TEXT.modules.adminUsers.resetPassword" placement="top">
|
||||
<el-button link type="warning" :icon="Key" @click="openReset(scope.row)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="TEXT.common.actions.delete" placement="top">
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
:disabled="scope.row.id === auth.user?.id"
|
||||
@click="onDelete(scope.row)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -66,7 +69,10 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Edit, Delete, Key, Lock, Unlock } from "@element-plus/icons-vue";
|
||||
import { fetchUsers, updateUser, deleteUser } from "../../api/users";
|
||||
import { fetchStudies } from "../../api/studies";
|
||||
import { listMembers } from "../../api/members";
|
||||
import type { UserInfo } from "../../types/api";
|
||||
import UserForm from "./UserForm.vue";
|
||||
import UserResetPassword from "./UserResetPassword.vue";
|
||||
@@ -86,6 +92,37 @@ const editingUser = ref<UserInfo | null>(null);
|
||||
const resetUser = ref<UserInfo | null>(null);
|
||||
const auth = useAuthStore();
|
||||
|
||||
const statusType = (status: string) => {
|
||||
switch (status) {
|
||||
case "ACTIVE":
|
||||
return "success";
|
||||
case "PENDING":
|
||||
return "warning";
|
||||
case "REJECTED":
|
||||
return "danger";
|
||||
default:
|
||||
return "info";
|
||||
}
|
||||
};
|
||||
|
||||
const statusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case "ACTIVE":
|
||||
return TEXT.enums.userStatus.ACTIVE;
|
||||
case "PENDING":
|
||||
return TEXT.enums.userStatus.PENDING;
|
||||
case "REJECTED":
|
||||
return TEXT.enums.userStatus.REJECTED;
|
||||
case "DISABLED":
|
||||
return TEXT.enums.userStatus.DISABLED;
|
||||
default:
|
||||
return status || TEXT.common.fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const isLastAdmin = (row: UserInfo) =>
|
||||
row.role === "ADMIN" && row.status === "ACTIVE" && activeAdminCount.value <= 1;
|
||||
|
||||
const loadUsers = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -146,67 +183,71 @@ const openReset = (row: UserInfo) => {
|
||||
};
|
||||
|
||||
const onDelete = async (row: UserInfo) => {
|
||||
const { value, action } = await ElMessageBox.prompt(
|
||||
const { action } = await ElMessageBox.prompt(
|
||||
TEXT.modules.adminUsers.deleteConfirm,
|
||||
TEXT.modules.adminUsers.deleteTitle,
|
||||
{
|
||||
inputType: "password",
|
||||
inputPlaceholder: TEXT.modules.adminUsers.deletePasswordPlaceholder,
|
||||
confirmButtonText: TEXT.modules.adminUsers.deleteConfirmButton,
|
||||
inputPlaceholder: TEXT.modules.adminUsers.deleteConfirmPlaceholder,
|
||||
confirmButtonText: TEXT.common.actions.confirm,
|
||||
confirmButtonClass: "el-button--danger",
|
||||
cancelButtonText: TEXT.common.actions.cancel,
|
||||
inputPattern: /^确认删除$/,
|
||||
inputErrorMessage: TEXT.modules.adminUsers.deleteConfirmInputError,
|
||||
}
|
||||
).catch(() => ({ action: "cancel", value: "" }));
|
||||
if (action !== "confirm" || !value) return;
|
||||
).catch(() => ({ action: "cancel" }));
|
||||
if (action !== "confirm") return;
|
||||
try {
|
||||
await deleteUser(row.id, { admin_password: value });
|
||||
await deleteUser(row.id);
|
||||
ElMessage.success(TEXT.modules.adminUsers.deleteSuccess);
|
||||
loadUsers();
|
||||
} catch (e: any) {
|
||||
const detail = e?.response?.data?.detail || e?.response?.data?.message;
|
||||
if (detail && detail.includes(TEXT.modules.adminProjectMembers.memberLabel)) {
|
||||
ElMessage.closeAll();
|
||||
ElMessage.error(detail);
|
||||
const blockingProjects = await findUserProjects(row.id);
|
||||
if (blockingProjects.length > 0) {
|
||||
ElMessage.error(`${detail},该用户仍在以下项目中:${blockingProjects.join("、")}`);
|
||||
} else {
|
||||
ElMessage.error(detail);
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(detail || TEXT.modules.adminUsers.deleteFailed);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const findUserProjects = async (userId: string) => {
|
||||
try {
|
||||
const { data } = await fetchStudies();
|
||||
const studies = Array.isArray(data) ? data : (data as any).items || [];
|
||||
const blocking: string[] = [];
|
||||
|
||||
// Check each study for membership
|
||||
// We run these in parallel but limited batch might be better if many studies.
|
||||
// For now assuming reasonable number of studies.
|
||||
await Promise.all(studies.map(async (study: any) => {
|
||||
try {
|
||||
const { data: membersData } = await listMembers(study.id, { limit: 1000, include_inactive: true }); // Large limit to catch all
|
||||
const members = Array.isArray(membersData) ? membersData : (membersData as any).items || [];
|
||||
if (members.some((m: any) => m.user_id === userId)) {
|
||||
blocking.push(study.name);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors checking specific study
|
||||
}
|
||||
}));
|
||||
return blocking;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers();
|
||||
});
|
||||
|
||||
const statusType = (status: string) => {
|
||||
switch (status) {
|
||||
case "ACTIVE":
|
||||
return "success";
|
||||
case "PENDING":
|
||||
return "warning";
|
||||
case "REJECTED":
|
||||
return "danger";
|
||||
default:
|
||||
return "info";
|
||||
}
|
||||
};
|
||||
|
||||
const statusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case "ACTIVE":
|
||||
return TEXT.enums.userStatus.ACTIVE;
|
||||
case "PENDING":
|
||||
return TEXT.enums.userStatus.PENDING;
|
||||
case "REJECTED":
|
||||
return TEXT.enums.userStatus.REJECTED;
|
||||
case "DISABLED":
|
||||
return TEXT.enums.userStatus.DISABLED;
|
||||
default:
|
||||
return status || TEXT.common.fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const isLastAdmin = (row: UserInfo) =>
|
||||
row.role === "ADMIN" && row.status === "ACTIVE" && activeAdminCount.value <= 1;
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<p class="ctms-page-subtitle">
|
||||
<el-tag size="small" effect="plain" type="info" class="mr-2">{{ displayText(detail.doc_type, TEXT.enums.documentType) }}</el-tag>
|
||||
<el-tag size="small" effect="plain" type="warning" class="mr-2">{{ displaySite(detail.site_id) }}</el-tag>
|
||||
<el-tag v-if="isReadOnly" size="small" effect="plain" type="info">中心已停用</el-tag>
|
||||
<span class="text-secondary text-sm">
|
||||
{{ TEXT.modules.fileVersionManagement.labels.currentEffective }}:
|
||||
<span class="font-medium text-main">{{ detail.current_effective_version?.version_no || TEXT.common.fallback }}</span>
|
||||
@@ -373,6 +374,15 @@ const sites = ref<Site[]>([]);
|
||||
const members = ref<StudyMember[]>([]);
|
||||
const workflowTemplates = ref<WorkflowTemplate[]>([]);
|
||||
|
||||
const siteActiveMap = computed(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
sites.value.forEach((site) => {
|
||||
if (site?.id) map[site.id] = !!site.is_active;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
const isReadOnly = computed(() => !!detail.site_id && siteActiveMap.value[detail.site_id] === false);
|
||||
|
||||
const userMap = computed(() =>
|
||||
users.value.reduce<Record<string, string>>((acc, user) => {
|
||||
acc[user.id] = getUserDisplayName(user) || user.email || user.username || user.id;
|
||||
@@ -468,6 +478,7 @@ const createTarget = () => ({
|
||||
});
|
||||
|
||||
const canAction = (action: string) => {
|
||||
if (isReadOnly.value && action !== "view") return false;
|
||||
const list = detail.can_actions || [];
|
||||
if (list.length === 0) return true;
|
||||
return list.includes(action);
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<div class="ctms-page-header">
|
||||
<div>
|
||||
<h1 class="ctms-page-title">{{ TEXT.modules.fileVersionManagement.title }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.fileVersionManagement.subtitle }}</p>
|
||||
</div>
|
||||
<div class="ctms-page-actions">
|
||||
<el-button type="primary" @click="openCreate">
|
||||
@@ -48,7 +47,13 @@
|
||||
</el-card>
|
||||
|
||||
<el-card class="ctms-table-card">
|
||||
<el-table :data="items" v-loading="loading" @row-click="handleRowClick" row-class-name="clickable-row" class="ctms-table">
|
||||
<el-table
|
||||
:data="sortedItems"
|
||||
v-loading="loading"
|
||||
@row-click="handleRowClick"
|
||||
:row-class-name="documentRowClass"
|
||||
class="ctms-table"
|
||||
>
|
||||
<el-table-column prop="doc_no" :label="TEXT.modules.fileVersionManagement.columns.docNo" width="160">
|
||||
<template #default="{ row }">
|
||||
<span class="font-mono font-medium">{{ row.doc_no }}</span>
|
||||
@@ -104,7 +109,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" @click.stop="confirmDelete(row)">
|
||||
<el-button link type="danger" :disabled="isInactiveSite(row.site_id)" @click.stop="confirmDelete(row)">
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
@@ -130,7 +135,7 @@
|
||||
</el-form-item>
|
||||
<el-form-item v-if="createForm.scope_type === 'SITE'" :label="TEXT.modules.fileVersionManagement.fields.site" prop="site_id">
|
||||
<el-select v-model="createForm.site_id" filterable style="width: 100%">
|
||||
<el-option v-for="item in siteOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
<el-option v-for="item in siteOptions" :key="item.value" :label="item.label" :value="item.value" :disabled="item.disabled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.docType" prop="doc_type">
|
||||
@@ -208,7 +213,20 @@ const scopeOptions = [
|
||||
{ value: "SITE", label: TEXT.enums.scopeType.SITE },
|
||||
];
|
||||
const siteOptions = computed(() =>
|
||||
sites.value.map((site) => ({ value: site.id, label: site.name }))
|
||||
sites.value.map((site) => ({ value: site.id, label: site.name, disabled: !site.is_active }))
|
||||
);
|
||||
const siteActiveMap = computed(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
sites.value.forEach((site) => {
|
||||
if (site?.id) map[site.id] = !!site.is_active;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
const isInactiveSite = (siteId?: string | null) => !!siteId && siteActiveMap.value[siteId] === false;
|
||||
const documentRowClass = ({ row }: { row: DocumentSummary }) =>
|
||||
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_id) ? " row-inactive" : ""}`.trim();
|
||||
const sortedItems = computed(() =>
|
||||
[...items.value].sort((a, b) => Number(isInactiveSite(a?.site_id)) - Number(isInactiveSite(b?.site_id)))
|
||||
);
|
||||
|
||||
const createForm = reactive({
|
||||
@@ -304,6 +322,11 @@ const submitCreate = async () => {
|
||||
if (!valid || !trialId.value) return;
|
||||
creating.value = true;
|
||||
try {
|
||||
if (createForm.scope_type === "SITE" && isInactiveSite(createForm.site_id)) {
|
||||
ElMessage.warning("中心已停用");
|
||||
creating.value = false;
|
||||
return;
|
||||
}
|
||||
await createDocument({
|
||||
trial_id: trialId.value,
|
||||
site_id: createForm.scope_type === "SITE" ? createForm.site_id : undefined,
|
||||
@@ -385,4 +408,16 @@ watch(
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.row-inactive td {
|
||||
color: var(--ctms-text-secondary);
|
||||
background-color: #fff7d6 !important;
|
||||
}
|
||||
|
||||
.row-inactive .el-tag {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,19 +14,20 @@
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.site" prop="center_id">
|
||||
<el-select v-model="form.center_id" :placeholder="TEXT.common.placeholders.select" style="width: 100%">
|
||||
<el-select v-model="form.center_id" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
|
||||
<el-option
|
||||
v-for="site in sites"
|
||||
:key="site.id"
|
||||
:label="site.name || TEXT.common.fallback"
|
||||
:value="site.id"
|
||||
:disabled="!site.is_active"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.direction" prop="direction">
|
||||
<el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select" style="width: 100%">
|
||||
<el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
|
||||
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
|
||||
<el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
|
||||
</el-select>
|
||||
@@ -34,7 +35,7 @@
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.status" prop="status">
|
||||
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" style="width: 100%">
|
||||
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
|
||||
<el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" />
|
||||
<el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" />
|
||||
<el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" />
|
||||
@@ -49,43 +50,45 @@
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.shipDate" prop="ship_date">
|
||||
<el-date-picker v-model="form.ship_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" />
|
||||
<el-date-picker v-model="form.ship_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.receiveDate" prop="receive_date">
|
||||
<el-date-picker v-model="form.receive_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" />
|
||||
<el-date-picker v-model="form.receive_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.quantity" prop="quantity">
|
||||
<el-input-number v-model="form.quantity" :min="0" :controls="false" :placeholder="TEXT.common.placeholders.input" style="width: 100%" />
|
||||
<el-input-number v-model="form.quantity" :min="0" :controls="false" :placeholder="TEXT.common.placeholders.input" style="width: 100%" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.batchNo" prop="batch_no">
|
||||
<el-input v-model="form.batch_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.batchNo" />
|
||||
<el-input v-model="form.batch_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.batchNo" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.carrier" prop="carrier">
|
||||
<el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" />
|
||||
<el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.trackingNo" prop="tracking_no">
|
||||
<el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" />
|
||||
<el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item :label="TEXT.common.fields.remark" prop="remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
|
||||
<el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<div class="actions">
|
||||
<el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
||||
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">
|
||||
{{ TEXT.common.actions.save }}
|
||||
</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
@@ -121,10 +124,18 @@ const study = useStudyStore();
|
||||
const saving = ref(false);
|
||||
const formRef = ref();
|
||||
const sites = ref<any[]>([]);
|
||||
const siteActiveMap = computed(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
sites.value.forEach((site) => {
|
||||
if (site?.id) map[site.id] = !!site.is_active;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
const shipmentId = computed(() => route.params.shipmentId as string | undefined);
|
||||
const isEdit = computed(() => !!shipmentId.value);
|
||||
const studyId = computed(() => study.currentStudy?.id || "");
|
||||
const isReadOnly = computed(() => isEdit.value && !!form.center_id && siteActiveMap.value[form.center_id] === false);
|
||||
|
||||
const form = reactive({
|
||||
center_id: "",
|
||||
@@ -185,6 +196,10 @@ const load = async () => {
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (!studyId.value) return;
|
||||
const valid = await formRef.value?.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<p class="page-subtitle">{{ TEXT.modules.feeContracts.detailSubtitle }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button type="primary" :disabled="!canWrite" @click="goEdit" class="header-action-btn copy-btn">
|
||||
<el-button type="primary" :disabled="!canWrite || isReadOnly" @click="goEdit" class="header-action-btn copy-btn">
|
||||
<el-icon class="el-icon--left"><Edit /></el-icon>
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
@@ -106,6 +106,7 @@
|
||||
:entity-type="'contract_fee'"
|
||||
:entity-id="contractId"
|
||||
:groups="attachmentGroups"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
</el-card>
|
||||
</template>
|
||||
@@ -115,6 +116,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Edit } from "@element-plus/icons-vue";
|
||||
import { getContractFee } from "../../api/feeContracts";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
@@ -145,6 +147,14 @@ const detail = reactive<any>({
|
||||
payments: [],
|
||||
});
|
||||
const sites = ref<any[]>([]);
|
||||
const siteActiveMap = computed(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
sites.value.forEach((site) => {
|
||||
if (site?.id) map[site.id] = !!site.is_active;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
const isReadOnly = computed(() => !!detail.center_id && siteActiveMap.value[detail.center_id] === false);
|
||||
|
||||
const attachmentGroups = [
|
||||
{
|
||||
@@ -204,7 +214,13 @@ const formatAmountWan = (value: any) => {
|
||||
return (numberValue / 10000).toFixed(2);
|
||||
};
|
||||
|
||||
const goEdit = () => router.push(`/fees/contracts/${contractId}/edit`);
|
||||
const goEdit = () => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
router.push(`/fees/contracts/${contractId}/edit`);
|
||||
};
|
||||
const goBack = () => router.push("/fees/contracts");
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -29,39 +29,69 @@
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.site" prop="center_id" required>
|
||||
<el-select v-model="form.center_id" :disabled="isEdit" :placeholder="TEXT.common.placeholders.select" class="full-width">
|
||||
<el-select
|
||||
v-model="form.center_id"
|
||||
:disabled="isEdit || isReadOnly"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
class="full-width"
|
||||
>
|
||||
<el-option
|
||||
v-for="site in sites"
|
||||
:key="site.id"
|
||||
:label="site.name || TEXT.common.fallback"
|
||||
:value="site.id"
|
||||
:disabled="!site.is_active"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.modules.feeContracts.contractAmount" prop="contract_amount" required>
|
||||
<el-input-number v-model="form.contract_amount" :min="0" :precision="2" :step="1000" class="full-width" controls-position="right" />
|
||||
<el-input-number
|
||||
v-model="form.contract_amount"
|
||||
:disabled="isReadOnly"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="1000"
|
||||
class="full-width"
|
||||
controls-position="right"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.modules.feeContracts.contractCases" prop="contract_cases" required>
|
||||
<el-input-number v-model="form.contract_cases" :min="0" :step="1" class="full-width" controls-position="right" />
|
||||
<el-input-number v-model="form.contract_cases" :disabled="isReadOnly" :min="0" :step="1" class="full-width" controls-position="right" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.modules.feeContracts.actualCases" prop="actual_cases">
|
||||
<el-input-number v-model="form.actual_cases" :min="0" :step="1" class="full-width" controls-position="right" />
|
||||
<el-input-number v-model="form.actual_cases" :disabled="isReadOnly" :min="0" :step="1" class="full-width" controls-position="right" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.modules.feeContracts.settlementAmount" prop="settlement_amount">
|
||||
<el-input-number v-model="form.settlement_amount" :min="0" :precision="2" :step="1000" class="full-width" controls-position="right" />
|
||||
<el-input-number
|
||||
v-model="form.settlement_amount"
|
||||
:disabled="isReadOnly"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="1000"
|
||||
class="full-width"
|
||||
controls-position="right"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.modules.feeContracts.finalPaymentAmount" prop="final_payment_amount">
|
||||
<el-input-number v-model="form.final_payment_amount" :min="0" :precision="2" :step="1000" class="full-width" controls-position="right" />
|
||||
<el-input-number
|
||||
v-model="form.final_payment_amount"
|
||||
:disabled="isReadOnly"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="1000"
|
||||
class="full-width"
|
||||
controls-position="right"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -71,7 +101,7 @@
|
||||
<template #header>
|
||||
<div class="card-header actions-header">
|
||||
<span class="header-title">{{ TEXT.modules.feeContracts.paymentTitle }}</span>
|
||||
<el-button type="primary" @click="addPayment">
|
||||
<el-button type="primary" :disabled="isReadOnly" @click="addPayment">
|
||||
<el-icon class="el-icon--left"><Plus /></el-icon>
|
||||
{{ TEXT.common.actions.add }}
|
||||
</el-button>
|
||||
@@ -90,7 +120,7 @@
|
||||
<span class="seq-circle">{{ index + 1 }}</span>
|
||||
<span class="seq-text">{{ TEXT.modules.feeContracts.paymentSeqUnit }}</span>
|
||||
</div>
|
||||
<el-button link type="danger" @click="removePayment(index)">
|
||||
<el-button link type="danger" :disabled="isReadOnly" @click="removePayment(index)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -98,13 +128,13 @@
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
<el-form-item :label="TEXT.common.fields.amount" :error="paymentErrors[index]?.amount">
|
||||
<el-input-number v-model="payment.amount" :min="0" :precision="2" class="full-width" controls-position="right" />
|
||||
<el-input-number v-model="payment.amount" :disabled="isReadOnly" :min="0" :precision="2" class="full-width" controls-position="right" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item :label="TEXT.modules.feeContracts.paidFlag" :error="paymentErrors[index]?.paid_date">
|
||||
<div class="status-control">
|
||||
<el-checkbox v-model="payment.is_paid" @change="() => onPaidToggle(payment)">
|
||||
<el-checkbox v-model="payment.is_paid" :disabled="isReadOnly" @change="() => onPaidToggle(payment)">
|
||||
{{ TEXT.modules.feeContracts.isPaid }}
|
||||
</el-checkbox>
|
||||
<el-date-picker
|
||||
@@ -113,6 +143,7 @@
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
:disabled="isReadOnly"
|
||||
class="status-picker"
|
||||
size="small"
|
||||
/>
|
||||
@@ -122,7 +153,7 @@
|
||||
<el-col :span="8">
|
||||
<el-form-item :label="TEXT.modules.feeContracts.verifiedFlag" :error="paymentErrors[index]?.verified_date">
|
||||
<div class="status-control">
|
||||
<el-checkbox v-model="payment.is_verified" @change="() => onVerifiedToggle(payment)">
|
||||
<el-checkbox v-model="payment.is_verified" :disabled="isReadOnly" @change="() => onVerifiedToggle(payment)">
|
||||
{{ TEXT.modules.feeContracts.isVerified }}
|
||||
</el-checkbox>
|
||||
<el-date-picker
|
||||
@@ -131,6 +162,7 @@
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
:disabled="isReadOnly"
|
||||
class="status-picker"
|
||||
size="small"
|
||||
/>
|
||||
@@ -140,7 +172,7 @@
|
||||
</el-row>
|
||||
|
||||
<el-form-item :label="TEXT.common.fields.remark" class="mb-0">
|
||||
<el-input v-model="payment.remark" type="textarea" :rows="2" :placeholder="TEXT.common.placeholders.input" />
|
||||
<el-input v-model="payment.remark" :disabled="isReadOnly" type="textarea" :rows="2" :placeholder="TEXT.common.placeholders.input" />
|
||||
</el-form-item>
|
||||
|
||||
<div v-if="paymentErrors[index]?.verification" class="payment-error">
|
||||
@@ -162,6 +194,7 @@
|
||||
:entity-type="'contract_fee'"
|
||||
:entity-id="form.id"
|
||||
:groups="attachmentGroups"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
|
||||
<StateEmpty v-else :description="TEXT.modules.feeContracts.uploadHint" class="compact-empty" />
|
||||
@@ -169,7 +202,7 @@
|
||||
|
||||
<div class="footer-actions">
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submit" class="save-btn">
|
||||
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit" class="save-btn">
|
||||
{{ TEXT.common.actions.save }}
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -207,6 +240,14 @@ const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const sites = ref<any[]>([]);
|
||||
const siteActiveMap = computed(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
sites.value.forEach((site) => {
|
||||
if (site?.id) map[site.id] = !!site.is_active;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
const isReadOnly = computed(() => isEdit.value && !!form.center_id && siteActiveMap.value[form.center_id] === false);
|
||||
|
||||
const formRef = ref();
|
||||
const form = reactive({
|
||||
@@ -302,6 +343,10 @@ const loadDetail = async () => {
|
||||
};
|
||||
|
||||
const addPayment = () => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
payments.value.push({
|
||||
tempId: `${Date.now()}-${Math.random()}`,
|
||||
amount: 0,
|
||||
@@ -314,6 +359,10 @@ const addPayment = () => {
|
||||
};
|
||||
|
||||
const removePayment = (index: number) => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
const payment = payments.value[index];
|
||||
if (payment?.id) {
|
||||
removedPaymentIds.value.push(payment.id);
|
||||
@@ -322,6 +371,7 @@ const removePayment = (index: number) => {
|
||||
};
|
||||
|
||||
const onPaidToggle = (payment: any) => {
|
||||
if (isReadOnly.value) return;
|
||||
if (!payment.is_paid) {
|
||||
payment.paid_date = "";
|
||||
payment.is_verified = false;
|
||||
@@ -330,6 +380,7 @@ const onPaidToggle = (payment: any) => {
|
||||
};
|
||||
|
||||
const onVerifiedToggle = (payment: any) => {
|
||||
if (isReadOnly.value) return;
|
||||
if (payment.is_verified && !payment.is_paid) {
|
||||
payment.is_paid = true;
|
||||
}
|
||||
@@ -367,6 +418,14 @@ const validatePayments = () => {
|
||||
|
||||
const submit = async () => {
|
||||
if (!study.currentStudy?.id) return;
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (form.center_id && siteActiveMap.value[form.center_id] === false) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
const formOk = await formRef.value?.validate?.().catch(() => false);
|
||||
if (!formOk) return;
|
||||
if (!validatePayments()) {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ TEXT.modules.feeContracts.title }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.feeContracts.subtitle }}</p>
|
||||
</div>
|
||||
<el-button type="primary" :disabled="!canWrite" @click="goNew" class="header-action-btn">
|
||||
<el-icon class="el-icon--left"><Plus /></el-icon>
|
||||
@@ -94,7 +93,13 @@
|
||||
<StateLoading v-else-if="loading" :rows="6" />
|
||||
|
||||
<el-card v-else class="table-card" shadow="never">
|
||||
<el-table :data="contracts" style="width: 100%" @row-click="onRowClick" :header-cell-style="{ background: '#f8f9fb' }">
|
||||
<el-table
|
||||
:data="sortedContracts"
|
||||
style="width: 100%"
|
||||
@row-click="onRowClick"
|
||||
:row-class-name="contractRowClass"
|
||||
:header-cell-style="{ background: '#f8f9fb' }"
|
||||
>
|
||||
<el-table-column prop="center_name" :label="TEXT.common.fields.site" min-width="180">
|
||||
<template #default="scope">
|
||||
<div class="site-cell">
|
||||
@@ -163,7 +168,7 @@
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="!canWrite"
|
||||
:disabled="!canWrite || isInactiveSite(scope.row.center_id)"
|
||||
@click.stop="remove(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
@@ -171,7 +176,7 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="contracts.length === 0" :description="TEXT.modules.feeContracts.empty" />
|
||||
<StateEmpty v-if="sortedContracts.length === 0" :description="TEXT.modules.feeContracts.empty" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -201,6 +206,19 @@ const loading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const contracts = ref<any[]>([]);
|
||||
const sites = ref<any[]>([]);
|
||||
const siteActiveMap = computed(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
sites.value.forEach((site) => {
|
||||
if (site?.id) map[site.id] = !!site.is_active;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
|
||||
const sortedContracts = computed(() =>
|
||||
[...contracts.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
|
||||
);
|
||||
const contractRowClass = ({ row }: { row: any }) =>
|
||||
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.center_id) ? " row-inactive" : ""}`.trim();
|
||||
|
||||
const filters = reactive({
|
||||
centerId: "",
|
||||
@@ -279,6 +297,10 @@ const onRowClick = (row: any) => {
|
||||
|
||||
const remove = async (row: any) => {
|
||||
if (!row?.id || !canWrite.value) return;
|
||||
if (isInactiveSite(row?.center_id)) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
|
||||
if (!ok) return;
|
||||
try {
|
||||
@@ -438,4 +460,16 @@ onMounted(async () => {
|
||||
color: var(--ctms-text-regular);
|
||||
font-family: var(--el-font-family);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.row-inactive td {
|
||||
color: var(--ctms-text-secondary);
|
||||
background-color: #fff7d6 !important;
|
||||
}
|
||||
|
||||
.row-inactive .el-tag {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<p class="page-subtitle">{{ TEXT.modules.feeSpecials.detailSubtitle }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button type="primary" :disabled="!canWrite" @click="goEdit" class="header-action-btn copy-btn">
|
||||
<el-button type="primary" :disabled="!canWrite || isReadOnly" @click="goEdit" class="header-action-btn copy-btn">
|
||||
<el-icon class="el-icon--left"><Edit /></el-icon>
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
@@ -82,6 +82,7 @@
|
||||
:entity-type="'special_expense'"
|
||||
:entity-id="expenseId"
|
||||
:groups="attachmentGroups"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
</el-card>
|
||||
</template>
|
||||
@@ -91,6 +92,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Edit } from "@element-plus/icons-vue";
|
||||
import { getSpecialExpense } from "../../api/feeSpecials";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
@@ -123,6 +125,14 @@ const detail = reactive<any>({
|
||||
verified_date: "",
|
||||
});
|
||||
const sites = ref<any[]>([]);
|
||||
const siteActiveMap = computed(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
sites.value.forEach((site) => {
|
||||
if (site?.id) map[site.id] = !!site.is_active;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
const isReadOnly = computed(() => !!detail.center_id && siteActiveMap.value[detail.center_id] === false);
|
||||
|
||||
const attachmentGroups = [
|
||||
{
|
||||
@@ -188,7 +198,13 @@ const getCategoryType = (category: string) => {
|
||||
return 'info';
|
||||
};
|
||||
|
||||
const goEdit = () => router.push(`/fees/special/${expenseId}/edit`);
|
||||
const goEdit = () => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
router.push(`/fees/special/${expenseId}/edit`);
|
||||
};
|
||||
const goBack = () => router.push("/fees/special");
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -29,36 +29,37 @@
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.site">
|
||||
<el-select v-model="form.center_id" clearable :placeholder="TEXT.common.placeholders.select" class="full-width">
|
||||
<el-select v-model="form.center_id" clearable :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select" class="full-width">
|
||||
<el-option
|
||||
v-for="site in sites"
|
||||
:key="site.id"
|
||||
:label="site.name || TEXT.common.fallback"
|
||||
:value="site.id"
|
||||
:disabled="!site.is_active"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.occurDate" prop="happen_date">
|
||||
<el-date-picker v-model="form.happen_date" type="date" value-format="YYYY-MM-DD" class="full-width" />
|
||||
<el-date-picker v-model="form.happen_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" class="full-width" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.category" prop="category" required>
|
||||
<el-select v-model="form.category" :placeholder="TEXT.common.placeholders.select" class="full-width">
|
||||
<el-select v-model="form.category" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select" class="full-width">
|
||||
<el-option v-for="option in categoryOptions" :key="option.value" :label="option.label" :value="option.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.amount" prop="amount" required>
|
||||
<el-input-number v-model="form.amount" :min="0" :precision="2" class="full-width" controls-position="right" />
|
||||
<el-input-number v-model="form.amount" :disabled="isReadOnly" :min="0" :precision="2" class="full-width" controls-position="right" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item :label="TEXT.common.fields.description">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" />
|
||||
<el-input v-model="form.description" :disabled="isReadOnly" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -76,7 +77,7 @@
|
||||
<div class="status-box">
|
||||
<div class="status-header">
|
||||
<span class="status-title">{{ TEXT.modules.feeSpecials.paidFlag }}</span>
|
||||
<el-switch v-model="form.is_paid" @change="onPaidToggle" />
|
||||
<el-switch v-model="form.is_paid" :disabled="isReadOnly" @change="onPaidToggle" />
|
||||
</div>
|
||||
<el-form-item :label="TEXT.common.fields.occurDate" :error="specialErrors.paid_date" class="status-form-item">
|
||||
<el-date-picker
|
||||
@@ -84,7 +85,7 @@
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
:disabled="!form.is_paid"
|
||||
:disabled="isReadOnly || !form.is_paid"
|
||||
class="full-width"
|
||||
/>
|
||||
</el-form-item>
|
||||
@@ -94,7 +95,7 @@
|
||||
<div class="status-box">
|
||||
<div class="status-header">
|
||||
<span class="status-title">{{ TEXT.modules.feeSpecials.verifiedFlag }}</span>
|
||||
<el-switch v-model="form.is_verified" @change="onVerifiedToggle" />
|
||||
<el-switch v-model="form.is_verified" :disabled="isReadOnly" @change="onVerifiedToggle" />
|
||||
</div>
|
||||
<el-form-item :label="TEXT.common.fields.occurDate" :error="specialErrors.verified_date" class="status-form-item">
|
||||
<el-date-picker
|
||||
@@ -102,7 +103,7 @@
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
:disabled="!form.is_verified"
|
||||
:disabled="isReadOnly || !form.is_verified"
|
||||
class="full-width"
|
||||
/>
|
||||
</el-form-item>
|
||||
@@ -126,6 +127,7 @@
|
||||
:entity-type="'special_expense'"
|
||||
:entity-id="form.id"
|
||||
:groups="attachmentGroups"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
|
||||
<StateEmpty v-else :description="TEXT.modules.feeSpecials.uploadHint" class="compact-empty" />
|
||||
@@ -133,7 +135,7 @@
|
||||
|
||||
<div class="footer-actions">
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submit" class="save-btn">
|
||||
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit" class="save-btn">
|
||||
{{ TEXT.common.actions.save }}
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -163,6 +165,14 @@ const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const sites = ref<any[]>([]);
|
||||
const siteActiveMap = computed(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
sites.value.forEach((site) => {
|
||||
if (site?.id) map[site.id] = !!site.is_active;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
const isReadOnly = computed(() => isEdit.value && !!form.center_id && siteActiveMap.value[form.center_id] === false);
|
||||
|
||||
const formRef = ref();
|
||||
const form = reactive({
|
||||
@@ -301,6 +311,14 @@ const validateSpecial = () => {
|
||||
|
||||
const submit = async () => {
|
||||
if (!study.currentStudy?.id) return;
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (form.center_id && siteActiveMap.value[form.center_id] === false) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
const formOk = await formRef.value?.validate?.().catch(() => false);
|
||||
if (!formOk) return;
|
||||
if (!validateSpecial()) {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ TEXT.modules.feeSpecials.title }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.feeSpecials.subtitle }}</p>
|
||||
</div>
|
||||
<el-button type="primary" :disabled="!canWrite" @click="goNew" class="header-action-btn">
|
||||
<el-icon class="el-icon--left"><Plus /></el-icon>
|
||||
@@ -109,7 +108,13 @@
|
||||
<StateLoading v-else-if="loading" :rows="6" />
|
||||
|
||||
<el-card v-else class="table-card" shadow="never">
|
||||
<el-table :data="expenses" style="width: 100%" @row-click="onRowClick" :header-cell-style="{ background: '#f8f9fb' }">
|
||||
<el-table
|
||||
:data="sortedExpenses"
|
||||
style="width: 100%"
|
||||
@row-click="onRowClick"
|
||||
:header-cell-style="{ background: '#f8f9fb' }"
|
||||
:row-class-name="expenseRowClass"
|
||||
>
|
||||
<el-table-column :label="TEXT.common.fields.occurDate" width="140">
|
||||
<template #default="scope">
|
||||
<span class="date-text">{{ displayDate(scope.row.happen_date) }}</span>
|
||||
@@ -161,7 +166,7 @@
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="!canWrite"
|
||||
:disabled="!canWrite || isInactiveSite(scope.row.center_id)"
|
||||
@click.stop="remove(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
@@ -169,7 +174,7 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="expenses.length === 0" :description="TEXT.modules.feeSpecials.empty" />
|
||||
<StateEmpty v-if="sortedExpenses.length === 0" :description="TEXT.modules.feeSpecials.empty" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -199,6 +204,19 @@ const loading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const expenses = ref<any[]>([]);
|
||||
const sites = ref<any[]>([]);
|
||||
const siteActiveMap = computed(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
sites.value.forEach((site) => {
|
||||
if (site?.id) map[site.id] = !!site.is_active;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
|
||||
const expenseRowClass = ({ row }: { row: any }) =>
|
||||
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.center_id) ? " row-inactive" : ""}`.trim();
|
||||
const sortedExpenses = computed(() =>
|
||||
[...expenses.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
|
||||
);
|
||||
|
||||
const filters = reactive({
|
||||
centerId: "",
|
||||
@@ -287,6 +305,10 @@ const onRowClick = (row: any) => {
|
||||
|
||||
const remove = async (row: any) => {
|
||||
if (!row?.id || !canWrite.value) return;
|
||||
if (isInactiveSite(row?.center_id)) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
|
||||
if (!ok) return;
|
||||
try {
|
||||
@@ -444,4 +466,16 @@ onMounted(async () => {
|
||||
.text-muted {
|
||||
color: var(--ctms-text-placeholder);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.row-inactive td {
|
||||
color: var(--ctms-text-secondary);
|
||||
background-color: #fff7d6 !important;
|
||||
}
|
||||
|
||||
.row-inactive .el-tag {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<p class="page-subtitle">{{ TEXT.modules.financeContracts.detailSubtitle }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button type="primary" :disabled="isReadOnly" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -28,16 +28,18 @@
|
||||
:study-id="studyId"
|
||||
entity-type="finance_contract"
|
||||
:entity-id="contractId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { getFinanceContract } from "../../api/financeContracts";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import { displayDate } from "../../utils/display";
|
||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
@@ -48,6 +50,7 @@ const study = useStudyStore();
|
||||
const loading = ref(false);
|
||||
const contractId = route.params.contractId as string;
|
||||
const studyId = study.currentStudy?.id || "";
|
||||
const sites = ref<any[]>([]);
|
||||
|
||||
const detail = reactive<any>({
|
||||
site_name: "",
|
||||
@@ -58,6 +61,25 @@ const detail = reactive<any>({
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const siteActiveMap = computed(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
sites.value.forEach((site) => {
|
||||
if (site?.name) map[site.name] = !!site.is_active;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
const isReadOnly = computed(() => !!detail.site_name && siteActiveMap.value[detail.site_name] === false);
|
||||
|
||||
const loadSites = async () => {
|
||||
if (!studyId) return;
|
||||
try {
|
||||
const { data } = await fetchSites(studyId, { limit: 500 });
|
||||
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch {
|
||||
sites.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!studyId || !contractId) return;
|
||||
loading.value = true;
|
||||
@@ -71,10 +93,19 @@ const load = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const goEdit = () => router.push(`/finance/contracts/${contractId}/edit`);
|
||||
const goEdit = () => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
router.push(`/finance/contracts/${contractId}/edit`);
|
||||
};
|
||||
const goBack = () => router.push("/finance/contracts");
|
||||
|
||||
onMounted(load);
|
||||
onMounted(async () => {
|
||||
await loadSites();
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -11,29 +11,29 @@
|
||||
<el-card>
|
||||
<el-form ref="formRef" :model="form" label-width="110px">
|
||||
<el-form-item :label="TEXT.common.fields.site" prop="site_name" required>
|
||||
<el-input v-model="form.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
|
||||
<el-input v-model="form.site_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.contractNo" prop="contract_no" required>
|
||||
<el-input v-model="form.contract_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.contractNo" />
|
||||
<el-input v-model="form.contract_no" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.contractNo" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.signedDate">
|
||||
<el-date-picker v-model="form.signed_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
||||
<el-date-picker v-model="form.signed_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.amount" prop="amount" required>
|
||||
<el-input-number v-model="form.amount" :min="0" :precision="2" :step="1000" />
|
||||
<el-input-number v-model="form.amount" :disabled="isReadOnly" :min="0" :precision="2" :step="1000" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.currency">
|
||||
<el-select v-model="form.currency" :placeholder="TEXT.common.placeholders.select">
|
||||
<el-select v-model="form.currency" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select">
|
||||
<el-option label="CNY" value="CNY" />
|
||||
<el-option label="USD" value="USD" />
|
||||
<el-option label="EUR" value="EUR" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
|
||||
<el-input v-model="form.remark" :disabled="isReadOnly" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
||||
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -44,6 +44,7 @@
|
||||
:study-id="studyId"
|
||||
entity-type="finance_contract"
|
||||
:entity-id="contractId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
<el-card v-else class="hint-card">
|
||||
<StateEmpty :description="TEXT.modules.financeContracts.uploadHint" />
|
||||
@@ -57,6 +58,7 @@ import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { createFinanceContract, getFinanceContract, updateFinanceContract } from "../../api/financeContracts";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||
import StateEmpty from "../../components/StateEmpty.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
@@ -65,6 +67,7 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
const study = useStudyStore();
|
||||
const saving = ref(false);
|
||||
const sites = ref<any[]>([]);
|
||||
|
||||
const contractId = computed(() => route.params.contractId as string | undefined);
|
||||
const isEdit = computed(() => !!contractId.value);
|
||||
@@ -79,6 +82,25 @@ const form = reactive({
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const siteActiveMap = computed(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
sites.value.forEach((site) => {
|
||||
if (site?.name) map[site.name] = !!site.is_active;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
const isReadOnly = computed(() => isEdit.value && !!form.site_name && siteActiveMap.value[form.site_name] === false);
|
||||
|
||||
const loadSites = async () => {
|
||||
if (!studyId.value) return;
|
||||
try {
|
||||
const { data } = await fetchSites(studyId.value, { limit: 500 });
|
||||
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch {
|
||||
sites.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!isEdit.value || !studyId.value || !contractId.value) return;
|
||||
try {
|
||||
@@ -98,6 +120,18 @@ const load = async () => {
|
||||
|
||||
const submit = async () => {
|
||||
if (!studyId.value) return;
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (!form.site_name) {
|
||||
ElMessage.warning(TEXT.common.messages.required);
|
||||
return;
|
||||
}
|
||||
if (siteActiveMap.value[form.site_name] === false) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
@@ -126,7 +160,10 @@ const submit = async () => {
|
||||
|
||||
const goBack = () => router.push("/finance/contracts");
|
||||
|
||||
onMounted(load);
|
||||
onMounted(async () => {
|
||||
await loadSites();
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<p class="page-subtitle">{{ TEXT.modules.financeSpecials.detailSubtitle }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button type="primary" :disabled="isReadOnly" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -27,16 +27,18 @@
|
||||
:study-id="studyId"
|
||||
entity-type="finance_special"
|
||||
:entity-id="specialId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { getFinanceSpecial } from "../../api/financeSpecials";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import { displayDate, displayEnum } from "../../utils/display";
|
||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
@@ -47,6 +49,7 @@ const study = useStudyStore();
|
||||
const loading = ref(false);
|
||||
const specialId = route.params.specialId as string;
|
||||
const studyId = study.currentStudy?.id || "";
|
||||
const sites = ref<any[]>([]);
|
||||
|
||||
const detail = reactive<any>({
|
||||
site_name: "",
|
||||
@@ -57,6 +60,25 @@ const detail = reactive<any>({
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const siteActiveMap = computed(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
sites.value.forEach((site) => {
|
||||
if (site?.name) map[site.name] = !!site.is_active;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
const isReadOnly = computed(() => !!detail.site_name && siteActiveMap.value[detail.site_name] === false);
|
||||
|
||||
const loadSites = async () => {
|
||||
if (!studyId) return;
|
||||
try {
|
||||
const { data } = await fetchSites(studyId, { limit: 500 });
|
||||
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch {
|
||||
sites.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!studyId || !specialId) return;
|
||||
loading.value = true;
|
||||
@@ -70,10 +92,19 @@ const load = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const goEdit = () => router.push(`/finance/special/${specialId}/edit`);
|
||||
const goEdit = () => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
router.push(`/finance/special/${specialId}/edit`);
|
||||
};
|
||||
const goBack = () => router.push("/finance/special");
|
||||
|
||||
onMounted(load);
|
||||
onMounted(async () => {
|
||||
await loadSites();
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -11,10 +11,10 @@
|
||||
<el-card>
|
||||
<el-form :model="form" label-width="110px">
|
||||
<el-form-item :label="TEXT.common.fields.site" required>
|
||||
<el-input v-model="form.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
|
||||
<el-input v-model="form.site_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.type" required>
|
||||
<el-select v-model="form.fee_type" :placeholder="TEXT.common.placeholders.select">
|
||||
<el-select v-model="form.fee_type" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select">
|
||||
<el-option :label="TEXT.enums.financeSpecialType.TRAVEL" value="TRAVEL" />
|
||||
<el-option :label="TEXT.enums.financeSpecialType.HOTEL" value="HOTEL" />
|
||||
<el-option :label="TEXT.enums.financeSpecialType.TRANSPORT" value="TRANSPORT" />
|
||||
@@ -22,19 +22,19 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.amount" required>
|
||||
<el-input-number v-model="form.amount" :min="0" :precision="2" :step="100" />
|
||||
<el-input-number v-model="form.amount" :disabled="isReadOnly" :min="0" :precision="2" :step="100" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.occurDate">
|
||||
<el-date-picker v-model="form.occur_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
||||
<el-date-picker v-model="form.occur_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.staff">
|
||||
<el-input v-model="form.staff_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.staff" />
|
||||
<el-input v-model="form.staff_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.staff" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
|
||||
<el-input v-model="form.remark" :disabled="isReadOnly" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
||||
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -45,6 +45,7 @@
|
||||
:study-id="studyId"
|
||||
entity-type="finance_special"
|
||||
:entity-id="specialId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
<el-card v-else class="hint-card">
|
||||
<StateEmpty :description="TEXT.modules.financeSpecials.uploadHint" />
|
||||
@@ -58,6 +59,7 @@ import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { createFinanceSpecial, getFinanceSpecial, updateFinanceSpecial } from "../../api/financeSpecials";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||
import StateEmpty from "../../components/StateEmpty.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
@@ -66,6 +68,7 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
const study = useStudyStore();
|
||||
const saving = ref(false);
|
||||
const sites = ref<any[]>([]);
|
||||
|
||||
const specialId = computed(() => route.params.specialId as string | undefined);
|
||||
const isEdit = computed(() => !!specialId.value);
|
||||
@@ -80,6 +83,25 @@ const form = reactive({
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const siteActiveMap = computed(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
sites.value.forEach((site) => {
|
||||
if (site?.name) map[site.name] = !!site.is_active;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
const isReadOnly = computed(() => isEdit.value && !!form.site_name && siteActiveMap.value[form.site_name] === false);
|
||||
|
||||
const loadSites = async () => {
|
||||
if (!studyId.value) return;
|
||||
try {
|
||||
const { data } = await fetchSites(studyId.value, { limit: 500 });
|
||||
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch {
|
||||
sites.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!isEdit.value || !studyId.value || !specialId.value) return;
|
||||
try {
|
||||
@@ -99,6 +121,18 @@ const load = async () => {
|
||||
|
||||
const submit = async () => {
|
||||
if (!studyId.value) return;
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (!form.site_name) {
|
||||
ElMessage.warning(TEXT.common.messages.required);
|
||||
return;
|
||||
}
|
||||
if (siteActiveMap.value[form.site_name] === false) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
@@ -127,7 +161,10 @@ const submit = async () => {
|
||||
|
||||
const goBack = () => router.push("/finance/special");
|
||||
|
||||
onMounted(load);
|
||||
onMounted(async () => {
|
||||
await loadSites();
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ TEXT.modules.drugShipments.title }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.drugShipments.subtitle }}</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="goNew">{{ TEXT.modules.drugShipments.newTitle }}</el-button>
|
||||
</div>
|
||||
@@ -43,7 +42,13 @@
|
||||
</el-card>
|
||||
|
||||
<el-card>
|
||||
<el-table :data="items" v-loading="loading" style="width: 100%" @row-click="onRowClick">
|
||||
<el-table
|
||||
:data="sortedItems"
|
||||
v-loading="loading"
|
||||
style="width: 100%"
|
||||
:row-class-name="shipmentRowClass"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160">
|
||||
<template #default="scope">{{ scope.row.site_name || TEXT.common.fallback }}</template>
|
||||
</el-table-column>
|
||||
@@ -78,17 +83,25 @@
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button link type="danger" size="small" @click.stop="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="isInactiveSite(scope.row.center_id)"
|
||||
@click.stop="remove(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="!loading && items.length === 0" :description="TEXT.modules.drugShipments.empty" />
|
||||
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.drugShipments.empty" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
@@ -103,6 +116,7 @@ const study = useStudyStore();
|
||||
const loading = ref(false);
|
||||
const items = ref<any[]>([]);
|
||||
const sites = ref<any[]>([]);
|
||||
const siteActiveMap = ref<Record<string, boolean>>({});
|
||||
const filters = reactive({
|
||||
center_id: "",
|
||||
direction: "",
|
||||
@@ -115,8 +129,13 @@ const loadSites = async () => {
|
||||
try {
|
||||
const { data } = await fetchSites(studyId, { limit: 500 });
|
||||
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||
siteActiveMap.value = sites.value.reduce((acc: Record<string, boolean>, site: any) => {
|
||||
acc[site.id] = !!site.is_active;
|
||||
return acc;
|
||||
}, {});
|
||||
} catch {
|
||||
sites.value = [];
|
||||
siteActiveMap.value = {};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -151,6 +170,12 @@ const onRowClick = (row: any) => {
|
||||
if (!row?.id) return;
|
||||
goDetail(row.id);
|
||||
};
|
||||
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
|
||||
const shipmentRowClass = ({ row }: { row: any }) =>
|
||||
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.center_id) ? " row-inactive" : ""}`.trim();
|
||||
const sortedItems = computed(() =>
|
||||
[...items.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
|
||||
);
|
||||
|
||||
const statusType = (status: string) => {
|
||||
switch (status) {
|
||||
@@ -172,6 +197,10 @@ const statusType = (status: string) => {
|
||||
const remove = async (row: any) => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
if (isInactiveSite(row?.center_id)) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
const ok = await ElMessageBox.confirm(TEXT.modules.drugShipments.confirmDelete, TEXT.common.labels.tips).catch(() => null);
|
||||
if (!ok) return;
|
||||
try {
|
||||
@@ -196,6 +225,7 @@ onMounted(async () => {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -218,3 +248,14 @@ onMounted(async () => {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.row-inactive td {
|
||||
color: var(--ctms-text-secondary);
|
||||
background-color: #fff7d6 !important;
|
||||
}
|
||||
|
||||
.row-inactive .el-tag {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -24,7 +24,14 @@
|
||||
</el-card>
|
||||
|
||||
<el-card>
|
||||
<el-table :data="items" v-loading="loading" style="width: 100%" class="ctms-table" @row-click="onRowClick">
|
||||
<el-table
|
||||
:data="sortedItems"
|
||||
v-loading="loading"
|
||||
style="width: 100%"
|
||||
class="ctms-table"
|
||||
@row-click="onRowClick"
|
||||
:row-class-name="contractRowClass"
|
||||
>
|
||||
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
|
||||
<el-table-column prop="contract_no" :label="TEXT.common.fields.contractNo" min-width="160" />
|
||||
<el-table-column prop="signed_date" :label="TEXT.common.fields.signedDate" width="140">
|
||||
@@ -44,21 +51,30 @@
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
||||
<template #default="scope">
|
||||
<el-button link type="danger" size="small" @click.stop="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="isInactiveSite(scope.row.site_name)"
|
||||
@click.stop="remove(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="!loading && items.length === 0" :description="TEXT.modules.financeContracts.empty" />
|
||||
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.financeContracts.empty" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { listFinanceContracts, deleteFinanceContract } from "../../api/financeContracts";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import { displayDate, displayDateTime } from "../../utils/display";
|
||||
import StateEmpty from "../../components/StateEmpty.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
@@ -67,11 +83,37 @@ const router = useRouter();
|
||||
const study = useStudyStore();
|
||||
const loading = ref(false);
|
||||
const items = ref<any[]>([]);
|
||||
const sites = ref<any[]>([]);
|
||||
const filters = reactive({
|
||||
site_name: "",
|
||||
contract_no: "",
|
||||
});
|
||||
|
||||
const siteActiveMap = computed(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
sites.value.forEach((site) => {
|
||||
if (site?.name) map[site.name] = !!site.is_active;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
const isInactiveSite = (siteName?: string) => !!siteName && siteActiveMap.value[siteName] === false;
|
||||
const contractRowClass = ({ row }: { row: any }) =>
|
||||
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_name) ? " row-inactive" : ""}`.trim();
|
||||
const sortedItems = computed(() =>
|
||||
[...items.value].sort((a, b) => Number(isInactiveSite(a?.site_name)) - Number(isInactiveSite(b?.site_name)))
|
||||
);
|
||||
|
||||
const loadSites = async () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
try {
|
||||
const { data } = await fetchSites(studyId, { limit: 500 });
|
||||
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch {
|
||||
sites.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
@@ -105,6 +147,10 @@ const onRowClick = (row: any) => {
|
||||
const remove = async (row: any) => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
if (isInactiveSite(row?.site_name)) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
|
||||
if (!ok) return;
|
||||
try {
|
||||
@@ -116,7 +162,10 @@ const remove = async (row: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
onMounted(async () => {
|
||||
await loadSites();
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -147,4 +196,17 @@ onMounted(load);
|
||||
.filter-card :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.row-inactive td {
|
||||
color: var(--ctms-text-secondary);
|
||||
background-color: #fff7d6 !important;
|
||||
}
|
||||
|
||||
.row-inactive .el-tag {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -29,7 +29,14 @@
|
||||
</el-card>
|
||||
|
||||
<el-card>
|
||||
<el-table :data="items" v-loading="loading" style="width: 100%" class="ctms-table" @row-click="onRowClick">
|
||||
<el-table
|
||||
:data="sortedItems"
|
||||
v-loading="loading"
|
||||
style="width: 100%"
|
||||
class="ctms-table"
|
||||
@row-click="onRowClick"
|
||||
:row-class-name="specialRowClass"
|
||||
>
|
||||
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
|
||||
<el-table-column prop="fee_type" :label="TEXT.common.fields.type" width="120">
|
||||
<template #default="scope">
|
||||
@@ -54,21 +61,30 @@
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
||||
<template #default="scope">
|
||||
<el-button link type="danger" size="small" @click.stop="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="isInactiveSite(scope.row.site_name)"
|
||||
@click.stop="remove(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="!loading && items.length === 0" :description="TEXT.modules.financeSpecials.empty" />
|
||||
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.financeSpecials.empty" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { listFinanceSpecials, deleteFinanceSpecial } from "../../api/financeSpecials";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import { displayDate, displayDateTime, displayEnum } from "../../utils/display";
|
||||
import StateEmpty from "../../components/StateEmpty.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
@@ -77,11 +93,37 @@ const router = useRouter();
|
||||
const study = useStudyStore();
|
||||
const loading = ref(false);
|
||||
const items = ref<any[]>([]);
|
||||
const sites = ref<any[]>([]);
|
||||
const filters = reactive({
|
||||
site_name: "",
|
||||
fee_type: "",
|
||||
});
|
||||
|
||||
const siteActiveMap = computed(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
sites.value.forEach((site) => {
|
||||
if (site?.name) map[site.name] = !!site.is_active;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
const isInactiveSite = (siteName?: string) => !!siteName && siteActiveMap.value[siteName] === false;
|
||||
const specialRowClass = ({ row }: { row: any }) =>
|
||||
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_name) ? " row-inactive" : ""}`.trim();
|
||||
const sortedItems = computed(() =>
|
||||
[...items.value].sort((a, b) => Number(isInactiveSite(a?.site_name)) - Number(isInactiveSite(b?.site_name)))
|
||||
);
|
||||
|
||||
const loadSites = async () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
try {
|
||||
const { data } = await fetchSites(studyId, { limit: 500 });
|
||||
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch {
|
||||
sites.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
@@ -115,6 +157,10 @@ const onRowClick = (row: any) => {
|
||||
const remove = async (row: any) => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
if (isInactiveSite(row?.site_name)) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
|
||||
if (!ok) return;
|
||||
try {
|
||||
@@ -126,7 +172,10 @@ const remove = async (row: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
onMounted(async () => {
|
||||
await loadSites();
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -157,4 +206,16 @@ onMounted(load);
|
||||
.filter-card :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.row-inactive td {
|
||||
color: var(--ctms-text-secondary);
|
||||
background-color: #fff7d6 !important;
|
||||
}
|
||||
|
||||
.row-inactive .el-tag {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<ModulePlaceholder
|
||||
:title="TEXT.modules.knowledgeInstructionFiles.title"
|
||||
:subtitle="TEXT.modules.knowledgeInstructionFiles.subtitle"
|
||||
:list-title="TEXT.modules.knowledgeInstructionFiles.listTitle"
|
||||
:empty-description="TEXT.modules.knowledgeInstructionFiles.emptyDescription"
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ TEXT.modules.knowledgeNotes.title }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.knowledgeNotes.subtitle }}</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="goNew">{{ TEXT.modules.knowledgeNotes.newTitle }}</el-button>
|
||||
</div>
|
||||
@@ -24,7 +23,14 @@
|
||||
</el-card>
|
||||
|
||||
<el-card>
|
||||
<el-table :data="items" v-loading="loading" style="width: 100%" class="ctms-table" @row-click="onRowClick">
|
||||
<el-table
|
||||
:data="sortedItems"
|
||||
v-loading="loading"
|
||||
style="width: 100%"
|
||||
class="ctms-table"
|
||||
@row-click="onRowClick"
|
||||
:row-class-name="noteRowClass"
|
||||
>
|
||||
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
|
||||
<el-table-column prop="title" :label="TEXT.common.fields.title" min-width="200" />
|
||||
<el-table-column prop="level" :label="TEXT.common.fields.level" width="120" />
|
||||
@@ -33,21 +39,30 @@
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
||||
<template #default="scope">
|
||||
<el-button link type="danger" size="small" @click.stop="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="isInactiveSite(scope.row.site_name)"
|
||||
@click.stop="remove(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="!loading && items.length === 0" :description="TEXT.modules.knowledgeNotes.empty" />
|
||||
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.knowledgeNotes.empty" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { listKnowledgeNotes, deleteKnowledgeNote } from "../../api/knowledgeNotes";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import { displayDateTime } from "../../utils/display";
|
||||
import StateEmpty from "../../components/StateEmpty.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
@@ -56,11 +71,37 @@ const router = useRouter();
|
||||
const study = useStudyStore();
|
||||
const loading = ref(false);
|
||||
const items = ref<any[]>([]);
|
||||
const sites = ref<any[]>([]);
|
||||
const filters = reactive({
|
||||
site_name: "",
|
||||
keyword: "",
|
||||
});
|
||||
|
||||
const siteActiveMap = computed(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
sites.value.forEach((site) => {
|
||||
if (site?.name) map[site.name] = !!site.is_active;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
const isInactiveSite = (siteName?: string) => !!siteName && siteActiveMap.value[siteName] === false;
|
||||
const noteRowClass = ({ row }: { row: any }) =>
|
||||
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_name) ? " row-inactive" : ""}`.trim();
|
||||
const sortedItems = computed(() =>
|
||||
[...items.value].sort((a, b) => Number(isInactiveSite(a?.site_name)) - Number(isInactiveSite(b?.site_name)))
|
||||
);
|
||||
|
||||
const loadSites = async () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
try {
|
||||
const { data } = await fetchSites(studyId, { limit: 500 });
|
||||
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch {
|
||||
sites.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
@@ -94,6 +135,10 @@ const onRowClick = (row: any) => {
|
||||
const remove = async (row: any) => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
if (isInactiveSite(row?.site_name)) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
|
||||
if (!ok) return;
|
||||
try {
|
||||
@@ -105,7 +150,10 @@ const remove = async (row: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
onMounted(async () => {
|
||||
await loadSites();
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -136,4 +184,16 @@ onMounted(load);
|
||||
.filter-card :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.row-inactive td {
|
||||
color: var(--ctms-text-secondary);
|
||||
background-color: #fff7d6 !important;
|
||||
}
|
||||
|
||||
.row-inactive .el-tag {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<ModulePlaceholder
|
||||
:title="TEXT.modules.knowledgeSupportFiles.title"
|
||||
:subtitle="TEXT.modules.knowledgeSupportFiles.subtitle"
|
||||
:list-title="TEXT.modules.knowledgeSupportFiles.listTitle"
|
||||
:empty-description="TEXT.modules.knowledgeSupportFiles.emptyDescription"
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">项目概览</h1>
|
||||
<p class="page-subtitle">多中心项目整体进度与入组情况</p>
|
||||
<p class="study-meta">
|
||||
<span class="study-name">{{ study.currentStudy.name }}</span>
|
||||
<span class="study-divider">/</span>
|
||||
@@ -81,6 +80,7 @@ import { useStudyStore } from "../../store/study";
|
||||
import { TEXT } from "../../locales";
|
||||
import { displayDateTime } from "../../utils/display";
|
||||
import { fetchProjectOverview } from "../../api/overview";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import StateEmpty from "../../components/StateEmpty.vue";
|
||||
import StateLoading from "../../components/StateLoading.vue";
|
||||
import CenterProgressRow from "./project-overview/CenterProgressRow.vue";
|
||||
@@ -146,14 +146,28 @@ const loadOverview = async () => {
|
||||
if (!studyId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const sitesResp = await fetchSites(studyId, { limit: 500 });
|
||||
const siteList = Array.isArray(sitesResp.data) ? sitesResp.data : (sitesResp.data as any).items || [];
|
||||
const siteActiveMap = new Map<string, boolean>(siteList.map((s: any) => [s.id, !!s.is_active]));
|
||||
|
||||
if (preferApi) {
|
||||
const { data } = await fetchProjectOverview(studyId);
|
||||
overview.value = adaptProjectOverview(data);
|
||||
usingDemo.value = false;
|
||||
return;
|
||||
} else {
|
||||
overview.value = adaptProjectOverview(overviewMock);
|
||||
usingDemo.value = true;
|
||||
}
|
||||
|
||||
if (overview.value) {
|
||||
overview.value.centers.forEach(c => {
|
||||
if (siteActiveMap.has(c.center_id)) {
|
||||
c.is_active = siteActiveMap.get(c.center_id);
|
||||
}
|
||||
});
|
||||
// Sort: Inactive last
|
||||
overview.value.centers.sort((a, b) => Number(b.is_active) - Number(a.is_active));
|
||||
}
|
||||
overview.value = adaptProjectOverview(overviewMock);
|
||||
usingDemo.value = true;
|
||||
} catch {
|
||||
overview.value = adaptProjectOverview(overviewMock);
|
||||
usingDemo.value = true;
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<div class="ctms-page-header">
|
||||
<div>
|
||||
<h1 class="ctms-page-title">{{ TEXT.modules.startupFeasibilityEthics.title }}</h1>
|
||||
<p class="ctms-page-subtitle">{{ TEXT.modules.startupFeasibilityEthics.subtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,11 +20,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<el-table
|
||||
:data="feasibilityItems"
|
||||
:data="sortedFeasibilityItems"
|
||||
v-loading="loadingFeasibility"
|
||||
class="ctms-table"
|
||||
:row-class-name="feasibilityRowClass"
|
||||
@row-click="onFeasibilityRowClick"
|
||||
row-class-name="clickable-row"
|
||||
>
|
||||
<el-table-column :label="TEXT.common.fields.site" width="180">
|
||||
<template #default="scope">
|
||||
@@ -55,11 +54,18 @@
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button link type="danger" @click.stop="removeFeasibility(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
:disabled="isInactiveSite(scope.row.site_id)"
|
||||
@click.stop="removeFeasibility(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="!loadingFeasibility && feasibilityItems.length === 0" :description="TEXT.modules.startupFeasibilityEthics.emptyFeasibility" />
|
||||
<StateEmpty v-if="!loadingFeasibility && sortedFeasibilityItems.length === 0" :description="TEXT.modules.startupFeasibilityEthics.emptyFeasibility" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="TEXT.modules.startupFeasibilityEthics.ethicsTab" name="ethics">
|
||||
@@ -74,11 +80,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<el-table
|
||||
:data="ethicsItems"
|
||||
:data="sortedEthicsItems"
|
||||
v-loading="loadingEthics"
|
||||
class="ctms-table"
|
||||
:row-class-name="ethicsRowClass"
|
||||
@row-click="onEthicsRowClick"
|
||||
row-class-name="clickable-row"
|
||||
>
|
||||
<el-table-column :label="TEXT.common.fields.site" width="180">
|
||||
<template #default="scope">
|
||||
@@ -111,11 +117,18 @@
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button link type="danger" @click.stop="removeEthics(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
:disabled="isInactiveSite(scope.row.site_id)"
|
||||
@click.stop="removeEthics(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="!loadingEthics && ethicsItems.length === 0" :description="TEXT.modules.startupFeasibilityEthics.emptyEthics" />
|
||||
<StateEmpty v-if="!loadingEthics && sortedEthicsItems.length === 0" :description="TEXT.modules.startupFeasibilityEthics.emptyEthics" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
@@ -148,7 +161,15 @@ const loadingEthics = ref(false);
|
||||
const sites = ref<Site[]>([]);
|
||||
|
||||
const siteMap = computed(() => new Map(sites.value.map((site) => [site.id, site.name])));
|
||||
const siteActiveMap = computed(() => new Map(sites.value.map((site) => [site.id, !!site.is_active])));
|
||||
const displaySite = (siteId?: string) => siteMap.value.get(siteId || "") || TEXT.common.fallback;
|
||||
const isInactiveSite = (siteId?: string) => siteId && siteActiveMap.value.get(siteId) === false;
|
||||
const sortedFeasibilityItems = computed(() =>
|
||||
[...feasibilityItems.value].sort((a, b) => Number(isInactiveSite(a?.site_id)) - Number(isInactiveSite(b?.site_id)))
|
||||
);
|
||||
const sortedEthicsItems = computed(() =>
|
||||
[...ethicsItems.value].sort((a, b) => Number(isInactiveSite(a?.site_id)) - Number(isInactiveSite(b?.site_id)))
|
||||
);
|
||||
|
||||
const statusLabelMap: Record<StageStatus, string> = {
|
||||
COMPLETED: "已完成",
|
||||
@@ -249,6 +270,10 @@ const goNewEthics = () => router.push("/startup/ethics/new");
|
||||
const goEthicsDetail = (id: string) => router.push(`/startup/ethics/${id}`);
|
||||
const onFeasibilityRowClick = (row: any) => goFeasibilityDetail(row.id);
|
||||
const onEthicsRowClick = (row: any) => goEthicsDetail(row.id);
|
||||
const feasibilityRowClass = ({ row }: { row: any }) =>
|
||||
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_id) ? " row-inactive" : ""}`.trim();
|
||||
const ethicsRowClass = ({ row }: { row: any }) =>
|
||||
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_id) ? " row-inactive" : ""}`.trim();
|
||||
|
||||
const removeFeasibility = async (row: any) => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
@@ -291,3 +316,14 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.row-inactive td {
|
||||
color: var(--ctms-text-secondary);
|
||||
background-color: #fff7d6 !important;
|
||||
}
|
||||
|
||||
.row-inactive .el-tag {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,12 +3,18 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ TEXT.modules.startupMeetingAuth.title }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.startupMeetingAuth.subtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card>
|
||||
<el-table :data="kickoffRows" v-loading="loading" style="width: 100%" class="ctms-table" @row-click="onKickoffRowClick">
|
||||
<el-table
|
||||
:data="kickoffRows"
|
||||
v-loading="loading"
|
||||
style="width: 100%"
|
||||
class="ctms-table"
|
||||
:row-class-name="kickoffRowClass"
|
||||
@row-click="onKickoffRowClick"
|
||||
>
|
||||
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="180" />
|
||||
<el-table-column prop="kickoff_date" :label="TEXT.common.fields.kickoffDate" width="160">
|
||||
<template #default="scope">{{ displayDate(scope.row.kickoff_date) }}</template>
|
||||
@@ -61,6 +67,14 @@ const memberNameMap = computed(() => {
|
||||
return map;
|
||||
});
|
||||
|
||||
const siteActiveMap = computed(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
sites.value.forEach((site) => {
|
||||
if (site?.id) map[site.id] = !!site.is_active;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
const contactLabel = (row: any) => {
|
||||
if (!row?.contact) return TEXT.common.fallback;
|
||||
return String(row.contact)
|
||||
@@ -76,17 +90,20 @@ const kickoffRows = computed(() => {
|
||||
if (item.site_id) acc[item.site_id] = item;
|
||||
return acc;
|
||||
}, {});
|
||||
return sites.value.map((site) => {
|
||||
const meeting = kickoffMap[site.id];
|
||||
return {
|
||||
site_id: site.id,
|
||||
site_name: site.name || TEXT.common.fallback,
|
||||
contact: site.contact,
|
||||
kickoff_date: meeting?.kickoff_date || null,
|
||||
meeting_id: meeting?.id,
|
||||
status: meeting?.kickoff_date ? "COMPLETED" : "PENDING",
|
||||
};
|
||||
});
|
||||
return sites.value
|
||||
.map((site) => {
|
||||
const meeting = kickoffMap[site.id];
|
||||
return {
|
||||
site_id: site.id,
|
||||
site_name: site.name || TEXT.common.fallback,
|
||||
contact: site.contact,
|
||||
kickoff_date: meeting?.kickoff_date || null,
|
||||
meeting_id: meeting?.id,
|
||||
status: meeting?.kickoff_date ? "COMPLETED" : "PENDING",
|
||||
is_active: !!site.is_active,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => Number(a.is_active === false) - Number(b.is_active === false));
|
||||
});
|
||||
|
||||
const loadKickoffs = async () => {
|
||||
@@ -115,6 +132,10 @@ const goKickoffDetail = (id: string) => router.push(`/startup/kickoff/${id}`);
|
||||
const onKickoffRowClick = async (row: any) => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId || !row?.site_id) return;
|
||||
if (row.is_active === false && !row.meeting_id) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (row.meeting_id) {
|
||||
goKickoffDetail(row.meeting_id);
|
||||
return;
|
||||
@@ -126,6 +147,8 @@ const onKickoffRowClick = async (row: any) => {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.createFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const kickoffRowClass = ({ row }: { row: any }) => (row?.is_active === false ? "row-inactive" : "");
|
||||
onMounted(() => {
|
||||
loadKickoffs();
|
||||
});
|
||||
@@ -157,3 +180,14 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.row-inactive td {
|
||||
color: var(--ctms-text-secondary);
|
||||
background-color: #fff7d6 !important;
|
||||
}
|
||||
|
||||
.row-inactive .el-tag {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,33 +3,19 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ TEXT.modules.subjectManagement.title }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.subjectManagement.subtitle }}</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="goNew">{{ TEXT.common.actions.add }}{{ TEXT.modules.subjectManagement.subjectLabel }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-card class="filter-card">
|
||||
<el-form :inline="true" :model="filters">
|
||||
<el-form-item :label="TEXT.common.fields.subjectNo">
|
||||
<el-input v-model="filters.subject_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.subjectNo" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.status">
|
||||
<el-select v-model="filters.status" :placeholder="TEXT.common.placeholders.select" clearable>
|
||||
<el-option :label="TEXT.enums.subjectStatus.SCREENING" value="SCREENING" />
|
||||
<el-option :label="TEXT.enums.subjectStatus.ENROLLED" value="ENROLLED" />
|
||||
<el-option :label="TEXT.enums.subjectStatus.COMPLETED" value="COMPLETED" />
|
||||
<el-option :label="TEXT.enums.subjectStatus.DROPPED" value="DROPPED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">{{ TEXT.common.actions.search }}</el-button>
|
||||
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card>
|
||||
<el-table :data="items" v-loading="loading" style="width: 100%" class="ctms-table" @row-click="onRowClick">
|
||||
<el-table
|
||||
:data="sortedItems"
|
||||
v-loading="loading"
|
||||
style="width: 100%"
|
||||
class="ctms-table"
|
||||
:row-class-name="subjectRowClass"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<el-table-column prop="subject_no" :label="TEXT.common.fields.subjectNo" min-width="140" />
|
||||
<el-table-column :label="TEXT.common.fields.site" min-width="160">
|
||||
<template #default="scope">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</template>
|
||||
@@ -42,17 +28,25 @@
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
||||
<template #default="scope">
|
||||
<el-button link type="danger" size="small" @click.stop="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="isInactiveSite(scope.row.site_id)"
|
||||
@click.stop="remove(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="!loading && items.length === 0" :description="TEXT.modules.subjectManagement.empty" />
|
||||
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.subjectManagement.empty" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
@@ -67,11 +61,7 @@ const study = useStudyStore();
|
||||
const loading = ref(false);
|
||||
const items = ref<any[]>([]);
|
||||
const siteMap = ref<Record<string, string>>({});
|
||||
const filters = reactive({
|
||||
subject_no: "",
|
||||
status: "",
|
||||
});
|
||||
|
||||
const siteActiveMap = ref<Record<string, boolean>>({});
|
||||
const loadSites = async () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
@@ -82,8 +72,13 @@ const loadSites = async () => {
|
||||
acc[site.id] = site.name;
|
||||
return acc;
|
||||
}, {});
|
||||
siteActiveMap.value = list.reduce((acc: Record<string, boolean>, site: any) => {
|
||||
acc[site.id] = !!site.is_active;
|
||||
return acc;
|
||||
}, {});
|
||||
} catch {
|
||||
siteMap.value = {};
|
||||
siteActiveMap.value = {};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -92,10 +87,7 @@ const load = async () => {
|
||||
if (!studyId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await fetchSubjects(studyId, {
|
||||
subject_no: filters.subject_no || undefined,
|
||||
status_filter: filters.status || undefined,
|
||||
});
|
||||
const { data } = await fetchSubjects(studyId);
|
||||
items.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
@@ -104,12 +96,6 @@ const load = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.subject_no = "";
|
||||
filters.status = "";
|
||||
load();
|
||||
};
|
||||
|
||||
const goNew = () => router.push("/subjects/new");
|
||||
const goDetail = (id: string) => router.push(`/subjects/${id}`);
|
||||
const onRowClick = (row: any) => {
|
||||
@@ -119,6 +105,10 @@ const onRowClick = (row: any) => {
|
||||
const remove = async (row: any) => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
if (isInactiveSite(row?.site_id)) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
|
||||
if (!ok) return;
|
||||
try {
|
||||
@@ -130,6 +120,13 @@ const remove = async (row: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
|
||||
const sortedItems = computed(() =>
|
||||
[...items.value].sort((a, b) => Number(isInactiveSite(a?.site_id)) - Number(isInactiveSite(b?.site_id)))
|
||||
);
|
||||
const subjectRowClass = ({ row }: { row: any }) =>
|
||||
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_id) ? " row-inactive" : ""}`.trim();
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSites();
|
||||
load();
|
||||
@@ -161,7 +158,15 @@ onMounted(async () => {
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.filter-card :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.row-inactive td {
|
||||
color: var(--ctms-text-secondary);
|
||||
background-color: #fff7d6 !important;
|
||||
}
|
||||
|
||||
.row-inactive .el-tag {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="center-row">
|
||||
<div class="center-row" :class="{ 'row-inactive': isInactive }">
|
||||
<div class="center-meta">
|
||||
<div class="center-name">{{ centerName }}</div>
|
||||
<div class="center-enrollment">
|
||||
@@ -46,6 +46,7 @@ const stages = computed(() =>
|
||||
completedAt: props.center[stage.completedKey],
|
||||
}))
|
||||
);
|
||||
const isInactive = computed(() => props.center.is_active === false);
|
||||
|
||||
const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase()}`;
|
||||
</script>
|
||||
@@ -174,4 +175,17 @@ const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase(
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.row-inactive {
|
||||
background-color: #fff7d6;
|
||||
}
|
||||
|
||||
.row-inactive .center-name {
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.row-inactive .stage-connector,
|
||||
.row-inactive .center-timeline {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface CenterOverview {
|
||||
enrollment_completed_at?: string;
|
||||
inspection_completed_at?: string;
|
||||
closeout_completed_at?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface EnrollmentByMonth {
|
||||
@@ -67,14 +68,14 @@ export const STAGE_ORDER: Array<{
|
||||
label: string;
|
||||
completedKey: StageCompletionKey;
|
||||
}> = [
|
||||
{ key: "institution_initiation_status", label: "机构立项", completedKey: "institution_initiation_completed_at" },
|
||||
{ key: "ethics_status", label: "伦理审批", completedKey: "ethics_completed_at" },
|
||||
{ key: "contract_sign_status", label: "合同签署", completedKey: "contract_sign_completed_at" },
|
||||
{ key: "startup_status", label: "启动", completedKey: "startup_completed_at" },
|
||||
{ key: "enrollment_status", label: "入组", completedKey: "enrollment_completed_at" },
|
||||
{ key: "inspection_status", label: "末次稽查", completedKey: "inspection_completed_at" },
|
||||
{ key: "closeout_status", label: "关中心", completedKey: "closeout_completed_at" },
|
||||
];
|
||||
{ key: "institution_initiation_status", label: "机构立项", completedKey: "institution_initiation_completed_at" },
|
||||
{ key: "ethics_status", label: "伦理审批", completedKey: "ethics_completed_at" },
|
||||
{ key: "contract_sign_status", label: "合同签署", completedKey: "contract_sign_completed_at" },
|
||||
{ key: "startup_status", label: "启动", completedKey: "startup_completed_at" },
|
||||
{ key: "enrollment_status", label: "入组", completedKey: "enrollment_completed_at" },
|
||||
{ key: "inspection_status", label: "末次稽查", completedKey: "inspection_completed_at" },
|
||||
{ key: "closeout_status", label: "关中心", completedKey: "closeout_completed_at" },
|
||||
];
|
||||
|
||||
const STATUS_ALIAS: Record<string, StageStatus> = {
|
||||
NOT_STARTED: "NOT_STARTED",
|
||||
@@ -130,6 +131,7 @@ export const adaptProjectOverview = (raw: unknown): ProjectOverviewViewModel =>
|
||||
enrollment_completed_at: toString((center as any).enrollment_completed_at),
|
||||
inspection_completed_at: toString((center as any).inspection_completed_at),
|
||||
closeout_completed_at: toString((center as any).closeout_completed_at),
|
||||
is_active: true, // Will be updated by view
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<p class="page-subtitle">{{ TEXT.modules.knowledgeNotes.detailSubtitle }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button type="primary" :disabled="isReadOnly" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -25,16 +25,18 @@
|
||||
:study-id="studyId"
|
||||
entity-type="knowledge_note"
|
||||
:entity-id="noteId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { getKnowledgeNote } from "../../api/knowledgeNotes";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
|
||||
@@ -44,6 +46,7 @@ const study = useStudyStore();
|
||||
const loading = ref(false);
|
||||
const noteId = route.params.noteId as string;
|
||||
const studyId = study.currentStudy?.id || "";
|
||||
const sites = ref<any[]>([]);
|
||||
|
||||
const detail = reactive<any>({
|
||||
site_name: "",
|
||||
@@ -52,6 +55,25 @@ const detail = reactive<any>({
|
||||
content: "",
|
||||
});
|
||||
|
||||
const siteActiveMap = computed(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
sites.value.forEach((site) => {
|
||||
if (site?.name) map[site.name] = !!site.is_active;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
const isReadOnly = computed(() => !!detail.site_name && siteActiveMap.value[detail.site_name] === false);
|
||||
|
||||
const loadSites = async () => {
|
||||
if (!studyId) return;
|
||||
try {
|
||||
const { data } = await fetchSites(studyId, { limit: 500 });
|
||||
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch {
|
||||
sites.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!studyId || !noteId) return;
|
||||
loading.value = true;
|
||||
@@ -65,10 +87,19 @@ const load = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const goEdit = () => router.push(`/knowledge/notes/${noteId}/edit`);
|
||||
const goEdit = () => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
router.push(`/knowledge/notes/${noteId}/edit`);
|
||||
};
|
||||
const goBack = () => router.push("/knowledge/notes");
|
||||
|
||||
onMounted(load);
|
||||
onMounted(async () => {
|
||||
await loadSites();
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -11,19 +11,19 @@
|
||||
<el-card>
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item :label="TEXT.common.fields.site" required>
|
||||
<el-input v-model="form.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
|
||||
<el-input v-model="form.site_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.title" required>
|
||||
<el-input v-model="form.title" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.title" />
|
||||
<el-input v-model="form.title" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.title" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.level">
|
||||
<el-input v-model="form.level" :placeholder="TEXT.common.placeholders.input" />
|
||||
<el-input v-model="form.level" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.content" required>
|
||||
<el-input v-model="form.content" type="textarea" :rows="4" :placeholder="TEXT.common.placeholders.input" />
|
||||
<el-input v-model="form.content" :disabled="isReadOnly" type="textarea" :rows="4" :placeholder="TEXT.common.placeholders.input" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
||||
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -34,6 +34,7 @@
|
||||
:study-id="studyId"
|
||||
entity-type="knowledge_note"
|
||||
:entity-id="noteId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
<el-card v-else class="hint-card">
|
||||
<StateEmpty :description="TEXT.modules.knowledgeNotes.uploadHint" />
|
||||
@@ -47,6 +48,7 @@ import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { createKnowledgeNote, getKnowledgeNote, updateKnowledgeNote } from "../../api/knowledgeNotes";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||
import StateEmpty from "../../components/StateEmpty.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
@@ -55,6 +57,7 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
const study = useStudyStore();
|
||||
const saving = ref(false);
|
||||
const sites = ref<any[]>([]);
|
||||
|
||||
const noteId = computed(() => route.params.noteId as string | undefined);
|
||||
const isEdit = computed(() => !!noteId.value);
|
||||
@@ -67,6 +70,25 @@ const form = reactive({
|
||||
content: "",
|
||||
});
|
||||
|
||||
const siteActiveMap = computed(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
sites.value.forEach((site) => {
|
||||
if (site?.name) map[site.name] = !!site.is_active;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
const isReadOnly = computed(() => isEdit.value && !!form.site_name && siteActiveMap.value[form.site_name] === false);
|
||||
|
||||
const loadSites = async () => {
|
||||
if (!studyId.value) return;
|
||||
try {
|
||||
const { data } = await fetchSites(studyId.value, { limit: 500 });
|
||||
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch {
|
||||
sites.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!isEdit.value || !studyId.value || !noteId.value) return;
|
||||
try {
|
||||
@@ -84,6 +106,18 @@ const load = async () => {
|
||||
|
||||
const submit = async () => {
|
||||
if (!studyId.value) return;
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (!form.site_name) {
|
||||
ElMessage.warning(TEXT.common.messages.required);
|
||||
return;
|
||||
}
|
||||
if (siteActiveMap.value[form.site_name] === false) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
@@ -110,7 +144,10 @@ const submit = async () => {
|
||||
|
||||
const goBack = () => router.push("/knowledge/notes");
|
||||
|
||||
onMounted(load);
|
||||
onMounted(async () => {
|
||||
await loadSites();
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
<p class="ctms-page-subtitle">{{ TEXT.modules.startupFeasibilityEthics.ethicsDetailSubtitle }}</p>
|
||||
</div>
|
||||
<div class="ctms-page-actions">
|
||||
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button type="primary" :disabled="isInactiveSite(detail.site_id)" @click="goEdit">
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button @click="goBack">
|
||||
<el-icon class="el-icon--left"><ArrowLeft /></el-icon>
|
||||
{{ TEXT.common.actions.back }}
|
||||
@@ -33,6 +35,7 @@
|
||||
:study-id="studyId"
|
||||
entity-type="startup_ethics"
|
||||
:entity-id="recordId"
|
||||
:readonly="isInactiveSite(detail.site_id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -61,7 +64,9 @@ const studyId = study.currentStudy?.id || "";
|
||||
const sites = ref<Site[]>([]);
|
||||
|
||||
const siteMap = computed(() => new Map(sites.value.map((site) => [site.id, site.name])));
|
||||
const siteActiveMap = computed(() => new Map(sites.value.map((site) => [site.id, !!site.is_active])));
|
||||
const displaySite = (siteId?: string) => siteMap.value.get(siteId || "") || TEXT.common.fallback;
|
||||
const isInactiveSite = (siteId?: string) => siteId && siteActiveMap.value.get(siteId) === false;
|
||||
|
||||
const detail = reactive<any>({
|
||||
site_id: "",
|
||||
@@ -137,7 +142,13 @@ const load = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const goEdit = () => router.push(`/startup/ethics/${recordId}/edit`);
|
||||
const goEdit = () => {
|
||||
if (isInactiveSite(detail.site_id)) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
router.push(`/startup/ethics/${recordId}/edit`);
|
||||
};
|
||||
const goBack = () => router.push("/startup/feasibility-ethics");
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -18,14 +18,14 @@
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.site">
|
||||
<el-select v-model="form.site_id" :placeholder="TEXT.common.placeholders.select" clearable style="width: 100%">
|
||||
<el-option v-for="site in siteOptions" :key="site.value" :label="site.label" :value="site.value" />
|
||||
<el-select v-model="form.site_id" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select" clearable style="width: 100%">
|
||||
<el-option v-for="site in siteOptions" :key="site.value" :label="site.label" :value="site.value" :disabled="site.disabled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.approvalNo">
|
||||
<el-input v-model="form.approval_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.approvalNo" />
|
||||
<el-input v-model="form.approval_no" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.approvalNo" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -33,28 +33,56 @@
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="6">
|
||||
<el-form-item :label="TEXT.common.fields.submitDate">
|
||||
<el-date-picker v-model="form.submit_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" />
|
||||
<el-date-picker
|
||||
v-model="form.submit_date"
|
||||
:disabled="isReadOnly"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item :label="TEXT.common.fields.acceptDate">
|
||||
<el-date-picker v-model="form.accept_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" />
|
||||
<el-date-picker
|
||||
v-model="form.accept_date"
|
||||
:disabled="isReadOnly"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item :label="TEXT.common.fields.meetingDate">
|
||||
<el-date-picker v-model="form.meeting_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" />
|
||||
<el-date-picker
|
||||
v-model="form.meeting_date"
|
||||
:disabled="isReadOnly"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item :label="TEXT.common.fields.approvedDate">
|
||||
<el-date-picker v-model="form.approved_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" />
|
||||
<el-date-picker
|
||||
v-model="form.approved_date"
|
||||
:disabled="isReadOnly"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item class="form-actions">
|
||||
<el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
||||
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -68,6 +96,7 @@
|
||||
:study-id="studyId"
|
||||
entity-type="startup_ethics"
|
||||
:entity-id="recordId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
</div>
|
||||
<el-card v-else class="hint-card">
|
||||
@@ -100,8 +129,16 @@ const studyId = computed(() => study.currentStudy?.id || "");
|
||||
const sites = ref<Site[]>([]);
|
||||
|
||||
const siteOptions = computed(() =>
|
||||
sites.value.map((site) => ({ value: site.id, label: site.name || TEXT.common.fallback }))
|
||||
sites.value.map((site) => ({ value: site.id, label: site.name || TEXT.common.fallback, disabled: !site.is_active }))
|
||||
);
|
||||
const siteActiveMap = computed(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
sites.value.forEach((site) => {
|
||||
if (site?.id) map[site.id] = !!site.is_active;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
const isReadOnly = computed(() => isEdit.value && !!form.site_id && siteActiveMap.value[form.site_id] === false);
|
||||
|
||||
const form = reactive({
|
||||
site_id: "",
|
||||
@@ -141,6 +178,14 @@ const load = async () => {
|
||||
|
||||
const submit = async () => {
|
||||
if (!studyId.value) return;
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (form.site_id && siteActiveMap.value[form.site_id] === false) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
<p class="ctms-page-subtitle">{{ TEXT.modules.startupFeasibilityEthics.feasibilityDetailSubtitle }}</p>
|
||||
</div>
|
||||
<div class="ctms-page-actions">
|
||||
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button type="primary" :disabled="isInactiveSite(detail.site_id)" @click="goEdit">
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button @click="goBack">
|
||||
<el-icon class="el-icon--left"><ArrowLeft /></el-icon>
|
||||
{{ TEXT.common.actions.back }}
|
||||
@@ -32,6 +34,7 @@
|
||||
:study-id="studyId"
|
||||
entity-type="startup_feasibility"
|
||||
:entity-id="recordId"
|
||||
:readonly="isInactiveSite(detail.site_id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -60,7 +63,9 @@ const studyId = study.currentStudy?.id || "";
|
||||
const sites = ref<Site[]>([]);
|
||||
|
||||
const siteMap = computed(() => new Map(sites.value.map((site) => [site.id, site.name])));
|
||||
const siteActiveMap = computed(() => new Map(sites.value.map((site) => [site.id, !!site.is_active])));
|
||||
const displaySite = (siteId?: string) => siteMap.value.get(siteId || "") || TEXT.common.fallback;
|
||||
const isInactiveSite = (siteId?: string) => siteId && siteActiveMap.value.get(siteId) === false;
|
||||
|
||||
const detail = reactive<any>({
|
||||
site_id: "",
|
||||
@@ -135,7 +140,13 @@ const load = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const goEdit = () => router.push(`/startup/feasibility/${recordId}/edit`);
|
||||
const goEdit = () => {
|
||||
if (isInactiveSite(detail.site_id)) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
router.push(`/startup/feasibility/${recordId}/edit`);
|
||||
};
|
||||
const goBack = () => router.push("/startup/feasibility-ethics");
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -18,14 +18,14 @@
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.site">
|
||||
<el-select v-model="form.site_id" :placeholder="TEXT.common.placeholders.select" clearable style="width: 100%">
|
||||
<el-option v-for="site in siteOptions" :key="site.value" :label="site.label" :value="site.value" />
|
||||
<el-select v-model="form.site_id" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select" clearable style="width: 100%">
|
||||
<el-option v-for="site in siteOptions" :key="site.value" :label="site.label" :value="site.value" :disabled="site.disabled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.projectNo">
|
||||
<el-input v-model="form.project_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectNo" />
|
||||
<el-input v-model="form.project_no" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectNo" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -33,23 +33,44 @@
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="8">
|
||||
<el-form-item :label="TEXT.common.fields.submitDate">
|
||||
<el-date-picker v-model="form.submit_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" />
|
||||
<el-date-picker
|
||||
v-model="form.submit_date"
|
||||
:disabled="isReadOnly"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item :label="TEXT.common.fields.acceptDate">
|
||||
<el-date-picker v-model="form.accept_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" />
|
||||
<el-date-picker
|
||||
v-model="form.accept_date"
|
||||
:disabled="isReadOnly"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item :label="TEXT.common.fields.approvedDate">
|
||||
<el-date-picker v-model="form.approved_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" />
|
||||
<el-date-picker
|
||||
v-model="form.approved_date"
|
||||
:disabled="isReadOnly"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item class="form-actions">
|
||||
<el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
||||
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -63,6 +84,7 @@
|
||||
:study-id="studyId"
|
||||
entity-type="startup_feasibility"
|
||||
:entity-id="recordId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
</div>
|
||||
<el-card v-else class="hint-card">
|
||||
@@ -95,8 +117,16 @@ const studyId = computed(() => study.currentStudy?.id || "");
|
||||
const sites = ref<Site[]>([]);
|
||||
|
||||
const siteOptions = computed(() =>
|
||||
sites.value.map((site) => ({ value: site.id, label: site.name || TEXT.common.fallback }))
|
||||
sites.value.map((site) => ({ value: site.id, label: site.name || TEXT.common.fallback, disabled: !site.is_active }))
|
||||
);
|
||||
const siteActiveMap = computed(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
sites.value.forEach((site) => {
|
||||
if (site?.id) map[site.id] = !!site.is_active;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
const isReadOnly = computed(() => isEdit.value && !!form.site_id && siteActiveMap.value[form.site_id] === false);
|
||||
|
||||
const form = reactive({
|
||||
site_id: "",
|
||||
@@ -134,6 +164,14 @@ const load = async () => {
|
||||
|
||||
const submit = async () => {
|
||||
if (!studyId.value) return;
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (form.site_id && siteActiveMap.value[form.site_id] === false) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
|
||||
@@ -6,7 +6,14 @@
|
||||
<p class="page-subtitle">{{ TEXT.modules.startupMeetingAuth.kickoffDetailSubtitle }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button v-if="!editMode" type="primary" :disabled="loading" @click="startEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button
|
||||
v-if="!editMode"
|
||||
type="primary"
|
||||
:disabled="loading || isReadOnly"
|
||||
@click="startEdit"
|
||||
>
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button v-else type="primary" :loading="saving" @click="saveEdit">{{ TEXT.common.actions.save }}</el-button>
|
||||
<el-button v-if="editMode" :disabled="saving" @click="cancelEdit">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
@@ -59,9 +66,16 @@
|
||||
<el-checkbox
|
||||
v-if="isTrainingEditing(scope.row)"
|
||||
v-model="trainingRowDraft.trained"
|
||||
:disabled="isReadOnly"
|
||||
@click.stop
|
||||
/>
|
||||
<el-checkbox
|
||||
v-else
|
||||
v-model="scope.row.trained"
|
||||
:disabled="isReadOnly"
|
||||
@change="toggleTraining(scope.row)"
|
||||
@click.stop
|
||||
/>
|
||||
<el-checkbox v-else v-model="scope.row.trained" @change="toggleTraining(scope.row)" @click.stop />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.authorized" width="120">
|
||||
@@ -69,9 +83,16 @@
|
||||
<el-checkbox
|
||||
v-if="isTrainingEditing(scope.row)"
|
||||
v-model="trainingRowDraft.authorized"
|
||||
:disabled="isReadOnly"
|
||||
@click.stop
|
||||
/>
|
||||
<el-checkbox
|
||||
v-else
|
||||
v-model="scope.row.authorized"
|
||||
:disabled="isReadOnly"
|
||||
@change="toggleTraining(scope.row)"
|
||||
@click.stop
|
||||
/>
|
||||
<el-checkbox v-else v-model="scope.row.authorized" @change="toggleTraining(scope.row)" @click.stop />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
||||
@@ -85,17 +106,19 @@
|
||||
</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button link type="primary" size="small" @click.stop="startTrainingEdit(scope.row)">
|
||||
<el-button link type="primary" size="small" :disabled="isReadOnly" @click.stop="startTrainingEdit(scope.row)">
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button link type="danger" size="small" @click.stop="removeTraining(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
||||
<el-button link type="danger" size="small" :disabled="isReadOnly" @click.stop="removeTraining(scope.row)">
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div
|
||||
class="training-add-row"
|
||||
:class="{ disabled: newTrainingActive || savingTraining }"
|
||||
:class="{ disabled: newTrainingActive || savingTraining || isReadOnly }"
|
||||
@click="startTrainingAdd"
|
||||
>
|
||||
<span class="training-add-icon">+</span>
|
||||
@@ -149,7 +172,7 @@ const saving = ref(false);
|
||||
const editMode = ref(false);
|
||||
const meetingId = route.params.meetingId as string;
|
||||
const studyId = study.currentStudy?.id || "";
|
||||
const siteInfo = reactive<{ name: string; contact?: string | null }>({ name: "" });
|
||||
const siteInfo = reactive<{ name: string; contact?: string | null; is_active?: boolean }>({ name: "" });
|
||||
const users = ref<any[]>([]);
|
||||
const members = ref<any[]>([]);
|
||||
const kickoffAttachmentGroups = [
|
||||
@@ -217,6 +240,7 @@ const statusLabel = computed(() =>
|
||||
detail.kickoff_date ? TEXT.modules.startupMeetingAuth.statusCompleted : TEXT.modules.startupMeetingAuth.statusPending
|
||||
);
|
||||
const statusTagType = computed(() => (detail.kickoff_date ? "success" : "info"));
|
||||
const isReadOnly = computed(() => siteInfo.is_active === false);
|
||||
|
||||
const load = async () => {
|
||||
if (!studyId || !meetingId) return;
|
||||
@@ -256,6 +280,10 @@ const loadTraining = async () => {
|
||||
};
|
||||
|
||||
const toggleTraining = async (row: any) => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (!studyId) return;
|
||||
try {
|
||||
await updateTrainingAuthorization(studyId, row.id, {
|
||||
@@ -282,14 +310,14 @@ const isTrainingEditing = (row: any) => {
|
||||
};
|
||||
|
||||
const startTrainingEdit = (row: any) => {
|
||||
if (savingTraining.value) return;
|
||||
if (savingTraining.value || isReadOnly.value) return;
|
||||
newTrainingActive.value = false;
|
||||
trainingEditingRowId.value = row?.id || null;
|
||||
resetTrainingDraft(row);
|
||||
};
|
||||
|
||||
const startTrainingAdd = () => {
|
||||
if (newTrainingActive.value || savingTraining.value) return;
|
||||
if (newTrainingActive.value || savingTraining.value || isReadOnly.value) return;
|
||||
trainingEditingRowId.value = null;
|
||||
newTrainingActive.value = true;
|
||||
resetTrainingDraft();
|
||||
@@ -302,6 +330,10 @@ const cancelTrainingEdit = () => {
|
||||
};
|
||||
|
||||
const saveTraining = async (row: any) => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (!studyId) return;
|
||||
if (!trainingRowDraft.name.trim()) {
|
||||
ElMessage.error(requiredMessage(TEXT.common.fields.name));
|
||||
@@ -332,6 +364,10 @@ const saveTraining = async (row: any) => {
|
||||
};
|
||||
|
||||
const removeTraining = async (row: any) => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (!studyId) return;
|
||||
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
|
||||
if (!ok) return;
|
||||
@@ -345,6 +381,10 @@ const removeTraining = async (row: any) => {
|
||||
};
|
||||
|
||||
const startEdit = () => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
editMode.value = true;
|
||||
draft.kickoff_date = detail.kickoff_date || "";
|
||||
};
|
||||
@@ -355,6 +395,10 @@ const cancelEdit = () => {
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (!studyId || !meetingId) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
|
||||
@@ -17,18 +17,19 @@
|
||||
<el-input :model-value="ownerLabel" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.kickoffDate">
|
||||
<el-date-picker v-model="form.kickoff_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
||||
<el-date-picker v-model="form.kickoff_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.attendees">
|
||||
<el-input
|
||||
v-model="form.attendees"
|
||||
:disabled="isReadOnly"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:placeholder="TEXT.modules.startupMeetingAuth.attendeesPlaceholder"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
||||
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -42,6 +43,7 @@
|
||||
:entity-type="group.entityType"
|
||||
:entity-id="meetingId"
|
||||
:title="group.title"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
</div>
|
||||
<el-card v-else class="hint-card">
|
||||
@@ -71,7 +73,7 @@ const saving = ref(false);
|
||||
const meetingId = computed(() => route.params.meetingId as string | undefined);
|
||||
const isEdit = computed(() => !!meetingId.value);
|
||||
const studyId = computed(() => study.currentStudy?.id || "");
|
||||
const siteInfo = reactive<{ name: string; contact?: string | null }>({ name: "" });
|
||||
const siteInfo = reactive<{ name: string; contact?: string | null; is_active?: boolean }>({ name: "", is_active: true });
|
||||
const siteId = ref("");
|
||||
const users = ref<any[]>([]);
|
||||
const members = ref<any[]>([]);
|
||||
@@ -96,6 +98,7 @@ const ownerLabel = computed(() => {
|
||||
.map((c) => memberNameMap.value[c] || c)
|
||||
.join("、") || TEXT.common.fallback;
|
||||
});
|
||||
const isReadOnly = computed(() => siteInfo.is_active === false);
|
||||
const kickoffAttachmentGroups = [
|
||||
{ entityType: "startup_kickoff_minutes", title: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel },
|
||||
{ entityType: "startup_kickoff_signin", title: TEXT.modules.startupMeetingAuth.kickoffSignInLabel },
|
||||
@@ -132,7 +135,7 @@ const load = async () => {
|
||||
const { data: sitesData } = await fetchSites(studyId.value, { limit: 500 });
|
||||
const list = Array.isArray(sitesData) ? sitesData : sitesData.items || [];
|
||||
const matched = list.find((site: any) => site.id === siteId.value);
|
||||
Object.assign(siteInfo, matched || { name: "", contact: "" });
|
||||
Object.assign(siteInfo, matched || { name: "", contact: "", is_active: true });
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
@@ -141,6 +144,10 @@ const load = async () => {
|
||||
|
||||
const submit = async () => {
|
||||
if (!studyId.value) return;
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<p class="page-subtitle">{{ TEXT.modules.startupMeetingAuth.trainingDetailSubtitle }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button type="primary" :disabled="isReadOnly" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -29,16 +29,18 @@
|
||||
:study-id="studyId"
|
||||
entity-type="training_authorization"
|
||||
:entity-id="recordId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { getTrainingAuthorization } from "../../api/startup";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import { displayDate, displayEnum } from "../../utils/display";
|
||||
import AttachmentList from "../../components/attachments/AttachmentList.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
@@ -49,6 +51,7 @@ const study = useStudyStore();
|
||||
const loading = ref(false);
|
||||
const recordId = route.params.recordId as string;
|
||||
const studyId = study.currentStudy?.id || "";
|
||||
const sites = ref<any[]>([]);
|
||||
|
||||
const detail = reactive<any>({
|
||||
name: "",
|
||||
@@ -61,6 +64,25 @@ const detail = reactive<any>({
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const siteActiveMap = computed(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
sites.value.forEach((site) => {
|
||||
if (site?.name) map[site.name] = !!site.is_active;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
const isReadOnly = computed(() => !!detail.site_name && siteActiveMap.value[detail.site_name] === false);
|
||||
|
||||
const loadSites = async () => {
|
||||
if (!studyId) return;
|
||||
try {
|
||||
const { data } = await fetchSites(studyId, { limit: 500 });
|
||||
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch {
|
||||
sites.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!studyId || !recordId) return;
|
||||
loading.value = true;
|
||||
@@ -74,10 +96,19 @@ const load = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const goEdit = () => router.push(`/startup/training/${recordId}/edit`);
|
||||
const goEdit = () => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
router.push(`/startup/training/${recordId}/edit`);
|
||||
};
|
||||
const goBack = () => router.push("/startup/meeting-auth");
|
||||
|
||||
onMounted(load);
|
||||
onMounted(async () => {
|
||||
await loadSites();
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -11,31 +11,31 @@
|
||||
<el-card>
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item :label="TEXT.common.fields.name" required>
|
||||
<el-input v-model="form.name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
|
||||
<el-input v-model="form.name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.labels.role">
|
||||
<el-input v-model="form.role" :placeholder="TEXT.common.placeholders.input + TEXT.common.labels.role" />
|
||||
<el-input v-model="form.role" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.labels.role" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.site">
|
||||
<el-input v-model="form.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
|
||||
<el-input v-model="form.site_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.trained">
|
||||
<el-switch v-model="form.trained" />
|
||||
<el-switch v-model="form.trained" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.trainedDate">
|
||||
<el-date-picker v-model="form.trained_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
||||
<el-date-picker v-model="form.trained_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.authorized">
|
||||
<el-switch v-model="form.authorized" />
|
||||
<el-switch v-model="form.authorized" :disabled="isReadOnly" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.authorizedDate">
|
||||
<el-date-picker v-model="form.authorized_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
||||
<el-date-picker v-model="form.authorized_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
|
||||
<el-input v-model="form.remark" :disabled="isReadOnly" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
||||
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -46,6 +46,7 @@
|
||||
:study-id="studyId"
|
||||
entity-type="training_authorization"
|
||||
:entity-id="recordId"
|
||||
:readonly="isReadOnly"
|
||||
/>
|
||||
<el-card v-else class="hint-card">
|
||||
<StateEmpty :description="TEXT.modules.startupMeetingAuth.trainingUploadHint" />
|
||||
@@ -59,6 +60,7 @@ import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
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";
|
||||
@@ -67,6 +69,7 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
const study = useStudyStore();
|
||||
const saving = ref(false);
|
||||
const sites = ref<any[]>([]);
|
||||
|
||||
const recordId = computed(() => route.params.recordId as string | undefined);
|
||||
const isEdit = computed(() => !!recordId.value);
|
||||
@@ -83,6 +86,25 @@ const form = reactive({
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const siteActiveMap = computed(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
sites.value.forEach((site) => {
|
||||
if (site?.name) map[site.name] = !!site.is_active;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
const isReadOnly = computed(() => isEdit.value && !!form.site_name && siteActiveMap.value[form.site_name] === false);
|
||||
|
||||
const loadSites = async () => {
|
||||
if (!studyId.value) return;
|
||||
try {
|
||||
const { data } = await fetchSites(studyId.value, { limit: 500 });
|
||||
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch {
|
||||
sites.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!isEdit.value || !studyId.value || !recordId.value) return;
|
||||
try {
|
||||
@@ -104,6 +126,14 @@ const load = async () => {
|
||||
|
||||
const submit = async () => {
|
||||
if (!studyId.value) return;
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (form.site_name && siteActiveMap.value[form.site_name] === false) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
@@ -134,7 +164,10 @@ const submit = async () => {
|
||||
|
||||
const goBack = () => router.push("/startup/meeting-auth");
|
||||
|
||||
onMounted(load);
|
||||
onMounted(async () => {
|
||||
await loadSites();
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
<el-button @click="cancelSubjectEdit">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button type="primary" @click="startSubjectEdit">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button type="primary" :disabled="isReadOnly" @click="startSubjectEdit">
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
|
||||
</template>
|
||||
</div>
|
||||
@@ -72,7 +74,9 @@
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.history" name="history">
|
||||
<div class="tab-actions">
|
||||
<el-button type="primary" @click="openHistoryDialog()">{{ TEXT.common.actions.newHistory }}</el-button>
|
||||
<el-button type="primary" :disabled="isReadOnly" @click="openHistoryDialog()">
|
||||
{{ TEXT.common.actions.newHistory }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="historyItems" v-loading="loadingHistory" style="width: 100%">
|
||||
<el-table-column prop="record_date" :label="TEXT.common.fields.recordDate" width="140">
|
||||
@@ -81,8 +85,24 @@
|
||||
<el-table-column prop="content" :label="TEXT.common.fields.content" min-width="240" />
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="200">
|
||||
<template #default="scope">
|
||||
<el-button link type="primary" size="small" @click="openHistoryDialog(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button link type="danger" size="small" @click="removeHistory(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="isReadOnly"
|
||||
@click="openHistoryDialog(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="isReadOnly"
|
||||
@click="removeHistory(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -91,7 +111,9 @@
|
||||
|
||||
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.visits" name="visits">
|
||||
<div class="tab-actions">
|
||||
<el-button type="primary" @click="openVisitDialog()">{{ TEXT.common.actions.newVisit }}</el-button>
|
||||
<el-button type="primary" :disabled="isReadOnly" @click="openVisitDialog()">
|
||||
{{ TEXT.common.actions.newVisit }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="visitItems" v-loading="loadingVisits" style="width: 100%">
|
||||
<el-table-column prop="visit_code" :label="TEXT.common.fields.visitCode" width="120" />
|
||||
@@ -135,12 +157,32 @@
|
||||
<el-table-column :label="TEXT.common.labels.actions" min-width="160">
|
||||
<template #default="scope">
|
||||
<template v-if="visitEditingRowId === scope.row.id">
|
||||
<el-button link type="primary" size="small" @click="saveVisitRow(scope.row)">{{ TEXT.common.actions.save }}</el-button>
|
||||
<el-button link size="small" @click="cancelVisitRow">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button link type="primary" size="small" :disabled="isReadOnly" @click="saveVisitRow(scope.row)">
|
||||
{{ TEXT.common.actions.save }}
|
||||
</el-button>
|
||||
<el-button link size="small" :disabled="isReadOnly" @click="cancelVisitRow">
|
||||
{{ TEXT.common.actions.cancel }}
|
||||
</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button link type="primary" size="small" @click="startVisitRowEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button link type="danger" size="small" @click="removeVisit(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="isReadOnly"
|
||||
@click="startVisitRowEdit(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="isReadOnly"
|
||||
@click="removeVisit(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -150,7 +192,9 @@
|
||||
|
||||
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.ae" name="ae">
|
||||
<div class="tab-actions">
|
||||
<el-button type="primary" @click="openAeDialog()">{{ TEXT.common.actions.newAe }}</el-button>
|
||||
<el-button type="primary" :disabled="isReadOnly" @click="openAeDialog()">
|
||||
{{ TEXT.common.actions.newAe }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="aeItems" v-loading="loadingAes" style="width: 100%">
|
||||
<el-table-column prop="onset_date" :label="TEXT.common.fields.occurDate" width="140">
|
||||
@@ -165,8 +209,24 @@
|
||||
<el-table-column prop="description" :label="TEXT.common.fields.remark" min-width="200" />
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="200">
|
||||
<template #default="scope">
|
||||
<el-button link type="primary" size="small" @click="openAeDialog(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button link type="danger" size="small" @click="removeAe(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="isReadOnly"
|
||||
@click="openAeDialog(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="isReadOnly"
|
||||
@click="removeAe(scope.row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -186,7 +246,7 @@
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="historyDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button type="primary" @click="saveHistory">{{ TEXT.common.actions.save }}</el-button>
|
||||
<el-button type="primary" :disabled="isReadOnly" @click="saveHistory">{{ TEXT.common.actions.save }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -217,7 +277,7 @@
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visitDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button type="primary" @click="saveVisit">{{ TEXT.common.actions.save }}</el-button>
|
||||
<el-button type="primary" :disabled="isReadOnly" @click="saveVisit">{{ TEXT.common.actions.save }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -254,14 +314,14 @@
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="aeDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button type="primary" @click="saveAe">{{ TEXT.common.actions.save }}</el-button>
|
||||
<el-button type="primary" :disabled="isReadOnly" @click="saveAe">{{ TEXT.common.actions.save }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
@@ -303,6 +363,8 @@ const subjectForm = reactive({
|
||||
});
|
||||
|
||||
const siteMap = ref<Record<string, string>>({});
|
||||
const siteActiveMap = ref<Record<string, boolean>>({});
|
||||
const isReadOnly = computed(() => !!detail.site_id && siteActiveMap.value[detail.site_id] === false);
|
||||
const activeTab = ref("history");
|
||||
|
||||
const historyItems = ref<any[]>([]);
|
||||
@@ -355,8 +417,13 @@ const loadSites = async () => {
|
||||
acc[site.id] = site.name;
|
||||
return acc;
|
||||
}, {});
|
||||
siteActiveMap.value = list.reduce((acc: Record<string, boolean>, site: any) => {
|
||||
acc[site.id] = !!site.is_active;
|
||||
return acc;
|
||||
}, {});
|
||||
} catch {
|
||||
siteMap.value = {};
|
||||
siteActiveMap.value = {};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -374,6 +441,10 @@ const loadSubject = async () => {
|
||||
};
|
||||
|
||||
const startSubjectEdit = () => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
subjectForm.status = detail.status || "SCREENING";
|
||||
subjectForm.consent_date = detail.consent_date || "";
|
||||
subjectForm.enrollment_date = detail.enrollment_date || "";
|
||||
@@ -387,6 +458,10 @@ const cancelSubjectEdit = () => {
|
||||
};
|
||||
|
||||
const saveSubjectEdit = async () => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (!studyId || !subjectId) return;
|
||||
subjectSaving.value = true;
|
||||
try {
|
||||
@@ -422,6 +497,10 @@ const loadHistories = async () => {
|
||||
};
|
||||
|
||||
const openHistoryDialog = (row?: any) => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
historyEditingId.value = row?.id || null;
|
||||
historyForm.record_date = row?.record_date || "";
|
||||
historyForm.content = row?.content || "";
|
||||
@@ -429,6 +508,10 @@ const openHistoryDialog = (row?: any) => {
|
||||
};
|
||||
|
||||
const saveHistory = async () => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (!studyId || !subjectId) return;
|
||||
try {
|
||||
const payload = {
|
||||
@@ -449,6 +532,10 @@ const saveHistory = async () => {
|
||||
};
|
||||
|
||||
const removeHistory = async (row: any) => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (!studyId || !subjectId) return;
|
||||
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
|
||||
if (!ok) return;
|
||||
@@ -474,6 +561,10 @@ const loadVisits = async () => {
|
||||
};
|
||||
|
||||
const openVisitDialog = (row?: any) => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
visitEditingId.value = row?.id || null;
|
||||
if (row) {
|
||||
visitForm.actual_date = row.actual_date || "";
|
||||
@@ -490,6 +581,10 @@ const openVisitDialog = (row?: any) => {
|
||||
};
|
||||
|
||||
const startVisitRowEdit = (row: any) => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
visitEditingRowId.value = row?.id || null;
|
||||
visitRowDraft.actual_date = row?.actual_date || "";
|
||||
visitRowDraft.notes = row?.notes || "";
|
||||
@@ -500,6 +595,10 @@ const cancelVisitRow = () => {
|
||||
};
|
||||
|
||||
const saveVisitRow = async (row: any) => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (!studyId || !subjectId || !row?.id) return;
|
||||
try {
|
||||
const payload = {
|
||||
@@ -559,6 +658,10 @@ const getVisitStatusTagType = (status: string) => {
|
||||
};
|
||||
|
||||
const saveVisit = async () => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (!studyId || !subjectId) return;
|
||||
try {
|
||||
if (visitEditingId.value) {
|
||||
@@ -585,6 +688,10 @@ const saveVisit = async () => {
|
||||
};
|
||||
|
||||
const removeVisit = async (row: any) => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (!studyId || !subjectId) return;
|
||||
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
|
||||
if (!ok) return;
|
||||
@@ -610,6 +717,10 @@ const loadAes = async () => {
|
||||
};
|
||||
|
||||
const openAeDialog = (row?: any) => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
aeEditingId.value = row?.id || null;
|
||||
Object.assign(aeForm, {
|
||||
term: row?.term || "",
|
||||
@@ -624,6 +735,10 @@ const openAeDialog = (row?: any) => {
|
||||
};
|
||||
|
||||
const saveAe = async () => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (!studyId || !subjectId) return;
|
||||
try {
|
||||
const payload = {
|
||||
@@ -650,6 +765,10 @@ const saveAe = async () => {
|
||||
};
|
||||
|
||||
const removeAe = async (row: any) => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (!studyId) return;
|
||||
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
|
||||
if (!ok) return;
|
||||
|
||||
@@ -11,11 +11,15 @@
|
||||
<el-card>
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item :label="TEXT.common.fields.subjectNo" required>
|
||||
<el-input v-model="form.subject_no" :disabled="isEdit" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.subjectNo" />
|
||||
<el-input
|
||||
v-model="form.subject_no"
|
||||
:disabled="isEdit || isReadOnly"
|
||||
:placeholder="TEXT.common.placeholders.input + TEXT.common.fields.subjectNo"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.site" required>
|
||||
<el-select v-model="form.site_id" :disabled="isEdit" :placeholder="TEXT.common.placeholders.select">
|
||||
<el-option v-for="site in sites" :key="site.id" :label="site.name" :value="site.id" />
|
||||
<el-select v-model="form.site_id" :disabled="isEdit || isReadOnly" :placeholder="TEXT.common.placeholders.select">
|
||||
<el-option v-for="site in sites" :key="site.id" :label="site.name" :value="site.id" :disabled="!site.is_active" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.screeningDate">
|
||||
@@ -24,7 +28,7 @@
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
:disabled="isEdit"
|
||||
:disabled="isEdit || isReadOnly"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.consentDate">
|
||||
@@ -33,10 +37,11 @@
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
:disabled="isReadOnly"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="isEdit" :label="TEXT.common.fields.status">
|
||||
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select">
|
||||
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" :disabled="isReadOnly">
|
||||
<el-option :label="TEXT.enums.subjectStatus.SCREENING" value="SCREENING" />
|
||||
<el-option :label="TEXT.enums.subjectStatus.ENROLLED" value="ENROLLED" />
|
||||
<el-option :label="TEXT.enums.subjectStatus.COMPLETED" value="COMPLETED" />
|
||||
@@ -44,16 +49,36 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="isEdit" :label="TEXT.common.fields.enrollmentDate">
|
||||
<el-date-picker v-model="form.enrollment_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
||||
<el-date-picker
|
||||
v-model="form.enrollment_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
:disabled="isReadOnly"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="isEdit" :label="TEXT.common.fields.completionDate">
|
||||
<el-date-picker v-model="form.completion_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
||||
<el-date-picker
|
||||
v-model="form.completion_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
:disabled="isReadOnly"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="isEdit" :label="TEXT.common.fields.dropReason">
|
||||
<el-input v-model="form.drop_reason" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
|
||||
<el-input
|
||||
v-model="form.drop_reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="TEXT.common.placeholders.input"
|
||||
:disabled="isReadOnly"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button>
|
||||
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">
|
||||
{{ TEXT.common.actions.save }}
|
||||
</el-button>
|
||||
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -75,10 +100,18 @@ const router = useRouter();
|
||||
const study = useStudyStore();
|
||||
const saving = ref(false);
|
||||
const sites = ref<any[]>([]);
|
||||
const siteActiveMap = computed(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
sites.value.forEach((site) => {
|
||||
if (site?.id) map[site.id] = !!site.is_active;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
const subjectId = computed(() => route.params.subjectId as string | undefined);
|
||||
const isEdit = computed(() => !!subjectId.value);
|
||||
const studyId = computed(() => study.currentStudy?.id || "");
|
||||
const isReadOnly = computed(() => isEdit.value && !!form.site_id && siteActiveMap.value[form.site_id] === false);
|
||||
|
||||
const form = reactive({
|
||||
subject_no: "",
|
||||
@@ -121,6 +154,10 @@ const load = async () => {
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
return;
|
||||
}
|
||||
if (!studyId.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user