Files
ctms/frontend/src/views/admin/Sites.vue
T
2026-02-24 16:53:22 +08:00

350 lines
10 KiB
Vue

<template>
<div class="page">
<el-card shadow="never" class="main-content-card unified-shell">
<div class="unified-action-bar actions-only-bar">
<div class="filter-spacer"></div>
<el-button type="primary" @click="openCreate">
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminSites.siteLabel }}
</el-button>
</div>
<div class="unified-section site-table-section">
<el-table :data="displaySites" v-loading="loading" stripe :row-class-name="siteRowClass">
<el-table-column prop="name" :label="TEXT.common.fields.siteName" width="220">
<template #default="scope">
<div class="site-name-cell">
<span>{{ scope.row.name }}</span>
<el-tag v-if="isLeadUnitSite(scope.row)" type="warning" effect="light" size="small">
{{ TEXT.modules.adminSites.leadUnitTag }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="city" :label="TEXT.common.fields.city" width="100" />
<el-table-column prop="pi_name" :label="TEXT.modules.adminSites.piLabel" width="120" />
<el-table-column :label="TEXT.common.fields.phone" width="140">
<template #default="scope">
{{ phoneText(scope.row) }}
</template>
</el-table-column>
<el-table-column prop="contact" :label="TEXT.common.fields.contact" width="160">
<template #default="scope">
{{ contactLabel(scope.row) }}
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.status" width="100">
<template #default="scope">
<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="200" align="center" class-name="action-column">
<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.is_active ? 'danger' : 'primary'"
size="small"
@click="toggleSite(scope.row)"
>
{{ 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>
</el-table>
</div>
</el-card>
<SiteForm
v-if="projectId"
v-model:visible="formVisible"
:study-id="projectId"
:site="editingSite"
:members="members"
:users="users"
@saved="loadSites"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { fetchStudyDetail } from "../../api/studies";
import { deleteSite, fetchSites, updateSite } from "../../api/sites";
import { fetchUsers } from "../../api/users";
import { listMembers } from "../../api/members";
import SiteForm from "./SiteForm.vue";
import type { Site, Study, UserInfo } from "../../types/api";
import { useAuthStore } from "../../store/auth";
import { evaluateAction } from "../../guards/actionGuard";
import { logAudit } from "../../audit";
import { TEXT } from "../../locales";
const route = useRoute();
const projectId = computed(() => route.params.projectId as string);
const auth = useAuthStore();
const project = ref<Study | null>(null);
const sites = ref<Site[]>([]);
const users = ref<UserInfo[]>([]);
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;
try {
const { data } = await fetchStudyDetail(projectId.value);
project.value = data;
} catch {
/* ignore */
}
};
const loadSites = async () => {
if (!projectId.value) return;
loading.value = true;
try {
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);
} finally {
loading.value = false;
}
};
const loadUsers = async () => {
try {
const { data } = await fetchUsers({ limit: 500 });
users.value = (data as any).items || [];
} catch {
users.value = [];
}
};
const loadMembers = async () => {
if (!projectId.value) return;
try {
const { data } = await listMembers(projectId.value, { limit: 500 });
members.value = Array.isArray(data) ? data : data.items || [];
} catch {
members.value = [];
}
};
const memberNameMap = computed(() => {
const map: Record<string, string> = {};
users.value.forEach((u) => {
map[u.id] = u.full_name || u.username || u.id;
});
members.value.forEach((m) => {
if (m.user_id && !map[m.user_id]) map[m.user_id] = m.full_name || m.username || m.user_id;
});
return map;
});
const contactLabel = (row: any) => {
if (!row?.contact) return TEXT.common.fallback;
return String(row.contact)
.split(",")
.map((c) => c.trim())
.filter(Boolean)
.map((c) => memberNameMap.value[c] || c)
.join("、") || TEXT.common.fallback;
};
const phoneText = (row: any) => row?.phone || row?.contact_phone || TEXT.common.fallback;
const normalizeSiteName = (value?: string | null) => String(value || "").trim().replace(/\s+/g, "").toLowerCase();
const isLeadUnitSite = (row: Site): boolean => {
const leadUnitName = normalizeSiteName(project.value?.lead_unit);
const siteName = normalizeSiteName(row.name);
if (!leadUnitName || !siteName) return false;
return siteName === leadUnitName;
};
const displaySites = computed(() => {
return sites.value
.map((item, index) => ({ item, index }))
.sort((a, b) => {
const aLead = isLeadUnitSite(a.item) ? 1 : 0;
const bLead = isLeadUnitSite(b.item) ? 1 : 0;
if (aLead !== bLead) return bLead - aLead;
return a.index - b.index;
})
.map((entry) => entry.item);
});
const siteRowClass = ({ row }: { row: Site }) => {
const classes: string[] = [];
if (!row.is_active) classes.push("row-inactive");
if (isLeadUnitSite(row)) classes.push("row-lead-unit");
return classes.join(" ");
};
const openCreate = () => {
loadUsers();
loadMembers();
editingSite.value = null;
formVisible.value = true;
};
const openEdit = (row: Site) => {
loadUsers();
loadMembers();
editingSite.value = row;
formVisible.value = true;
};
const toggleSite = async (row: Site) => {
if (!projectId.value) return;
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: "site.manage",
target: { siteId: row.id, studyId: projectId.value },
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
logAudit(decision.auditType, {
targetId: row.id,
targetName: row.name,
reason: decision.reason,
severity: decision.severity,
});
return;
}
if (row.is_active) {
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 ? "已锁定" : "已解锁");
loadSites();
logAudit("SITE_STATUS_CHANGED", {
targetId: row.id,
targetName: row.name,
before: { is_active: row.is_active },
after: { is_active: !row.is_active },
severity: "normal",
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.actionFailed);
logAudit("SITE_STATUS_CHANGED", {
targetId: row.id,
targetName: row.name,
before: { is_active: row.is_active },
after: { is_active: !row.is_active },
severity: "warning",
reason: e?.response?.data?.message,
});
}
};
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();
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
}
.actions-only-bar {
display: flex;
justify-content: flex-end;
align-items: center;
}
.site-table-section {
padding-top: 12px;
padding-bottom: 12px;
}
.site-name-cell {
display: inline-flex;
align-items: center;
gap: 8px;
}
</style>
<style>
.row-inactive td {
color: var(--ctms-text-secondary);
background-color: #fff7d6 !important;
}
.row-inactive .el-tag {
opacity: 0.6;
}
.row-lead-unit td {
background-color: #f3f8ff !important;
}
.row-lead-unit.row-inactive td {
background-color: #edf2fb !important;
}
.el-table .action-column .cell {
padding-left: 8px;
padding-right: 8px;
}
</style>