管理后台前后端逻辑、UI美化

This commit is contained in:
Cheng Zhou
2026-01-16 13:50:08 +08:00
parent 05c1f9579a
commit 7fdcfdaadd
82 changed files with 3210 additions and 549 deletions
+7 -2
View File
@@ -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}`);
+9 -2
View File
@@ -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`, {});
+2 -2
View File
@@ -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;
+5 -4
View File
@@ -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: "结果",
+1
View File
@@ -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;
+72 -17
View File
@@ -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>
+443 -40
View File
@@ -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>
-16
View File
@@ -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,
+6 -4
View File
@@ -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()]);
+162 -27
View File
@@ -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>
+70 -9
View File
@@ -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>
+100 -59
View File
@@ -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);
+40 -5
View File
@@ -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>
+26 -11
View File
@@ -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;
+18 -2
View File
@@ -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 () => {
+72 -13
View File
@@ -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()) {
+38 -4
View File
@@ -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 () => {
+28 -10
View File
@@ -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()) {
+38 -4
View File
@@ -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>
+35 -4
View File
@@ -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>
+45 -8
View File
@@ -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>
+35 -4
View File
@@ -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>
+45 -8
View File
@@ -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>
+46 -5
View File
@@ -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>
+67 -5
View File
@@ -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>
+66 -5
View File
@@ -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"
/>
+66 -6
View File
@@ -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"
/>
+18 -4
View File
@@ -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>
+47 -13
View File
@@ -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>
+47 -42
View File
@@ -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
};
});
+35 -4
View File
@@ -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>
+43 -6
View File
@@ -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>
+13 -2
View File
@@ -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(() => {
+54 -9
View File
@@ -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(() => {
+46 -8
View File
@@ -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 = {
+53 -9
View File
@@ -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 {
+11 -4
View File
@@ -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 = {
+35 -4
View File
@@ -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>
+43 -10
View File
@@ -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>
+135 -16
View File
@@ -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;
+46 -9
View File
@@ -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 {